libeufin

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

commit 77012addddc5ade4765a3a68f25be12c44667bef
parent 1935dc625b28540cc8f20ac670fc86313f8b3daf
Author: Antoine A <>
Date:   Thu,  3 Oct 2024 17:10:19 +0200

bank: support setting withdrawal amount during confirmation

Diffstat:
Mbank/src/main/kotlin/tech/libeufin/bank/Constants.kt | 4++--
Mbank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt | 6++++++
Mbank/src/main/kotlin/tech/libeufin/bank/api/BankIntegrationApi.kt | 4----
Mbank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt | 14++++++++++++--
Mbank/src/main/kotlin/tech/libeufin/bank/auth/Tan.kt | 10+++++++++-
Mbank/src/main/kotlin/tech/libeufin/bank/db/WithdrawalDAO.kt | 23+++++++++++++++++------
Mbank/src/main/kotlin/tech/libeufin/bank/helpers.kt | 20++------------------
Mbank/src/test/kotlin/BankIntegrationApiTest.kt | 8--------
Mbank/src/test/kotlin/CoreBankApiTest.kt | 60+++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mbank/src/test/kotlin/GcTest.kt | 2+-
Mdatabase-versioning/libeufin-bank-procedures.sql | 31+++++++++++++++++++------------
11 files changed, 125 insertions(+), 57 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 = "5:0:2" +const val COREBANK_API_VERSION: String = "5:1:2" const val CONVERSION_API_VERSION: String = "0:1:0" -const val INTEGRATION_API_VERSION: String = "3:0:4" +const val INTEGRATION_API_VERSION: String = "4:0:4" diff --git a/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt b/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt @@ -610,4 +610,10 @@ data class PublicAccount( data class AccountPasswordChange( val new_password: String, val old_password: String? = null +) + +// Request POST /accounts/{USERNAME}/withdrawals/{WITHDRAWAL_ID}/confirm +@Serializable +data class BankAccountConfirmWithdrawalRequest( + val amount: TalerAmount? = null ) \ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/api/BankIntegrationApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/api/BankIntegrationApi.kt @@ -96,10 +96,6 @@ fun Routing.bankIntegrationApi(db: Database, ctx: BankConfig) { "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 diff --git a/bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt @@ -555,11 +555,13 @@ private fun Routing.coreBankWithdrawalApi(db: Database, ctx: BankConfig) { } post("/accounts/{USERNAME}/withdrawals/{withdrawal_id}/confirm") { val id = call.uuidPath("withdrawal_id") - val challenge = call.checkChallenge(db, Operation.withdrawal) + val (req, challenge) = call.receiveChallenge<BankAccountConfirmWithdrawalRequest>(db, Operation.withdrawal, BankAccountConfirmWithdrawalRequest()) + req.amount?.run(ctx::checkRegionalCurrency) when (db.withdrawal.confirm( username, id, Instant.now(), + req.amount, challenge != null, ctx.wireTransferFees, ctx.minAmount, @@ -581,6 +583,14 @@ private fun Routing.coreBankWithdrawalApi(db: Database, ctx: BankConfig) { "Insufficient funds", TalerErrorCode.BANK_UNALLOWED_DEBIT ) + WithdrawalConfirmationResult.MissingAmount -> throw conflict( + "An amount is required", + TalerErrorCode.BANK_AMOUNT_REQUIRED + ) + WithdrawalConfirmationResult.AmountDiffers -> throw conflict( + "Given amount is different from the current", + TalerErrorCode.BANK_AMOUNT_DIFFERS + ) WithdrawalConfirmationResult.BadAmount -> throw conflict( "Amount either to high or too low", TalerErrorCode.BANK_UNALLOWED_DEBIT @@ -590,7 +600,7 @@ private fun Routing.coreBankWithdrawalApi(db: Database, ctx: BankConfig) { TalerErrorCode.BANK_UNKNOWN_CREDITOR ) WithdrawalConfirmationResult.TanRequired -> { - call.respondChallenge(db, Operation.withdrawal, StoredUUID(id)) + call.respondChallenge(db, Operation.withdrawal, req) } WithdrawalConfirmationResult.Success -> call.respond(HttpStatusCode.NoContent) } diff --git a/bank/src/main/kotlin/tech/libeufin/bank/auth/Tan.kt b/bank/src/main/kotlin/tech/libeufin/bank/auth/Tan.kt @@ -22,6 +22,7 @@ import io.ktor.http.* import io.ktor.server.application.* import io.ktor.server.request.* import io.ktor.server.response.* +import io.ktor.server.plugins.* import kotlinx.serialization.json.Json import tech.libeufin.bank.* import tech.libeufin.bank.db.Database @@ -70,13 +71,20 @@ suspend inline fun <reified B> ApplicationCall.respondChallenge( */ suspend inline fun <reified B> ApplicationCall.receiveChallenge( db: Database, - op: Operation + op: Operation, + default: B? = null ): Pair<B, Challenge?> { val id = request.headers[X_CHALLENGE_ID]?.toLongOrNull() return if (id != null) { val challenge = db.tan.challenge(id, username, op)!! Pair(Json.decodeFromString(challenge.body), challenge) } else { + if (default != null) { + val contentLenght = request.headers[HttpHeaders.ContentLength]?.toIntOrNull() + if (contentLenght == 0) { + return Pair(default, null) + } + } Pair(this.receive(), null) } } diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/WithdrawalDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/WithdrawalDAO.kt @@ -126,7 +126,6 @@ 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 data object BalanceInsufficient: WithdrawalSelectionResult data object BadAmount: WithdrawalSelectionResult @@ -150,7 +149,6 @@ class WithdrawalDAO(private val db: Database) { out_account_not_found, out_account_is_not_exchange, out_status, - out_missing_amount, out_amount_differs, out_balance_insufficient, out_bad_amount @@ -183,7 +181,6 @@ class WithdrawalDAO(private val db: Database) { it.getBoolean("out_bad_amount") -> WithdrawalSelectionResult.BadAmount 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 @@ -202,7 +199,9 @@ class WithdrawalDAO(private val db: Database) { BadAmount, NotSelected, AlreadyAborted, - TanRequired + TanRequired, + MissingAmount, + AmountDiffers } /** Confirm withdrawal operation [uuid] */ @@ -210,6 +209,7 @@ class WithdrawalDAO(private val db: Database) { username: String, uuid: UUID, timestamp: Instant, + amount: TalerAmount?, is2fa: Boolean, wireTransferFees: TalerAmount, minAmount: TalerAmount, @@ -223,8 +223,13 @@ class WithdrawalDAO(private val db: Database) { out_bad_amount, out_not_selected, out_aborted, - out_tan_required - FROM confirm_taler_withdrawal(?,?,?,?,(?,?)::taler_amount,(?,?)::taler_amount,(?,?)::taler_amount); + out_tan_required, + out_missing_amount, + out_amount_differs + FROM confirm_taler_withdrawal( + ?,?,?,?,(?,?)::taler_amount,(?,?)::taler_amount,(?,?)::taler_amount, + ${if (amount != null) "(?, ?)::taler_amount" else "NULL"} + ); """ ) { setString(1, username) @@ -237,6 +242,10 @@ class WithdrawalDAO(private val db: Database) { setInt(8, minAmount.frac) setLong(9, maxAmount.value) setInt(10, maxAmount.frac) + if (amount != null) { + setLong(11, amount.value) + setInt(12, amount.frac) + } one { when { it.getBoolean("out_no_op") -> WithdrawalConfirmationResult.UnknownOperation @@ -246,6 +255,8 @@ class WithdrawalDAO(private val db: Database) { it.getBoolean("out_not_selected") -> WithdrawalConfirmationResult.NotSelected it.getBoolean("out_aborted") -> WithdrawalConfirmationResult.AlreadyAborted it.getBoolean("out_tan_required") -> WithdrawalConfirmationResult.TanRequired + it.getBoolean("out_missing_amount") -> WithdrawalConfirmationResult.MissingAmount + it.getBoolean("out_amount_differs") -> WithdrawalConfirmationResult.AmountDiffers else -> WithdrawalConfirmationResult.Success } } diff --git a/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt b/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt @@ -120,21 +120,4 @@ fun Route.conditional(implemented: Boolean, callback: Route.() -> Unit): Route = if (!implemented) { throw apiError(HttpStatusCode.NotImplemented, "API not implemented", TalerErrorCode.END) } - } - -@Serializable(with = StoredUUID.Serializer::class) -data class StoredUUID(val value: UUID) { - internal object Serializer : KSerializer<StoredUUID> { - override val descriptor: SerialDescriptor = - PrimitiveSerialDescriptor("StoredUUID", PrimitiveKind.STRING) - - override fun serialize(encoder: Encoder, value: StoredUUID) { - encoder.encodeString(value.value.toString()) - } - - override fun deserialize(decoder: Decoder): StoredUUID { - val string = decoder.decodeString() - return StoredUUID(UUID.fromString(string)) - } - } -} + } +\ No newline at end of file diff --git a/bank/src/test/kotlin/BankIntegrationApiTest.kt b/bank/src/test/kotlin/BankIntegrationApiTest.kt @@ -174,14 +174,6 @@ class BankIntegrationApiTest { }.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) - // Check insufficient fund client.post("/taler-integration/withdrawal-operation/$uuid") { json { diff --git a/bank/src/test/kotlin/CoreBankApiTest.kt b/bank/src/test/kotlin/CoreBankApiTest.kt @@ -1419,10 +1419,46 @@ class CoreBankWithdrawalApiTest { val uuid = it.withdrawal_id withdrawalSelect(uuid) + // Check amount differs + client.postA("/accounts/merchant/withdrawals/$uuid/confirm") { + json { "amount" to "KUDOS:2" } + }.assertConflict(TalerErrorCode.BANK_AMOUNT_DIFFERS) + // Check OK client.postA("/accounts/merchant/withdrawals/$uuid/confirm").assertNoContent() // Check idempotence client.postA("/accounts/merchant/withdrawals/$uuid/confirm").assertNoContent() + + // Check amount differs + client.postA("/accounts/merchant/withdrawals/$uuid/confirm") { + json { "amount" to "KUDOS:2" } + }.assertConflict(TalerErrorCode.BANK_AMOUNT_DIFFERS) + } + + // Check confirm with amount + client.postA("/accounts/merchant/withdrawals") { + json {} + }.assertOkJson<BankAccountCreateWithdrawalResponse> { + val uuid = it.withdrawal_id + withdrawalSelect(uuid) + + // Check missing amount + client.postA("/accounts/merchant/withdrawals/$uuid/confirm") + .assertConflict(TalerErrorCode.BANK_AMOUNT_REQUIRED) + + // Check OK + client.postA("/accounts/merchant/withdrawals/$uuid/confirm") { + json { "amount" to "KUDOS:1" } + }.assertNoContent() + // Check idempotence + client.postA("/accounts/merchant/withdrawals/$uuid/confirm") { + json { "amount" to "KUDOS:1" } + }.assertNoContent() + + // Check amount differs + client.postA("/accounts/merchant/withdrawals/$uuid/confirm") { + json { "amount" to "KUDOS:2" } + }.assertConflict(TalerErrorCode.BANK_AMOUNT_DIFFERS) } // Check confirm aborted @@ -1474,9 +1510,9 @@ class CoreBankWithdrawalApiTest { client.postA("/accounts/merchant/withdrawals/${UUID.randomUUID()}/confirm") .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) - // Check 2fa + // Check 2fa without body fillTanInfo("merchant") - assertBalance("merchant", "-KUDOS:6") + assertBalance("merchant", "-KUDOS:7") client.postA("/accounts/merchant/withdrawals") { json { "amount" to "KUDOS:1" } }.assertOkJson<BankAccountCreateWithdrawalResponse> { @@ -1485,9 +1521,27 @@ class CoreBankWithdrawalApiTest { client.postA("/accounts/merchant/withdrawals/$uuid/confirm") .assertChallenge { _,_-> - assertBalance("merchant", "-KUDOS:6") + assertBalance("merchant", "-KUDOS:7") + }.assertNoContent() + } + + // Check 2fa with body + fillTanInfo("merchant") + assertBalance("merchant", "-KUDOS:8") + client.postA("/accounts/merchant/withdrawals") { + json {} + }.assertOkJson<BankAccountCreateWithdrawalResponse> { + val uuid = it.withdrawal_id + withdrawalSelect(uuid) + + client.postA("/accounts/merchant/withdrawals/$uuid/confirm") { + json { "amount" to "KUDOS:1" } + } + .assertChallenge { _,_-> + assertBalance("merchant", "-KUDOS:8") }.assertNoContent() } + assertBalance("merchant", "-KUDOS:9") } @Test diff --git a/bank/src/test/kotlin/GcTest.kt b/bank/src/test/kotlin/GcTest.kt @@ -110,7 +110,7 @@ class GcTest { db.withdrawal.setDetails(uuid, exchangePayto, EddsaPublicKey.rand(), null, ZERO, ZERO, MAX) ) assertEquals( - db.withdrawal.confirm(account, uuid, time, false, ZERO, ZERO, MAX), + db.withdrawal.confirm(account, uuid, time, null, false, ZERO, ZERO, MAX), WithdrawalConfirmationResult.Success ) assertIs<CashoutCreationResult.Success>( diff --git a/database-versioning/libeufin-bank-procedures.sql b/database-versioning/libeufin-bank-procedures.sql @@ -950,7 +950,6 @@ CREATE FUNCTION select_taler_withdrawal( 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, OUT out_balance_insufficient BOOLEAN, OUT out_bad_amount BOOLEAN, @@ -973,13 +972,12 @@ SELECT END, selection_done 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, + amount != in_amount, wallet_bank_account - INTO not_selected, out_status, out_already_selected, out_missing_amount, out_amount_differs, account_id + INTO not_selected, out_status, out_already_selected, out_amount_differs, account_id FROM taler_withdrawal_operations WHERE withdrawal_uuid=in_withdrawal_uuid; -IF NOT FOUND OR out_already_selected OR out_missing_amount OR out_amount_differs THEN +IF NOT FOUND OR out_already_selected OR out_amount_differs THEN out_no_op=NOT FOUND; RETURN; END IF; @@ -1061,12 +1059,15 @@ CREATE FUNCTION confirm_taler_withdrawal( IN in_wire_transfer_fees taler_amount, IN in_min_amount taler_amount, IN in_max_amount taler_amount, + IN in_amount taler_amount, OUT out_no_op BOOLEAN, OUT out_balance_insufficient BOOLEAN, OUT out_bad_amount BOOLEAN, OUT out_creditor_not_found BOOLEAN, OUT out_exchange_not_found BOOLEAN, OUT out_not_selected BOOLEAN, + OUT out_missing_amount BOOLEAN, + OUT out_amount_differs BOOLEAN, OUT out_aborted BOOLEAN, OUT out_tan_required BOOLEAN ) @@ -1081,7 +1082,7 @@ DECLARE exchange_bank_account_id INT8; tx_row_id INT8; BEGIN --- Check op exists +-- Check op exists and conflict SELECT confirmation_done, aborted, NOT selection_done, @@ -1089,7 +1090,9 @@ SELECT selected_exchange_payto, wallet_bank_account, (amount).val, (amount).frac, - (NOT in_is_tan AND tan_channel IS NOT NULL) + (NOT in_is_tan AND tan_channel IS NOT NULL), + amount IS NULL AND in_amount IS NULL, + amount != in_amount INTO already_confirmed, out_aborted, out_not_selected, @@ -1097,14 +1100,18 @@ SELECT selected_exchange_payto_local, wallet_bank_account_local, amount_local.val, amount_local.frac, - out_tan_required + out_tan_required, + out_missing_amount, + out_amount_differs FROM taler_withdrawal_operations JOIN bank_accounts ON wallet_bank_account=bank_account_id JOIN customers ON owning_customer_id=customer_id WHERE withdrawal_uuid=in_withdrawal_uuid AND username=in_username AND deleted_at IS NULL; -IF NOT FOUND OR already_confirmed OR out_aborted OR out_not_selected THEN - out_no_op=NOT FOUND; +out_no_op=NOT FOUND; +IF out_no_op OR already_confirmed OR out_aborted OR out_not_selected OR out_missing_amount OR out_amount_differs THEN RETURN; +ELSIF in_amount IS NOT NULL THEN + amount_local = in_amount; END IF; -- Check exchange account then 2fa @@ -1137,9 +1144,9 @@ IF out_balance_insufficient OR out_bad_amount THEN RETURN; END IF; --- Confirm operation +-- Confirm operation and update amount UPDATE taler_withdrawal_operations - SET confirmation_done = true + SET amount=amount_local, confirmation_done = true WHERE withdrawal_uuid=in_withdrawal_uuid; -- Register incoming transaction