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:
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,