diff options
author | Antoine A <> | 2024-01-23 19:38:32 +0100 |
---|---|---|
committer | Antoine A <> | 2024-01-23 19:38:32 +0100 |
commit | 51300a1a75c137c215189dff8ca38e53d9ec5dea (patch) | |
tree | 321a2b6d2b98553c4ac805adb759efbaedcbf3c5 | |
parent | bb7e455b0f71ba1870f4233f58bcb4bd4fbf05ed (diff) | |
download | libeufin-51300a1a75c137c215189dff8ca38e53d9ec5dea.tar.gz libeufin-51300a1a75c137c215189dff8ca38e53d9ec5dea.tar.bz2 libeufin-51300a1a75c137c215189dff8ca38e53d9ec5dea.zip |
Share IbanPayto logic and improve full IBAN payto logic
22 files changed, 240 insertions, 353 deletions
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt index 099d4d82..d17842fe 100644 --- 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, IbanPayto> { // Prohibit reserved usernames: if (RESERVED_ACCOUNTS.contains(req.username)) throw conflict( @@ -183,7 +183,7 @@ suspend fun createAccount(db: Database, ctx: BankConfig, req: RegisterAccountReq var retry = if (req.payto_uri == null) IBAN_ALLOCATION_RETRY_COUNTER else 0 while (true) { - val internalPayto = req.payto_uri ?: IbanPayTo(genIbanPaytoUri()) + val internalPayto = req.payto_uri ?: IbanPayto(genIbanPaytoUri()) val res = db.account.create( login = req.username, name = req.name, diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Main.kt b/bank/src/main/kotlin/tech/libeufin/bank/Main.kt index c2f4e8b1..575efe6c 100644 --- a/bank/src/main/kotlin/tech/libeufin/bank/Main.kt +++ b/bank/src/main/kotlin/tech/libeufin/bank/Main.kt @@ -179,6 +179,7 @@ fun Application.corebankWebApp(db: Database, ctx: BankConfig) { rootCause is CommonError -> when (rootCause) { is CommonError.AmountFormat -> TalerErrorCode.BANK_BAD_FORMAT_AMOUNT is CommonError.AmountNumberTooBig -> TalerErrorCode.BANK_NUMBER_TOO_BIG + is CommonError.IbanPayto -> TalerErrorCode.GENERIC_JSON_INVALID } else -> TalerErrorCode.GENERIC_JSON_INVALID } @@ -364,7 +365,7 @@ class EditAccount : CliktCommand( private val email: String? by option(help = "E-Mail address used for TAN transmission") private val phone: String? by option(help = "Phone number used for TAN transmission") private val tan_channel: String? by option(help = "which channel TAN challenges should be sent to") - private val cashout_payto_uri: IbanPayTo? by option(help = "Payto URI of a fiant account who receive cashout amount").convert { IbanPayTo(it) } + private val cashout_payto_uri: IbanPayto? by option(help = "Payto URI of a fiant account who receive cashout amount").convert { IbanPayto(it) } private val debit_threshold: TalerAmount? by option(help = "Max debit allowed for this account").convert { TalerAmount(it) } override fun run() = cliCmd(logger, common.log) { @@ -425,13 +426,13 @@ class CreateAccountOption: OptionGroup() { ).flag() val email: String? by option(help = "E-Mail address used for TAN transmission") val phone: String? by option(help = "Phone number used for TAN transmission") - val cashout_payto_uri: IbanPayTo? by option( + val cashout_payto_uri: IbanPayto? by option( help = "Payto URI of a fiant account who receive cashout amount" - ).convert { IbanPayTo(it) } - val internal_payto_uri: IbanPayTo? by option(hidden = true).convert { IbanPayTo(it) } - val payto_uri: IbanPayTo? by option( + ).convert { IbanPayto(it) } + val internal_payto_uri: IbanPayto? by option(hidden = true).convert { IbanPayto(it) } + val payto_uri: IbanPayto? by option( help = "Payto URI of this account" - ).convert { IbanPayTo(it) } + ).convert { IbanPayto(it) } val debit_threshold: TalerAmount? by option( help = "Max debit allowed for this account") .convert { TalerAmount(it) } diff --git a/bank/src/main/kotlin/tech/libeufin/bank/TalerCommon.kt b/bank/src/main/kotlin/tech/libeufin/bank/TalerCommon.kt index 40736d09..389ebc8b 100644 --- a/bank/src/main/kotlin/tech/libeufin/bank/TalerCommon.kt +++ b/bank/src/main/kotlin/tech/libeufin/bank/TalerCommon.kt @@ -319,88 +319,4 @@ class ExchangeUrl { return ExchangeUrl(decoder.decodeString()) } } -} - -sealed class PaytoUri { - abstract val amount: TalerAmount? - abstract val message: String? - abstract val receiverName: String? -} - -// TODO x-taler-bank Payto - -@Serializable(with = IbanPayTo.Serializer::class) -class IbanPayTo: PaytoUri { - val parsed: URI - val canonical: String - val iban: String - override val amount: TalerAmount? - override val message: String? - override val receiverName: String? - - constructor(raw: String) { - parsed = URI(raw) - require(parsed.scheme == "payto") { "expect a payto URI" } - require(parsed.host == "iban") { "expect a IBAN payto URI" } - - val splitPath = parsed.path.split("/").filter { it.isNotEmpty() } - require(splitPath.size < 3 && splitPath.isNotEmpty()) { "too many path segments" } - val rawIban = if (splitPath.size == 1) splitPath[0] else splitPath[1] - iban = rawIban.uppercase().replace(SEPARATOR, "") - checkIban(iban) - canonical = "payto://iban/$iban" - - val params = (parsed.query ?: "").parseUrlEncodedParameters(); - amount = params["amount"]?.run { TalerAmount(this) } - message = params["message"] - receiverName = params["receiver-name"] - } - - /** Canonical IBAN payto with receiver-name parameter if present */ - fun maybeFull(): String { - return canonical + if (receiverName != null) ("?receiver-name=" + receiverName.encodeURLParameter()) else "" - } - - /** Canonical IBAN payto with receiver-name parameter, fail if absent */ - fun expectFull(): String { - return canonical + "?receiver-name=" + receiverName!!.encodeURLParameter() - } - - /** Canonical IBAN payto with receiver-name parameter set to [defaultName] if absent */ - fun fullOptName(defaultName: String): String { - return canonical + "?receiver-name=" + (receiverName ?: defaultName).encodeURLParameter() - } - - override fun toString(): String = canonical - - internal object Serializer : KSerializer<IbanPayTo> { - override val descriptor: SerialDescriptor = - PrimitiveSerialDescriptor("IbanPayTo", PrimitiveKind.STRING) - - override fun serialize(encoder: Encoder, value: IbanPayTo) { - encoder.encodeString(value.parsed.toString()) - } - - override fun deserialize(decoder: Decoder): IbanPayTo { - 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 badRequest("Iban malformed, modulo is $mod expected 1") - } - } -} +}
\ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt b/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt index 170881e4..b4857085 100644 --- a/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt +++ b/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt @@ -172,8 +172,8 @@ data class RegisterAccountRequest( val is_public: Boolean = false, val is_taler_exchange: Boolean = false, val contact_data: ChallengeContactData? = null, - val cashout_payto_uri: IbanPayTo? = null, - val payto_uri: IbanPayTo? = null, + val cashout_payto_uri: IbanPayto? = null, + val payto_uri: IbanPayto? = null, val debit_threshold: TalerAmount? = null, val tan_channel: TanChannel? = null, ) @@ -189,7 +189,7 @@ data class RegisterAccountResponse( @Serializable data class AccountReconfiguration( val contact_data: ChallengeContactData? = null, - val cashout_payto_uri: Option<IbanPayTo?> = Option.None, + val cashout_payto_uri: Option<IbanPayto?> = Option.None, val name: String? = null, val is_public: Boolean? = null, val debit_threshold: TalerAmount? = null, @@ -358,7 +358,7 @@ data class AccountData( @Serializable data class TransactionCreateRequest( - val payto_uri: IbanPayTo, + val payto_uri: IbanPayto, val amount: TalerAmount? ) @@ -440,7 +440,7 @@ data class BankWithdrawalOperationStatus( @Serializable data class BankWithdrawalOperationPostRequest( val reserve_pub: EddsaPublicKey, - val selected_exchange: IbanPayTo, + val selected_exchange: IbanPayto, ) /** @@ -523,7 +523,7 @@ data class ConversionResponse( data class AddIncomingRequest( val amount: TalerAmount, val reserve_pub: EddsaPublicKey, - val debit_account: IbanPayTo + val debit_account: IbanPayto ) /** @@ -611,7 +611,7 @@ data class TransferRequest( val amount: TalerAmount, val exchange_base_url: ExchangeUrl, val wtid: ShortHashCode, - val credit_account: IbanPayTo + val credit_account: IbanPayto ) /** diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt index 5b7db6ad..cfd65156 100644 --- a/bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt +++ b/bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt @@ -41,8 +41,8 @@ class AccountDAO(private val db: Database) { name: String, email: String?, phone: String?, - cashoutPayto: IbanPayTo?, - internalPaytoUri: IbanPayTo, + cashoutPayto: IbanPayto?, + internalPaytoUri: IbanPayto, isPublic: Boolean, isTalerExchange: Boolean, maxDebt: TalerAmount, @@ -71,7 +71,7 @@ class AccountDAO(private val db: Database) { setString(1, name) setString(2, email) setString(3, phone) - setString(4, cashoutPayto?.fullOptName(name)) + setString(4, cashoutPayto?.full(name)?.full) setBoolean(5, checkPaytoIdempotent) setString(6, internalPaytoUri.canonical) setBoolean(7, isPublic) @@ -122,7 +122,7 @@ class AccountDAO(private val db: Database) { setString(3, name) setString(4, email) setString(5, phone) - setString(6, cashoutPayto?.fullOptName(name)) + setString(6, cashoutPayto?.full(name)?.full) setString(7, tanChannel?.name) oneOrNull { it.getLong("customer_id") }!! } @@ -223,7 +223,7 @@ class AccountDAO(private val db: Database) { suspend fun reconfig( login: String, name: String?, - cashoutPayto: Option<IbanPayTo?>, + cashoutPayto: Option<IbanPayto?>, phone: Option<String?>, email: Option<String?>, tan_channel: Option<TanChannel?>, @@ -292,12 +292,12 @@ class AccountDAO(private val db: Database) { null -> null } // Cashout payto with a receiver-name using if receiver-name is missing the new named if present or the current one - val cashoutPaytoNamed = cashoutPayto.get()?.fullOptName(name ?: curr.name) + val fullCashoutPayto = cashoutPayto.get()?.full(name ?: curr.name) // Check reconfig rights if (checkName && name != curr.name) return@transaction AccountPatchResult.NonAdminName - if (checkCashout && cashoutPaytoNamed != curr.cashoutPayTo) + if (checkCashout && fullCashoutPayto?.full != curr.cashoutPayTo) return@transaction AccountPatchResult.NonAdminCashout if (checkDebtLimit && debtLimit != curr.debtLimit) return@transaction AccountPatchResult.NonAdminDebtLimit @@ -352,7 +352,7 @@ class AccountDAO(private val db: Database) { }, "WHERE customer_id = ?", sequence { - cashoutPayto.some { yield(cashoutPaytoNamed) } + cashoutPayto.some { yield(fullCashoutPayto?.full) } phone.some { yield(it) } email.some { yield(it) } tan_channel.some { yield(it?.name) } diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/TransactionDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/TransactionDAO.kt index b4f5c025..b6038dc5 100644 --- a/bank/src/main/kotlin/tech/libeufin/bank/db/TransactionDAO.kt +++ b/bank/src/main/kotlin/tech/libeufin/bank/db/TransactionDAO.kt @@ -43,7 +43,7 @@ class TransactionDAO(private val db: Database) { /** Create a new transaction */ suspend fun create( - creditAccountPayto: IbanPayTo, + creditAccountPayto: IbanPayto, debitAccountUsername: String, subject: String, amount: TalerAmount, diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/WithdrawalDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/WithdrawalDAO.kt index 13dcbff3..72b92490 100644 --- a/bank/src/main/kotlin/tech/libeufin/bank/db/WithdrawalDAO.kt +++ b/bank/src/main/kotlin/tech/libeufin/bank/db/WithdrawalDAO.kt @@ -102,7 +102,7 @@ class WithdrawalDAO(private val db: Database) { /** Set details ([exchangePayto] & [reservePub]) for withdrawal operation [uuid] */ suspend fun setDetails( uuid: UUID, - exchangePayto: IbanPayTo, + exchangePayto: IbanPayto, reservePub: EddsaPublicKey ): WithdrawalSelectionResult = db.serializable { conn -> val stmt = conn.prepareStatement(""" diff --git a/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt b/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt index 16799103..b40223c6 100644 --- a/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt +++ b/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt @@ -125,7 +125,7 @@ suspend fun maybeCreateAdminAccount(db: Database, ctx: BankConfig, pw: String? = login = "admin", password = pwStr, name = "Bank administrator", - internalPaytoUri = IbanPayTo(genIbanPaytoUri()), + internalPaytoUri = IbanPayto(genIbanPaytoUri()), isPublic = false, isTalerExchange = false, maxDebt = ctx.defaultDebtLimit, diff --git a/bank/src/test/kotlin/CoreBankApiTest.kt b/bank/src/test/kotlin/CoreBankApiTest.kt index 0df15208..bd001feb 100644 --- a/bank/src/test/kotlin/CoreBankApiTest.kt +++ b/bank/src/test/kotlin/CoreBankApiTest.kt @@ -193,20 +193,20 @@ class CoreBankAccountsApiTest { } // Check given payto - val ibanPayto = IbanPayTo(genIbanPaytoUri()) + val IbanPayto = IbanPayto(genIbanPaytoUri()) val req = obj { "username" to "foo" "password" to "password" "name" to "Jane" "is_public" to true - "payto_uri" to ibanPayto + "payto_uri" to IbanPayto "is_taler_exchange" to true } // Check Ok client.post("/accounts") { json(req) }.assertOkJson<RegisterAccountResponse> { - assertEquals(ibanPayto.canonical, it.internal_payto_uri) + assertEquals(IbanPayto.canonical, it.internal_payto_uri) } // Testing idempotency client.post("/accounts") { @@ -303,13 +303,13 @@ class CoreBankAccountsApiTest { "username" to "cashout_guess" "password" to "cashout_guess-password" "name" to "Mr Guess My Name" - "cashout_payto_uri" to ibanPayto + "cashout_payto_uri" to IbanPayto } }.assertOk() client.getA("/accounts/cashout_guess").assertOkJson<AccountData> { - assertEquals(ibanPayto.fullOptName("Mr Guess My Name"), it.cashout_payto_uri) + assertEquals(IbanPayto.full("Mr Guess My Name").full, it.cashout_payto_uri) } - val full = ibanPayto.fullOptName("Santa Claus") + val full = IbanPayto.full("Santa Claus").full client.post("/accounts") { json { "username" to "cashout_keep" @@ -486,7 +486,7 @@ class CoreBankAccountsApiTest { } // Successful attempt now - val cashout = IbanPayTo(genIbanPaytoUri()) + val cashout = IbanPayto(genIbanPaytoUri()) val req = obj { "cashout_payto_uri" to cashout "name" to "Roger" @@ -519,7 +519,7 @@ class CoreBankAccountsApiTest { // Check patch client.getA("/accounts/merchant").assertOkJson<AccountData> { obj -> assertEquals("Roger", obj.name) - assertEquals(cashout.fullOptName(obj.name), obj.cashout_payto_uri) + assertEquals(cashout.full(obj.name).full, obj.cashout_payto_uri) assertEquals("+99", obj.contact_data?.phone?.get()) assertEquals("foo@example.com", obj.contact_data?.email?.get()) assertEquals(TalerAmount("KUDOS:100"), obj.debit_threshold) @@ -533,7 +533,7 @@ class CoreBankAccountsApiTest { }.assertNoContent() client.getA("/accounts/merchant").assertOkJson<AccountData> { obj -> assertEquals("Roger", obj.name) - assertEquals(cashout.fullOptName(obj.name), obj.cashout_payto_uri) + assertEquals(cashout.full(obj.name).full, obj.cashout_payto_uri) assertEquals("+99", obj.contact_data?.phone?.get()) assertEquals("foo@example.com", obj.contact_data?.email?.get()) assertEquals(TalerAmount("KUDOS:100"), obj.debit_threshold) @@ -557,10 +557,10 @@ class CoreBankAccountsApiTest { } }.assertOk() for ((cashout, name, expect) in listOf( - Triple(cashout.canonical, null, cashout.fullOptName("Mr Cashout Cashout")), - Triple(cashout.canonical, "New name", cashout.fullOptName("New name")), - Triple(cashout.fullOptName("Full name"), null, cashout.fullOptName("Full name")), - Triple(cashout.fullOptName("Full second name"), "Another name", cashout.fullOptName("Full second name")) + Triple(cashout.canonical, null, cashout.full("Mr Cashout Cashout").full), + Triple(cashout.canonical, "New name", cashout.full("New name").full), + Triple(cashout.full("Full name").full, null, cashout.full("Full name").full), + Triple(cashout.full("Full second name").full, "Another name", cashout.full("Full second name").full) )) { client.patch("/accounts/cashout") { pwAuth("admin") diff --git a/bank/src/test/kotlin/DatabaseTest.kt b/bank/src/test/kotlin/DatabaseTest.kt index f03e151c..01cb0dd2 100644 --- a/bank/src/test/kotlin/DatabaseTest.kt +++ b/bank/src/test/kotlin/DatabaseTest.kt @@ -168,40 +168,6 @@ class DatabaseTest { assertEquals(Triple(true, false, false), cTry(this, "new-code", expired)) } }} - - // Testing iban payto uri normalization - @Test - fun ibanPayto() = setup { _, _ -> - val canonical = "payto://iban/CH9300762011623852957" - val inputs = listOf( - "payto://iban/BIC/CH9300762011623852957?receiver-name=NotGiven", - "payto://iban/CH9300762011623852957?receiver-name=Grothoff%20Hans", - "payto://iban/ch%209300-7620-1162-3852-957", - ) - val names = listOf( - "NotGiven", "Grothoff Hans", null - ) - val full = listOf( - "payto://iban/CH9300762011623852957?receiver-name=NotGiven", - "payto://iban/CH9300762011623852957?receiver-name=Grothoff%20Hans", - canonical - ) - for ((i, input) in inputs.withIndex()) { - val payto = IbanPayTo(input) - assertEquals(canonical, payto.canonical) - assertEquals(full[i], payto.maybeFull()) - assertEquals(names[i], payto.receiverName) - } - - assertEquals( - "payto://iban/CH9300762011623852957?receiver-name=Grothoff%20Hans", - IbanPayTo("payto://iban/CH9300762011623852957?receiver-name=Grothoff%20Hans").fullOptName("Santa Claus") - ) - assertEquals( - "payto://iban/CH9300762011623852957?receiver-name=Santa%20Claus", - IbanPayTo("payto://iban/CH9300762011623852957").fullOptName("Santa Claus") - ) - } } diff --git a/bank/src/test/kotlin/helpers.kt b/bank/src/test/kotlin/helpers.kt index fac02408..69c0ede9 100644 --- a/bank/src/test/kotlin/helpers.kt +++ b/bank/src/test/kotlin/helpers.kt @@ -36,19 +36,19 @@ import tech.libeufin.common.* /* ----- Setup ----- */ -val merchantPayto = IbanPayTo(genIbanPaytoUri()) -val exchangePayto = IbanPayTo(genIbanPaytoUri()) -val customerPayto = IbanPayTo(genIbanPaytoUri()) -val unknownPayto = IbanPayTo(genIbanPaytoUri()) -var tmpPayTo = IbanPayTo(genIbanPaytoUri()) +val merchantPayto = IbanPayto(genIbanPaytoUri()) +val exchangePayto = IbanPayto(genIbanPaytoUri()) +val customerPayto = IbanPayto(genIbanPaytoUri()) +val unknownPayto = IbanPayto(genIbanPaytoUri()) +var tmpPayTo = IbanPayto(genIbanPaytoUri()) val paytos = mapOf( "merchant" to merchantPayto, "exchange" to exchangePayto, "customer" to customerPayto ) -fun genTmpPayTo(): IbanPayTo { - tmpPayTo = IbanPayTo(genIbanPaytoUri()) +fun genTmpPayTo(): IbanPayto { + tmpPayTo = IbanPayto(genIbanPaytoUri()) return tmpPayTo } diff --git a/common/src/main/kotlin/Cli.kt b/common/src/main/kotlin/Cli.kt index d0a61e7e..2db1e906 100644 --- a/common/src/main/kotlin/Cli.kt +++ b/common/src/main/kotlin/Cli.kt @@ -38,11 +38,11 @@ fun cliCmd(logger: Logger, level: Level, lambda: () -> Unit) { try { lambda() } catch (e: Throwable) { - var msg = StringBuilder(e.message) + var msg = StringBuilder(e.message ?: e::class.simpleName) var cause = e.cause; while (cause != null) { msg.append(": ") - msg.append(cause.message) + msg.append(cause.message ?: cause::class.simpleName) cause = cause.cause } logger.error(msg.toString()) diff --git a/common/src/main/kotlin/IbanPayto.kt b/common/src/main/kotlin/IbanPayto.kt deleted file mode 100644 index d3ab7576..00000000 --- a/common/src/main/kotlin/IbanPayto.kt +++ /dev/null @@ -1,104 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2024 Taler Systems S.A. - - * LibEuFin is free software; you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation; either version 3, or - * (at your option) any later version. - - * LibEuFin is distributed in the hope that it will be useful, but - * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY - * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General - * Public License for more details. - - * You should have received a copy of the GNU Affero General Public - * License along with LibEuFin; see the file COPYING. If not, see - * <http://www.gnu.org/licenses/> - */ - -package tech.libeufin.common - -import org.slf4j.Logger -import org.slf4j.LoggerFactory -import java.net.URI -import java.net.URLDecoder - -private val logger: Logger = LoggerFactory.getLogger("libeufin-common") - -// Payto information. -data class IbanPayto( - // represent query param "sender-name" or "receiver-name". - val receiverName: String?, - val iban: String, - val bic: String?, - // Typically, a wire transfer's subject. - val message: String?, - val amount: String? -) - -// Return the value of query string parameter 'name', or null if not found. -// 'params' is the list of key-value elements of all the query parameters found in the URI. -private fun getQueryParamOrNull(name: String, params: List<Pair<String, String>>?): String? { - if (params == null) return null - return params.firstNotNullOfOrNull { pair -> - URLDecoder.decode(pair.second, Charsets.UTF_8).takeIf { pair.first == name } - } -} - -// Parses a Payto URI, returning null if the input is invalid. -fun parsePayto(payto: String): IbanPayto? { - /** - * This check is due because URIs having a "payto:" prefix without - * slashes are correctly parsed by the Java 'URI' class. 'mailto' - * for example lacks the double-slash part. - */ - if (!payto.startsWith("payto://")) { - logger.error("Invalid payto URI: $payto") - return null - } - - val javaParsedUri = try { - URI(payto) - } catch (e: java.lang.Exception) { - logger.error("'${payto}' is not a valid URI") - return null - } - if (javaParsedUri.scheme != "payto") { - logger.error("'${payto}' is not payto") - return null - } - val wireMethod = javaParsedUri.host - if (wireMethod != "iban") { - logger.error("Only 'iban' is supported, not '$wireMethod'") - return null - } - val splitPath = javaParsedUri.path.split("/").filter { it.isNotEmpty() } - if (splitPath.size > 2) { - logger.error("too many path segments in iban payto URI: $payto") - return null - } - val (iban, bic) = if (splitPath.size == 1) { - Pair(splitPath[0], null) - } else Pair(splitPath[1], splitPath[0]) - - val params: List<Pair<String, String>>? = if (javaParsedUri.query != null) { - val queryString: List<String> = javaParsedUri.query.split("&") - queryString.map { - val split = it.split("="); - if (split.size != 2) { - logger.error("parameter '$it' was malformed") - return null - } - Pair(split[0], split[1]) - } - } else null - - return IbanPayto( - iban = iban, - bic = bic, - amount = getQueryParamOrNull("amount", params), - message = getQueryParamOrNull("message", params), - receiverName = getQueryParamOrNull("receiver-name", params) - ) -}
\ No newline at end of file diff --git a/common/src/main/kotlin/TalerCommon.kt b/common/src/main/kotlin/TalerCommon.kt index 96d20a86..76c8d801 100644 --- a/common/src/main/kotlin/TalerCommon.kt +++ b/common/src/main/kotlin/TalerCommon.kt @@ -23,10 +23,13 @@ import kotlinx.serialization.* import kotlinx.serialization.descriptors.* import kotlinx.serialization.encoding.* import kotlinx.serialization.json.* +import io.ktor.http.* +import java.net.URI sealed class CommonError(msg: String): Exception(msg) { class AmountFormat(msg: String): CommonError(msg) class AmountNumberTooBig(msg: String): CommonError(msg) + class IbanPayto(msg: String): CommonError(msg) } @Serializable(with = TalerAmount.Serializer::class) @@ -97,4 +100,102 @@ class TalerAmount { const val MAX_VALUE = 4503599627370496L; // 2^52 private val PATTERN = Regex("([A-Z]{1,11}):([0-9]+)(?:\\.([0-9]{1,8}))?"); } +} + + +sealed class PaytoUri { + abstract val amount: TalerAmount? + abstract val message: String? + abstract val receiverName: String? +} + +// TODO x-taler-bank Payto + +@Serializable(with = IbanPayto.Serializer::class) +class IbanPayto: PaytoUri { + val parsed: URI + val canonical: String + val iban: String + override val amount: TalerAmount? + override val message: String? + override val receiverName: String? + + constructor(raw: String) { + println(raw) + try { + parsed = URI(raw) + } catch (e: Exception) { + throw CommonError.IbanPayto("expecteda valid URI") + } + + if (parsed.scheme != "payto") throw CommonError.IbanPayto("expect a payto URI") + if (parsed.host != "iban") throw CommonError.IbanPayto("expect a IBAN payto URI") + + val splitPath = parsed.path.split("/").filter { it.isNotEmpty() } + val rawIban = when (splitPath.size) { + 1 -> splitPath[0] + 2 -> splitPath[1] + else -> throw CommonError.IbanPayto("too many path segments") + } + iban = rawIban.uppercase().replace(SEPARATOR, "") + checkIban(iban) + canonical = "payto://iban/$iban" + + val params = (parsed.query ?: "").parseUrlEncodedParameters(); + amount = params["amount"]?.run { TalerAmount(this) } + message = params["message"] + receiverName = params["receiver-name"] + } + + /** Full IBAN payto with receiver-name parameter if present */ + fun maybeFull(): FullIbanPayto? { + return FullIbanPayto(this, receiverName ?: return null) + } + + /** 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 + + internal object Serializer : KSerializer<IbanPayto> { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("IbanPayto", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: IbanPayto) { + encoder.encodeString(value.parsed.toString()) + } + + override fun deserialize(decoder: Decoder): IbanPayto { + 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") + } + } +} + +class FullIbanPayto(val payto: IbanPayto, val receiverName: String) { + val full = payto.canonical + "?receiver-name=" + receiverName.encodeURLParameter() }
\ No newline at end of file diff --git a/common/src/test/kotlin/PaytoTest.kt b/common/src/test/kotlin/PaytoTest.kt index a18dafe0..e1a6b976 100644 --- a/common/src/test/kotlin/PaytoTest.kt +++ b/common/src/test/kotlin/PaytoTest.kt @@ -18,34 +18,63 @@ */ import org.junit.Test -import tech.libeufin.common.IbanPayto -import tech.libeufin.common.parsePayto +import tech.libeufin.common.* +import kotlin.test.* class PaytoTest { - @Test fun wrongCases() { - assert(parsePayto("http://iban/BIC123/IBAN123?receiver-name=The%20Name") == null) - assert(parsePayto("payto:iban/BIC123/IBAN123?receiver-name=The%20Name&address=house") == null) - assert(parsePayto("payto://wrong/BIC123/IBAN123?sender-name=Foo&receiver-name=Foo") == null) + assertFailsWith<CommonError.IbanPayto> { IbanPayto("http://iban/BIC123/IBAN123?receiver-name=The%20Name") } + assertFailsWith<CommonError.IbanPayto> { IbanPayto("payto:iban/BIC123/IBAN123?receiver-name=The%20Name&address=house") } + assertFailsWith<CommonError.IbanPayto> { IbanPayto("payto://wrong/BIC123/IBAN123?sender-name=Foo&receiver-name=Foo") } } @Test fun parsePaytoTest() { - val withBic: IbanPayto = parsePayto("payto://iban/BIC123/IBAN123?receiver-name=The%20Name")!! - assert(withBic.iban == "IBAN123") - assert(withBic.bic == "BIC123") - assert(withBic.receiverName == "The Name") - val complete = parsePayto("payto://iban/BIC123/IBAN123?sender-name=The%20Name&amount=EUR:1&message=donation")!! - assert(withBic.iban == "IBAN123") - assert(withBic.bic == "BIC123") - assert(withBic.receiverName == "The Name") - assert(complete.message == "donation") - assert(complete.amount == "EUR:1") - val withoutOptionals = parsePayto("payto://iban/IBAN123")!! - assert(withoutOptionals.bic == null) - assert(withoutOptionals.message == null) - assert(withoutOptionals.receiverName == null) - assert(withoutOptionals.amount == null) + val withBic = IbanPayto("payto://iban/BIC123/CH9300762011623852957?receiver-name=The%20Name") + assertEquals(withBic.iban, "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.receiverName, "The Name") + assertEquals(complete.message, "donation") + assertEquals(complete.amount.toString(), "EUR:1") + val withoutOptionals = IbanPayto("payto://iban/CH9300762011623852957") + assertNull(withoutOptionals.message) + assertNull(withoutOptionals.receiverName) + assertNull(withoutOptionals.amount) + } + + @Test + fun normalization() { + val canonical = "payto://iban/CH9300762011623852957" + val inputs = listOf( + "payto://iban/BIC/CH9300762011623852957?receiver-name=NotGiven", + "payto://iban/CH9300762011623852957?receiver-name=Grothoff%20Hans", + "payto://iban/ch%209300-7620-1162-3852-957", + ) + val names = listOf( + "NotGiven", "Grothoff Hans", null + ) + val full = listOf( + "payto://iban/CH9300762011623852957?receiver-name=NotGiven", + "payto://iban/CH9300762011623852957?receiver-name=Grothoff%20Hans", + canonical + ) + for ((i, input) in inputs.withIndex()) { + val payto = IbanPayto(input) + assertEquals(canonical, payto.canonical) + assertEquals(full[i], payto.maybeFull()?.full ?: payto.canonical) + assertEquals(names[i], payto.receiverName) + } + + assertEquals( + "payto://iban/CH9300762011623852957?receiver-name=Grothoff%20Hans", + IbanPayto("payto://iban/CH9300762011623852957?receiver-name=Grothoff%20Hans").full("Santa Claus").full + ) + assertEquals( + "payto://iban/CH9300762011623852957?receiver-name=Santa%20Claus", + IbanPayto("payto://iban/CH9300762011623852957").full("Santa Claus").full + ) } }
\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Database.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Database.kt index be913d38..82d493e8 100644 --- a/nexus/src/main/kotlin/tech/libeufin/nexus/Database.kt +++ b/nexus/src/main/kotlin/tech/libeufin/nexus/Database.kt @@ -88,7 +88,7 @@ enum class DatabaseSubmissionState { data class InitiatedPayment( val amount: TalerAmount, val wireTransferSubject: String, - val creditPaytoUri: String, + val creditPaytoUri: FullIbanPayto, val initiationTime: Instant, val requestUid: String ) @@ -98,16 +98,6 @@ data class InitiatedPayment( * into the database. */ enum class PaymentInitiationOutcome { - /** - * The Payto address to send the payment to was invalid. - */ - BAD_CREDIT_PAYTO, - - /** - * The receiver payto address lacks the name, that would - * cause the bank to reject the pain.001. - */ - RECEIVER_NAME_MISSING, /** * The row contains a client_request_uid that exists @@ -461,7 +451,7 @@ class Database(dbConfig: String): DbPool(dbConfig, "libeufin_nexus") { } maybeMap[rowId] = InitiatedPayment( amount = it.getAmount("amount", currency), - creditPaytoUri = it.getString("credit_payto_uri"), + creditPaytoUri = IbanPayto(it.getString("credit_payto_uri")).requireFull(), wireTransferSubject = it.getString("wire_transfer_subject"), initiationTime = initiationTime, requestUid = it.getString("request_uid") @@ -497,11 +487,7 @@ class Database(dbConfig: String): DbPool(dbConfig, "libeufin_nexus") { stmt.setLong(1, paymentData.amount.value) stmt.setInt(2, paymentData.amount.frac) stmt.setString(3, paymentData.wireTransferSubject) - parsePayto(paymentData.creditPaytoUri).apply { - if (this == null) return@conn PaymentInitiationOutcome.BAD_CREDIT_PAYTO - if (this.receiverName == null) return@conn PaymentInitiationOutcome.RECEIVER_NAME_MISSING - } - stmt.setString(4, paymentData.creditPaytoUri) + stmt.setString(4, paymentData.creditPaytoUri.full) val initiationTime = paymentData.initiationTime.toDbMicros() ?: run { throw Exception("Initiation time could not be converted to microseconds for the database.") } diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt index c43bb378..dbd178b3 100644 --- a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt +++ b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt @@ -97,18 +97,12 @@ class NexusSubmitException( private suspend fun submitInitiatedPayment( ctx: SubmissionContext, initiatedPayment: InitiatedPayment -) { - val creditor = parsePayto(initiatedPayment.creditPaytoUri) - if (creditor?.receiverName == null) - throw NexusSubmitException( - "Won't create pain.001 without the receiver name", - stage = NexusSubmissionStage.pain - ) +) { val xml = createPain001( requestUid = initiatedPayment.requestUid, initiationTimestamp = initiatedPayment.initiationTime, amount = initiatedPayment.amount, - creditAccount = creditor, + creditAccount = initiatedPayment.creditPaytoUri, debitAccount = ctx.cfg.myIbanAccount, wireTransferSubject = initiatedPayment.wireTransferSubject ) diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt index 3edfbded..005b010d 100644 --- a/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt +++ b/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt @@ -77,7 +77,7 @@ fun createPain001( debitAccount: IbanAccountMetadata, amount: TalerAmount, wireTransferSubject: String, - creditAccount: IbanPayto + creditAccount: FullIbanPayto ): String { val namespace = Pain001Namespaces( fullNamespace = "urn:iso:std:iso:20022:tech:xsd:pain.001.001.09", @@ -85,11 +85,6 @@ fun createPain001( ) val zonedTimestamp = ZonedDateTime.ofInstant(initiationTimestamp, ZoneId.of("UTC")) val amountWithoutCurrency: String = getAmountNoCurrency(amount) - val creditorName: String = creditAccount.receiverName - ?: throw NexusSubmitException( - "Cannot operate without the creditor name", - stage=NexusSubmissionStage.pain - ) return constructXml { root("Document") { attribute( @@ -157,10 +152,10 @@ fun createPain001( text(amountWithoutCurrency) } element("Cdtr/Nm") { - text(creditorName) + text(creditAccount.receiverName) } element("CdtrAcct/Id/IBAN") { - text(creditAccount.iban) + text(creditAccount.payto.iban) } element("RmtInf/Ustrd") { text(wireTransferSubject) diff --git a/nexus/src/test/kotlin/Common.kt b/nexus/src/test/kotlin/Common.kt index 7812499a..f5c2f07e 100644 --- a/nexus/src/test/kotlin/Common.kt +++ b/nexus/src/test/kotlin/Common.kt @@ -103,7 +103,7 @@ fun genInitPay( ) = InitiatedPayment( amount = TalerAmount(44, 0, "KUDOS"), - creditPaytoUri = "payto://iban/TEST-IBAN?receiver-name=Test", + creditPaytoUri = IbanPayto("payto://iban/CH9300762011623852957?receiver-name=Test").requireFull(), wireTransferSubject = subject, initiationTime = Instant.now(), requestUid = requestUid @@ -123,7 +123,7 @@ fun genInPay(subject: String) = fun genOutPay(subject: String, messageId: String) = OutgoingPayment( amount = TalerAmount(44, 0, "KUDOS"), - creditPaytoUri = "payto://iban/TEST-IBAN?receiver-name=Test", + creditPaytoUri = "payto://iban/CH9300762011623852957?receiver-name=Test", wireTransferSubject = subject, executionTime = Instant.now(), messageId = messageId diff --git a/nexus/src/test/kotlin/DatabaseTest.kt b/nexus/src/test/kotlin/DatabaseTest.kt index 1d3ffcd7..58fa9735 100644 --- a/nexus/src/test/kotlin/DatabaseTest.kt +++ b/nexus/src/test/kotlin/DatabaseTest.kt @@ -206,7 +206,7 @@ class PaymentInitiationsTest { } val initPay = InitiatedPayment( amount = TalerAmount(44, 0, "KUDOS"), - creditPaytoUri = "payto://iban/TEST-IBAN?receiver-name=Test", + creditPaytoUri = IbanPayto("payto://iban/CH9300762011623852957?receiver-name=Test").requireFull(), wireTransferSubject = "test", requestUid = "unique", initiationTime = Instant.now() diff --git a/testbench/src/main/kotlin/Main.kt b/testbench/src/main/kotlin/Main.kt index b1c549c2..fef935b9 100644 --- a/testbench/src/main/kotlin/Main.kt +++ b/testbench/src/main/kotlin/Main.kt @@ -135,7 +135,7 @@ class Cli : CliktCommand("Run integration tests on banks provider") { step("Test submit one transaction") nexusDb.initiatedPaymentCreate(InitiatedPayment( amount = TalerAmount("CFH:42"), - creditPaytoUri = payto, + creditPaytoUri = IbanPayto(payto).requireFull(), wireTransferSubject = "single transaction test", initiationTime = Instant.now(), requestUid = Base32Crockford.encode(randBytes(16)) @@ -147,7 +147,7 @@ class Cli : CliktCommand("Run integration tests on banks provider") { repeat(4) { nexusDb.initiatedPaymentCreate(InitiatedPayment( amount = TalerAmount("CFH:${100L+it}"), - creditPaytoUri = payto, + creditPaytoUri = IbanPayto(payto).requireFull(), wireTransferSubject = "multi transaction test $it", initiationTime = Instant.now(), requestUid = Base32Crockford.encode(randBytes(16)) @@ -198,7 +198,7 @@ class Cli : CliktCommand("Run integration tests on banks provider") { // TODO interactive payment editor nexusDb.initiatedPaymentCreate(InitiatedPayment( amount = TalerAmount("CFH:1.1"), - creditPaytoUri = "payto://iban/CH6208704048981247126?receiver-name=Grothoff%20Hans", + creditPaytoUri = IbanPayto("payto://iban/CH6208704048981247126?receiver-name=Grothoff%20Hans").requireFull(), wireTransferSubject = "single transaction test", initiationTime = Instant.now(), requestUid = Base32Crockford.encode(randBytes(16)) diff --git a/testbench/src/test/kotlin/IntegrationTest.kt b/testbench/src/test/kotlin/IntegrationTest.kt index 87f75baa..dd4023aa 100644 --- a/testbench/src/test/kotlin/IntegrationTest.kt +++ b/testbench/src/test/kotlin/IntegrationTest.kt @@ -101,12 +101,13 @@ class IntegrationTest { @Test fun mini() { - bankCmd.run("dbinit -c conf/mini.conf -r") - bankCmd.run("passwd admin password -c conf/mini.conf") - bankCmd.run("dbinit -c conf/mini.conf") // Indempotent + val flags = "-c conf/mini.conf -L DEBUG" + bankCmd.run("dbinit $flags -r") + bankCmd.run("passwd admin password $flags") + bankCmd.run("dbinit $flags") // Indempotent server { - bankCmd.run("serve -c conf/mini.conf") + bankCmd.run("serve $flags") } setup { _ -> @@ -117,9 +118,10 @@ class IntegrationTest { @Test fun errors() { - nexusCmd.run("dbinit -c conf/integration.conf -r") - bankCmd.run("dbinit -c conf/integration.conf -r") - bankCmd.run("passwd admin password -c conf/integration.conf") + val flags = "-c conf/integration.conf -L DEBUG" + nexusCmd.run("dbinit $flags -r") + bankCmd.run("dbinit $flags -r") + bankCmd.run("passwd admin password $flags") suspend fun checkCount(db: NexusDb, nbIncoming: Int, nbBounce: Int, nbTalerable: Int) { db.conn { conn -> @@ -136,8 +138,8 @@ class IntegrationTest { } setup { db -> - val userPayTo = IbanPayTo(genIbanPaytoUri()) - val fiatPayTo = IbanPayTo(genIbanPaytoUri()) + val userPayTo = IbanPayto(genIbanPaytoUri()) + val fiatPayTo = IbanPayto(genIbanPaytoUri()) // Load conversion setup manually as the server would refuse to start without an exchange account val sqlProcedures = File("../database-versioning/libeufin-conversion-setup.sql") @@ -160,7 +162,7 @@ class IntegrationTest { } // Create exchange account - bankCmd.run("create-account -c conf/integration.conf -u exchange -p password --name 'Mr Money' --exchange") + bankCmd.run("create-account $flags -u exchange -p password --name 'Mr Money' --exchange") assertException("ERROR: cashin currency conversion failed: missing conversion rates") { ingestIncomingPayment(db, payment) @@ -168,7 +170,7 @@ class IntegrationTest { // Start server server { - bankCmd.run("serve -c conf/integration.conf") + bankCmd.run("serve $flags") } // Set conversion rates @@ -193,7 +195,7 @@ class IntegrationTest { } // Allow admin debt - bankCmd.run("edit-account admin --debit_threshold KUDOS:100 -c conf/integration.conf") + bankCmd.run("edit-account admin --debit_threshold KUDOS:100 $flags") // Too small amount checkCount(db, 0, 0, 0) @@ -224,21 +226,22 @@ class IntegrationTest { @Test fun conversion() { - nexusCmd.run("dbinit -c conf/integration.conf -r") - bankCmd.run("dbinit -c conf/integration.conf -r") - bankCmd.run("passwd admin password -c conf/integration.conf") - bankCmd.run("edit-account admin --debit_threshold KUDOS:1000 -c conf/integration.conf") - bankCmd.run("create-account -c conf/integration.conf -u exchange -p password --name 'Mr Money' --exchange") - nexusCmd.run("dbinit -c conf/integration.conf") // Idempotent - bankCmd.run("dbinit -c conf/integration.conf") // Idempotent + val flags = "-c conf/integration.conf -L DEBUG" + nexusCmd.run("dbinit $flags -r") + bankCmd.run("dbinit $flags -r") + bankCmd.run("passwd admin password $flags") + bankCmd.run("edit-account admin --debit_threshold KUDOS:1000 $flags") + bankCmd.run("create-account $flags -u exchange -p password --name 'Mr Money' --exchange") + nexusCmd.run("dbinit $flags") // Idempotent + bankCmd.run("dbinit $flags") // Idempotent server { - bankCmd.run("serve -c conf/integration.conf") + bankCmd.run("serve $flags") } setup { db -> - val userPayTo = IbanPayTo(genIbanPaytoUri()) - val fiatPayTo = IbanPayTo(genIbanPaytoUri()) + val userPayTo = IbanPayto(genIbanPaytoUri()) + val fiatPayTo = IbanPayto(genIbanPaytoUri()) // Create user client.post("http://0.0.0.0:8080/accounts") { |