libeufin

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

commit ab6b90924a3f9c14c11ace80ea91772144ad4c84
parent 1f1c99b56623000c61b5b735604683ffe911e31b
Author: Antoine A <>
Date:   Thu,  1 Feb 2024 14:22:41 +0100

receiver-name everywhere

Diffstat:
Mbank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt | 8++++----
Mbank/src/main/kotlin/tech/libeufin/bank/Main.kt | 5++---
Mbank/src/main/kotlin/tech/libeufin/bank/RevenueApi.kt | 2+-
Mbank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt | 26+++++++++++++-------------
Mbank/src/main/kotlin/tech/libeufin/bank/WireGatewayApi.kt | 4++--
Mbank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt | 16+++++++++-------
Mbank/src/main/kotlin/tech/libeufin/bank/db/ExchangeDAO.kt | 6++++--
Mbank/src/main/kotlin/tech/libeufin/bank/db/TransactionDAO.kt | 15++++++++++-----
Mbank/src/test/kotlin/CoreBankApiTest.kt | 6+++---
Mcommon/src/main/kotlin/DB.kt | 7+++++++
Mcommon/src/main/kotlin/TalerCommon.kt | 81++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Mcommon/src/test/kotlin/PaytoTest.kt | 4++--
Mnexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt | 2+-
13 files changed, 111 insertions(+), 71 deletions(-)

