libeufin

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

commit 7dd1b0ccbbcf42750a84baeda411e3165e0a2105
parent 259a31fd6a6df23a2e239f407f64f0410b873c96
Author: Antoine A <>
Date:   Thu, 13 Jun 2024 17:33:11 +0200

bank: check amount with fee during withdrawal creation and selection

Diffstat:
Mbank/src/main/kotlin/tech/libeufin/bank/api/BankIntegrationApi.kt | 8++++++--
Mbank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt | 3++-
Mbank/src/main/kotlin/tech/libeufin/bank/db/WithdrawalDAO.kt | 26+++++++++++++++++++-------
Mbank/src/test/kotlin/AmountTest.kt | 5+++--
Mbank/src/test/kotlin/BankIntegrationApiTest.kt | 11++++++++++-
Mbank/src/test/kotlin/CoreBankApiTest.kt | 19+++++++++++++++++++
Mbank/src/test/kotlin/GcTest.kt | 10+++++-----
Mbank/src/test/kotlin/helpers.kt | 2+-
Mcommon/src/main/kotlin/TalerCommon.kt | 2++
Mdatabase-versioning/libeufin-bank-procedures.sql | 33+++++++++++++++++++++++++++++----
10 files changed, 96 insertions(+), 23 deletions(-)

diff --git a/bank/src/main/kotlin/tech/libeufin/bank/api/BankIntegrationApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/api/BankIntegrationApi.kt @@ -58,9 +58,9 @@ fun Routing.bankIntegrationApi(db: Database, ctx: BankConfig) { val req = call.receive<BankWithdrawalOperationPostRequest>() req.amount?.run(ctx::checkRegionalCurrency) val res = db.withdrawal.setDetails( - uuid, req.selected_exchange, req.reserve_pub, req.amount + uuid, req.selected_exchange, req.reserve_pub, req.amount, ctx.wireTransferFees ) - // TODO check amount + when (res) { WithdrawalSelectionResult.UnknownOperation -> throw notFound( "Withdrawal operation '$uuid' not found", @@ -90,6 +90,10 @@ fun Routing.bankIntegrationApi(db: Database, ctx: BankConfig) { "Given amount is different from the current", TalerErrorCode.BANK_AMOUNT_DIFFERS ) + WithdrawalSelectionResult.BalanceInsufficient -> throw conflict( + "Insufficient funds", + TalerErrorCode.BANK_UNALLOWED_DEBIT + ) 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 @@ -509,7 +509,8 @@ private fun Routing.coreBankWithdrawalApi(db: Database, ctx: BankConfig) { opId, req.amount, req.suggested_amount, - Instant.now() + Instant.now(), + ctx.wireTransferFees )) { WithdrawalCreationResult.UnknownAccount -> throw unknownAccount(username) WithdrawalCreationResult.AccountIsExchange -> throw conflict( diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/WithdrawalDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/WithdrawalDAO.kt @@ -45,7 +45,8 @@ class WithdrawalDAO(private val db: Database) { uuid: UUID, amount: TalerAmount?, suggested_amount: TalerAmount?, - now: Instant + now: Instant, + wireTransferFees: TalerAmount, ): WithdrawalCreationResult = db.serializable { conn -> val stmt = conn.prepareStatement(""" SELECT @@ -56,7 +57,7 @@ class WithdrawalDAO(private val db: Database) { ?,?, ${if (amount != null) "(?,?)::taler_amount" else "NULL"}, ${if (suggested_amount != null) "(?,?)::taler_amount" else "NULL"}, - ? + ?, (?, ?)::taler_amount ); """) stmt.setString(1, login) @@ -73,6 +74,8 @@ class WithdrawalDAO(private val db: Database) { id += 2 } stmt.setLong(id, now.micros()) + stmt.setLong(id+1, wireTransferFees.value) + stmt.setInt(id+2, wireTransferFees.frac) stmt.executeQuery().use { when { !it.next() -> @@ -115,6 +118,7 @@ class WithdrawalDAO(private val db: Database) { data object AccountIsNotExchange: WithdrawalSelectionResult data object MissingAmount: WithdrawalSelectionResult data object AmountDiffers: WithdrawalSelectionResult + data object BalanceInsufficient: WithdrawalSelectionResult } /** Set details ([exchangePayto] & [reservePub] & [amount]) for withdrawal operation [uuid] */ @@ -122,7 +126,8 @@ class WithdrawalDAO(private val db: Database) { uuid: UUID, exchangePayto: Payto, reservePub: EddsaPublicKey, - amount: TalerAmount? + amount: TalerAmount?, + wireTransferFees: TalerAmount, ): WithdrawalSelectionResult = db.serializable { conn -> val stmt = conn.prepareStatement(""" SELECT @@ -133,10 +138,12 @@ class WithdrawalDAO(private val db: Database) { out_account_is_not_exchange, out_status, out_missing_amount, - out_amount_differs + out_amount_differs, + out_balance_insufficient FROM select_taler_withdrawal( ?, ?, ?, ?, - ${if (amount != null) "(?, ?)::taler_amount" else "NULL"} + ${if (amount != null) "(?, ?)::taler_amount" else "NULL"}, + (?,?)::taler_amount ); """ ) @@ -144,14 +151,19 @@ class WithdrawalDAO(private val db: Database) { stmt.setBytes(2, reservePub.raw) stmt.setString(3, "Taler withdrawal $reservePub") stmt.setString(4, exchangePayto.canonical) + var id = 5 if (amount != null) { - stmt.setLong(5, amount.value) - stmt.setInt(6, amount.frac) + stmt.setLong(id, amount.value) + stmt.setInt(id+1, amount.frac) + id += 2 } + stmt.setLong(id, wireTransferFees.value) + stmt.setInt(id+1, wireTransferFees.frac) stmt.executeQuery().use { when { !it.next() -> throw internalServerError("No result from DB procedure select_taler_withdrawal") + it.getBoolean("out_balance_insufficient") -> WithdrawalSelectionResult.BalanceInsufficient it.getBoolean("out_no_op") -> WithdrawalSelectionResult.UnknownOperation it.getBoolean("out_already_selected") -> WithdrawalSelectionResult.AlreadySelected it.getBoolean("out_missing_amount") -> WithdrawalSelectionResult.MissingAmount diff --git a/bank/src/test/kotlin/AmountTest.kt b/bank/src/test/kotlin/AmountTest.kt @@ -55,7 +55,7 @@ class AmountTest { timestamp = Instant.now(), is2fa = false, requestUid = null, - wireTransferFees = TalerAmount("KUDOS:0") + wireTransferFees = TalerAmount.zero("KUDOS") ) val txBool = when (txRes) { BankTransactionResult.BalanceInsufficient -> false @@ -71,7 +71,8 @@ class AmountTest { uuid = UUID.randomUUID(), amount = due, suggested_amount = null, - now = Instant.now() + now = Instant.now(), + wireTransferFees = TalerAmount.zero("KUDOS") ) val wBool = when (wRes) { WithdrawalCreationResult.BalanceInsufficient -> false diff --git a/bank/src/test/kotlin/BankIntegrationApiTest.kt b/bank/src/test/kotlin/BankIntegrationApiTest.kt @@ -58,7 +58,7 @@ class BankIntegrationApiTest { assert(!it.selection_done) assert(!it.aborted) assert(!it.transfer_done) - assertEquals(it.card_fees, TalerAmount("KUDOS:0")) + assertEquals(it.card_fees, TalerAmount.zero("KUDOS")) assertEquals(amount, it.amount) assertEquals(suggested, it.suggested_amount) assertEquals(listOf("iban"), it.wire_types) @@ -180,6 +180,15 @@ class BankIntegrationApiTest { } }.assertConflict(TalerErrorCode.BANK_AMOUNT_REQUIRED) + // Check insufficient fund + client.post("/taler-integration/withdrawal-operation/$uuid") { + json { + "reserve_pub" to EddsaPublicKey.rand() + "selected_exchange" to exchangePayto.canonical + "amount" to "KUDOS:11" + } + }.assertConflict(TalerErrorCode.BANK_UNALLOWED_DEBIT) + client.post("/taler-integration/withdrawal-operation/$uuid") { json { "reserve_pub" to EddsaPublicKey.rand() diff --git a/bank/src/test/kotlin/CoreBankApiTest.kt b/bank/src/test/kotlin/CoreBankApiTest.kt @@ -1297,6 +1297,25 @@ class CoreBankWithdrawalApiTest { json { "suggested_amount" to "EUR:90" } }.assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH) } + + @Test + fun createWithFee() = bankSetup(conf = "test_with_fees.conf") { + // Check insufficient fund + for (amount in listOf("KUDOS:11", "KUDOS:10")) { + for (name in listOf("amount", "suggested_amount")) { + client.postA("/accounts/merchant/withdrawals") { + json { name to amount } + }.assertConflict(TalerErrorCode.BANK_UNALLOWED_DEBIT) + } + } + + // Check OK + for (name in listOf("amount", "suggested_amount")) { + client.postA("/accounts/merchant/withdrawals") { + json { name to "KUDOS:9.9" } + }.assertOk() + } + } // GET /withdrawals/withdrawal_id @Test diff --git a/bank/src/test/kotlin/GcTest.kt b/bank/src/test/kotlin/GcTest.kt @@ -98,26 +98,26 @@ class GcTest { for (time in times) { val uuid = UUID.randomUUID() assertEquals( - db.withdrawal.create(account, uuid, from, null, time), + db.withdrawal.create(account, uuid, from, null, time, TalerAmount.zero("KUDOS")), WithdrawalCreationResult.Success ) assertIs<WithdrawalSelectionResult.Success>( - db.withdrawal.setDetails(uuid, exchangePayto, EddsaPublicKey.rand(), null) + db.withdrawal.setDetails(uuid, exchangePayto, EddsaPublicKey.rand(), null, TalerAmount.zero("KUDOS")) ) assertEquals( - db.withdrawal.confirm(account, uuid, TalerAmount("KUDOS:0"), time, false), + db.withdrawal.confirm(account, uuid, TalerAmount.zero("KUDOS"), time, false), WithdrawalConfirmationResult.Success ) assertIs<CashoutCreationResult.Success>( db.cashout.create(account, ShortHashCode.rand(), from, to, "", time, false), ) assertIs<BankTransactionResult.Success>( - db.transaction.create(customerPayto, account, "", from, time, false, ShortHashCode.rand(), TalerAmount("KUDOS:0")), + db.transaction.create(customerPayto, account, "", from, time, false, ShortHashCode.rand(), TalerAmount.zero("KUDOS")), ) } for (time in listOf(now, abort, clean, delete)) { assertEquals( - db.withdrawal.create(account, UUID.randomUUID(), from, null, time), + db.withdrawal.create(account, UUID.randomUUID(), from, null, time, TalerAmount.zero("KUDOS")), WithdrawalCreationResult.Success ) } diff --git a/bank/src/test/kotlin/helpers.kt b/bank/src/test/kotlin/helpers.kt @@ -81,7 +81,7 @@ fun bankSetup( lambda: suspend ApplicationTestBuilder.(Database) -> Unit ) = setup(conf) { db, cfg -> // Creating the exchange and merchant accounts first. - val bonus = TalerAmount("KUDOS:0") + val bonus = TalerAmount.zero("KUDOS") assertIs<AccountCreationResult.Success>(db.account.create( login = "merchant", password = "merchant-password", diff --git a/common/src/main/kotlin/TalerCommon.kt b/common/src/main/kotlin/TalerCommon.kt @@ -242,6 +242,8 @@ class TalerAmount { const val FRACTION_BASE = 100000000 const val MAX_VALUE = 4503599627370496L // 2^52 private val PATTERN = Regex("([A-Z]{1,11}):([0-9]+)(?:\\.([0-9]{1,8}))?") + + fun zero(currency: String) = TalerAmount(0, 0, currency) } } diff --git a/database-versioning/libeufin-bank-procedures.sql b/database-versioning/libeufin-bank-procedures.sql @@ -506,6 +506,7 @@ CREATE FUNCTION create_taler_withdrawal( IN in_amount taler_amount, IN in_suggested_amount taler_amount, IN in_now_date INT8, + IN in_wire_transfer_fees taler_amount, -- Error status OUT out_account_not_found BOOLEAN, OUT out_account_is_exchange BOOLEAN, @@ -514,6 +515,7 @@ CREATE FUNCTION create_taler_withdrawal( LANGUAGE plpgsql AS $$ DECLARE account_id INT8; +amount_with_fee taler_amount; BEGIN -- Check account exists SELECT bank_account_id, is_taler_exchange @@ -528,13 +530,19 @@ END IF; -- Check enough funds IF in_amount IS NOT NULL THEN - SELECT account_balance_is_sufficient(account_id, in_amount) INTO out_balance_insufficient; + SELECT sum.val, sum.frac + INTO amount_with_fee.val, amount_with_fee.frac + FROM amount_add(in_amount, in_wire_transfer_fees) as sum; + SELECT account_balance_is_sufficient(account_id, amount_with_fee) 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; + SELECT sum.val, sum.frac + INTO amount_with_fee.val, amount_with_fee.frac + FROM amount_add(in_suggested_amount, in_wire_transfer_fees) as sum; + SELECT account_balance_is_sufficient(account_id, amount_with_fee) INTO out_balance_insufficient; IF out_balance_insufficient THEN RETURN; END IF; @@ -553,6 +561,7 @@ CREATE FUNCTION select_taler_withdrawal( IN in_subject TEXT, IN in_selected_exchange_payto TEXT, IN in_amount taler_amount, + IN in_wire_transfer_fees taler_amount, -- Error status OUT out_no_op BOOLEAN, OUT out_already_selected BOOLEAN, @@ -561,12 +570,15 @@ CREATE FUNCTION select_taler_withdrawal( OUT out_account_is_not_exchange BOOLEAN, OUT out_missing_amount BOOLEAN, OUT out_amount_differs BOOLEAN, + OUT out_balance_insufficient BOOLEAN, -- Success return OUT out_status TEXT ) LANGUAGE plpgsql AS $$ DECLARE not_selected BOOLEAN; +account_id int8; +amount_with_fee taler_amount; BEGIN -- Check for conflict and idempotence SELECT @@ -579,8 +591,9 @@ SELECT 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 - INTO not_selected, out_status, out_already_selected, out_missing_amount, out_amount_differs + amount IS NOT NULL AND amount != in_amount, + wallet_bank_account + INTO not_selected, out_status, out_already_selected, out_missing_amount, 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 @@ -597,6 +610,7 @@ IF not_selected THEN IF out_reserve_pub_reuse THEN RETURN; END IF; + -- Check exchange account SELECT NOT is_taler_exchange INTO out_account_is_not_exchange @@ -607,6 +621,17 @@ IF not_selected THEN RETURN; END IF; + IF in_amount IS NOT NULL THEN + -- Check enough funds + SELECT sum.val, sum.frac + INTO amount_with_fee.val, amount_with_fee.frac + FROM amount_add(in_amount, in_wire_transfer_fees) as sum; + SELECT account_balance_is_sufficient(account_id, amount_with_fee) INTO out_balance_insufficient; + IF out_balance_insufficient THEN + RETURN; + END IF; + END IF; + -- Update withdrawal operation UPDATE taler_withdrawal_operations SET selected_exchange_payto=in_selected_exchange_payto,