libeufin

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

commit 336568dc7e9d70e14c12e69e5b3b448968f70065
parent dcac26dacf579751b96a145840815980b1d34a3d
Author: Antoine A <>
Date:   Wed, 14 Aug 2024 12:43:50 +0200

bank: fix withdrawal max_amount computation

Diffstat:
Mbank/src/test/kotlin/AmountTest.kt | 151++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Mcommon/src/main/kotlin/Client.kt | 9+++++++++
Mdatabase-versioning/libeufin-bank-procedures.sql | 13++++++++-----
3 files changed, 116 insertions(+), 57 deletions(-)

diff --git a/bank/src/test/kotlin/AmountTest.kt b/bank/src/test/kotlin/AmountTest.kt @@ -17,17 +17,18 @@ * <http://www.gnu.org/licenses/> */ +import io.ktor.http.* import org.junit.Test import tech.libeufin.bank.db.TransactionDAO.BankTransactionResult import tech.libeufin.bank.db.WithdrawalDAO.WithdrawalCreationResult import tech.libeufin.common.* -import tech.libeufin.common.db.one +import tech.libeufin.common.db.* import java.time.Instant import java.util.* import kotlin.test.assertEquals class AmountTest { - // Test amount computation in database + // Test amount computation in db @Test fun computationTest() = bankSetup { db -> db.conn { conn -> conn.execSQLUpdate("UPDATE libeufin_bank.bank_accounts SET balance.val = 100000 WHERE internal_payto_uri = '${customerPayto.canonical}'") @@ -38,50 +39,45 @@ class AmountTest { ,max_debt = (?, ?)::taler_amount WHERE internal_payto_uri = '${merchantPayto.canonical}' """) - suspend fun routine(balance: TalerAmount, due: TalerAmount, hasBalanceDebt: Boolean, maxDebt: TalerAmount): Boolean { + suspend fun routine( + balance: TalerAmount, + hasDebt: Boolean, + maxDebt: TalerAmount, + amount: TalerAmount + ): Boolean { stmt.setLong(1, balance.value) stmt.setInt(2, balance.frac) - stmt.setBoolean(3, hasBalanceDebt) + stmt.setBoolean(3, hasDebt) stmt.setLong(4, maxDebt.value) stmt.setInt(5, maxDebt.frac) // Check bank transaction stmt.executeUpdate() - val txRes = db.transaction.create( - creditAccountPayto = customerPayto, - debitAccountUsername = "merchant", - subject = "test", - amount = due, - timestamp = Instant.now(), - is2fa = false, - requestUid = null, - wireTransferFees = TalerAmount.zero("KUDOS"), - minAmount = TalerAmount.zero("KUDOS"), - maxAmount = TalerAmount.max("KUDOS") - ) - val txBool = when (txRes) { - BankTransactionResult.BalanceInsufficient -> false - is BankTransactionResult.Success -> true + val txRes = client.postA("/accounts/merchant/transactions") { + json { + "payto_uri" to "$customerPayto?message=" + "amount" to amount + } + } + val txBool = when { + txRes.isStatus(HttpStatusCode.OK, null) -> true + txRes.isStatus(HttpStatusCode.Conflict, TalerErrorCode.BANK_UNALLOWED_DEBIT) -> false else -> throw Exception("Unexpected error $txRes") } // Check whithdraw stmt.executeUpdate() - 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, - timestamp = Instant.now(), - wireTransferFees = TalerAmount.zero("KUDOS"), - minAmount = TalerAmount.zero("KUDOS"), - maxAmount = TalerAmount.max("KUDOS") - ) - val wBool = when (wRes) { - WithdrawalCreationResult.BalanceInsufficient -> false - WithdrawalCreationResult.Success -> true - else -> throw Exception("Unexpected error $txRes") + for ((amount, suggested) in listOf(Pair(amount, null), Pair(null, amount), Pair(amount, amount))) { + val wRes = client.postA("/accounts/merchant/withdrawals") { + json { + "amount" to amount + "suggested_amount" to suggested + } + } + val wBool = when { + wRes.isStatus(HttpStatusCode.OK, null) -> true + wRes.isStatus(HttpStatusCode.Conflict, TalerErrorCode.BANK_UNALLOWED_DEBIT) -> false + else -> throw Exception("Unexpected error $wRes") } // Logic must be the same assertEquals(wBool, txBool) @@ -93,44 +89,95 @@ class AmountTest { // Balance enough, assert for true assert(routine( balance = TalerAmount(10, 0, "KUDOS"), - due = TalerAmount(8, 0, "KUDOS"), - hasBalanceDebt = false, - maxDebt = TalerAmount(100, 0, "KUDOS") + hasDebt = false, + maxDebt = TalerAmount(100, 0, "KUDOS"), + amount = TalerAmount(8, 0, "KUDOS"), )) // Balance still sufficient, thanks for big enough debt permission. Assert true. assert(routine( balance = TalerAmount(10, 0, "KUDOS"), - due = TalerAmount(80, 0, "KUDOS"), - hasBalanceDebt = false, - maxDebt = TalerAmount(100, 0, "KUDOS") + hasDebt = false, + maxDebt = TalerAmount(100, 0, "KUDOS"), + amount = TalerAmount(80, 0, "KUDOS"), )) // Balance not enough, max debt cannot cover, asserting for false. assert(!routine( balance = TalerAmount(10, 0, "KUDOS"), - due = TalerAmount(80, 0, "KUDOS"), - hasBalanceDebt = true, - maxDebt = TalerAmount(50, 0, "KUDOS") + hasDebt = true, + maxDebt = TalerAmount(50, 0, "KUDOS"), + amount = TalerAmount(80, 0, "KUDOS"), )) // Balance becomes enough, due to a larger max debt, asserting for true. assert(routine( balance = TalerAmount(10, 0, "KUDOS"), - due = TalerAmount(80, 0, "KUDOS"), - hasBalanceDebt = false, - maxDebt = TalerAmount(70, 0, "KUDOS") + hasDebt = false, + maxDebt = TalerAmount(70, 0, "KUDOS"), + amount = TalerAmount(80, 0, "KUDOS"), )) // Max debt not enough for the smallest fraction, asserting for false assert(!routine( balance = TalerAmount(0, 0, "KUDOS"), - due = TalerAmount(0, 2, "KUDOS"), - hasBalanceDebt = false, - maxDebt = TalerAmount(0, 1, "KUDOS") + hasDebt = false, + maxDebt = TalerAmount(0, 1, "KUDOS"), + amount = TalerAmount(0, 2, "KUDOS"), )) // Same as above, but already in debt. assert(!routine( balance = TalerAmount(0, 1, "KUDOS"), - due = TalerAmount(0, 1, "KUDOS"), - hasBalanceDebt = true, - maxDebt = TalerAmount(0, 1, "KUDOS") + hasDebt = true, + maxDebt = TalerAmount(0, 1, "KUDOS"), + amount = TalerAmount(0, 1, "KUDOS"), + )) + }} + + // Max withdrawal amount computation in db + @Test + fun maxComputationTest() = bankSetup { db -> db.conn { conn -> + val update = conn.prepareStatement(""" + UPDATE libeufin_bank.bank_accounts + SET balance = (?, ?)::taler_amount + ,has_debt = ? + ,max_debt = (?, ?)::taler_amount + WHERE bank_account_id = 1 + """) + val select = conn.prepareStatement(""" + SELECT + (max_amount).val as max_amount_val + ,(max_amount).frac as max_amount_frac + FROM account_max_amount(1, (?, ?)::taler_amount) AS max_amount + """) + select.apply { + val max = TalerAmount.max("KUDOS") + setLong(1, max.value) + setInt(2, max.frac) + } + suspend fun routine( + balance: TalerAmount, + hasDebt: Boolean, + maxDebt: TalerAmount + ): TalerAmount { + update.apply { + setLong(1, balance.value) + setInt(2, balance.frac) + setBoolean(3, hasDebt) + setLong(4, maxDebt.value) + setInt(5, maxDebt.frac) + executeUpdate() + } + return select.one { it.getAmount("max_amount", "KUDOS") } + } + + // Without debt + assertEquals(TalerAmount(110, 3, "KUDOS"), routine( + balance = TalerAmount(10, 1, "KUDOS"), + hasDebt = false, + maxDebt = TalerAmount(100, 2, "KUDOS"), + )) + // With debt + assertEquals(TalerAmount(90, 1, "KUDOS"), routine( + balance = TalerAmount(10, 1, "KUDOS"), + hasDebt = true, + maxDebt = TalerAmount(100, 2, "KUDOS"), )) }} diff --git a/common/src/main/kotlin/Client.kt b/common/src/main/kotlin/Client.kt @@ -78,6 +78,15 @@ suspend inline fun <reified B> HttpResponse.assertAcceptedJson(lambda: (B) -> Un /* ----- Assert ----- */ +suspend fun HttpResponse.isStatus(status: HttpStatusCode, err: TalerErrorCode?): Boolean { + if (status != this.status) return false + if (err != null) { + val body = json<TalerError>() + return err.code == body.code + } + return true +} + suspend fun HttpResponse.assertStatus(status: HttpStatusCode, err: TalerErrorCode?): HttpResponse { assertEquals(status, this.status, if (err != null) "$err" else err) if (err != null) { diff --git a/database-versioning/libeufin-bank-procedures.sql b/database-versioning/libeufin-bank-procedures.sql @@ -171,12 +171,15 @@ CREATE FUNCTION account_max_amount( LANGUAGE plpgsql AS $$ BEGIN -- add balance and max_debt -WITH added AS ( - SELECT amount_add(balance, max_debt) as sum FROM bank_accounts - WHERE bank_account_id=in_account_id -) SELECT (sum).val, (sum).frac +WITH computed AS ( + SELECT CASE has_debt + WHEN false THEN amount_add(balance, max_debt) + ELSE (SELECT diff FROM amount_left_minus_right(max_debt, balance)) + END AS amount + FROM bank_accounts WHERE bank_account_id=in_account_id +) SELECT (amount).val, (amount).frac INTO out_max_amount.val, out_max_amount.frac - FROM added; + FROM computed; IF in_max_amount.val < out_max_amount.val OR (in_max_amount.val = out_max_amount.val OR in_max_amount.frac < out_max_amount.frac) THEN