diff --git a/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt @@ -138,7 +138,7 @@ private fun Routing.coreBankTokenApi(db: Database) { } } -suspend fun createAccount(db: Database, ctx: BankConfig, req: RegisterAccountRequest, isAdmin: Boolean): Pair<AccountCreationResult, IbanPayto> { +suspend fun createAccount(db: Database, ctx: BankConfig, req: RegisterAccountRequest, isAdmin: Boolean): Pair<AccountCreationResult, FullIbanPayto> { // Prohibit reserved usernames: if (RESERVED_ACCOUNTS.contains(req.username)) throw conflict( @@ -205,7 +205,7 @@ suspend fun createAccount(db: Database, ctx: BankConfig, req: RegisterAccountReq retry-- continue } - return Pair(res, internalPayto) + return Pair(res, internalPayto.withName(req.name)) } } @@ -264,10 +264,10 @@ private fun Routing.coreBankAccountsApi(db: Database, ctx: BankConfig) { TalerErrorCode.BANK_REGISTER_USERNAME_REUSE ) AccountCreationResult.PayToReuse -> throw conflict( - "Bank internalPayToUri reuse '${internalPayto.canonical}'", + "Bank internalPayToUri reuse '${internalPayto.payto}'", TalerErrorCode.BANK_REGISTER_PAYTO_URI_REUSE ) - AccountCreationResult.Success -> call.respond(RegisterAccountResponse(internalPayto.canonical)) + AccountCreationResult.Success -> call.respond(RegisterAccountResponse(internalPayto)) } } } diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Main.kt b/bank/src/main/kotlin/tech/libeufin/bank/Main.kt @@ -141,10 +141,10 @@ fun Application.corebankWebApp(db: Database, ctx: BankConfig) { } install(StatusPages) { exception<Exception> { call, cause -> + logger.debug("request failed", cause) when (cause) { is LibeufinException -> call.err(cause) is SQLException -> { - logger.debug("request failed", cause) when (cause.sqlState) { PSQLState.SERIALIZATION_FAILURE.state -> call.err( HttpStatusCode.InternalServerError, @@ -194,7 +194,6 @@ fun Application.corebankWebApp(db: Database, ctx: BankConfig) { ) } else -> { - logger.debug("request failed", cause) call.err( HttpStatusCode.InternalServerError, cause.message, @@ -478,7 +477,7 @@ class CreateAccount : CliktCommand( AccountCreationResult.LoginReuse -> throw Exception("Account username reuse '${req.username}'") AccountCreationResult.PayToReuse -> - throw Exception("Bank internalPayToUri reuse '${internalPayto.canonical}'") + throw Exception("Bank internalPayToUri reuse '${internalPayto.payto}'") AccountCreationResult.Success -> logger.info("Account '${req.username}' created") } diff --git a/bank/src/main/kotlin/tech/libeufin/bank/RevenueApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/RevenueApi.kt @@ -43,7 +43,7 @@ fun Routing.revenueApi(db: Database, ctx: BankConfig) { if (items.isEmpty()) { call.respond(HttpStatusCode.NoContent) } else { - call.respond(RevenueIncomingHistory(items, bankAccount.internalPaytoUri)) + call.respond(RevenueIncomingHistory(items, bankAccount.payto)) } } } diff --git a/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt b/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt @@ -180,7 +180,7 @@ data class RegisterAccountRequest( @Serializable data class RegisterAccountResponse( - val internal_payto_uri: String + val internal_payto_uri: FullIbanPayto ) /** @@ -245,7 +245,7 @@ data class MonitorWithConversion( * from/to the database. */ data class BankInfo( - val internalPaytoUri: String, + val payto: FullIbanPayto, val bankAccountId: Long, val isTalerExchange: Boolean, ) @@ -341,7 +341,7 @@ data class Balance( data class AccountMinimalData( val username: String, val name: String, - val payto_uri: String, + val payto_uri: FullIbanPayto, val balance: Balance, val debit_threshold: TalerAmount, val is_public: Boolean, @@ -363,7 +363,7 @@ data class ListBankAccountsResponse( data class AccountData( val name: String, val balance: Balance, - val payto_uri: String, + val payto_uri: FullIbanPayto, val debit_threshold: TalerAmount, val contact_data: ChallengeContactData? = null, val cashout_payto_uri: String? = null, @@ -387,8 +387,8 @@ data class TransactionCreateResponse( or from GET /transactions */ @Serializable data class BankAccountTransactionInfo( - val creditor_payto_uri: String, - val debtor_payto_uri: String, + val creditor_payto_uri: FullIbanPayto, + val debtor_payto_uri: FullIbanPayto, val amount: TalerAmount, val direction: TransactionDirection, val subject: String, @@ -557,7 +557,7 @@ data class AddIncomingResponse( @Serializable data class IncomingHistory( val incoming_transactions: List<IncomingReserveTransaction>, - val credit_account: String // Receiver's Payto URI. + val credit_account: FullIbanPayto ) /** @@ -569,7 +569,7 @@ data class IncomingReserveTransaction( val row_id: Long, // DB row ID of the payment. val date: TalerProtocolTimestamp, val amount: TalerAmount, - val debit_account: String, // Payto of the sender. + val debit_account: FullIbanPayto, val reserve_pub: EddsaPublicKey ) @@ -579,7 +579,7 @@ data class IncomingReserveTransaction( @Serializable data class OutgoingHistory( val outgoing_transactions: List<OutgoingTransaction>, - val debit_account: String // Debitor's Payto URI. + val debit_account: FullIbanPayto ) /** @@ -590,7 +590,7 @@ data class OutgoingTransaction( val row_id: Long, // DB row ID of the payment. val date: TalerProtocolTimestamp, val amount: TalerAmount, - val credit_account: String, // Payto of the receiver. + val credit_account: FullIbanPayto, val wtid: ShortHashCode, val exchange_base_url: String, ) @@ -598,7 +598,7 @@ data class OutgoingTransaction( @Serializable data class RevenueIncomingHistory( val incoming_transactions : List<RevenueIncomingBankTransaction>, - val credit_account: String + val credit_account: FullIbanPayto ) @Serializable @@ -606,7 +606,7 @@ data class RevenueIncomingBankTransaction( val row_id: Long, val date: TalerProtocolTimestamp, val amount: TalerAmount, - val debit_account: String, + val debit_account: FullIbanPayto, val subject: String ) @@ -645,7 +645,7 @@ data class PublicAccountsResponse( @Serializable data class PublicAccount( val username: String, - val payto_uri: String, + val payto_uri: FullIbanPayto, val balance: Balance, val is_taler_exchange: Boolean, ) diff --git a/bank/src/main/kotlin/tech/libeufin/bank/WireGatewayApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/WireGatewayApi.kt @@ -82,7 +82,7 @@ fun Routing.wireGatewayApi(db: Database, ctx: BankConfig) { } auth(db, TokenScope.readonly) { suspend fun <T> PipelineContext<Unit, ApplicationCall>.historyEndpoint( - reduce: (List<T>, String) -> Any, + reduce: (List<T>, FullIbanPayto) -> Any, dbLambda: suspend ExchangeDAO.(HistoryParams, Long) -> List<T> ) { val params = HistoryParams.extract(context.request.queryParameters) @@ -99,7 +99,7 @@ fun Routing.wireGatewayApi(db: Database, ctx: BankConfig) { if (items.isEmpty()) { call.respond(HttpStatusCode.NoContent) } else { - call.respond(reduce(items, bankAccount.internalPaytoUri)) + call.respond(reduce(items, bankAccount.payto)) } } get("/accounts/{USERNAME}/taler-wire-gateway/history/incoming") { diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt @@ -96,7 +96,7 @@ class AccountDAO(private val db: Database) { ,creation_time ) VALUES (?, ?) """).run { - setString(1, internalPaytoUri.iban) + setString(1, internalPaytoUri.iban.value) setLong(2, now) if (!executeUpdateViolation()) { conn.rollback() @@ -423,6 +423,7 @@ class AccountDAO(private val db: Database) { SELECT bank_account_id ,internal_payto_uri + ,name ,is_taler_exchange FROM bank_accounts JOIN customers @@ -432,7 +433,7 @@ class AccountDAO(private val db: Database) { stmt.setString(1, login) stmt.oneOrNull { BankInfo( - internalPaytoUri = it.getString("internal_payto_uri"), + payto = it.getFullPayto("internal_payto_uri", "name"), isTalerExchange = it.getBoolean("is_taler_exchange"), bankAccountId = it.getLong("bank_account_id") ) @@ -471,7 +472,7 @@ class AccountDAO(private val db: Database) { ), tan_channel = it.getString("tan_channel")?.run { TanChannel.valueOf(this) }, cashout_payto_uri = it.getString("cashout_payto"), - payto_uri = it.getString("internal_payto_uri"), + payto_uri = it.getFullPayto("internal_payto_uri", "name"), balance = Balance( amount = it.getAmount("balance", db.bankCurrency), credit_debit_indicator = @@ -499,8 +500,9 @@ class AccountDAO(private val db: Database) { (balance).frac AS balance_frac, has_debt, internal_payto_uri, - c.login - ,is_taler_exchange + c.login, + is_taler_exchange, + name FROM bank_accounts JOIN customers AS c ON owning_customer_id = c.customer_id WHERE is_public=true AND c.login LIKE ? AND @@ -512,7 +514,7 @@ class AccountDAO(private val db: Database) { ) { PublicAccount( username = it.getString("login"), - payto_uri = it.getString("internal_payto_uri"), + payto_uri = it.getFullPayto("internal_payto_uri", "name"), balance = Balance( amount = it.getAmount("balance", db.bankCurrency), credit_debit_indicator = if (it.getBoolean("has_debt")) { @@ -565,7 +567,7 @@ class AccountDAO(private val db: Database) { debit_threshold = it.getAmount("max_debt", db.bankCurrency), is_public = it.getBoolean("is_public"), is_taler_exchange = it.getBoolean("is_taler_exchange"), - payto_uri = it.getString("internal_payto_uri"), + payto_uri = it.getFullPayto("internal_payto_uri", "name"), ) } } \ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/ExchangeDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/ExchangeDAO.kt @@ -40,6 +40,7 @@ class ExchangeDAO(private val db: Database) { ,(amount).val AS amount_val ,(amount).frac AS amount_frac ,debtor_payto_uri + ,debtor_name ,reserve_pub FROM taler_exchange_incoming AS tfr JOIN bank_account_transactions AS txs @@ -50,7 +51,7 @@ class ExchangeDAO(private val db: Database) { row_id = it.getLong("bank_transaction_id"), date = it.getTalerTimestamp("transaction_date"), amount = it.getAmount("amount", db.bankCurrency), - debit_account = it.getString("debtor_payto_uri"), + debit_account = it.getFullPayto("debtor_payto_uri", "debtor_name"), reserve_pub = EddsaPublicKey(it.getBytes("reserve_pub")), ) } @@ -67,6 +68,7 @@ class ExchangeDAO(private val db: Database) { ,(amount).val AS amount_val ,(amount).frac AS amount_frac ,creditor_payto_uri + ,creditor_name ,wtid ,exchange_base_url FROM taler_exchange_outgoing AS tfr @@ -78,7 +80,7 @@ class ExchangeDAO(private val db: Database) { row_id = it.getLong("bank_transaction_id"), date = it.getTalerTimestamp("transaction_date"), amount = it.getAmount("amount", db.bankCurrency), - credit_account = it.getString("creditor_payto_uri"), + credit_account = it.getFullPayto("creditor_payto_uri", "creditor_name"), wtid = ShortHashCode(it.getBytes("wtid")), exchange_base_url = it.getString("exchange_base_url") ) diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/TransactionDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/TransactionDAO.kt @@ -145,7 +145,9 @@ class TransactionDAO(private val db: Database) { val stmt = conn.prepareStatement(""" SELECT creditor_payto_uri + ,creditor_name ,debtor_payto_uri + ,debtor_name ,subject ,(amount).val AS amount_val ,(amount).frac AS amount_frac @@ -161,8 +163,8 @@ class TransactionDAO(private val db: Database) { stmt.setString(2, login) stmt.oneOrNull { BankAccountTransactionInfo( - creditor_payto_uri = it.getString("creditor_payto_uri"), - debtor_payto_uri = it.getString("debtor_payto_uri"), + creditor_payto_uri = it.getFullPayto("creditor_payto_uri", "creditor_name"), + debtor_payto_uri = it.getFullPayto("debtor_payto_uri", "debtor_name"), amount = it.getAmount("amount", db.bankCurrency), direction = TransactionDirection.valueOf(it.getString("direction")), subject = it.getString("subject"), @@ -184,7 +186,9 @@ class TransactionDAO(private val db: Database) { ,(amount).val AS amount_val ,(amount).frac AS amount_frac ,debtor_payto_uri + ,debtor_name ,creditor_payto_uri + ,creditor_name ,subject ,direction FROM bank_account_transactions WHERE @@ -192,8 +196,8 @@ class TransactionDAO(private val db: Database) { BankAccountTransactionInfo( row_id = it.getLong("bank_transaction_id"), date = it.getTalerTimestamp("transaction_date"), - debtor_payto_uri = it.getString("debtor_payto_uri"), - creditor_payto_uri = it.getString("creditor_payto_uri"), + creditor_payto_uri = it.getFullPayto("creditor_payto_uri", "creditor_name"), + debtor_payto_uri = it.getFullPayto("debtor_payto_uri", "debtor_name"), amount = it.getAmount("amount", db.bankCurrency), subject = it.getString("subject"), direction = TransactionDirection.valueOf(it.getString("direction")) @@ -213,6 +217,7 @@ class TransactionDAO(private val db: Database) { ,(amount).val AS amount_val ,(amount).frac AS amount_frac ,debtor_payto_uri + ,debtor_name ,subject FROM bank_account_transactions WHERE direction='credit' AND """) { @@ -220,7 +225,7 @@ class TransactionDAO(private val db: Database) { row_id = it.getLong("bank_transaction_id"), date = it.getTalerTimestamp("transaction_date"), amount = it.getAmount("amount", db.bankCurrency), - debit_account = it.getString("debtor_payto_uri"), + debit_account = it.getFullPayto("debtor_payto_uri", "debtor_name"), subject = it.getString("subject") ) } diff --git a/bank/src/test/kotlin/CoreBankApiTest.kt b/bank/src/test/kotlin/CoreBankApiTest.kt @@ -206,7 +206,7 @@ class CoreBankAccountsApiTest { client.post("/accounts") { json(req) }.assertOkJson<RegisterAccountResponse> { - assertEquals(IbanPayto.canonical, it.internal_payto_uri) + assertEquals(IbanPayto.withName("Jane").full, it.internal_payto_uri.full) } // Testing idempotency client.post("/accounts") { @@ -902,10 +902,10 @@ class CoreBankTransactionsApiTest { } }.assertConflict(TalerErrorCode.BANK_SAME_ACCOUNT) // Transaction to admin - val adminPayto = db.account.bankInfo("admin")!!.internalPaytoUri + val adminPayto = db.account.bankInfo("admin")!!.payto client.postA("/accounts/merchant/transactions") { json(valid_req) { - "payto_uri" to "$adminPayto?message=payout" + "payto_uri" to "${adminPayto.payto}?message=payout" } }.assertConflict(TalerErrorCode.BANK_ADMIN_CREDITOR) diff --git a/common/src/main/kotlin/DB.kt b/common/src/main/kotlin/DB.kt @@ -337,4 +337,11 @@ fun ResultSet.getAmount(name: String, currency: String): TalerAmount{ getInt("${name}_frac"), currency ) +} + +fun ResultSet.getFullPayto(payto: String, name: String): FullIbanPayto{ + return FullIbanPayto( + IbanPayto(getString(payto)), + getString(name), + ) } \ No newline at end of file diff --git a/common/src/main/kotlin/TalerCommon.kt b/common/src/main/kotlin/TalerCommon.kt @@ -102,6 +102,31 @@ class TalerAmount { } } +@JvmInline +value class IBAN private constructor(val value: String) { + override fun toString(): String = value + + companion object { + private val SEPARATOR = Regex("[\\ \\-]"); + + fun parse(raw: String): IBAN { + val iban: String = raw.uppercase().replace(SEPARATOR, "") + val builder = StringBuilder(iban.length + iban.asSequence().map { if (it.isDigit()) 1 else 2 }.sum()) + (iban.subSequence(4, iban.length).asSequence() + iban.subSequence(0, 4).asSequence()).forEach { + if (it.isDigit()) { + builder.append(it) + } else { + builder.append((it.code - 'A'.code) + 10) + } + } + val str = builder.toString() + val mod = str.toBigInteger().mod(97.toBigInteger()).toInt(); + if (mod != 1) throw CommonError.IbanPayto("Iban malformed, modulo is $mod expected 1") + return IBAN(iban) + } + } +} + sealed class PaytoUri { abstract val amount: TalerAmount? @@ -115,11 +140,13 @@ sealed class PaytoUri { class IbanPayto: PaytoUri { val parsed: URI val canonical: String - val iban: String + val iban: IBAN override val amount: TalerAmount? override val message: String? override val receiverName: String? + // TODO maybe add a fster builder that performs less expensive checks when the payto is from the database ? + constructor(raw: String) { try { parsed = URI(raw) @@ -136,8 +163,7 @@ class IbanPayto: PaytoUri { 2 -> splitPath[1] else -> throw CommonError.IbanPayto("too many path segments") } - iban = rawIban.uppercase().replace(SEPARATOR, "") - checkIban(iban) + iban = IBAN.parse(rawIban) canonical = "payto://iban/$iban" val params = (parsed.query ?: "").parseUrlEncodedParameters(); @@ -146,20 +172,21 @@ class IbanPayto: PaytoUri { receiverName = params["receiver-name"] } + /** Full IBAN payto with receiver-name parameter set to [name] */ + fun withName(name: String): FullIbanPayto = FullIbanPayto(this, name) + /** Full IBAN payto with receiver-name parameter if present */ fun maybeFull(): FullIbanPayto? { - return FullIbanPayto(this, receiverName ?: return null) + return withName(receiverName ?: return null) } + /** Full IBAN payto with receiver-name parameter set to [defaultName] if absent */ + fun full(defaultName: String): FullIbanPayto = withName(receiverName ?: defaultName) + /** Full IBAN payto with receiver-name parameter if present, fail if absent */ fun requireFull(): FullIbanPayto { return maybeFull() ?: throw Exception("Missing receiver-name") - } - - /** Full IBAN payto with receiver-name parameter set to [defaultName] if absent */ - fun full(defaultName: String): FullIbanPayto { - return FullIbanPayto(this, receiverName ?: defaultName) - } + } override fun toString(): String = canonical @@ -175,26 +202,24 @@ class IbanPayto: PaytoUri { return IbanPayto(decoder.decodeString()) } } - - companion object { - private val SEPARATOR = Regex("[\\ \\-]"); - - fun checkIban(iban: String) { - val builder = StringBuilder(iban.length + iban.asSequence().map { if (it.isDigit()) 1 else 2 }.sum()) - (iban.subSequence(4, iban.length).asSequence() + iban.subSequence(0, 4).asSequence()).forEach { - if (it.isDigit()) { - builder.append(it) - } else { - builder.append((it.code - 'A'.code) + 10) - } - } - val str = builder.toString() - val mod = str.toBigInteger().mod(97.toBigInteger()).toInt(); - if (mod != 1) throw CommonError.IbanPayto("Iban malformed, modulo is $mod expected 1") - } - } } +@Serializable(with = FullIbanPayto.Serializer::class) class FullIbanPayto(val payto: IbanPayto, val receiverName: String) { val full = payto.canonical + "?receiver-name=" + receiverName.encodeURLParameter() + + override fun toString(): String = full + + internal object Serializer : KSerializer<FullIbanPayto> { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("IbanPayto", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: FullIbanPayto) { + encoder.encodeString(value.full) + } + + override fun deserialize(decoder: Decoder): FullIbanPayto { + return IbanPayto(decoder.decodeString()).requireFull() + } + } } \ No newline at end of file diff --git a/common/src/test/kotlin/PaytoTest.kt b/common/src/test/kotlin/PaytoTest.kt @@ -32,10 +32,10 @@ class PaytoTest { @Test fun parsePaytoTest() { val withBic = IbanPayto("payto://iban/BIC123/CH9300762011623852957?receiver-name=The%20Name") - assertEquals(withBic.iban, "CH9300762011623852957") + assertEquals(withBic.iban.value, "CH9300762011623852957") assertEquals(withBic.receiverName, "The Name") val complete = IbanPayto("payto://iban/BIC123/CH9300762011623852957?sender-name=The%20Name&amount=EUR:1&message=donation") - assertEquals(withBic.iban, "CH9300762011623852957") + assertEquals(withBic.iban.value, "CH9300762011623852957") assertEquals(withBic.receiverName, "The Name") assertEquals(complete.message, "donation") assertEquals(complete.amount.toString(), "EUR:1") diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt @@ -113,7 +113,7 @@ fun createPain001( text(amountWithoutCurrency) } el("Cdtr/Nm", creditAccount.receiverName) - el("CdtrAcct/Id/IBAN", creditAccount.payto.iban) + el("CdtrAcct/Id/IBAN", creditAccount.payto.iban.value) el("RmtInf/Ustrd", wireTransferSubject) } }