libeufin

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

commit 26097f20f8003b981a6d231fcd91592e818323d0
parent c643f732b6ecbfb0b8457b66bb98fed79f346a84
Author: MS <ms@taler.net>
Date:   Tue, 19 Sep 2023 13:56:46 +0200

Starting the Taler API for the SPA.

Introducing helpers to check if a balance is enough
before initiating the withdrawal.

Diffstat:
Mbank/src/main/kotlin/tech/libeufin/bank/Database.kt | 3++-
Mbank/src/main/kotlin/tech/libeufin/bank/Main.kt | 3++-
Mbank/src/main/kotlin/tech/libeufin/bank/helpers.kt | 80++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Abank/src/main/kotlin/tech/libeufin/bank/talerWebHandlers.kt | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbank/src/main/kotlin/tech/libeufin/bank/tokenHandlers.kt | 19+++++++++++++++++++
Mbank/src/main/kotlin/tech/libeufin/bank/types.kt | 13++++++++++---
Mbank/src/test/kotlin/AmountTest.kt | 49+++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 216 insertions(+), 6 deletions(-)

diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Database.kt b/bank/src/main/kotlin/tech/libeufin/bank/Database.kt @@ -30,7 +30,8 @@ import kotlin.math.abs private const val DB_CTR_LIMIT = 1000000 -fun Customer.expectRowId(): Long = this.dbRowId ?: throw internalServerError("Cutsomer '$login' had no DB row ID") +fun Customer.expectRowId(): Long = this.dbRowId ?: throw internalServerError("Cutsomer '$login' had no DB row ID.") +fun BankAccount.expectBalance(): TalerAmount = this.balance ?: throw internalServerError("Bank account '${this.internalPaytoUri}' lacks balance.") class Database(private val dbConfig: String) { diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Main.kt b/bank/src/main/kotlin/tech/libeufin/bank/Main.kt @@ -50,6 +50,7 @@ val logger: Logger = LoggerFactory.getLogger("tech.libeufin.bank") val db = Database(System.getProperty("BANK_DB_CONNECTION_STRING")) const val GENERIC_UNDEFINED = -1 // Filler for ECs that don't exist yet. val TOKEN_DEFAULT_DURATION_US = Duration.ofDays(1L).seconds * 1000000 +const val FRACTION_BASE = 100000000 /** @@ -224,7 +225,7 @@ val webApp: Application.() -> Unit = { this.accountsMgmtHandlers() this.tokenHandlers() this.transactionsHandlers() - // this.talerHandlers() + this.talerWebHandlers() // this.walletIntegrationHandlers() } } \ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt b/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt @@ -230,7 +230,7 @@ fun parseTalerAmount( // Fraction is at most 8 digits, so it's always < than MAX_INT. val fraction: Int = match.destructured.component3().run { var frac = 0 - var power = 100000000 + var power = FRACTION_BASE if (this.isNotEmpty()) // Skips the dot and processes the fractional chars. this.substring(1).forEach { chr -> @@ -257,4 +257,82 @@ fun parseTalerAmount( ) } +private fun normalizeAmount(amt: TalerAmount): TalerAmount { + if (amt.frac > FRACTION_BASE) { + val normalValue = amt.value + (amt.frac / FRACTION_BASE) + val normalFrac = amt.frac % FRACTION_BASE + return TalerAmount( + value = normalValue, + frac = normalFrac, + maybeCurrency = amt.currency + ) + } + return amt +} + + +// Adds two amounts and returns the normalized version. +private fun amountAdd(first: TalerAmount, second: TalerAmount): TalerAmount { + if (first.currency != second.currency) + throw badRequest( + "Currency mismatch, balance '${first.currency}', price '${second.currency}'", + TalerErrorCode.TALER_EC_GENERIC_CURRENCY_MISMATCH + ) + val valueAdd = first.value + second.value + if (valueAdd < first.value) + throw badRequest("Amount value overflowed") + val fracAdd = first.frac + second.frac + if (fracAdd < first.frac) + throw badRequest("Amount fraction overflowed") + return normalizeAmount(TalerAmount( + value = valueAdd, + frac = fracAdd, + maybeCurrency = first.currency + )) +} + +/** + * Checks whether the balance could cover the due amount. Returns true + * when it does, false otherwise. Note: this function is only a checker, + * meaning that no actual business state should change after it runs. + * The place where business states change is in the SQL that's loaded in + * the database. + */ +fun isBalanceEnough( + balance: TalerAmount, + due: TalerAmount, + maxDebt: TalerAmount, + hasBalanceDebt: Boolean +): Boolean { + val normalMaxDebt = normalizeAmount(maxDebt) // Very unlikely to be needed. + if (hasBalanceDebt) { + val chargedBalance = amountAdd(balance, due) + if (chargedBalance.value > normalMaxDebt.value) return false // max debt surpassed + if ( + (chargedBalance.value == normalMaxDebt.value) && + (chargedBalance.frac > maxDebt.frac) + ) + return false + return true + } + /** + * Balance doesn't have debt, but it MIGHT get one. The following + * block calculates how much debt the balance would get, should a + * subtraction of 'due' occur. + */ + if (balance.currency != due.currency) + throw badRequest( + "Currency mismatch, balance '${balance.currency}', due '${due.currency}'", + TalerErrorCode.TALER_EC_GENERIC_CURRENCY_MISMATCH + ) + val valueDiff = if (balance.value < due.value) due.value - balance.value else 0L + val fracDiff = if (balance.frac < due.frac) due.frac - balance.frac else 0 + // Getting the normalized version of such diff. + val normalDiff = normalizeAmount(TalerAmount(valueDiff, fracDiff, balance.currency)) + // Failing if the normalized diff surpasses the max debt. + if (normalDiff.value > normalMaxDebt.value) return false + if ((normalDiff.value == normalMaxDebt.value) && + (normalDiff.frac > normalMaxDebt.frac)) return false + return true +} fun getBankCurrency(): String = db.configGet("internal_currency") ?: throw internalServerError("Bank lacks currency") \ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/talerWebHandlers.kt b/bank/src/main/kotlin/tech/libeufin/bank/talerWebHandlers.kt @@ -0,0 +1,55 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2019 Stanisci and Dold. + + * LibEuFin is free software; you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation; either version 3, or + * (at your option) any later version. + + * LibEuFin 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 Affero General + * Public License for more details. + + * You should have received a copy of the GNU Affero General Public + * License along with LibEuFin; see the file COPYING. If not, see + * <http://www.gnu.org/licenses/> + */ + +/* This file contains all the Taler handlers that do NOT + * communicate with wallets, therefore any handler that serves + * to SPAs or CLI HTTP clients. + */ + +package tech.libeufin.bank + +import io.ktor.server.application.* +import io.ktor.server.request.* +import io.ktor.server.routing.* + +fun Routing.talerWebHandlers() { + post("/accounts/{USERNAME}/withdrawals") { + val c = call.myAuth(TokenScope.readwrite) ?: throw unauthorized() + // Admin not allowed to withdraw in the name of customers: + val accountName = call.expectUriComponent("USERNAME") + if (c.login != accountName) + throw unauthorized("User ${c.login} not allowed to withdraw for account '${accountName}'") + val req = call.receive<BankAccountCreateWithdrawalRequest>() + // Checking that the user has enough funds. + val b = db.bankAccountGetFromOwnerId(c.expectRowId()) + ?: throw internalServerError("Customer '${c.login}' lacks bank account.") + + throw NotImplementedError() + } + get("/accounts/{USERNAME}/withdrawals/{W_ID}") { + throw NotImplementedError() + } + post("/accounts/{USERNAME}/withdrawals/abort") { + throw NotImplementedError() + } + post("/accounts/{USERNAME}/withdrawals/confirm") { + throw NotImplementedError() + } +} + diff --git a/bank/src/main/kotlin/tech/libeufin/bank/tokenHandlers.kt b/bank/src/main/kotlin/tech/libeufin/bank/tokenHandlers.kt @@ -1,3 +1,22 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2019 Stanisci and Dold. + + * LibEuFin is free software; you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation; either version 3, or + * (at your option) any later version. + + * LibEuFin 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 Affero General + * Public License for more details. + + * You should have received a copy of the GNU Affero General Public + * License along with LibEuFin; see the file COPYING. If not, see + * <http://www.gnu.org/licenses/> + */ + package tech.libeufin.bank import io.ktor.server.application.* diff --git a/bank/src/main/kotlin/tech/libeufin/bank/types.kt b/bank/src/main/kotlin/tech/libeufin/bank/types.kt @@ -364,7 +364,8 @@ data class BankAccountTransactionCreate( val amount: String ) -// GET /transactions/T_ID +/* History element, either from GET /transactions/T_ID + or from GET /transactions */ @Serializable data class BankAccountTransactionInfo( val creditor_payto_uri: String, @@ -376,7 +377,14 @@ data class BankAccountTransactionInfo( val date: Long ) +// Response type for histories, namely GET /transactions @Serializable data class BankAccountTransactionsResponse( val transactions: MutableList<BankAccountTransactionInfo> -) -\ No newline at end of file +) + +// Taler withdrawal request. +@Serializable +data class BankAccountCreateWithdrawalRequest( + val amount: String +) diff --git a/bank/src/test/kotlin/AmountTest.kt b/bank/src/test/kotlin/AmountTest.kt @@ -21,9 +21,58 @@ import org.junit.Test import tech.libeufin.bank.FracDigits import tech.libeufin.bank.TalerAmount +import tech.libeufin.bank.isBalanceEnough import tech.libeufin.bank.parseTalerAmount class AmountTest { + @Test + fun amountAdditionTest() { + // Balance enough, assert for true + assert(isBalanceEnough( + balance = TalerAmount(10, 0, "KUDOS"), + due = TalerAmount(8, 0, "KUDOS"), + hasBalanceDebt = false, + maxDebt = TalerAmount(100, 0, "KUDOS") + )) + // Balance still sufficient, thanks for big enough debt permission. Assert true. + assert(isBalanceEnough( + balance = TalerAmount(10, 0, "KUDOS"), + due = TalerAmount(80, 0, "KUDOS"), + hasBalanceDebt = false, + maxDebt = TalerAmount(100, 0, "KUDOS") + )) + // Balance not enough, max debt cannot cover, asserting for false. + assert(!isBalanceEnough( + balance = TalerAmount(10, 0, "KUDOS"), + due = TalerAmount(80, 0, "KUDOS"), + hasBalanceDebt = true, + maxDebt = TalerAmount(50, 0, "KUDOS") + )) + // Balance becomes enough, due to a larger max debt, asserting for true. + assert(isBalanceEnough( + balance = TalerAmount(10, 0, "KUDOS"), + due = TalerAmount(80, 0, "KUDOS"), + hasBalanceDebt = false, + maxDebt = TalerAmount(70, 0, "KUDOS") + )) + // Max debt not enough for the smallest fraction, asserting for false + assert(!isBalanceEnough( + balance = TalerAmount(0, 0, "KUDOS"), + due = TalerAmount(0, 2, "KUDOS"), + hasBalanceDebt = false, + maxDebt = TalerAmount(0, 1, "KUDOS") + )) + // Same as above, but already in debt. + assert(!isBalanceEnough( + balance = TalerAmount(0, 1, "KUDOS"), + due = TalerAmount(0, 1, "KUDOS"), + hasBalanceDebt = true, + maxDebt = TalerAmount(0, 1, "KUDOS") + )) + + + } + /* Testing that currency is fetched from the config and set in the TalerAmount dedicated field. */ @Test