libeufin

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

commit 0920cb07545501e54659f6a7ae94afbab86633f5
parent 29bca27b9822b9b8a23db5ce0ba8a632fa200b26
Author: MS <ms@taler.net>
Date:   Thu, 26 Oct 2023 16:09:22 +0200

nexus: get pain.001 to validate at PostFinance sandbox.

Diffstat:
Mnexus/src/main/kotlin/tech/libeufin/nexus/Database.kt | 17+++++++++++++++++
Mnexus/src/main/kotlin/tech/libeufin/nexus/EbicsSetup.kt | 87++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
Mnexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt | 64+++++++++++++++++++++++++++++++---------------------------------
Mnexus/src/main/kotlin/tech/libeufin/nexus/Main.kt | 17+++++++++++++++++
Mnexus/src/test/kotlin/Common.kt | 8++++++--
Mnexus/src/test/kotlin/Ebics.kt | 52----------------------------------------------------
Anexus/src/test/kotlin/PostFinance.kt | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Anexus/src/test/kotlin/pain001.xml | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
8 files changed, 274 insertions(+), 121 deletions(-)

diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Database.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Database.kt @@ -18,6 +18,23 @@ data class TalerAmount( val currency: String ) +/** + * Stringifies TalerAmount's. NOTE: the caller must enforce + * length-checks on the output fractional part, to ensure compatibility + * with the bank. + * + * @return the amount in the $currency:x.y format. + */ +fun TalerAmount.stringify(): String { + if (fraction == 0) { + return "$currency:$value" + } else { + val fractionFormat = this.fraction.toString().padStart(8, '0').dropLastWhile { it == '0' } + if (fractionFormat.length > 2) throw Exception("Sub-cent amounts not supported") + return "$currency:$value.$fractionFormat" + } +} + // INCOMING PAYMENTS STRUCTS /** diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSetup.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSetup.kt @@ -32,6 +32,7 @@ import TalerConfigError import kotlinx.serialization.encodeToString import tech.libeufin.nexus.ebics.* import tech.libeufin.util.* +import tech.libeufin.util.ebics_h004.HTDResponseOrderData import java.time.Instant import kotlin.reflect.typeOf @@ -347,6 +348,57 @@ private fun makePdf(privs: ClientPrivateKeysFile, cfg: EbicsSetupConfig) { } /** + * Extracts bank account information and stores it to the bank account + * metadata file that's found in the configuration. It returns void in + * case of success, or fails the whole process upon errors. + * + * @param cfg configuration handle. + * @param bankAccounts bank response to EBICS HTD request. + * @param showAssociatedAccounts if true, the function only shows the + * users account, without persisting anything to disk. + */ +fun extractBankAccountMetadata( + cfg: EbicsSetupConfig, + bankAccounts: HTDResponseOrderData, + showAssociatedAccounts: Boolean +) { + // Now trying to extract whatever IBAN & BIC pair the bank gave in the response. + val foundIban: String? = findIban(bankAccounts.partnerInfo.accountInfoList) + val foundBic: String? = findBic(bankAccounts.partnerInfo.accountInfoList) + // _some_ IBAN & BIC _might_ have been found, compare it with the config. + if (foundIban == null) + logger.warn("Bank seems NOT to show any IBAN for our account.") + if (foundBic == null) + logger.warn("Bank seems NOT to show any BIC for our account.") + // Warn the user if instead one IBAN was found but that differs from the config. + if (foundIban != null && foundIban != cfg.accountNumber) { + logger.error("Bank has another IBAN for us: $foundIban, while config has: ${cfg.accountNumber}") + exitProcess(1) + } + // Users wants to only _see_ the accounts, NOT checking values and returning here. + if (showAssociatedAccounts) { + println("Bank associates this account to the EBICS user ${cfg.ebicsUserId}: IBAN: $foundIban, BIC: $foundBic, Name: ${bankAccounts.userInfo.name}") + return + } + // No divergences were found, either because the config was right + // _or_ the bank didn't give any information. Setting the account + // metadata accordingly. + val accountMetaData = BankAccountMetadataFile( + account_holder_name = bankAccounts.userInfo.name ?: "Account holder name not given", + account_holder_iban = foundIban ?: run iban@ { + logger.warn("Bank did not show any IBAN for us, defaulting to the one we configured.") + return@iban cfg.accountNumber }, + bank_code = foundBic ?: run bic@ { + logger.warn("Bank did not show any BIC for us, setting it as null.") + return@bic null } + ) + if (!syncJsonToDisk(accountMetaData, cfg.bankAccountMetadataFilename)) { + logger.error("Failed to persist bank account meta-data at: ${cfg.bankAccountMetadataFilename}") + exitProcess(1) + } +} + +/** * CLI class implementing the "ebics-setup" subcommand. */ class EbicsSetup: CliktCommand("Set up the EBICS subscriber") { @@ -469,40 +521,7 @@ class EbicsSetup: CliktCommand("Set up the EBICS subscriber") { exitProcess(1) } logger.info("Subscriber's bank accounts fetched.") - // Now trying to extract whatever IBAN & BIC pair the bank gave in the response. - val foundIban: String? = findIban(bankAccounts.partnerInfo.accountInfoList) - val foundBic: String? = findBic(bankAccounts.partnerInfo.accountInfoList) - // _some_ IBAN & BIC _might_ have been found, compare it with the config. - if (foundIban == null) - logger.warn("Bank seems NOT to show any IBAN for our account.") - if (foundBic == null) - logger.warn("Bank seems NOT to show any BIC for our account.") - // Warn the user if instead one IBAN was found but that differs from the config. - if (foundIban != null && foundIban != cfg.accountNumber) { - logger.error("Bank has another IBAN for us: $foundIban, while config has: ${cfg.accountNumber}") - exitProcess(1) - } - // Users wants only _see_ the accounts, NOT checking values and returning here. - if (showAssociatedAccounts) { - println("Bank associates this account to the EBICS user ${cfg.ebicsUserId}: IBAN: $foundIban, BIC: $foundBic, Name: ${bankAccounts.userInfo.name}") - return - } - // No divergences were found, either because the config was right - // _or_ the bank didn't give any information. Setting the account - // metadata accordingly. - val accountMetaData = BankAccountMetadataFile( - account_holder_name = bankAccounts.userInfo.name ?: "Account holder name not given", - account_holder_iban = foundIban ?: run iban@ { - logger.warn("Bank did not show any IBAN for us, defaulting to the one we configured.") - return@iban cfg.accountNumber }, - bank_code = foundBic ?: run bic@ { - logger.warn("Bank did not show any BIC for us, setting it as null.") - return@bic null } - ) - if (!syncJsonToDisk(accountMetaData, cfg.bankAccountMetadataFilename)) { - logger.error("Failed to persist bank account meta-data at: ${cfg.bankAccountMetadataFilename}") - exitProcess(1) - } + extractBankAccountMetadata(cfg, bankAccounts, showAssociatedAccounts) println("setup ready") } } \ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt @@ -2,7 +2,6 @@ package tech.libeufin.nexus import tech.libeufin.util.IbanPayto import tech.libeufin.util.constructXml -import tech.libeufin.util.parsePayto import java.time.Instant import java.time.ZoneId import java.time.ZonedDateTime @@ -18,16 +17,23 @@ data class Pain001Namespaces( * Create a pain.001 document. It requires the debtor BIC. * * @param requestUid UID of this request, helps to make this request idempotent. - * @param debtorMetadataFile bank account information of the EBICS subscriber that - * sends this request. - * @param amount amount to pay. + * @param initiationTimestamp timestamp when the payment was initiated in the database. + * Although this is NOT the pain.001 creation timestamp, it + * will help making idempotent requests where one MsgId is + * always associated with one, and only one creation timestamp. + * @param debtorAccount [IbanPayto] bank account information of the EBICS subscriber that + * sends this request. It's expected to contain IBAN, BIC, and NAME. + * @param amount amount to pay. The caller is responsible for sanity-checking this + * value to match the bank expectation. For example, that the decimal + * part formats always to at most two digits. * @param wireTransferSubject wire transfer subject. - * @param creditAccount payment receiver. + * @param creditAccount payment receiver in [IbanPayto]. It should contain IBAN and NAME. * @return raw pain.001 XML, or throws if the debtor BIC is not found. */ fun createPain001( requestUid: String, - debtorMetadataFile: BankAccountMetadataFile, + initiationTimestamp: Instant, + debitAccount: IbanPayto, amount: TalerAmount, wireTransferSubject: String, creditAccount: IbanPayto @@ -36,17 +42,14 @@ fun createPain001( fullNamespace = "urn:iso:std:iso:20022:tech:xsd:pain.001.001.09", xsdFilename = "pain.001.001.09.ch.03.xsd" ) - val creationTimestamp = Instant.now() - val amountWithoutCurrency: String = amount.toString().split(":").run { + val zonedTimestamp = ZonedDateTime.ofInstant(initiationTimestamp, ZoneId.of("UTC")) + val amountWithoutCurrency: String = amount.stringify().split(":").run { if (this.size != 2) throw Exception("Invalid stringified amount: $amount") return@run this[1] } - if (debtorMetadataFile.bank_code == null) - throw Exception("Need debtor BIC, but not found in the debtor account metadata file.") - // Current version expects the receiver BIC, TODO: try also without. - if (creditAccount.bic == null) - throw Exception("Expecting the receiver BIC.") - + val debtorBic: String = debitAccount.bic ?: throw Exception("Cannot operate without the debtor BIC") + val debtorName: String = debitAccount.receiverName ?: throw Exception("Cannot operate without the debtor name") + val creditorName: String = creditAccount.receiverName ?: throw Exception("Cannot operate without the creditor name") return constructXml(indent = true) { root("Document") { attribute("xmlns", namespace.fullNamespace) @@ -62,20 +65,16 @@ fun createPain001( } element("CreDtTm") { val dateFormatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME - val zoned = ZonedDateTime.ofInstant( - creationTimestamp, - ZoneId.systemDefault() // FIXME: should this be UTC? - ) - text(dateFormatter.format(zoned)) + text(dateFormatter.format(zonedTimestamp)) } element("NbOfTxs") { text("1") } element("CtrlSum") { - text(amount.toString()) + text(amountWithoutCurrency) } - element("InitgPty/Nm") { // optional - text(debtorMetadataFile.account_holder_name) + element("InitgPty/Nm") { + text(debtorName) } } element("PmtInf") { @@ -92,23 +91,25 @@ fun createPain001( text("1") } element("CtrlSum") { - text(amount.toString()) + text(amountWithoutCurrency) } element("PmtTpInf/SvcLvl/Cd") { text("SDVA") } element("ReqdExctnDt") { - text(DateTimeFormatter.ISO_DATE.format(creationTimestamp)) + element("Dt") { + text(DateTimeFormatter.ISO_DATE.format(zonedTimestamp)) + } } - element("Dbtr/Nm") { // optional - text(debtorMetadataFile.account_holder_name) + element("Dbtr/Nm") { + text(debtorName) } element("DbtrAcct/Id/IBAN") { - text(debtorMetadataFile.account_holder_iban) + text(debitAccount.iban) } element("DbtrAgt/FinInstnId") { element("BICFI") { - text(debtorMetadataFile.bank_code) + text(debtorBic) } } element("ChrgBr") { @@ -131,11 +132,8 @@ fun createPain001( } } } - creditAccount.receiverName.apply { - if (this != null) - element("Cdtr/Nm") { - text(this@apply) - } + element("Cdtr/Nm") { + text(creditorName) } element("CdtrAcct/Id/IBAN") { text(creditAccount.iban) diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt @@ -201,6 +201,23 @@ data class BankPublicKeysFile( ) /** + * Gets the bank account metadata file, according to the + * location found in the configuration. The caller may still + * have to handle the exception, in case the found file doesn't + * parse to the wanted JSON type. + * + * @param cfg configuration handle. + * @return [BankAccountMetadataFile] or null, if the file wasn't found. + */ +fun loadBankAccountFile(cfg: EbicsSetupConfig): BankAccountMetadataFile? { + val f = File(cfg.bankAccountMetadataFilename) + if (!f.exists()) { + logger.error("Bank account metadata file not found in ${cfg.bankAccountMetadataFilename}") + return null + } + return myJson.decodeFromString(f.readText()) +} +/** * Load the bank keys file from disk. * * @param location the keys file location. diff --git a/nexus/src/test/kotlin/Common.kt b/nexus/src/test/kotlin/Common.kt @@ -56,7 +56,11 @@ fun getMockedClient( } // Partial config to talk to PostFinance. -fun getPofiConfig(userId: String, partnerId: String) = """ +fun getPofiConfig( + userId: String, + partnerId: String, + accountOwner: String? = "NotGiven" + ) = """ [nexus-ebics] CURRENCY = KUDOS HOST_BASE_URL = https://isotest.postfinance.ch/ebicsweb/ebicsweb @@ -64,7 +68,7 @@ fun getPofiConfig(userId: String, partnerId: String) = """ USER_ID = $userId PARTNER_ID = $partnerId SYSTEM_ID = not-used - ACCOUNT_NUMBER = not-used-yet + ACCOUNT_NUMBER = payto://iban/POFICHBE/CH9789144829733648596?receiver-name=$accountOwner BANK_PUBLIC_KEYS_FILE = /tmp/enc-auth-keys.json CLIENT_PRIVATE_KEYS_FILE = /tmp/my-private-keys.json ACCOUNT_META_DATA_FILE = /tmp/ebics-meta.json diff --git a/nexus/src/test/kotlin/Ebics.kt b/nexus/src/test/kotlin/Ebics.kt @@ -67,56 +67,4 @@ class Ebics { val pdf = generateKeysPdf(clientKeys, config) File("/tmp/libeufin-nexus-test-keys.pdf").writeBytes(pdf) } -} - -@Ignore // manual tests -class PostFinance { - private fun prep(): EbicsSetupConfig { - val handle = TalerConfig(NEXUS_CONFIG_SOURCE) - val ebicsUserId = File("/tmp/pofi-ebics-user-id.txt").readText() - val ebicsPartnerId = File("/tmp/pofi-ebics-partner-id.txt").readText() - handle.loadFromString(getPofiConfig(ebicsUserId, ebicsPartnerId)) - return EbicsSetupConfig(handle) - } - // Tests sending client keys to the PostFinance test platform. - @Test - fun postClientKeys() { - val cfg = prep() - runBlocking { - val httpClient = HttpClient() - assertTrue(doKeysRequestAndUpdateState(cfg, clientKeys, httpClient, KeysOrderType.INI)) - assertTrue(doKeysRequestAndUpdateState(cfg, clientKeys, httpClient, KeysOrderType.HIA)) - } - } - - // Tests getting the PostFinance keys from their test platform. - @Test - fun getBankKeys() { - val cfg = prep() - val keys = loadPrivateKeysFromDisk(cfg.clientPrivateKeysFilename) - assertNotNull(keys) - assertTrue(keys.submitted_ini) - assertTrue(keys.submitted_hia) - runBlocking { - assertTrue(doKeysRequestAndUpdateState( - cfg, - keys, - HttpClient(), - KeysOrderType.HPB - )) - } - } - - // Tests the HTD message type. - @Test - fun fetchAccounts() { - val cfg = prep() - val clientKeys = loadPrivateKeysFromDisk(cfg.clientPrivateKeysFilename) - assertNotNull(clientKeys) - val bankKeys = loadBankKeys(cfg.bankPublicKeysFilename) - assertNotNull(bankKeys) - val htd = runBlocking { fetchBankAccounts(cfg, clientKeys, bankKeys, HttpClient()) } - assertNotNull(htd) - println(htd.partnerInfo.accountInfoList?.size) - } } \ No newline at end of file diff --git a/nexus/src/test/kotlin/PostFinance.kt b/nexus/src/test/kotlin/PostFinance.kt @@ -0,0 +1,85 @@ +import io.ktor.client.* +import kotlinx.coroutines.runBlocking +import org.junit.Ignore +import org.junit.Test +import tech.libeufin.nexus.* +import tech.libeufin.nexus.ebics.fetchBankAccounts +import tech.libeufin.util.IbanPayto +import tech.libeufin.util.parsePayto +import java.io.File +import java.time.Instant +import java.time.ZoneId +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class Iso20022 { + @Test + fun sendPayment() { + val xml = createPain001( + "random", + Instant.now(), + parsePayto("payto://iban/POFICHBE/CH9789144829733648596?receiver-name=NotGiven")!!, + TalerAmount(4, 0, "CHF"), + "Test reimbursement", + parsePayto("payto://iban/CH9300762011623852957?receiver-name=NotGiven")!! + ) + println(xml) + File("/tmp/pain.001-test.xml").writeText(xml) + } +} + +@Ignore +class PostFinance { + private fun prep(): EbicsSetupConfig { + val handle = TalerConfig(NEXUS_CONFIG_SOURCE) + val ebicsUserId = File("/tmp/pofi-ebics-user-id.txt").readText() + val ebicsPartnerId = File("/tmp/pofi-ebics-partner-id.txt").readText() + handle.loadFromString(getPofiConfig(ebicsUserId, ebicsPartnerId)) + return EbicsSetupConfig(handle) + } + + // Tests sending client keys to the PostFinance test platform. + @Test + fun postClientKeys() { + val cfg = prep() + runBlocking { + val httpClient = HttpClient() + assertTrue(doKeysRequestAndUpdateState(cfg, clientKeys, httpClient, KeysOrderType.INI)) + assertTrue(doKeysRequestAndUpdateState(cfg, clientKeys, httpClient, KeysOrderType.HIA)) + } + } + + // Tests getting the PostFinance keys from their test platform. + @Test + fun getBankKeys() { + val cfg = prep() + val keys = loadPrivateKeysFromDisk(cfg.clientPrivateKeysFilename) + assertNotNull(keys) + assertTrue(keys.submitted_ini) + assertTrue(keys.submitted_hia) + runBlocking { + assertTrue( + doKeysRequestAndUpdateState( + cfg, + keys, + HttpClient(), + KeysOrderType.HPB + ) + ) + } + } + + // Tests the HTD message type. + @Test + fun fetchAccounts() { + val cfg = prep() + val clientKeys = loadPrivateKeysFromDisk(cfg.clientPrivateKeysFilename) + assertNotNull(clientKeys) + val bankKeys = loadBankKeys(cfg.bankPublicKeysFilename) + assertNotNull(bankKeys) + val htd = runBlocking { fetchBankAccounts(cfg, clientKeys, bankKeys, HttpClient()) } + extractBankAccountMetadata(cfg, htd!!, false) + } +} +\ No newline at end of file diff --git a/nexus/src/test/kotlin/pain001.xml b/nexus/src/test/kotlin/pain001.xml @@ -0,0 +1,63 @@ +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<Document xmlns="urn:iso:std:iso:20022:tech:xsd:pain.001.001.09" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="urn:iso:std:iso:20022:tech:xsd:pain.001.001.09 pain.001.001.09.ch.03.xsd"> + <CstmrCdtTrfInitn> + <GrpHdr> + <MsgId>random</MsgId> + <CreDtTm>2023-10-26T12:54:13.423443377Z</CreDtTm> + <NbOfTxs>1</NbOfTxs> + <CtrlSum>TalerAmount(value=4, fraction=0, currency=CHF)</CtrlSum> + <InitgPty> + <Nm>Marcello Stanisci</Nm> + </InitgPty> + </GrpHdr> + <PmtInf> + <PmtInfId>NOT GIVEN</PmtInfId> + <PmtMtd>TRF</PmtMtd> + <BtchBookg>true</BtchBookg> + <NbOfTxs>1</NbOfTxs> + <CtrlSum>TalerAmount(value=4, fraction=0, currency=CHF)</CtrlSum> + <PmtTpInf> + <SvcLvl> + <Cd>SDVA</Cd> + </SvcLvl> + </PmtTpInf> + <ReqdExctnDt>2023-10-26Z</ReqdExctnDt> + <Dbtr> + <Nm>Marcello Stanisci</Nm> + </Dbtr> + <DbtrAcct> + <Id> + <IBAN>not-used-yet</IBAN> + </Id> + </DbtrAcct> + <DbtrAgt> + <FinInstnId> + <BICFI>POFICHBE</BICFI> + </FinInstnId> + </DbtrAgt> + <ChrgBr>SLEV</ChrgBr> + <CdtTrfTxInf> + <PmtId> + <InstrId>NOT PROVIDED</InstrId> + <EndToEndId>NOT PROVIDED</EndToEndId> + </PmtId> + <Amt> + <InstdAmt Ccy="CHF">4</InstdAmt> + </Amt> + <CdtrAgt> + <FinInstnId> + <BICFI>BIC</BICFI> + </FinInstnId> + </CdtrAgt> + <CdtrAcct> + <Id> + <IBAN>INVALID</IBAN> + </Id> + </CdtrAcct> + <RmtInf> + <Ustrd>Test reimbursement</Ustrd> + </RmtInf> + </CdtTrfTxInf> + </PmtInf> + </CstmrCdtTrfInitn> +</Document> +\ No newline at end of file