libeufin

Integration and sandbox testing for FinTech APIs and data formats
Log | Files | Refs | Submodules | README | LICENSE

commit 15ccaad99c4629b6226f9c2d9ef4107b4f4c91ce
parent c4aac943d0f81327d837584063d8f619d6326257
Author: Antoine A <>
Date:   Fri, 20 Sep 2024 14:00:55 +0200

nexus: ebics-setup better HKD use
warn when required orders are unsupported or unauthorized
show subscriber status
improve debug output

Diffstat:
Mnexus/src/main/kotlin/tech/libeufin/nexus/XmlCombinators.kt | 17++++++++++++++++-
Mnexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsSetup.kt | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Mnexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsAdministrative.kt | 124++++++++++++++++++++++++++++++++++++++++---------------------------------------
Mnexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsOrder.kt | 34+++++++++++++++++++++++++++++++++-
4 files changed, 178 insertions(+), 80 deletions(-)

diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/XmlCombinators.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/XmlCombinators.kt @@ -183,7 +183,22 @@ class XmlDestructor internal constructor(private val el: Element) { fun dateTime(): LocalDateTime = LocalDateTime.parse(text(), DateTimeFormatter.ISO_DATE_TIME) inline fun <reified T : Enum<T>> enum(): T = java.lang.Enum.valueOf(T::class.java, text()) - fun attr(index: String): String = el.getAttribute(index) + fun optAttr(index: String): String? { + val attr = el.getAttribute(index) + if (attr == "") { + return null + } else { + return attr + } + } + fun attr(index: String): String { + val attr = optAttr(index) + if (attr == null) { + throw DestructionError("missing attribute '$index' at '${el.tagName}'") + } + return attr + } + companion object { fun <T> fromStream(xml: InputStream, root: String, f: XmlDestructor.() -> T): T { diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsSetup.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsSetup.kt @@ -222,28 +222,77 @@ class EbicsSetup: CliktCommand() { clientKeys, bankKeys ).download(EbicsOrder.V3("HKD"), null, null) { stream -> - val hkd = EbicsAdministrative.parseHKD(stream) - val account = hkd.account - // TODO parse and check more information - if (account.currency != null && account.currency != cfg.currency) - logger.error("Expected CURRENCY '${cfg.currency}' from config got '${account.currency}' from bank") - if (account.iban != null && account.iban != cfg.account.iban) - logger.error("Expected IBAN '${cfg.account.iban}' from config got '${account.iban}' from bank") - if (account.name != null && account.name != cfg.account.name) - logger.warn("Expected NAME '${cfg.account.name}' from config got '${account.name}' from bank") - - logger.debug("User status: ${hkd.status.name} ${hkd.status.description}") - logger.debug("Supported orders:") - for (order in hkd.orders) { - logger.debug("${order.type}${order.params}: ${order.description}") + val (partner, users) = EbicsAdministrative.parseHKD(stream) + val user = users.find { it -> it.id == cfg.ebicsUserId } + // Debug logging + logger.debug { + buildString { + if (partner.name != null || partner.accounts.isNotEmpty()) { + append("Partner Info: ") + if (partner.name != null) { + append("'") + append(partner.name) + append("' - ") + } + for ((iban, currency) in partner.accounts) { + append(iban) + append('_') + append(currency) + } + append('\n') + } + append("Supported orders:\n") + for ((order, description) in partner.orders) { + append("- ") + append(order.fullDescription()) + append(": ") + append(description) + append('\n') + } + if (user != null) { + append("Authorized orders:\n") + for ((order) in partner.orders) { + append("- ") + append(order.fullDescription()) + append('\n') + } + } + } } - logger.debug("Authorized orders:") - for (order in hkd.permissions) { - logger.debug("${order.type}${order.params}") + + // Check partner info match config + if (partner.name != null && partner.name != cfg.account.name) + logger.warn("Expected NAME '${cfg.account.name}' from config got '${partner.name}' from bank") + val account = partner.accounts.find { it.iban == cfg.account.iban } + if (account != null) { + if (account.currency != null && account.currency != cfg.currency) + logger.error("Expected CURRENCY '${cfg.currency}' from config got '${account.currency}' from bank") + } else if (partner.accounts.isNotEmpty()) { + val ibans = partner.accounts.map { it.iban }.joinToString(", ") + logger.error("Expected IBAN ${cfg.account.iban} from config got $ibans from bank") + } + + val requireOrders = cfg.dialect.orders() + + // Check partner support required orders + val unsupportedOrder = requireOrders subtract partner.orders.map { it.order } + if (unsupportedOrder.isNotEmpty()) { + logger.warn("Unsupported orders: {}", unsupportedOrder.map(EbicsOrder::fullDescription).joinToString(", ")) + } + + // Check user is authorized for required orders + if (user != null) { + val unauthorizedOrders = requireOrders subtract user.permissions subtract unsupportedOrder + if (unauthorizedOrders.isNotEmpty()) { + logger.warn("Unauthorized orders: {}", unauthorizedOrders.map(EbicsOrder::fullDescription).joinToString(", ")) + } + + logger.info("Subscriber status: {}", user.status.description) } } } } catch (e: Exception) { + // This can happen if HKD is not supported logger.warn("HKD failed: ${e.fmt()}") } diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsAdministrative.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsAdministrative.kt @@ -30,22 +30,28 @@ data class VersionNumber(val number: Float, val schema: String) { } data class HKD ( - val account: AccountInfo, - val orders: List<OrderInfo>, - val permissions: List<OrderInfo>, - val status: UserStatus + val partner: PartnerInfo, + val users: List<UserInfo> ) -data class AccountInfo ( - val currency: String?, - val iban: String?, - val name: String? +data class PartnerInfo( + val name: String?, + val accounts: List<AccountInfo>, + val orders: List<OrderInfo> ) data class OrderInfo ( - val type: String, - val params: String, + val order: EbicsOrder, val description: String, ) -// TODO use this in ebics setup to get current state and required actions +data class AccountInfo ( + val currency: String, + val iban: String, +) +data class UserInfo( + val id: String, + val status: UserStatus, + val permissions: List<EbicsOrder>, +) + enum class UserStatus(val description: String) { Ready("Subscriber is permitted access"), New("Subscriber is established, pending access permission"), @@ -71,7 +77,7 @@ object EbicsAdministrative { EbicsReturnCode.lookup(one("ReturnCode").text()) } val versions = map("VersionNumber") { - VersionNumber(text().toFloat(), attr("ProtocolVersion")) + VersionNumber(text().toFloat(), attr("ProtocolVersion")!!) } EbicsResponse( technicalCode = technicalCode, @@ -82,66 +88,62 @@ object EbicsAdministrative { } fun parseHKD(stream: InputStream): HKD { - fun XmlDestructor.orderInfo() = OrderInfo( - one("AdminOrderType").text(), + fun XmlDestructor.order(): EbicsOrder { + var order = EbicsOrder.V3(one("AdminOrderType").text()) opt("Service") { - // TODO user a structured type to enable comparison - val params = StringBuilder() - opt("ServiceName")?.run { - params.append(" ${text()}") - } - opt("Scope")?.run { - params.append(" ${text()}") - } - opt("ServiceOption")?.run { - params.append(" ${text()}") - } - opt("MsgName")?.run { - params.append(" ${text()}") - } - opt("Container")?.run { - params.append(" ${attr("containerType")}") - } - params.toString() - } ?: "", - opt("Description")?.text() ?: "" - ) - // TODO handle multiple partner, accounts and user using their respective ids + order = order.copy( + service = opt("ServiceName")?.text(), + scope = opt("Scope")?.text(), + option = opt("ServiceOption")?.text(), + container = opt("Container")?.attr("containerType"), + message = opt("MsgName")?.text(), + version = opt("MsgName")?.optAttr("version"), + ) + } + return order + } return XmlDestructor.fromStream(stream, "HKDResponseOrderData") { - val (account, orders) = one("PartnerInfo") { - var currency: String? = null - var iban: String? = null - val name = opt("AddressInfo")?.one("Name")?.text() - opt("AccountInfo") { - currency = attr("Currency") + val partnerInfo = one("PartnerInfo") { + val name = one("AddressInfo").opt("Name")?.text() + val accounts = map("AccountInfo") { + var currency = attr("Currency") + lateinit var iban: String each("AccountNumber") { if (attr("international") == "true") { iban = text() } } + AccountInfo(currency, iban) + } + val orders = map("OrderInfo") { + OrderInfo( + order = order(), + description = one("Description").text() + ) } - val orders = map("OrderInfo") { orderInfo() } - Pair(AccountInfo(currency, iban, name), orders) + PartnerInfo(name, accounts, orders) } - val (permissions, status) = one("UserInfo") { - val userId = one("UserID").text() - val status = when (val status = one("UserID").attr("Status")) { - "1" -> UserStatus.Ready - "2" -> UserStatus.New - "3" -> UserStatus.INI - "4" -> UserStatus.HIA - "5" -> UserStatus.Initialised - "6" -> UserStatus.SuspendedFailedAttempts - // 7 is not applicable per spec - "8" -> UserStatus.SuspendedSPR - "9" -> UserStatus.SuspendedBank - else -> throw Exception("Unknown user statte $status") + val usersInfo = map("UserInfo") { + val (id, status) = one("UserID") { + val id = text() + val status = when (val status = attr("Status")) { + "1" -> UserStatus.Ready + "2" -> UserStatus.New + "3" -> UserStatus.INI + "4" -> UserStatus.HIA + "5" -> UserStatus.Initialised + "6" -> UserStatus.SuspendedFailedAttempts + // 7 is not applicable per spec + "8" -> UserStatus.SuspendedSPR + "9" -> UserStatus.SuspendedBank + else -> throw Exception("Unknown user statte $status") + } + Pair(id, status) } - val userName = opt("Name")?.text() - val permissions = map("Permission") { orderInfo() } - Pair(permissions, status) + val permissions = map("Permission") { order() } + UserInfo(id, status, permissions) } - HKD(account, orders, permissions, status) + HKD(partnerInfo, usersInfo) } } } diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsOrder.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsOrder.kt @@ -44,6 +44,7 @@ sealed class EbicsOrder(val schema: String) { ) } + /** Minimal text description for file logging */ fun description(): String = buildString { when (this@EbicsOrder) { is V2_5 -> { @@ -53,7 +54,7 @@ sealed class EbicsOrder(val schema: String) { } is V3 -> { append(type) - for (part in sequenceOf(service, message, option)) { + for (part in sequenceOf(service, option, message)) { if (part != null) { append('-') append(part) @@ -63,6 +64,34 @@ sealed class EbicsOrder(val schema: String) { } } + /** Full text description */ + fun fullDescription(): String = buildString { + when (this@EbicsOrder) { + is V2_5 -> { + append(type) + append(' ') + append(attribute) + } + is V3 -> { + append(type) + for (part in sequenceOf(service, scope, option, container)) { + if (part != null) { + append(' ') + append(part) + } + } + if (message != null) { + append(' ') + append(message) + if (version != null) { + append('v') + append(version) + } + } + } + } + } + fun doc(): OrderDoc? { return when (this) { is V2_5 -> { @@ -163,4 +192,7 @@ enum class Dialect { gls -> EbicsOrder.V3("BTU", "SCT", null, "pain.001") } } + + fun orders(): Set<EbicsOrder> = + (sequenceOf(EbicsOrder.V3("HAA")) + OrderDoc.entries.map { downloadDoc(it, false) }).toSet() } \ No newline at end of file