commit e8c44ca6a3b7e5d91b74f060689bc2e4389faa35 parent cd53b2f7d703319eac3ba792706f3f43d2be09ef Author: Antoine A <> Date: Fri, 2 Feb 2024 19:26:50 +0100 Prepare support for more payto URI kind Diffstat:
33 files changed, 365 insertions(+), 296 deletions(-)
diff --git a/Makefile b/Makefile @@ -114,6 +114,10 @@ nexus-test: install-nobuild-nexus-files ebics-test: ./gradlew :ebics:test --tests $(test) -i +.PHONY: common-test +common-test: + ./gradlew :common:test --tests $(test) -i + .PHONY: testbench-test testbench-test: install-nobuild-bank-files install-nobuild-nexus-files ./gradlew :testbench:test --tests $(test) -i diff --git a/bank/src/main/kotlin/tech/libeufin/bank/BankIntegrationApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/BankIntegrationApi.kt @@ -78,11 +78,11 @@ fun Routing.bankIntegrationApi(db: Database, ctx: BankConfig) { TalerErrorCode.BANK_DUPLICATE_RESERVE_PUB_SUBJECT ) is WithdrawalSelectionResult.UnknownAccount -> throw conflict( - "Account ${req.selected_exchange.canonical} not found", + "Account ${req.selected_exchange} not found", TalerErrorCode.BANK_UNKNOWN_ACCOUNT ) is WithdrawalSelectionResult.AccountIsNotExchange -> throw conflict( - "Account ${req.selected_exchange.canonical} is not an exchange", + "Account ${req.selected_exchange} is not an exchange", TalerErrorCode.BANK_ACCOUNT_IS_NOT_EXCHANGE ) is WithdrawalSelectionResult.Success -> { diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Config.kt b/bank/src/main/kotlin/tech/libeufin/bank/Config.kt @@ -49,7 +49,8 @@ data class BankConfig( val fiatCurrency: String?, val fiatCurrencySpec: CurrencySpecification?, val spaPath: Path?, - val tanChannels: Map<TanChannel, Path> + val tanChannels: Map<TanChannel, Path>, + val payto: BankPaytoCtx ) @Serializable @@ -104,6 +105,15 @@ fun TalerConfig.loadBankConfig(): BankConfig { } } } + val method = when (val raw = lookupString("libeufin-bank", "payment_method")) { + "iban" -> WireMethod.IBAN + "x-taler-bank" -> WireMethod.X_TALER_BANK + else -> throw TalerConfigError("expected wire method for section libeufin-bank, option payment_method, but $raw is unknown") + } + val payto = when (method) { + WireMethod.IBAN -> BankPaytoCtx(bic = lookupString("libeufin-bank", "iban_payto_bic")) + WireMethod.X_TALER_BANK -> BankPaytoCtx(hostname = lookupString("libeufin-bank", "x_taler_bank_payto_hostname")) + } return BankConfig( regionalCurrency = regionalCurrency, regionalCurrencySpec = currencySpecificationFor(regionalCurrency), @@ -119,7 +129,8 @@ fun TalerConfig.loadBankConfig(): BankConfig { allowConversion = allowConversion, fiatCurrency = fiatCurrency, fiatCurrencySpec = fiatCurrencySpec, - tanChannels = tanChannels + tanChannels = tanChannels, + payto = payto ) } 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,12 @@ private fun Routing.coreBankTokenApi(db: Database) { } } -suspend fun createAccount(db: Database, ctx: BankConfig, req: RegisterAccountRequest, isAdmin: Boolean): Pair<AccountCreationResult, FullIbanPayto> { +suspend fun createAccount( + db: Database, + cfg: BankConfig, + req: RegisterAccountRequest, + isAdmin: Boolean +): Pair<AccountCreationResult, String> { // Prohibit reserved usernames: if (RESERVED_ACCOUNTS.contains(req.username)) throw conflict( @@ -160,7 +165,7 @@ suspend fun createAccount(db: Database, ctx: BankConfig, req: RegisterAccountReq ) } else if (req.tan_channel != null) { - if (ctx.tanChannels.get(req.tan_channel) == null) { + if (cfg.tanChannels.get(req.tan_channel) == null) { throw unsupportedTanChannel(req.tan_channel) } val missing = when (req.tan_channel) { @@ -183,7 +188,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.rand() as Payto val res = db.account.create( login = req.username, name = req.name, @@ -194,9 +199,9 @@ suspend fun createAccount(db: Database, ctx: BankConfig, req: RegisterAccountReq internalPaytoUri = internalPayto, isPublic = req.is_public, isTalerExchange = req.is_taler_exchange, - maxDebt = req.debit_threshold ?: ctx.defaultDebtLimit, - bonus = if (!req.is_taler_exchange) ctx.registrationBonus - else TalerAmount(0, 0, ctx.regionalCurrency), + maxDebt = req.debit_threshold ?: cfg.defaultDebtLimit, + bonus = if (!req.is_taler_exchange) cfg.registrationBonus + else TalerAmount(0, 0, cfg.regionalCurrency), tanChannel = req.tan_channel, checkPaytoIdempotent = req.payto_uri != null ) @@ -205,13 +210,13 @@ suspend fun createAccount(db: Database, ctx: BankConfig, req: RegisterAccountReq retry-- continue } - return Pair(res, internalPayto.withName(req.name)) + return Pair(res, internalPayto.bank(req.name, cfg.payto)) } } suspend fun patchAccount( db: Database, - ctx: BankConfig, + cfg: BankConfig, req: AccountReconfiguration, username: String, isAdmin: Boolean, @@ -219,7 +224,7 @@ suspend fun patchAccount( channel: TanChannel? = null, info: String? = null ): AccountPatchResult { - req.debit_threshold?.run { ctx.checkRegionalCurrency(this) } + req.debit_threshold?.run { cfg.checkRegionalCurrency(this) } if (username == "admin" && req.is_public == true) throw conflict( @@ -227,7 +232,7 @@ suspend fun patchAccount( TalerErrorCode.END ) - if (req.tan_channel is Option.Some && req.tan_channel.value != null && ctx.tanChannels.get(req.tan_channel.value ) == null) { + if (req.tan_channel is Option.Some && req.tan_channel.value != null && !cfg.tanChannels.contains(req.tan_channel.value)) { throw unsupportedTanChannel(req.tan_channel.value) } @@ -244,8 +249,8 @@ suspend fun patchAccount( is2fa = is2fa, faChannel = channel, faInfo = info, - allowEditName = ctx.allowEditName, - allowEditCashout = ctx.allowEditCashout + allowEditName = cfg.allowEditName, + allowEditCashout = cfg.allowEditCashout ) } @@ -264,7 +269,7 @@ private fun Routing.coreBankAccountsApi(db: Database, ctx: BankConfig) { TalerErrorCode.BANK_REGISTER_USERNAME_REUSE ) AccountCreationResult.PayToReuse -> throw conflict( - "Bank internalPayToUri reuse '${internalPayto.payto}'", + "Bank internalPayToUri reuse '$internalPayto'", TalerErrorCode.BANK_REGISTER_PAYTO_URI_REUSE ) AccountCreationResult.Success -> call.respond(RegisterAccountResponse(internalPayto)) @@ -353,7 +358,7 @@ private fun Routing.coreBankAccountsApi(db: Database, ctx: BankConfig) { } get("/public-accounts") { val params = AccountParams.extract(call.request.queryParameters) - val publicAccounts = db.account.pagePublic(params) + val publicAccounts = db.account.pagePublic(params, ctx.payto) if (publicAccounts.isEmpty()) { call.respond(HttpStatusCode.NoContent) } else { @@ -363,7 +368,7 @@ private fun Routing.coreBankAccountsApi(db: Database, ctx: BankConfig) { authAdmin(db, TokenScope.readonly) { get("/accounts") { val params = AccountParams.extract(call.request.queryParameters) - val accounts = db.account.pageAdmin(params) + val accounts = db.account.pageAdmin(params, ctx.payto) if (accounts.isEmpty()) { call.respond(HttpStatusCode.NoContent) } else { @@ -373,7 +378,7 @@ private fun Routing.coreBankAccountsApi(db: Database, ctx: BankConfig) { } auth(db, TokenScope.readonly, allowAdmin = true) { get("/accounts/{USERNAME}") { - val account = db.account.get(username) ?: throw unknownAccount(username) + val account = db.account.get(username, ctx.payto) ?: throw unknownAccount(username) call.respond(account) } } @@ -383,10 +388,10 @@ private fun Routing.coreBankTransactionsApi(db: Database, ctx: BankConfig) { auth(db, TokenScope.readonly) { get("/accounts/{USERNAME}/transactions") { val params = HistoryParams.extract(call.request.queryParameters) - val bankAccount = call.bankInfo(db) + val bankAccount = call.bankInfo(db, ctx.payto) val history: List<BankAccountTransactionInfo> = - db.transaction.pollHistory(params, bankAccount.bankAccountId) + db.transaction.pollHistory(params, bankAccount.bankAccountId, ctx.payto) if (history.isEmpty()) { call.respond(HttpStatusCode.NoContent) } else { @@ -395,7 +400,7 @@ private fun Routing.coreBankTransactionsApi(db: Database, ctx: BankConfig) { } get("/accounts/{USERNAME}/transactions/{T_ID}") { val tId = call.longParameter("T_ID") - val tx = db.transaction.get(tId, username) ?: throw notFound( + val tx = db.transaction.get(tId, username, ctx.payto) ?: throw notFound( "Bank transaction '$tId' not found", TalerErrorCode.BANK_TRANSACTION_NOT_FOUND ) diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Main.kt b/bank/src/main/kotlin/tech/libeufin/bank/Main.kt @@ -179,7 +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 + is CommonError.Payto -> TalerErrorCode.GENERIC_JSON_INVALID } else -> TalerErrorCode.GENERIC_JSON_INVALID } @@ -264,7 +264,7 @@ class ServeBank : CliktCommand("Run libeufin-bank HTTP server", name = "serve") runBlocking { if (ctx.allowConversion) { logger.info("Ensure exchange account exists") - val info = db.account.bankInfo("exchange") + val info = db.account.bankInfo("exchange", ctx.payto) if (info == null) { throw Exception("Exchange account missing: an exchange account named 'exchange' is required for conversion to be enabled") } else if (!info.isTalerExchange) { @@ -364,7 +364,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 { Payto.parse(it).expectIban() } private val debit_threshold: TalerAmount? by option(help = "Max debit allowed for this account").convert { TalerAmount(it) } override fun run() = cliCmd(logger, common.log) { @@ -427,11 +427,10 @@ class CreateAccountOption: OptionGroup() { val phone: String? by option(help = "Phone number used for TAN transmission") 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 { Payto.parse(it).expectIban() } + val payto_uri: Payto? by option( help = "Payto URI of this account" - ).convert { IbanPayto(it) } + ).convert { Payto.parse(it) } val debit_threshold: TalerAmount? by option( help = "Max debit allowed for this account") .convert { TalerAmount(it) } @@ -477,7 +476,7 @@ class CreateAccount : CliktCommand( AccountCreationResult.LoginReuse -> throw Exception("Account username reuse '${req.username}'") AccountCreationResult.PayToReuse -> - throw Exception("Bank internalPayToUri reuse '${internalPayto.payto}'") + throw Exception("Bank internalPayToUri reuse '$internalPayto'") 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 @@ -37,8 +37,8 @@ fun Routing.revenueApi(db: Database, ctx: BankConfig) { } get("/accounts/{USERNAME}/taler-revenue/history") { val params = HistoryParams.extract(context.request.queryParameters) - val bankAccount = call.bankInfo(db) - val items = db.transaction.revenueHistory(params, bankAccount.bankAccountId); + val bankAccount = call.bankInfo(db, ctx.payto) + val items = db.transaction.revenueHistory(params, bankAccount.bankAccountId, ctx.payto); if (items.isEmpty()) { call.respond(HttpStatusCode.NoContent) diff --git a/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt b/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt @@ -80,6 +80,11 @@ enum class Operation { withdrawal } +enum class WireMethod { + IBAN, + X_TALER_BANK +} + @Serializable(with = Option.Serializer::class) sealed class Option<out T> { data object None : Option<Nothing>() @@ -173,14 +178,14 @@ data class RegisterAccountRequest( val is_taler_exchange: Boolean = false, val contact_data: ChallengeContactData? = null, val cashout_payto_uri: IbanPayto? = null, - val payto_uri: IbanPayto? = null, + val payto_uri: Payto? = null, val debit_threshold: TalerAmount? = null, val tan_channel: TanChannel? = null, ) @Serializable data class RegisterAccountResponse( - val internal_payto_uri: FullIbanPayto + val internal_payto_uri: String ) /** @@ -245,7 +250,7 @@ data class MonitorWithConversion( * from/to the database. */ data class BankInfo( - val payto: FullIbanPayto, + val payto: String, val bankAccountId: Long, val isTalerExchange: Boolean, ) @@ -341,7 +346,7 @@ data class Balance( data class AccountMinimalData( val username: String, val name: String, - val payto_uri: FullIbanPayto, + val payto_uri: String, val balance: Balance, val debit_threshold: TalerAmount, val is_public: Boolean, @@ -363,7 +368,7 @@ data class ListBankAccountsResponse( data class AccountData( val name: String, val balance: Balance, - val payto_uri: FullIbanPayto, + val payto_uri: String, val debit_threshold: TalerAmount, val contact_data: ChallengeContactData? = null, val cashout_payto_uri: String? = null, @@ -374,7 +379,7 @@ data class AccountData( @Serializable data class TransactionCreateRequest( - val payto_uri: IbanPayto, + val payto_uri: Payto, val amount: TalerAmount? ) @@ -387,8 +392,8 @@ data class TransactionCreateResponse( or from GET /transactions */ @Serializable data class BankAccountTransactionInfo( - val creditor_payto_uri: FullIbanPayto, - val debtor_payto_uri: FullIbanPayto, + val creditor_payto_uri: String, + val debtor_payto_uri: String, val amount: TalerAmount, val direction: TransactionDirection, val subject: String, @@ -456,7 +461,7 @@ data class BankWithdrawalOperationStatus( @Serializable data class BankWithdrawalOperationPostRequest( val reserve_pub: EddsaPublicKey, - val selected_exchange: IbanPayto, + val selected_exchange: Payto, ) /** @@ -539,7 +544,7 @@ data class ConversionResponse( data class AddIncomingRequest( val amount: TalerAmount, val reserve_pub: EddsaPublicKey, - val debit_account: IbanPayto + val debit_account: Payto ) /** @@ -557,7 +562,7 @@ data class AddIncomingResponse( @Serializable data class IncomingHistory( val incoming_transactions: List<IncomingReserveTransaction>, - val credit_account: FullIbanPayto + val credit_account: String ) /** @@ -569,7 +574,7 @@ data class IncomingReserveTransaction( val row_id: Long, // DB row ID of the payment. val date: TalerProtocolTimestamp, val amount: TalerAmount, - val debit_account: FullIbanPayto, + val debit_account: String, val reserve_pub: EddsaPublicKey ) @@ -579,7 +584,7 @@ data class IncomingReserveTransaction( @Serializable data class OutgoingHistory( val outgoing_transactions: List<OutgoingTransaction>, - val debit_account: FullIbanPayto + val debit_account: String ) /** @@ -590,7 +595,7 @@ data class OutgoingTransaction( val row_id: Long, // DB row ID of the payment. val date: TalerProtocolTimestamp, val amount: TalerAmount, - val credit_account: FullIbanPayto, + val credit_account: String, val wtid: ShortHashCode, val exchange_base_url: String, ) @@ -598,7 +603,7 @@ data class OutgoingTransaction( @Serializable data class RevenueIncomingHistory( val incoming_transactions : List<RevenueIncomingBankTransaction>, - val credit_account: FullIbanPayto + val credit_account: String ) @Serializable @@ -606,7 +611,7 @@ data class RevenueIncomingBankTransaction( val row_id: Long, val date: TalerProtocolTimestamp, val amount: TalerAmount, - val debit_account: FullIbanPayto, + val debit_account: String, val subject: String ) @@ -619,7 +624,7 @@ data class TransferRequest( val amount: TalerAmount, val exchange_base_url: ExchangeUrl, val wtid: ShortHashCode, - val credit_account: IbanPayto + val credit_account: Payto ) /** @@ -645,7 +650,7 @@ data class PublicAccountsResponse( @Serializable data class PublicAccount( val username: String, - val payto_uri: FullIbanPayto, + val payto_uri: String, 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,11 +82,11 @@ fun Routing.wireGatewayApi(db: Database, ctx: BankConfig) { } auth(db, TokenScope.readonly) { suspend fun <T> PipelineContext<Unit, ApplicationCall>.historyEndpoint( - reduce: (List<T>, FullIbanPayto) -> Any, - dbLambda: suspend ExchangeDAO.(HistoryParams, Long) -> List<T> + reduce: (List<T>, String) -> Any, + dbLambda: suspend ExchangeDAO.(HistoryParams, Long, BankPaytoCtx) -> List<T> ) { val params = HistoryParams.extract(context.request.queryParameters) - val bankAccount = call.bankInfo(db) + val bankAccount = call.bankInfo(db, ctx.payto) if (!bankAccount.isTalerExchange) throw conflict( @@ -94,7 +94,7 @@ fun Routing.wireGatewayApi(db: Database, ctx: BankConfig) { TalerErrorCode.BANK_ACCOUNT_IS_NOT_EXCHANGE ) - val items = db.exchange.dbLambda(params, bankAccount.bankAccountId); + val items = db.exchange.dbLambda(params, bankAccount.bankAccountId, ctx.payto); if (items.isEmpty()) { call.respond(HttpStatusCode.NoContent) diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt @@ -42,7 +42,7 @@ class AccountDAO(private val db: Database) { email: String?, phone: String?, cashoutPayto: IbanPayto?, - internalPaytoUri: IbanPayto, + internalPaytoUri: Payto, 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?.full(name)?.full) + setString(4, cashoutPayto?.full(name)) setBoolean(5, checkPaytoIdempotent) setString(6, internalPaytoUri.canonical) setBoolean(7, isPublic) @@ -90,19 +90,20 @@ class AccountDAO(private val db: Database) { AccountCreationResult.LoginReuse } } else { - conn.prepareStatement(""" - INSERT INTO iban_history( - iban - ,creation_time - ) VALUES (?, ?) - """).run { - setString(1, internalPaytoUri.iban.value) - setLong(2, now) - if (!executeUpdateViolation()) { - conn.rollback() - return@transaction AccountCreationResult.PayToReuse + if (internalPaytoUri is IbanPayto) + conn.prepareStatement(""" + INSERT INTO iban_history( + iban + ,creation_time + ) VALUES (?, ?) + """).run { + setString(1, internalPaytoUri.iban.value) + setLong(2, now) + if (!executeUpdateViolation()) { + conn.rollback() + return@transaction AccountCreationResult.PayToReuse + } } - } val customerId = conn.prepareStatement(""" INSERT INTO customers ( @@ -122,7 +123,7 @@ class AccountDAO(private val db: Database) { setString(3, name) setString(4, email) setString(5, phone) - setString(6, cashoutPayto?.full(name)?.full) + setString(6, cashoutPayto?.full(name)) setString(7, tanChannel?.name) oneOrNull { it.getLong("customer_id") }!! } @@ -297,7 +298,7 @@ class AccountDAO(private val db: Database) { // Check reconfig rights if (checkName && name != curr.name) return@transaction AccountPatchResult.NonAdminName - if (checkCashout && fullCashoutPayto?.full != curr.cashoutPayTo) + if (checkCashout && fullCashoutPayto != curr.cashoutPayTo) return@transaction AccountPatchResult.NonAdminCashout if (checkDebtLimit && debtLimit != curr.debtLimit) return@transaction AccountPatchResult.NonAdminDebtLimit @@ -352,7 +353,7 @@ class AccountDAO(private val db: Database) { }, "WHERE customer_id = ?", sequence { - cashoutPayto.some { yield(fullCashoutPayto?.full) } + cashoutPayto.some { yield(fullCashoutPayto) } phone.some { yield(it) } email.some { yield(it) } tan_channel.some { yield(it?.name) } @@ -418,7 +419,7 @@ class AccountDAO(private val db: Database) { } /** Get bank info of account [login] */ - suspend fun bankInfo(login: String): BankInfo? = db.conn { conn -> + suspend fun bankInfo(login: String, ctx: BankPaytoCtx): BankInfo? = db.conn { conn -> val stmt = conn.prepareStatement(""" SELECT bank_account_id @@ -433,7 +434,7 @@ class AccountDAO(private val db: Database) { stmt.setString(1, login) stmt.oneOrNull { BankInfo( - payto = it.getFullPayto("internal_payto_uri", "name"), + payto = it.getBankPayto("internal_payto_uri", "name", ctx), isTalerExchange = it.getBoolean("is_taler_exchange"), bankAccountId = it.getLong("bank_account_id") ) @@ -441,7 +442,7 @@ class AccountDAO(private val db: Database) { } /** Get data of account [login] */ - suspend fun get(login: String): AccountData? = db.conn { conn -> + suspend fun get(login: String, ctx: BankPaytoCtx): AccountData? = db.conn { conn -> val stmt = conn.prepareStatement(""" SELECT name @@ -472,7 +473,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.getFullPayto("internal_payto_uri", "name"), + payto_uri = it.getBankPayto("internal_payto_uri", "name", ctx), balance = Balance( amount = it.getAmount("balance", db.bankCurrency), credit_debit_indicator = @@ -490,7 +491,7 @@ class AccountDAO(private val db: Database) { } /** Get a page of all public accounts */ - suspend fun pagePublic(params: AccountParams): List<PublicAccount> + suspend fun pagePublic(params: AccountParams, ctx: BankPaytoCtx): List<PublicAccount> = db.page( params.page, "bank_account_id", @@ -514,7 +515,7 @@ class AccountDAO(private val db: Database) { ) { PublicAccount( username = it.getString("login"), - payto_uri = it.getFullPayto("internal_payto_uri", "name"), + payto_uri = it.getBankPayto("internal_payto_uri", "name", ctx), balance = Balance( amount = it.getAmount("balance", db.bankCurrency), credit_debit_indicator = if (it.getBoolean("has_debt")) { @@ -528,7 +529,7 @@ class AccountDAO(private val db: Database) { } /** Get a page of accounts */ - suspend fun pageAdmin(params: AccountParams): List<AccountMinimalData> + suspend fun pageAdmin(params: AccountParams, ctx: BankPaytoCtx): List<AccountMinimalData> = db.page( params.page, "bank_account_id", @@ -567,7 +568,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.getFullPayto("internal_payto_uri", "name"), + payto_uri = it.getBankPayto("internal_payto_uri", "name", ctx), ) } } \ 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 @@ -31,7 +31,8 @@ class ExchangeDAO(private val db: Database) { /** Query [exchangeId] history of taler incoming transactions */ suspend fun incomingHistory( params: HistoryParams, - exchangeId: Long + exchangeId: Long, + ctx: BankPaytoCtx ): List<IncomingReserveTransaction> = db.poolHistory(params, exchangeId, NotificationWatcher::listenIncoming, """ SELECT @@ -51,7 +52,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.getFullPayto("debtor_payto_uri", "debtor_name"), + debit_account = it.getBankPayto("debtor_payto_uri", "debtor_name", ctx), reserve_pub = EddsaPublicKey(it.getBytes("reserve_pub")), ) } @@ -59,7 +60,8 @@ class ExchangeDAO(private val db: Database) { /** Query [exchangeId] history of taler outgoing transactions */ suspend fun outgoingHistory( params: HistoryParams, - exchangeId: Long + exchangeId: Long, + ctx: BankPaytoCtx ): List<OutgoingTransaction> = db.poolHistory(params, exchangeId, NotificationWatcher::listenOutgoing, """ SELECT @@ -80,7 +82,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.getFullPayto("creditor_payto_uri", "creditor_name"), + credit_account = it.getBankPayto("creditor_payto_uri", "creditor_name", ctx), wtid = ShortHashCode(it.getBytes("wtid")), exchange_base_url = it.getString("exchange_base_url") ) diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/TanDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/TanDAO.kt @@ -104,12 +104,12 @@ class TanDAO(private val db: Database) { } /** Result of TAN challenge solution */ - sealed class TanSolveResult { - data class Success(val body: String, val op: Operation, val channel: TanChannel?, val info: String?): TanSolveResult() - data object NotFound: TanSolveResult() - data object NoRetry: TanSolveResult() - data object Expired: TanSolveResult() - data object BadCode: TanSolveResult() + sealed interface TanSolveResult { + data class Success(val body: String, val op: Operation, val channel: TanChannel?, val info: String?): TanSolveResult + data object NotFound: TanSolveResult + data object NoRetry: TanSolveResult + data object Expired: TanSolveResult + data object BadCode: TanSolveResult } /** Solve TAN challenge */ diff --git 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: Payto, debitAccountUsername: String, subject: String, amount: TalerAmount, @@ -141,7 +141,7 @@ class TransactionDAO(private val db: Database) { } /** Get transaction [rowId] owned by [login] */ - suspend fun get(rowId: Long, login: String): BankAccountTransactionInfo? = db.conn { conn -> + suspend fun get(rowId: Long, login: String, ctx: BankPaytoCtx): BankAccountTransactionInfo? = db.conn { conn -> val stmt = conn.prepareStatement(""" SELECT creditor_payto_uri @@ -163,8 +163,8 @@ class TransactionDAO(private val db: Database) { stmt.setString(2, login) stmt.oneOrNull { BankAccountTransactionInfo( - creditor_payto_uri = it.getFullPayto("creditor_payto_uri", "creditor_name"), - debtor_payto_uri = it.getFullPayto("debtor_payto_uri", "debtor_name"), + creditor_payto_uri = it.getBankPayto("creditor_payto_uri", "creditor_name", ctx), + debtor_payto_uri = it.getBankPayto("debtor_payto_uri", "debtor_name", ctx), amount = it.getAmount("amount", db.bankCurrency), direction = TransactionDirection.valueOf(it.getString("direction")), subject = it.getString("subject"), @@ -177,7 +177,8 @@ class TransactionDAO(private val db: Database) { /** Pool [accountId] transactions history */ suspend fun pollHistory( params: HistoryParams, - accountId: Long + accountId: Long, + ctx: BankPaytoCtx ): List<BankAccountTransactionInfo> { return db.poolHistory(params, accountId, NotificationWatcher::listenBank, """ SELECT @@ -196,8 +197,8 @@ class TransactionDAO(private val db: Database) { BankAccountTransactionInfo( row_id = it.getLong("bank_transaction_id"), date = it.getTalerTimestamp("transaction_date"), - creditor_payto_uri = it.getFullPayto("creditor_payto_uri", "creditor_name"), - debtor_payto_uri = it.getFullPayto("debtor_payto_uri", "debtor_name"), + creditor_payto_uri = it.getBankPayto("creditor_payto_uri", "creditor_name", ctx), + debtor_payto_uri = it.getBankPayto("debtor_payto_uri", "debtor_name", ctx), amount = it.getAmount("amount", db.bankCurrency), subject = it.getString("subject"), direction = TransactionDirection.valueOf(it.getString("direction")) @@ -208,7 +209,8 @@ class TransactionDAO(private val db: Database) { /** Query [accountId] history of incoming transactions to its account */ suspend fun revenueHistory( params: HistoryParams, - accountId: Long + accountId: Long, + ctx: BankPaytoCtx ): List<RevenueIncomingBankTransaction> = db.poolHistory(params, accountId, NotificationWatcher::listenRevenue, """ SELECT @@ -225,7 +227,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.getFullPayto("debtor_payto_uri", "debtor_name"), + debit_account = it.getBankPayto("debtor_payto_uri", "debtor_name", ctx), subject = it.getString("subject") ) } diff --git 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: Payto, 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 @@ -48,11 +48,8 @@ fun ApplicationCall.expectParameter(name: String) = ) /** Retrieve the bank account info for the selected username*/ -suspend fun ApplicationCall.bankInfo(db: Database): BankInfo - = db.account.bankInfo(username) ?: throw unknownAccount(username) - -// Generates a new Payto-URI with IBAN scheme. -fun genIbanPaytoUri(): String = "payto://iban/${getIban()}" +suspend fun ApplicationCall.bankInfo(db: Database, ctx: BankPaytoCtx): BankInfo + = db.account.bankInfo(username, ctx) ?: throw unknownAccount(username) /** * Builds the taler://withdraw-URI. Such URI will serve the requests @@ -125,7 +122,7 @@ suspend fun maybeCreateAdminAccount(db: Database, ctx: BankConfig, pw: String? = login = "admin", password = pwStr, name = "Bank administrator", - internalPaytoUri = IbanPayto(genIbanPaytoUri()), + internalPaytoUri = IbanPayto.rand(), isPublic = false, isTalerExchange = false, maxDebt = ctx.defaultDebtLimit, diff --git a/bank/src/test/kotlin/AmountTest.kt b/bank/src/test/kotlin/AmountTest.kt @@ -32,13 +32,13 @@ class AmountTest { // Test amount computation in database @Test fun computationTest() = bankSetup { db -> db.conn { conn -> - conn.execSQLUpdate("UPDATE libeufin_bank.bank_accounts SET balance.val = 100000 WHERE internal_payto_uri = '$customerPayto'") + conn.execSQLUpdate("UPDATE libeufin_bank.bank_accounts SET balance.val = 100000 WHERE internal_payto_uri = '${customerPayto.canonical}'") val stmt = conn.prepareStatement(""" UPDATE libeufin_bank.bank_accounts SET balance = (?, ?)::taler_amount ,has_debt = ? ,max_debt = (?, ?)::taler_amount - WHERE internal_payto_uri = '$merchantPayto' + WHERE internal_payto_uri = '${merchantPayto.canonical}' """) suspend fun routine(balance: TalerAmount, due: TalerAmount, hasBalanceDebt: Boolean, maxDebt: TalerAmount): Boolean { stmt.setLong(1, balance.value) diff --git a/bank/src/test/kotlin/BankIntegrationApiTest.kt b/bank/src/test/kotlin/BankIntegrationApiTest.kt @@ -75,7 +75,7 @@ class BankIntegrationApiTest { val reserve_pub = randEddsaPublicKey() val req = obj { "reserve_pub" to reserve_pub - "selected_exchange" to exchangePayto + "selected_exchange" to exchangePayto.canonical } // Check bad UUID diff --git a/bank/src/test/kotlin/CoreBankApiTest.kt b/bank/src/test/kotlin/CoreBankApiTest.kt @@ -187,26 +187,26 @@ class CoreBankAccountsApiTest { // Check payto conflict client.post("/accounts") { json(req) { - "payto_uri" to genIbanPaytoUri() + "payto_uri" to IbanPayto.rand() } }.assertConflict(TalerErrorCode.BANK_REGISTER_USERNAME_REUSE) } // Check given payto - val IbanPayto = IbanPayto(genIbanPaytoUri()) + val payto = IbanPayto.rand() val req = obj { "username" to "foo" "password" to "password" "name" to "Jane" "is_public" to true - "payto_uri" to IbanPayto + "payto_uri" to payto "is_taler_exchange" to true } // Check Ok client.post("/accounts") { json(req) }.assertOkJson<RegisterAccountResponse> { - assertEquals(IbanPayto.withName("Jane").full, it.internal_payto_uri.full) + assertEquals(payto.full("Jane"), 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 payto } }.assertOk() client.getA("/accounts/cashout_guess").assertOkJson<AccountData> { - assertEquals(IbanPayto.full("Mr Guess My Name").full, it.cashout_payto_uri) + assertEquals(payto.full("Mr Guess My Name"), it.cashout_payto_uri) } - val full = IbanPayto.full("Santa Claus").full + val full = payto.full("Santa Claus") client.post("/accounts") { json { "username" to "cashout_keep" @@ -486,7 +486,7 @@ class CoreBankAccountsApiTest { } // Successful attempt now - val cashout = IbanPayto(genIbanPaytoUri()) + val cashout = IbanPayto.rand() 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.full(obj.name).full, obj.cashout_payto_uri) + assertEquals(cashout.full(obj.name), 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.full(obj.name).full, obj.cashout_payto_uri) + assertEquals(cashout.full(obj.name), 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) @@ -556,11 +556,12 @@ class CoreBankAccountsApiTest { "name" to "Mr Cashout Cashout" } }.assertOk() + val canonical = Payto.parse(cashout.canonical).expectIban() for ((cashout, name, expect) in listOf( - 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) + Triple(cashout.canonical, null, canonical.full("Mr Cashout Cashout")), + Triple(cashout.canonical, "New name", canonical.full("New name")), + Triple(cashout.full("Full name"), null, cashout.full("Full name")), + Triple(cashout.full("Full second name"), "Another name", cashout.full("Full second name")) )) { client.patch("/accounts/cashout") { pwAuth("admin") @@ -597,7 +598,7 @@ class CoreBankAccountsApiTest { TalerErrorCode.BANK_NON_ADMIN_PATCH_LEGAL_NAME ) checkAdminOnly( - obj { "cashout_payto_uri" to genIbanPaytoUri() }, + obj { "cashout_payto_uri" to IbanPayto.rand() }, TalerErrorCode.BANK_NON_ADMIN_PATCH_CASHOUT ) // Check idempotent @@ -902,10 +903,11 @@ class CoreBankTransactionsApiTest { } }.assertConflict(TalerErrorCode.BANK_SAME_ACCOUNT) // Transaction to admin - val adminPayto = db.account.bankInfo("admin")!!.payto + val adminPayto = client.getA("/accounts/admin") + .assertOkJson<AccountData>().payto_uri client.postA("/accounts/merchant/transactions") { json(valid_req) { - "payto_uri" to "${adminPayto.payto}?message=payout" + "payto_uri" to "$adminPayto&message=payout" } }.assertConflict(TalerErrorCode.BANK_ADMIN_CREDITOR) diff --git a/bank/src/test/kotlin/WireGatewayApiTest.kt b/bank/src/test/kotlin/WireGatewayApiTest.kt @@ -46,7 +46,7 @@ class WireGatewayApiTest { "amount" to "KUDOS:55" "exchange_base_url" to "http://exchange.example.com/" "wtid" to randShortHashCode() - "credit_account" to merchantPayto + "credit_account" to merchantPayto.canonical }; authRoutine(HttpMethod.Post, "/accounts/merchant/taler-wire-gateway/transfer", valid_req) @@ -207,7 +207,7 @@ class WireGatewayApiTest { val valid_req = obj { "amount" to "KUDOS:44" "reserve_pub" to randEddsaPublicKey() - "debit_account" to merchantPayto + "debit_account" to merchantPayto.canonical }; authRoutine(HttpMethod.Post, "/accounts/merchant/taler-wire-gateway/admin/add-incoming", valid_req, requireAdmin = true) diff --git a/bank/src/test/kotlin/helpers.kt b/bank/src/test/kotlin/helpers.kt @@ -37,11 +37,11 @@ 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.rand() +val exchangePayto = IbanPayto.rand() +val customerPayto = IbanPayto.rand() +val unknownPayto = IbanPayto.rand() +var tmpPayTo = IbanPayto.rand() val paytos = mapOf( "merchant" to merchantPayto, "exchange" to exchangePayto, @@ -49,7 +49,7 @@ val paytos = mapOf( ) fun genTmpPayTo(): IbanPayto { - tmpPayTo = IbanPayto(genIbanPaytoUri()) + tmpPayTo = IbanPayto.rand() return tmpPayTo } diff --git a/common/src/main/kotlin/DB.kt b/common/src/main/kotlin/DB.kt @@ -339,9 +339,6 @@ fun ResultSet.getAmount(name: String, currency: String): TalerAmount{ ) } -fun ResultSet.getFullPayto(payto: String, name: String): FullIbanPayto{ - return FullIbanPayto( - IbanPayto(getString(payto)), - getString(name), - ) +fun ResultSet.getBankPayto(payto: String, name: String, ctx: BankPaytoCtx): String { + return Payto.parse(getString(payto)).bank(getString(name), ctx) } \ No newline at end of file diff --git a/common/src/main/kotlin/TalerCommon.kt b/common/src/main/kotlin/TalerCommon.kt @@ -29,7 +29,7 @@ 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) + class Payto(msg: String): CommonError(msg) } @Serializable(with = TalerAmount.Serializer::class) @@ -121,105 +121,139 @@ value class IBAN private constructor(val value: String) { } 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") + if (mod != 1) throw CommonError.Payto("Iban malformed, modulo is $mod expected 1") return IBAN(iban) } + + fun rand(): IBAN { + val ccNoCheck = "131400" // DE00 + val bban = (0..10).map { + (0..9).random() + }.joinToString("") // 10 digits account number + var checkDigits: String = "98".toBigInteger().minus("$bban$ccNoCheck".toBigInteger().mod("97".toBigInteger())).toString() + if (checkDigits.length == 1) { + checkDigits = "0${checkDigits}" + } + return IBAN("DE$checkDigits$bban") + } } } - -sealed class PaytoUri { +@Serializable(with = Payto.Serializer::class) +sealed class Payto { + abstract val parsed: URI + abstract val canonical: String abstract val amount: TalerAmount? abstract val message: String? abstract val receiverName: String? -} -// TODO x-taler-bank Payto + /** Transform a payto URI to its bank form, using [name] as the receiver-name and the bank [ctx] */ + fun bank(name: String, ctx: BankPaytoCtx): String = when (this) { + is IbanPayto -> "payto://iban/${ctx.bic!!}/$iban?receiver-name=${name.encodeURLParameter()}" + } -@Serializable(with = IbanPayto.Serializer::class) -class IbanPayto: PaytoUri { - val parsed: URI - val canonical: String - val iban: IBAN - override val amount: TalerAmount? - override val message: String? - override val receiverName: String? + fun expectIban(): IbanPayto { + return when (this) { + is IbanPayto -> this + else -> throw CommonError.Payto("expected a IBAN payto URI got '${parsed.host}'") + } + } - // TODO maybe add a fster builder that performs less expensive checks when the payto is from the database ? + internal object Serializer : KSerializer<Payto> { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("Payto", PrimitiveKind.STRING) - constructor(raw: String) { - try { - parsed = URI(raw) - } catch (e: Exception) { - throw CommonError.IbanPayto("expecteda valid URI") + override fun serialize(encoder: Encoder, value: Payto) { + encoder.encodeString(value.toString()) } - - 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") + + override fun deserialize(decoder: Decoder): Payto { + return Payto.parse(decoder.decodeString()) } - iban = IBAN.parse(rawIban) - 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 set to [name] */ - fun withName(name: String): FullIbanPayto = FullIbanPayto(this, name) - - /** Full IBAN payto with receiver-name parameter if present */ - fun maybeFull(): FullIbanPayto? { - return withName(receiverName ?: return null) + companion object { + fun parse(raw: String): Payto { + val parsed = try { + URI(raw) + } catch (e: Exception) { + throw CommonError.Payto("expected a valid URI") + } + if (parsed.scheme != "payto") throw CommonError.Payto("expect a payto URI got '${parsed.scheme}'") + + val params = (parsed.query ?: "").parseUrlEncodedParameters(); + val amount = params["amount"]?.run { TalerAmount(this) } + val message = params["message"] + val receiverName = params["receiver-name"] + + return when (parsed.host) { + "iban" -> { + val splitPath = parsed.path.split("/").filter { it.isNotEmpty() } + val (bic, rawIban) = when (splitPath.size) { + 1 -> Pair(null, splitPath[0]) + 2 -> Pair(splitPath[0], splitPath[1]) + else -> throw CommonError.Payto("too many path segments for a IBAN payto URI") + } + val iban = IBAN.parse(rawIban) + IbanPayto( + parsed, + "payto://iban/$iban", + amount, + message, + receiverName, + bic, + iban + ) + } + else -> throw CommonError.Payto("unsupported payto URI kind '${parsed.host}'") + } + } } +} - /** Full IBAN payto with receiver-name parameter set to [defaultName] if absent */ - fun full(defaultName: String): FullIbanPayto = withName(receiverName ?: defaultName) +// TODO x-taler-bank Payto + +@Serializable(with = IbanPayto.Serializer::class) +class IbanPayto internal constructor( + override val parsed: URI, + override val canonical: String, + override val amount: TalerAmount?, + override val message: String?, + override val receiverName: String?, + val bic: String?, + val iban: IBAN +): Payto() { - /** Full IBAN payto with receiver-name parameter if present, fail if absent */ - fun requireFull(): FullIbanPayto { - return maybeFull() ?: throw Exception("Missing receiver-name") - } + override fun toString(): String = parsed.toString() - override fun toString(): String = canonical + /** Transform an IBAN payto URI to its full form, using [defaultName] if receiver-name is missing */ + fun full(defaultName: String): String { + val bic = if (this.bic != null) "$bic/" else "" + return "payto://iban/$bic$iban?receiver-name=${(receiverName ?: defaultName).encodeURLParameter()}" + } 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()) + encoder.encodeString(value.toString()) } override fun deserialize(decoder: Decoder): IbanPayto { - return IbanPayto(decoder.decodeString()) + return Payto.parse(decoder.decodeString()).expectIban() } } -} - -@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() + companion object { + fun rand(): IbanPayto { + return Payto.parse("payto://iban/SANDBOXX/${IBAN.rand()}").expectIban() } } -} -\ No newline at end of file +} + +/** Context specific data nescessary to create a bank payto URI from a canonical payto URI */ +data class BankPaytoCtx( + val bic: String? = null, + val hostname: String? = null +) +\ No newline at end of file diff --git a/common/src/main/kotlin/iban.kt b/common/src/main/kotlin/iban.kt @@ -19,18 +19,6 @@ package tech.libeufin.common -fun getIban(): String { - val ccNoCheck = "131400" // DE00 - val bban = (0..10).map { - (0..9).random() - }.joinToString("") // 10 digits account number - var checkDigits: String = "98".toBigInteger().minus("$bban$ccNoCheck".toBigInteger().mod("97".toBigInteger())).toString() - if (checkDigits.length == 1) { - checkDigits = "0${checkDigits}" - } - return "DE$checkDigits$bban" -} - // Taken from the ISO20022 XSD schema private val bicRegex = Regex("^[A-Z]{6}[A-Z2-9][A-NP-Z0-9]([A-Z0-9]{3})?$") diff --git a/common/src/test/kotlin/PaytoTest.kt b/common/src/test/kotlin/PaytoTest.kt @@ -24,30 +24,34 @@ import kotlin.test.* class PaytoTest { @Test fun wrongCases() { - 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") } + assertFailsWith<CommonError.Payto> { Payto.parse("http://iban/BIC123/IBAN123?receiver-name=The%20Name") } + assertFailsWith<CommonError.Payto> { Payto.parse("payto:iban/BIC123/IBAN123?receiver-name=The%20Name&address=house") } + assertFailsWith<CommonError.Payto> { Payto.parse("payto://wrong/BIC123/IBAN123?sender-name=Foo&receiver-name=Foo") } } @Test fun parsePaytoTest() { - val withBic = IbanPayto("payto://iban/BIC123/CH9300762011623852957?receiver-name=The%20Name") + val withBic = Payto.parse("payto://iban/BIC123/CH9300762011623852957?receiver-name=The%20Name").expectIban() 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") + val complete = Payto.parse("payto://iban/BIC123/CH9300762011623852957?sender-name=The%20Name&amount=EUR:1&message=donation").expectIban() assertEquals(withBic.iban.value, "CH9300762011623852957") assertEquals(withBic.receiverName, "The Name") assertEquals(complete.message, "donation") assertEquals(complete.amount.toString(), "EUR:1") - val withoutOptionals = IbanPayto("payto://iban/CH9300762011623852957") + val withoutOptionals = Payto.parse("payto://iban/CH9300762011623852957").expectIban() assertNull(withoutOptionals.message) assertNull(withoutOptionals.receiverName) assertNull(withoutOptionals.amount) } @Test - fun normalization() { + fun forms() { + val ctx = BankPaytoCtx( + bic = "TESTBIC" + ) val canonical = "payto://iban/CH9300762011623852957" + val bank = "payto://iban/TESTBIC/CH9300762011623852957?receiver-name=Name" val inputs = listOf( "payto://iban/BIC/CH9300762011623852957?receiver-name=NotGiven", "payto://iban/CH9300762011623852957?receiver-name=Grothoff%20Hans", @@ -57,24 +61,16 @@ class PaytoTest { "NotGiven", "Grothoff Hans", null ) val full = listOf( - "payto://iban/CH9300762011623852957?receiver-name=NotGiven", + "payto://iban/BIC/CH9300762011623852957?receiver-name=NotGiven", "payto://iban/CH9300762011623852957?receiver-name=Grothoff%20Hans", - canonical + "payto://iban/CH9300762011623852957?receiver-name=Santa%20Claus", ) for ((i, input) in inputs.withIndex()) { - val payto = IbanPayto(input) + val payto = Payto.parse(input).expectIban() assertEquals(canonical, payto.canonical) - assertEquals(full[i], payto.maybeFull()?.full ?: payto.canonical) + assertEquals(bank, payto.bank("Name", ctx)) + assertEquals(full[i], payto.full("Santa Claus")) 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/contrib/bank.conf b/contrib/bank.conf @@ -3,6 +3,15 @@ # Internal currency of the libeufin-bank CURRENCY = KUDOS +# Supported payment method, this can either be iban or x-taler-bank +PAYMENT_METHOD = iban + +# Bank BIC used in generated iban payto URI +IBAN_PAYTO_BIC = SANDBOXX + +# Bank hostname used in generated x-taler-bank payto URI +# X_TALER_BANK_PAYTO_HOSTNAME=bank.$FOO.taler.net + # Default debt limit for newly created accounts Default is CURRENCY:0 # DEFAULT_DEBT_LIMIT = KUDOS:200 diff --git a/ebics/src/main/kotlin/Ebics.kt b/ebics/src/main/kotlin/Ebics.kt @@ -57,14 +57,14 @@ data class EbicsDateRange( val end: Instant ) -sealed class EbicsOrderParams +sealed interface EbicsOrderParams data class EbicsStandardOrderParams( val dateRange: EbicsDateRange? = null -) : EbicsOrderParams() +) : EbicsOrderParams data class EbicsGenericOrderParams( val params: Map<String, String> = mapOf() -) : EbicsOrderParams() +) : EbicsOrderParams enum class EbicsInitState { SENT, NOT_SENT, UNKNOWN diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Database.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Database.kt @@ -86,9 +86,10 @@ enum class DatabaseSubmissionState { * the database. */ data class InitiatedPayment( + val id: Long, val amount: TalerAmount, val wireTransferSubject: String, - val creditPaytoUri: FullIbanPayto, + val creditPaytoUri: String, val initiationTime: Instant, val requestUid: String ) @@ -426,7 +427,7 @@ class Database(dbConfig: String): DbPool(dbConfig, "libeufin_nexus") { * @param currency in which currency should the payment be submitted to the bank. * @return [Map] of the initiated payment row ID and [InitiatedPayment] */ - suspend fun initiatedPaymentsSubmittableGet(currency: String): Map<Long, InitiatedPayment> = conn { conn -> + suspend fun initiatedPaymentsSubmittableGet(currency: String): List<InitiatedPayment> = conn { conn -> val stmt = conn.prepareStatement(""" SELECT initiated_outgoing_transaction_id @@ -440,25 +441,21 @@ class Database(dbConfig: String): DbPool(dbConfig, "libeufin_nexus") { WHERE (submitted='unsubmitted' OR submitted='transient_failure') AND ((amount).val != 0 OR (amount).frac != 0); """) - val maybeMap = mutableMapOf<Long, InitiatedPayment>() - stmt.executeQuery().use { - if (!it.next()) return@use - do { - val rowId = it.getLong("initiated_outgoing_transaction_id") - val initiationTime = it.getLong("initiation_time").microsToJavaInstant() - if (initiationTime == null) { // nexus fault - throw Exception("Found invalid timestamp at initiated payment with ID: $rowId") - } - maybeMap[rowId] = InitiatedPayment( - amount = it.getAmount("amount", currency), - creditPaytoUri = IbanPayto(it.getString("credit_payto_uri")).requireFull(), - wireTransferSubject = it.getString("wire_transfer_subject"), - initiationTime = initiationTime, - requestUid = it.getString("request_uid") - ) - } while (it.next()) + stmt.all { + val rowId = it.getLong("initiated_outgoing_transaction_id") + val initiationTime = it.getLong("initiation_time").microsToJavaInstant() + if (initiationTime == null) { // nexus fault + throw Exception("Found invalid timestamp at initiated payment with ID: $rowId") + } + InitiatedPayment( + id = it.getLong("initiated_outgoing_transaction_id"), + amount = it.getAmount("amount", currency), + creditPaytoUri = it.getString("credit_payto_uri"), + wireTransferSubject = it.getString("wire_transfer_subject"), + initiationTime = initiationTime, + requestUid = it.getString("request_uid") + ) } - return@conn maybeMap } /** * Initiate a payment in the database. The "submit" @@ -487,7 +484,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) - stmt.setString(4, paymentData.creditPaytoUri.full) + stmt.setString(4, paymentData.creditPaytoUri.toString()) 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 @@ -90,15 +90,27 @@ class NexusSubmitException( */ private suspend fun submitInitiatedPayment( ctx: SubmissionContext, - initiatedPayment: InitiatedPayment + payment: InitiatedPayment ) { + val creditAccount = try { + val payto = Payto.parse(payment.creditPaytoUri).expectIban() + IbanAccountMetadata( + iban = payto.iban.value, + bic = payto.bic, + name = payto.receiverName!! + ) + } catch (e: Exception) { + throw e // TODO handle payto error + } + + val xml = createPain001( - requestUid = initiatedPayment.requestUid, - initiationTimestamp = initiatedPayment.initiationTime, - amount = initiatedPayment.amount, - creditAccount = initiatedPayment.creditPaytoUri, + requestUid = payment.requestUid, + initiationTimestamp = payment.initiationTime, + amount = payment.amount, + creditAccount = creditAccount, debitAccount = ctx.cfg.myIbanAccount, - wireTransferSubject = initiatedPayment.wireTransferSubject + wireTransferSubject = payment.wireTransferSubject ) ctx.fileLogger.logSubmit(xml) try { @@ -152,9 +164,9 @@ private fun submitBatch( logger.debug("Running submit at: ${Instant.now()}") runBlocking { db.initiatedPaymentsSubmittableGet(ctx.cfg.currency).forEach { - logger.debug("Submitting payment initiation with row ID: ${it.key}") + logger.debug("Submitting payment initiation with row ID: ${it.id}") val submissionState = try { - submitInitiatedPayment(ctx, initiatedPayment = it.value) + submitInitiatedPayment(ctx, it) DatabaseSubmissionState.success } catch (e: NexusSubmitException) { logger.error(e.message) @@ -179,7 +191,7 @@ private fun submitBatch( NexusSubmissionStage.ebics -> DatabaseSubmissionState.permanent_failure } } - db.initiatedPaymentSetSubmittedState(it.key, submissionState) + db.initiatedPaymentSetSubmittedState(it.id, submissionState) } } } diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt @@ -75,7 +75,7 @@ fun createPain001( debitAccount: IbanAccountMetadata, amount: TalerAmount, wireTransferSubject: String, - creditAccount: FullIbanPayto + creditAccount: IbanAccountMetadata ): String { val namespace = Pain001Namespaces( fullNamespace = "urn:iso:std:iso:20022:tech:xsd:pain.001.001.09", @@ -102,7 +102,8 @@ fun createPain001( el("ReqdExctnDt/Dt", DateTimeFormatter.ISO_DATE.format(zonedTimestamp)) el("Dbtr/Nm", debitAccount.name) el("DbtrAcct/Id/IBAN", debitAccount.iban) - el("DbtrAgt/FinInstnId/BICFI", debitAccount.bic) + if (debitAccount.bic != null) + el("DbtrAgt/FinInstnId/BICFI", debitAccount.bic) el("CdtTrfTxInf") { el("PmtId") { el("InstrId", "NOTPROVIDED") @@ -112,8 +113,9 @@ fun createPain001( attr("Ccy", amount.currency) text(amountWithoutCurrency) } - el("Cdtr/Nm", creditAccount.receiverName) - el("CdtrAcct/Id/IBAN", creditAccount.payto.iban.value) + el("Cdtr/Nm", creditAccount.name) + // TODO write credit account bic if we have it + el("CdtrAcct/Id/IBAN", creditAccount.iban) el("RmtInf/Ustrd", wireTransferSubject) } } diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt @@ -43,7 +43,7 @@ val logger: Logger = LoggerFactory.getLogger("libeufin-nexus") */ data class IbanAccountMetadata( val iban: String, - val bic: String, + val bic: String?, val name: String ) diff --git a/nexus/src/test/kotlin/Common.kt b/nexus/src/test/kotlin/Common.kt @@ -100,8 +100,9 @@ fun genInitPay( requestUid: String = "unique" ) = InitiatedPayment( + id = -1, amount = TalerAmount(44, 0, "KUDOS"), - creditPaytoUri = IbanPayto("payto://iban/CH9300762011623852957?receiver-name=Test").requireFull(), + creditPaytoUri = "payto://iban/CH9300762011623852957?receiver-name=Test", wireTransferSubject = subject, initiationTime = Instant.now(), requestUid = requestUid diff --git a/nexus/src/test/kotlin/DatabaseTest.kt b/nexus/src/test/kotlin/DatabaseTest.kt @@ -205,8 +205,9 @@ class PaymentInitiationsTest { assertEquals(beEmpty.size, 0) } val initPay = InitiatedPayment( + id = -1, amount = TalerAmount(44, 0, "KUDOS"), - creditPaytoUri = IbanPayto("payto://iban/CH9300762011623852957?receiver-name=Test").requireFull(), + creditPaytoUri = "payto://iban/CH9300762011623852957?receiver-name=Test", wireTransferSubject = "test", requestUid = "unique", initiationTime = Instant.now() @@ -218,8 +219,8 @@ class PaymentInitiationsTest { val haveOne = db.initiatedPaymentsSubmittableGet("KUDOS") assertTrue("Size ${haveOne.size} instead of 1") { haveOne.size == 1 - && haveOne.containsKey(1) - && haveOne[1]?.requestUid == "unique" + && haveOne.first().id == 1L + && haveOne.first().requestUid == "unique" } assertTrue(db.initiatedPaymentSetSubmittedState(1, DatabaseSubmissionState.success)) assertNotNull(db.initiatedPaymentGetFromUid("unique")) @@ -286,10 +287,11 @@ class PaymentInitiationsTest { // Expecting all the payments BUT the #3 in the result. db.initiatedPaymentsSubmittableGet("KUDOS").apply { + assertEquals(3, this.size) - assertEquals("#1", this[1]?.wireTransferSubject) - assertEquals("#2", this[2]?.wireTransferSubject) - assertEquals("#4", this[4]?.wireTransferSubject) + assertEquals("#1", this[0]?.wireTransferSubject) + assertEquals("#2", this[1]?.wireTransferSubject) + assertEquals("#4", this[2]?.wireTransferSubject) } } } diff --git a/testbench/src/main/kotlin/Main.kt b/testbench/src/main/kotlin/Main.kt @@ -159,8 +159,9 @@ class Cli : CliktCommand("Run integration tests on banks provider") { put("tx", suspend { step("Test submit one transaction") nexusDb.initiatedPaymentCreate(InitiatedPayment( + id = -1, amount = TalerAmount("CFH:42"), - creditPaytoUri = IbanPayto(payto).requireFull(), + creditPaytoUri = payto, wireTransferSubject = "single transaction test", initiationTime = Instant.now(), requestUid = Base32Crockford.encode(randBytes(16)) @@ -171,8 +172,9 @@ class Cli : CliktCommand("Run integration tests on banks provider") { step("Test submit many transaction") repeat(4) { nexusDb.initiatedPaymentCreate(InitiatedPayment( + id = -1, amount = TalerAmount("CFH:${100L+it}"), - creditPaytoUri = IbanPayto(payto).requireFull(), + creditPaytoUri = payto, wireTransferSubject = "multi transaction test $it", initiationTime = Instant.now(), requestUid = Base32Crockford.encode(randBytes(16)) @@ -185,8 +187,9 @@ class Cli : CliktCommand("Run integration tests on banks provider") { step("Submit new transaction") // TODO interactive payment editor nexusDb.initiatedPaymentCreate(InitiatedPayment( + id = -1, amount = TalerAmount("CFH:1.1"), - creditPaytoUri = IbanPayto(payto).requireFull(), + creditPaytoUri = payto, 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 @@ -138,8 +138,8 @@ class IntegrationTest { } setup { db -> - val userPayTo = IbanPayto(genIbanPaytoUri()) - val fiatPayTo = IbanPayto(genIbanPaytoUri()) + val userPayTo = IbanPayto.rand() + val fiatPayTo = IbanPayto.rand() // Load conversion setup manually as the server would refuse to start without an exchange account val sqlProcedures = Path("../database-versioning/libeufin-conversion-setup.sql") @@ -151,7 +151,7 @@ class IntegrationTest { val reservePub = randBytes(32) val payment = IncomingPayment( amount = TalerAmount("EUR:10"), - debitPaytoUri = userPayTo.canonical, + debitPaytoUri = userPayTo.toString(), wireTransferSubject = "Error test ${Base32Crockford.encode(reservePub)}", executionTime = Instant.now(), bankId = "error" @@ -210,7 +210,7 @@ class IntegrationTest { // Check success ingestIncomingPayment(db, IncomingPayment( amount = TalerAmount("EUR:10"), - debitPaytoUri = userPayTo.canonical, + debitPaytoUri = userPayTo.toString(), wireTransferSubject = "Success ${Base32Crockford.encode(randBytes(32))}", executionTime = Instant.now(), bankId = "success" @@ -240,8 +240,8 @@ class IntegrationTest { } setup { db -> - val userPayTo = IbanPayto(genIbanPaytoUri()) - val fiatPayTo = IbanPayto(genIbanPaytoUri()) + val userPayTo = IbanPayto.rand() + val fiatPayTo = IbanPayto.rand() // Create user client.post("http://0.0.0.0:8080/accounts") { @@ -284,7 +284,7 @@ class IntegrationTest { ingestIncomingPayment(db, IncomingPayment( amount = amount, - debitPaytoUri = userPayTo.canonical, + debitPaytoUri = userPayTo.toString(), wireTransferSubject = subject, executionTime = Instant.now(), bankId = Base32Crockford.encode(reservePub)