libeufin

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

commit 5de415d9534784711a33b710f25dfb802399188c
parent cf7de966155dad0d7e3d26986e0684149d0fa16b
Author: Antoine A <>
Date:   Mon, 27 May 2024 09:27:07 +0900

support creating a withdrawal operation without amount being known a priori

Diffstat:
Mbank/src/main/kotlin/tech/libeufin/bank/Constants.kt | 4++--
Mbank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt | 14+++++++++++---
Mbank/src/main/kotlin/tech/libeufin/bank/api/BankIntegrationApi.kt | 22+++++++++++++++-------
Mbank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt | 11+++++++++--
Mbank/src/main/kotlin/tech/libeufin/bank/db/WithdrawalDAO.kt | 57++++++++++++++++++++++++++++++++++++++++++++++-----------
Mbank/src/test/kotlin/AmountTest.kt | 29++++++++++++++++-------------
Mbank/src/test/kotlin/BankIntegrationApiTest.kt | 93+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Mbank/src/test/kotlin/CoreBankApiTest.kt | 30++++++++++++++++++++++++++----
Mbank/src/test/kotlin/GcTest.kt | 6+++---
Mcommon/src/main/kotlin/TalerErrorCode.kt | 84+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Adatabase-versioning/libeufin-bank-0005.sql | 24++++++++++++++++++++++++
Mdatabase-versioning/libeufin-bank-procedures.sql | 36+++++++++++++++++++++++++++---------
12 files changed, 335 insertions(+), 75 deletions(-)

diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Constants.kt b/bank/src/main/kotlin/tech/libeufin/bank/Constants.kt @@ -37,6 +37,6 @@ val RESERVED_ACCOUNTS = setOf("admin", "bank") const val IBAN_ALLOCATION_RETRY_COUNTER: Int = 5 // API version -const val COREBANK_API_VERSION: String = "4:8:0" +const val COREBANK_API_VERSION: String = "4:8:1" const val CONVERSION_API_VERSION: String = "0:1:0" -const val INTEGRATION_API_VERSION: String = "2:0:2" +const val INTEGRATION_API_VERSION: String = "2:0:3" diff --git a/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt b/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt @@ -416,7 +416,9 @@ data class BankAccountTransactionsResponse( // Taler withdrawal request. @Serializable data class BankAccountCreateWithdrawalRequest( - val amount: TalerAmount + val amount: TalerAmount? = null, + val suggested_amount: TalerAmount? = null, + val card_fees: TalerAmount? = null ) // Taler withdrawal response. @@ -448,13 +450,18 @@ data class CurrencySpecification( @Serializable data class BankWithdrawalOperationStatus( val status: WithdrawalStatus, - val amount: TalerAmount, + val amount: TalerAmount? = null, + val suggested_amount: TalerAmount? = null, + val max_amount: TalerAmount? = null, + val card_fees: TalerAmount? = null, val sender_wire: String? = null, val suggested_exchange: String? = null, + val required_exchange: String? = null, val confirm_transfer_url: String? = null, + val wire_types: List<String>, val selected_reserve_pub: EddsaPublicKey? = null, val selected_exchange_account: String? = null, - val wire_types: List<String>, + val currency: String? = null, // TODO remove val aborted: Boolean, val selection_done: Boolean, @@ -468,6 +475,7 @@ data class BankWithdrawalOperationStatus( data class BankWithdrawalOperationPostRequest( val reserve_pub: EddsaPublicKey, val selected_exchange: Payto, + val amount: TalerAmount? = null ) /** diff --git a/bank/src/main/kotlin/tech/libeufin/bank/api/BankIntegrationApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/api/BankIntegrationApi.kt @@ -56,31 +56,39 @@ fun Routing.bankIntegrationApi(db: Database, ctx: BankConfig) { post("/taler-integration/withdrawal-operation/{wopid}") { val uuid = call.uuidPath("wopid") val req = call.receive<BankWithdrawalOperationPostRequest>() - + req.amount?.run(ctx::checkRegionalCurrency) val res = db.withdrawal.setDetails( - uuid, req.selected_exchange, req.reserve_pub + uuid, req.selected_exchange, req.reserve_pub, req.amount ) when (res) { - is WithdrawalSelectionResult.UnknownOperation -> throw notFound( + WithdrawalSelectionResult.UnknownOperation -> throw notFound( "Withdrawal operation '$uuid' not found", TalerErrorCode.BANK_TRANSACTION_NOT_FOUND ) - is WithdrawalSelectionResult.AlreadySelected -> throw conflict( + WithdrawalSelectionResult.AlreadySelected -> throw conflict( "Cannot select different exchange and reserve pub. under the same withdrawal operation", TalerErrorCode.BANK_WITHDRAWAL_OPERATION_RESERVE_SELECTION_CONFLICT ) - is WithdrawalSelectionResult.RequestPubReuse -> throw conflict( + WithdrawalSelectionResult.RequestPubReuse -> throw conflict( "Reserve pub. already used", TalerErrorCode.BANK_DUPLICATE_RESERVE_PUB_SUBJECT ) - is WithdrawalSelectionResult.UnknownAccount -> throw conflict( + WithdrawalSelectionResult.UnknownAccount -> throw conflict( "Account ${req.selected_exchange} not found", TalerErrorCode.BANK_UNKNOWN_ACCOUNT ) - is WithdrawalSelectionResult.AccountIsNotExchange -> throw conflict( + WithdrawalSelectionResult.AccountIsNotExchange -> throw conflict( "Account ${req.selected_exchange} is not an exchange", TalerErrorCode.BANK_ACCOUNT_IS_NOT_EXCHANGE ) + WithdrawalSelectionResult.MissingAmount -> throw conflict( + "An amount is required", + TalerErrorCode.BANK_AMOUNT_REQUIRED + ) + WithdrawalSelectionResult.AmountDiffers -> throw conflict( + "Given amount is different from the current", + TalerErrorCode.BANK_AMOUNT_DIFFERS + ) is WithdrawalSelectionResult.Success -> { call.respond(BankWithdrawalOperationPostResponse( transfer_done = res.status == WithdrawalStatus.confirmed, diff --git a/bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt @@ -489,9 +489,16 @@ private fun Routing.coreBankWithdrawalApi(db: Database, ctx: BankConfig) { auth(db, TokenScope.readwrite) { post("/accounts/{USERNAME}/withdrawals") { val req = call.receive<BankAccountCreateWithdrawalRequest>() - ctx.checkRegionalCurrency(req.amount) + req.amount?.run(ctx::checkRegionalCurrency) + req.suggested_amount?.run(ctx::checkRegionalCurrency) val opId = UUID.randomUUID() - when (db.withdrawal.create(username, opId, req.amount, Instant.now())) { + when (db.withdrawal.create( + username, + opId, + req.amount, + req.suggested_amount, + Instant.now() + )) { WithdrawalCreationResult.UnknownAccount -> throw unknownAccount(username) WithdrawalCreationResult.AccountIsExchange -> throw conflict( "Exchange account cannot perform withdrawal operation", diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/WithdrawalDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/WithdrawalDAO.kt @@ -43,7 +43,8 @@ class WithdrawalDAO(private val db: Database) { suspend fun create( login: String, uuid: UUID, - amount: TalerAmount, + amount: TalerAmount?, + suggested_amount: TalerAmount?, now: Instant ): WithdrawalCreationResult = db.serializable { conn -> val stmt = conn.prepareStatement(""" @@ -51,13 +52,27 @@ class WithdrawalDAO(private val db: Database) { out_account_not_found, out_account_is_exchange, out_balance_insufficient - FROM create_taler_withdrawal(?, ?, (?,?)::taler_amount, ?); + FROM create_taler_withdrawal( + ?,?, + ${if (amount != null) "(?,?)::taler_amount" else "NULL"}, + ${if (suggested_amount != null) "(?,?)::taler_amount" else "NULL"}, + ? + ); """) stmt.setString(1, login) stmt.setObject(2, uuid) - stmt.setLong(3, amount.value) - stmt.setInt(4, amount.frac) - stmt.setLong(5, now.micros()) + var id = 3 + if (amount != null) { + stmt.setLong(id, amount.value) + stmt.setInt(id+1, amount.frac) + id += 2 + } + if (suggested_amount != null) { + stmt.setLong(id, suggested_amount.value) + stmt.setInt(id+1, suggested_amount.frac) + id += 2 + } + stmt.setLong(id, now.micros()) stmt.executeQuery().use { when { !it.next() -> @@ -98,13 +113,16 @@ class WithdrawalDAO(private val db: Database) { data object RequestPubReuse: WithdrawalSelectionResult data object UnknownAccount: WithdrawalSelectionResult data object AccountIsNotExchange: WithdrawalSelectionResult + data object MissingAmount: WithdrawalSelectionResult + data object AmountDiffers: WithdrawalSelectionResult } - /** Set details ([exchangePayto] & [reservePub]) for withdrawal operation [uuid] */ + /** Set details ([exchangePayto] & [reservePub] & [amount]) for withdrawal operation [uuid] */ suspend fun setDetails( uuid: UUID, exchangePayto: Payto, - reservePub: EddsaPublicKey + reservePub: EddsaPublicKey, + amount: TalerAmount? ): WithdrawalSelectionResult = db.serializable { conn -> val stmt = conn.prepareStatement(""" SELECT @@ -113,20 +131,31 @@ class WithdrawalDAO(private val db: Database) { out_reserve_pub_reuse, out_account_not_found, out_account_is_not_exchange, - out_status - FROM select_taler_withdrawal(?, ?, ?, ?); + out_status, + out_missing_amount, + out_amount_differs + FROM select_taler_withdrawal( + ?, ?, ?, ?, + ${if (amount != null) "(?, ?)::taler_amount" else "NULL"} + ); """ ) stmt.setObject(1, uuid) stmt.setBytes(2, reservePub.raw) stmt.setString(3, "Taler withdrawal $reservePub") stmt.setString(4, exchangePayto.canonical) + if (amount != null) { + stmt.setLong(5, amount.value) + stmt.setInt(6, amount.frac) + } stmt.executeQuery().use { when { !it.next() -> throw internalServerError("No result from DB procedure select_taler_withdrawal") it.getBoolean("out_no_op") -> WithdrawalSelectionResult.UnknownOperation it.getBoolean("out_already_selected") -> WithdrawalSelectionResult.AlreadySelected + it.getBoolean("out_missing_amount") -> WithdrawalSelectionResult.MissingAmount + it.getBoolean("out_amount_differs") -> WithdrawalSelectionResult.AmountDiffers it.getBoolean("out_reserve_pub_reuse") -> WithdrawalSelectionResult.RequestPubReuse it.getBoolean("out_account_not_found") -> WithdrawalSelectionResult.UnknownAccount it.getBoolean("out_account_is_not_exchange") -> WithdrawalSelectionResult.AccountIsNotExchange @@ -280,6 +309,8 @@ class WithdrawalDAO(private val db: Database) { END as status ,(amount).val as amount_val ,(amount).frac as amount_frac + ,(suggested_amount).val as suggested_amount_val + ,(suggested_amount).frac as suggested_amount_frac ,selection_done ,aborted ,confirmation_done @@ -294,7 +325,10 @@ class WithdrawalDAO(private val db: Database) { stmt.oneOrNull { BankWithdrawalOperationStatus( status = WithdrawalStatus.valueOf(it.getString("status")), - amount = it.getAmount("amount", db.bankCurrency), + amount = it.getOptAmount("amount", db.bankCurrency), + suggested_amount = it.getOptAmount("suggested_amount", db.bankCurrency), + max_amount = null, + card_fees = null, selection_done = it.getBoolean("selection_done"), transfer_done = it.getBoolean("confirmation_done"), aborted = it.getBoolean("aborted"), @@ -308,7 +342,8 @@ class WithdrawalDAO(private val db: Database) { WireMethod.IBAN -> "iban" WireMethod.X_TALER_BANK -> "x-taler-bank" } - ) + ), + currency = db.bankCurrency ) } } diff --git a/bank/src/test/kotlin/AmountTest.kt b/bank/src/test/kotlin/AmountTest.kt @@ -64,20 +64,23 @@ class AmountTest { // Check whithdraw stmt.executeUpdate() - val wRes = db.withdrawal.create( - login = "merchant", - uuid = UUID.randomUUID(), - amount = due, - now = Instant.now() - ) - val wBool = when (wRes) { - WithdrawalCreationResult.BalanceInsufficient -> false - WithdrawalCreationResult.Success -> true - else -> throw Exception("Unexpected error $txRes") + for ((amount, suggested) in listOf(Pair(due, null), Pair(null, due), Pair(due, due))) { + val wRes = db.withdrawal.create( + login = "merchant", + uuid = UUID.randomUUID(), + amount = due, + suggested_amount = null, + now = Instant.now() + ) + val wBool = when (wRes) { + WithdrawalCreationResult.BalanceInsufficient -> false + WithdrawalCreationResult.Success -> true + else -> throw Exception("Unexpected error $txRes") + } + // Logic must be the same + assertEquals(wBool, txBool) } - - // Logic must be the same - assertEquals(wBool, txBool) + return txBool } diff --git a/bank/src/test/kotlin/BankIntegrationApiTest.kt b/bank/src/test/kotlin/BankIntegrationApiTest.kt @@ -36,21 +36,33 @@ class BankIntegrationApiTest { // GET /taler-integration/withdrawal-operation/UUID @Test - fun get() = bankSetup { _ -> - val amount = TalerAmount("KUDOS:9.0") + fun get() = bankSetup { // Check OK - client.postA("/accounts/customer/withdrawals") { - json { "amount" to amount } - }.assertOkJson<BankAccountCreateWithdrawalResponse> { resp -> - val uuid = resp.taler_withdraw_uri.split("/").last() - client.get("/taler-integration/withdrawal-operation/$uuid") - .assertOkJson<BankWithdrawalOperationStatus> { - assert(!it.selection_done) - assert(!it.aborted) - assert(!it.transfer_done) - assertEquals(amount, it.amount) - assertEquals(listOf("iban"), it.wire_types) - assertEquals(amount, it.amount) + for (valid in listOf( + Pair(null, null), + Pair("KUDOS:1.0", null), + Pair(null, "KUDOS:2.0") , + Pair("KUDOS:3.0", "KUDOS:4.0") + )) { + val amount = valid.first?.run(::TalerAmount) + val suggested = valid.second?.run(::TalerAmount) + client.postA("/accounts/merchant/withdrawals") { + json { + "amount" to amount + "suggested_amount" to suggested + } + }.assertOkJson<BankAccountCreateWithdrawalResponse> { + val uuid = it.taler_withdraw_uri.split("/").last() + client.get("/taler-integration/withdrawal-operation/$uuid") + .assertOkJson<BankWithdrawalOperationStatus> { + assert(!it.selection_done) + assert(!it.aborted) + assert(!it.transfer_done) + assertEquals(amount, it.amount) + assertEquals(suggested, it.suggested_amount) + assertEquals(listOf("iban"), it.wire_types) + assertEquals("KUDOS", it.currency) + } } } @@ -88,7 +100,7 @@ class BankIntegrationApiTest { client.postA("/accounts/merchant/withdrawals") { json { "amount" to "KUDOS:1" } }.assertOkJson<BankAccountCreateWithdrawalResponse> { - val uuid = it.taler_withdraw_uri.split("/").last() + val uuid = it.withdrawal_id // Check OK client.post("/taler-integration/withdrawal-operation/$uuid") { @@ -115,12 +127,20 @@ class BankIntegrationApiTest { client.postA("/accounts/merchant/withdrawals") { json { "amount" to "KUDOS:1" } }.assertOkJson<BankAccountCreateWithdrawalResponse> { - val uuid = it.taler_withdraw_uri.split("/").last() + val uuid = it.withdrawal_id // Check reserve_pub_reuse client.post("/taler-integration/withdrawal-operation/$uuid") { json(req) }.assertConflict(TalerErrorCode.BANK_DUPLICATE_RESERVE_PUB_SUBJECT) + + // Check amount differs + client.post("/taler-integration/withdrawal-operation/$uuid") { + json(req) { + "amount" to "KUDOS:2" + } + }.assertConflict(TalerErrorCode.BANK_AMOUNT_DIFFERS) + // Check unknown account client.post("/taler-integration/withdrawal-operation/$uuid") { json { @@ -128,6 +148,7 @@ class BankIntegrationApiTest { "selected_exchange" to unknownPayto } }.assertConflict(TalerErrorCode.BANK_UNKNOWN_ACCOUNT) + // Check account not exchange client.post("/taler-integration/withdrawal-operation/$uuid") { json { @@ -135,6 +156,40 @@ class BankIntegrationApiTest { "selected_exchange" to merchantPayto } }.assertConflict(TalerErrorCode.BANK_ACCOUNT_IS_NOT_EXCHANGE) + + client.post("/taler-integration/withdrawal-operation/$uuid") { + json { + "reserve_pub" to EddsaPublicKey.rand() + "selected_exchange" to exchangePayto.canonical + "amount" to "KUDOS:1" + } + }.assertOkJson<BankWithdrawalOperationPostResponse>() + } + + client.postA("/accounts/merchant/withdrawals") { + json {} + }.assertOkJson<BankAccountCreateWithdrawalResponse> { + val uuid = it.withdrawal_id + + // Check missing amount + client.post("/taler-integration/withdrawal-operation/$uuid") { + json { + "reserve_pub" to EddsaPublicKey.rand() + "selected_exchange" to exchangePayto.canonical + } + }.assertConflict(TalerErrorCode.BANK_AMOUNT_REQUIRED) + + client.post("/taler-integration/withdrawal-operation/$uuid") { + json { + "reserve_pub" to EddsaPublicKey.rand() + "selected_exchange" to exchangePayto.canonical + "amount" to "KUDOS:1.1" + } + }.assertOkJson<BankWithdrawalOperationPostResponse>() + client.get("/taler-integration/withdrawal-operation/$uuid") + .assertOkJson<BankWithdrawalOperationStatus> { + assertEquals(TalerAmount("KUDOS:1.1"), it.amount) + } } } @@ -145,7 +200,7 @@ class BankIntegrationApiTest { client.postA("/accounts/merchant/withdrawals") { json { "amount" to "KUDOS:1" } }.assertOkJson<BankAccountCreateWithdrawalResponse> { - val uuid = it.taler_withdraw_uri.split("/").last() + val uuid = it.withdrawal_id // Check OK client.postA("/taler-integration/withdrawal-operation/$uuid/abort").assertNoContent() @@ -157,7 +212,7 @@ class BankIntegrationApiTest { client.postA("/accounts/merchant/withdrawals") { json { "amount" to "KUDOS:1" } }.assertOkJson<BankAccountCreateWithdrawalResponse> { - val uuid = it.taler_withdraw_uri.split("/").last() + val uuid = it.withdrawal_id withdrawalSelect(uuid) // Check OK @@ -170,7 +225,7 @@ class BankIntegrationApiTest { client.postA("/accounts/merchant/withdrawals") { json { "amount" to "KUDOS:1" } }.assertOkJson<BankAccountCreateWithdrawalResponse> { - val uuid = it.taler_withdraw_uri.split("/").last() + val uuid = it.withdrawal_id withdrawalSelect(uuid) client.postA("/accounts/merchant/withdrawals/$uuid/confirm").assertNoContent() diff --git a/bank/src/test/kotlin/CoreBankApiTest.kt b/bank/src/test/kotlin/CoreBankApiTest.kt @@ -1142,10 +1142,21 @@ class CoreBankWithdrawalApiTest { authRoutine(HttpMethod.Post, "/accounts/merchant/withdrawals") // Check OK - client.postA("/accounts/merchant/withdrawals") { - json { "amount" to "KUDOS:9.0" } - }.assertOkJson<BankAccountCreateWithdrawalResponse> { - assertEquals("taler+http://withdraw/localhost:80/taler-integration/${it.withdrawal_id}", it.taler_withdraw_uri) + for (valid in listOf( + obj {}, + obj { "amount" to "KUDOS:1.0" }, + obj { "suggested_amount" to "KUDOS:2.0" }, + obj { + "amount" to "KUDOS:3.0" + "suggested_amount" to "KUDOS:4.0" + } + )) { + // Check OK + client.postA("/accounts/merchant/withdrawals") { + json(valid) + }.assertOkJson<BankAccountCreateWithdrawalResponse> { + assertEquals("taler+http://withdraw/localhost:80/taler-integration/${it.withdrawal_id}", it.taler_withdraw_uri) + } } // Check exchange account @@ -1157,6 +1168,17 @@ class CoreBankWithdrawalApiTest { client.postA("/accounts/merchant/withdrawals") { json { "amount" to "KUDOS:90" } }.assertConflict(TalerErrorCode.BANK_UNALLOWED_DEBIT) + client.postA("/accounts/merchant/withdrawals") { + json { "suggested_amount" to "KUDOS:90" } + }.assertConflict(TalerErrorCode.BANK_UNALLOWED_DEBIT) + + // Check wrong currency + client.postA("/accounts/merchant/withdrawals") { + json { "amount" to "EUR:90" } + }.assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH) + client.postA("/accounts/merchant/withdrawals") { + json { "suggested_amount" to "EUR:90" } + }.assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH) } // GET /withdrawals/withdrawal_id diff --git a/bank/src/test/kotlin/GcTest.kt b/bank/src/test/kotlin/GcTest.kt @@ -98,11 +98,11 @@ class GcTest { for (time in times) { val uuid = UUID.randomUUID() assertEquals( - db.withdrawal.create(account, uuid, from, time), + db.withdrawal.create(account, uuid, from, null, time), WithdrawalCreationResult.Success ) assertIs<WithdrawalSelectionResult.Success>( - db.withdrawal.setDetails(uuid, exchangePayto, EddsaPublicKey.rand()) + db.withdrawal.setDetails(uuid, exchangePayto, EddsaPublicKey.rand(), null) ) assertEquals( db.withdrawal.confirm(account, uuid, time, false), @@ -117,7 +117,7 @@ class GcTest { } for (time in listOf(now, abort, clean, delete)) { assertEquals( - db.withdrawal.create(account, UUID.randomUUID(), from, time), + db.withdrawal.create(account, UUID.randomUUID(), from, null, time), WithdrawalCreationResult.Success ) } diff --git a/common/src/main/kotlin/TalerErrorCode.kt b/common/src/main/kotlin/TalerErrorCode.kt @@ -355,7 +355,7 @@ enum class TalerErrorCode(val code: Int) { /** * The backend could not locate a required template to generate an HTML reply. The system administrator should check if the resource files are installed in the correct location and are readable to the service. - * Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500). + * Returned with an HTTP status code of #MHD_HTTP_NOT_ACCEPTABLE (406). * (A value of 0 indicates that the error is generated client-side). */ GENERIC_FAILED_TO_LOAD_TEMPLATE(74), @@ -1946,7 +1946,7 @@ enum class TalerErrorCode(val code: Int) { /** - * The payto-URI hash did not match. Hence the request was denied. + * The KYC authorization signature was invalid. Hence the request was denied. * Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403). * (A value of 0 indicates that the error is generated client-side). */ @@ -2018,6 +2018,22 @@ enum class TalerErrorCode(val code: Int) { /** + * The exchange is unaware of the given requirement row. + * Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404). + * (A value of 0 indicates that the error is generated client-side). + */ + EXCHANGE_KYC_CHECK_REQUEST_UNKNOWN(1939), + + + /** + * The exchange has no account public key to check the KYC authorization signature against. Hence the request was denied. The user should do a wire transfer to the exchange with the KYC authorization key in the subject. + * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409). + * (A value of 0 indicates that the error is generated client-side). + */ + EXCHANGE_KYC_CHECK_AUTHORIZATION_KEY_UNKNOWN(1940), + + + /** * The exchange does not know a contract under the given contract public key. * Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404). * (A value of 0 indicates that the error is generated client-side). @@ -3634,6 +3650,22 @@ enum class TalerErrorCode(val code: Int) { /** + * Specified amount will not work for this withdrawal. + * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409). + * (A value of 0 indicates that the error is generated client-side). + */ + BANK_AMOUNT_DIFFERS(5148), + + + /** + * The backend requires an amount to be specified. + * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409). + * (A value of 0 indicates that the error is generated client-side). + */ + BANK_AMOUNT_REQUIRED(5149), + + + /** * The sync service failed find the account in its database. * Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404). * (A value of 0 indicates that the error is generated client-side). @@ -4610,6 +4642,54 @@ enum class TalerErrorCode(val code: Int) { /** + * The Donau is not aware of the donation unit requested for the operation. + * Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404). + * (A value of 0 indicates that the error is generated client-side). + */ + DONAU_GENERIC_DONATION_UNIT_UNKNOWN(8611), + + + /** + * The Donau failed to talk to the process responsible for its private donation unit keys or the helpers had no donation units (properly) configured. + * Returned with an HTTP status code of #MHD_HTTP_BAD_GATEWAY (502). + * (A value of 0 indicates that the error is generated client-side). + */ + DONAU_DONATION_UNIT_HELPER_UNAVAILABLE(8612), + + + /** + * The Donau failed to talk to the process responsible for its private signing keys. + * Returned with an HTTP status code of #MHD_HTTP_BAD_GATEWAY (502). + * (A value of 0 indicates that the error is generated client-side). + */ + DONAU_SIGNKEY_HELPER_UNAVAILABLE(8613), + + + /** + * The response from the online signing key helper process was malformed. + * Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500). + * (A value of 0 indicates that the error is generated client-side). + */ + DONAU_SIGNKEY_HELPER_BUG(8614), + + + /** + * The number of segments included in the URI does not match the number of segments expected by the endpoint. + * Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404). + * (A value of 0 indicates that the error is generated client-side). + */ + DONAU_GENERIC_WRONG_NUMBER_OF_SEGMENTS(8615), + + + /** + * The signature of the donation receipt is not valid. + * Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403). + * (A value of 0 indicates that the error is generated client-side). + */ + DONAU_DONATION_RECEIPT_SIGNATURE_INVALID(8616), + + + /** * A generic error happened in the LibEuFin nexus. See the enclose details JSON for more information. * Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0). * (A value of 0 indicates that the error is generated client-side). diff --git a/database-versioning/libeufin-bank-0005.sql b/database-versioning/libeufin-bank-0005.sql @@ -0,0 +1,24 @@ +-- +-- This file is part of TALER +-- Copyright (C) 2024 Taler Systems SA +-- +-- TALER is free software; you can redistribute it and/or modify it under the +-- terms of the GNU General Public License as published by the Free Software +-- Foundation; either version 3, or (at your option) any later version. +-- +-- TALER 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 General Public License for more details. +-- +-- You should have received a copy of the GNU General Public License along with +-- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + +BEGIN; + +SELECT _v.register_patch('libeufin-bank-0005', NULL, NULL); +SET search_path TO libeufin_bank; + +ALTER TABLE taler_withdrawal_operations ADD suggested_amount taler_amount; +ALTER TABLE taler_withdrawal_operations ALTER COLUMN amount DROP NOT NULL; + +COMMIT; diff --git a/database-versioning/libeufin-bank-procedures.sql b/database-versioning/libeufin-bank-procedures.sql @@ -505,6 +505,7 @@ CREATE FUNCTION create_taler_withdrawal( IN in_account_username TEXT, IN in_withdrawal_uuid UUID, IN in_amount taler_amount, + IN in_suggested_amount taler_amount, IN in_now_date INT8, -- Error status OUT out_account_not_found BOOLEAN, @@ -527,15 +528,23 @@ IF NOT FOUND OR out_account_is_exchange THEN END IF; -- Check enough funds -SELECT account_balance_is_sufficient(account_id, in_amount) INTO out_balance_insufficient; -IF out_balance_insufficient THEN - RETURN; +IF in_amount IS NOT NULL THEN + SELECT account_balance_is_sufficient(account_id, in_amount) INTO out_balance_insufficient; + IF out_balance_insufficient THEN + RETURN; + END IF; +END IF; +IF in_suggested_amount IS NOT NULL THEN + SELECT account_balance_is_sufficient(account_id, in_suggested_amount) INTO out_balance_insufficient; + IF out_balance_insufficient THEN + RETURN; + END IF; END IF; -- Create withdrawal operation INSERT INTO taler_withdrawal_operations - (withdrawal_uuid, wallet_bank_account, amount, creation_date) - VALUES (in_withdrawal_uuid, account_id, in_amount, in_now_date); + (withdrawal_uuid, wallet_bank_account, amount, suggested_amount, creation_date) + VALUES (in_withdrawal_uuid, account_id, in_amount, in_suggested_amount, in_now_date); END $$; COMMENT ON FUNCTION create_taler_withdrawal IS 'Create a new withdrawal operation'; @@ -544,12 +553,15 @@ CREATE FUNCTION select_taler_withdrawal( IN in_reserve_pub BYTEA, IN in_subject TEXT, IN in_selected_exchange_payto TEXT, + IN in_amount taler_amount, -- Error status OUT out_no_op BOOLEAN, OUT out_already_selected BOOLEAN, OUT out_reserve_pub_reuse BOOLEAN, OUT out_account_not_found BOOLEAN, OUT out_account_is_not_exchange BOOLEAN, + OUT out_missing_amount BOOLEAN, + OUT out_amount_differs BOOLEAN, -- Success return OUT out_status TEXT ) @@ -566,11 +578,13 @@ SELECT ELSE 'selected' END, selection_done - AND (selected_exchange_payto != in_selected_exchange_payto OR reserve_pub != in_reserve_pub) - INTO not_selected, out_status, out_already_selected + AND (selected_exchange_payto != in_selected_exchange_payto OR reserve_pub != in_reserve_pub OR amount != in_amount), + amount IS NULL AND in_amount IS NULL, + amount IS NOT NULL AND amount != in_amount + INTO not_selected, out_status, out_already_selected, out_missing_amount, out_amount_differs FROM taler_withdrawal_operations WHERE withdrawal_uuid=in_withdrawal_uuid; -IF NOT FOUND OR out_already_selected THEN +IF NOT FOUND OR out_already_selected OR out_missing_amount OR out_amount_differs THEN out_no_op=NOT FOUND; RETURN; END IF; @@ -596,7 +610,11 @@ IF not_selected THEN -- Update withdrawal operation UPDATE taler_withdrawal_operations - SET selected_exchange_payto=in_selected_exchange_payto, reserve_pub=in_reserve_pub, subject=in_subject, selection_done=true + SET selected_exchange_payto=in_selected_exchange_payto, + reserve_pub=in_reserve_pub, + subject=in_subject, + selection_done=true, + amount=COALESCE(amount, in_amount) WHERE withdrawal_uuid=in_withdrawal_uuid; -- Notify status change