libeufin

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

commit 603925cadb993ccebef1fb5186aa102c09b62d06
parent 3679a4b32b777767857dd10b2b19740ce7469ddd
Author: Antoine A <>
Date:   Thu, 23 Nov 2023 15:56:15 +0000

Handle missing conversion rate in the database

Diffstat:
Mbank/src/main/kotlin/tech/libeufin/bank/ConversionApi.kt | 60++++++++++++++++++++++++++++++++++--------------------------
Mbank/src/main/kotlin/tech/libeufin/bank/db/ConversionDAO.kt | 39+++++++++++++++++++++++++++------------
Mbank/src/test/kotlin/ConversionApiTest.kt | 13+++++++++++++
Mdatabase-versioning/libeufin-bank-procedures.sql | 26++++++++++++++++++--------
Mdatabase-versioning/libeufin-conversion-setup.sql | 10+++++++---
Mintegration/test/IntegrationTest.kt | 1+
6 files changed, 100 insertions(+), 49 deletions(-)

diff --git a/bank/src/main/kotlin/tech/libeufin/bank/ConversionApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/ConversionApi.kt @@ -25,6 +25,7 @@ import io.ktor.server.response.* import io.ktor.server.routing.* import java.util.* import tech.libeufin.util.* +import tech.libeufin.bank.ConversionDAO.* import net.taler.common.errorcodes.TalerErrorCode fun Routing.conversionApi(db: Database, ctx: BankConfig) = conditional(ctx.allowConversion) { @@ -39,6 +40,24 @@ fun Routing.conversionApi(db: Database, ctx: BankConfig) = conditional(ctx.allow ) ) } + suspend fun ApplicationCall.convert( + input: TalerAmount, + conversion: suspend ConversionDAO.(TalerAmount) -> ConversionResult, + output: (TalerAmount) -> ConversionResponse + ) { + when (val res = db.conversion.(conversion)(input)) { + is ConversionResult.Success -> respond(output(res.converted)) + is ConversionResult.ToSmall -> throw conflict( + "$input is too small to be converted", + TalerErrorCode.BANK_BAD_CONVERSION + ) + is ConversionResult.MissingConfig -> throw libeufinError( + HttpStatusCode.NotImplemented, + "conversion rate not configured yet", + TalerErrorCode.END + ) + } + } get("/conversion-info/cashout-rate") { val params = RateParams.extract(call.request.queryParameters) @@ -46,40 +65,29 @@ fun Routing.conversionApi(db: Database, ctx: BankConfig) = conditional(ctx.allow params.credit?.let { ctx.checkFiatCurrency(it) } if (params.debit != null) { - val credit = db.conversion.toCashout(params.debit) ?: - throw conflict( - "${params.debit} is too small to be converted", - TalerErrorCode.BANK_BAD_CONVERSION - ) - call.respond(ConversionResponse(params.debit, credit)) + call.convert(params.debit, ConversionDAO::toCashout) { + ConversionResponse(params.debit, it) + } } else { - val debit = db.conversion.fromCashout(params.credit!!) ?: - throw conflict( - "${params.debit} is too small to be converted", - TalerErrorCode.BANK_BAD_CONVERSION - ) - call.respond(ConversionResponse(debit, params.credit)) + call.convert(params.credit!!, ConversionDAO::fromCashout) { + ConversionResponse(it, params.credit) + } } } get("/conversion-info/cashin-rate") { val params = RateParams.extract(call.request.queryParameters) + params.debit?.let { ctx.checkFiatCurrency(it) } + params.credit?.let { ctx.checkRegionalCurrency(it) } + if (params.debit != null) { - ctx.checkFiatCurrency(params.debit) - val credit = db.conversion.toCashin(params.debit) ?: - throw conflict( - "${params.debit} is too small to be converted", - TalerErrorCode.BANK_BAD_CONVERSION - ) - call.respond(ConversionResponse(params.debit, credit)) + call.convert(params.debit, ConversionDAO::toCashin) { + ConversionResponse(params.debit, it) + } } else { - ctx.checkRegionalCurrency(params.credit!!) - val debit = db.conversion.fromCashin(params.credit) ?: - throw conflict( - "${params.credit} is too small to be converted", - TalerErrorCode.BANK_BAD_CONVERSION - ) - call.respond(ConversionResponse(debit, params.credit)) + call.convert(params.credit!!, ConversionDAO::fromCashin) { + ConversionResponse(it, params.credit) + } } } } \ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/ConversionDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/ConversionDAO.kt @@ -61,28 +61,43 @@ class ConversionDAO(private val db: Database) { } } + /** Clear in-db conversion config */ + suspend fun clearConfig() = db.serializable { conn -> + conn.prepareStatement("DELETE FROM config WHERE key LIKE 'cashin%' OR key like 'cashout%'").executeUpdate() + } + + /** Result of conversions operations */ + sealed class ConversionResult { + data class Success(val converted: TalerAmount): ConversionResult() + object ToSmall: ConversionResult() + object MissingConfig: ConversionResult() + } + /** Perform [direction] conversion of [amount] using in-db [function] */ - private suspend fun conversion(amount: TalerAmount, direction: String, function: String): TalerAmount? = db.conn { conn -> - val stmt = conn.prepareStatement("SELECT too_small, (converted).val AS amount_val, (converted).frac AS amount_frac FROM $function((?, ?)::taler_amount, ?)") + private suspend fun conversion(amount: TalerAmount, direction: String, function: String): ConversionResult = db.conn { conn -> + val stmt = conn.prepareStatement("SELECT too_small, no_config, (converted).val AS amount_val, (converted).frac AS amount_frac FROM $function((?, ?)::taler_amount, ?)") stmt.setLong(1, amount.value) stmt.setInt(2, amount.frac) stmt.setString(3, direction) stmt.executeQuery().use { - it.next() - if (!it.getBoolean("too_small")) { - it.getAmount("amount", if (amount.currency == db.bankCurrency) db.fiatCurrency!! else db.bankCurrency) - } else { - null + when { + !it.next() -> + throw internalServerError("No result from DB procedure $function") + it.getBoolean("no_config") -> ConversionResult.MissingConfig + it.getBoolean("too_small") -> ConversionResult.ToSmall + else -> ConversionResult.Success( + it.getAmount("amount", if (amount.currency == db.bankCurrency) db.fiatCurrency!! else db.bankCurrency) + ) } } } - + /** Convert [regional] amount to fiat using cashout rate */ - suspend fun toCashout(regional: TalerAmount): TalerAmount? = conversion(regional, "cashout", "conversion_to") + suspend fun toCashout(regional: TalerAmount): ConversionResult = conversion(regional, "cashout", "conversion_to") /** Convert [fiat] amount to regional using cashin rate */ - suspend fun toCashin(fiat: TalerAmount): TalerAmount? = conversion(fiat, "cashin", "conversion_to") + suspend fun toCashin(fiat: TalerAmount): ConversionResult = conversion(fiat, "cashin", "conversion_to") /** Convert [fiat] amount to regional using inverse cashout rate */ - suspend fun fromCashout(fiat: TalerAmount): TalerAmount? = conversion(fiat, "cashout", "conversion_from") + suspend fun fromCashout(fiat: TalerAmount): ConversionResult = conversion(fiat, "cashout", "conversion_from") /** Convert [regional] amount to fiat using inverse cashin rate */ - suspend fun fromCashin(regional: TalerAmount): TalerAmount? = conversion(regional, "cashin", "conversion_from") + suspend fun fromCashin(regional: TalerAmount): ConversionResult = conversion(regional, "cashin", "conversion_from") } \ No newline at end of file diff --git a/bank/src/test/kotlin/ConversionApiTest.kt b/bank/src/test/kotlin/ConversionApiTest.kt @@ -109,6 +109,19 @@ class ConversionApiTest { } @Test + fun noRate() = bankSetup { db -> + db.conversion.clearConfig() + client.get("/conversion-info/cashin-rate") + .assertBadRequest() + client.get("/conversion-info/cashout-rate") + .assertBadRequest() + client.get("/conversion-info/cashin-rate?amount_credit=KUDOS:1") + .assertNotImplemented() + client.get("/conversion-info/cashout-rate?amount_credit=EUR:1") + .assertNotImplemented() + } + + @Test fun notImplemented() = bankSetup("test_restrict.conf") { _ -> client.get("/conversion-info/cashin-rate") .assertNotImplemented() diff --git a/database-versioning/libeufin-bank-procedures.sql b/database-versioning/libeufin-bank-procedures.sql @@ -975,6 +975,7 @@ CREATE OR REPLACE FUNCTION cashin( -- Error status OUT out_no_account BOOLEAN, OUT out_too_small BOOLEAN, + OUT out_no_config BOOLEAN, OUT out_balance_insufficient BOOLEAN ) LANGUAGE plpgsql AS $$ @@ -1002,10 +1003,10 @@ SELECT bank_account_id WHERE login = 'admin'; -- Perform conversion -SELECT (converted).val, (converted).frac, too_small - INTO converted_amount.val, converted_amount.frac, out_too_small +SELECT (converted).val, (converted).frac, too_small, no_config + INTO converted_amount.val, converted_amount.frac, out_too_small, out_no_config FROM conversion_to(in_amount, 'cashin'::text); -IF out_too_small THEN +IF out_too_small OR out_no_config THEN RETURN; END IF; @@ -1061,7 +1062,7 @@ account_id BIGINT; challenge_id BIGINT; BEGIN -- check conversion -SELECT too_small OR in_amount_credit!=converted INTO out_bad_conversion FROM conversion_to(in_amount_debit, 'cashout'::text); +SELECT too_small OR no_config OR in_amount_credit!=converted INTO out_bad_conversion FROM conversion_to(in_amount_debit, 'cashout'::text); IF out_bad_conversion THEN RETURN; END IF; @@ -1183,7 +1184,7 @@ ELSIF already_confirmed OR out_aborted OR out_no_cashout_payto THEN END IF; -- check conversion -SELECT too_small OR amount_credit_local!=converted INTO out_bad_conversion FROM conversion_to(amount_debit_local, 'cashout'::text); +SELECT too_small OR no_config OR amount_credit_local!=converted INTO out_bad_conversion FROM conversion_to(amount_debit_local, 'cashout'::text); IF out_bad_conversion THEN RETURN; END IF; @@ -1517,7 +1518,8 @@ CREATE OR REPLACE FUNCTION conversion_to( IN amount taler_amount, IN direction TEXT, OUT converted taler_amount, - OUT too_small BOOLEAN + OUT too_small BOOLEAN, + OUT no_config BOOLEAN ) LANGUAGE plpgsql AS $$ DECLARE @@ -1529,6 +1531,10 @@ DECLARE BEGIN -- Check min amount SELECT value['val']::int8, value['frac']::int4 INTO min_amount.val, min_amount.frac FROM config WHERE key=direction||'_min_amount'; + IF NOT FOUND THEN + no_config = true; + RETURN; + END IF; SELECT NOT ok INTO too_small FROM amount_left_minus_right(amount, min_amount); IF too_small THEN converted = (0, 0); @@ -1553,7 +1559,8 @@ CREATE OR REPLACE FUNCTION conversion_from( IN amount taler_amount, IN direction TEXT, OUT converted taler_amount, - OUT too_small BOOLEAN + OUT too_small BOOLEAN, + OUT no_config BOOLEAN ) LANGUAGE plpgsql AS $$ DECLARE @@ -1568,7 +1575,10 @@ BEGIN SELECT value['val']::int8, value['frac']::int4 INTO out_fee.val, out_fee.frac FROM config WHERE key=direction||'_fee'; SELECT value['val']::int8, value['frac']::int4 INTO tiny_amount.val, tiny_amount.frac FROM config WHERE key=direction||'_tiny_amount'; SELECT (value->>'mode')::rounding_mode INTO mode FROM config WHERE key=direction||'_rounding_mode'; - + IF NOT FOUND THEN + no_config = true; + RETURN; + END IF; SELECT result.val, result.frac INTO converted.val, converted.frac FROM conversion_revert_ratio(amount_add(amount, out_fee), at_ratio, tiny_amount, mode) as result; diff --git a/database-versioning/libeufin-conversion-setup.sql b/database-versioning/libeufin-conversion-setup.sql @@ -48,14 +48,15 @@ LANGUAGE plpgsql AS $$ too_small BOOLEAN; balance_insufficient BOOLEAN; no_account BOOLEAN; + no_config BOOLEAN; BEGIN SELECT (amount).val, (amount).frac, wire_transfer_subject, execution_time, debit_payto_uri INTO local_amount.val, local_amount.frac, subject, now_date, payto_uri FROM libeufin_nexus.incoming_transactions WHERE incoming_transaction_id = NEW.incoming_transaction_id; SET search_path TO libeufin_bank; - SELECT out_too_small, out_balance_insufficient, out_no_account - INTO too_small, balance_insufficient, no_account + SELECT out_too_small, out_balance_insufficient, out_no_account, out_no_config + INTO too_small, balance_insufficient, no_account, no_config FROM libeufin_bank.cashin(now_date, payto_uri, local_amount, subject); SET search_path TO libeufin_nexus; @@ -66,7 +67,10 @@ LANGUAGE plpgsql AS $$ RAISE EXCEPTION 'TODO soft error bounce: too small amount'; END IF; IF balance_insufficient THEN - RAISE EXCEPTION 'TODO hard error bounce'; + RAISE EXCEPTION 'TODO hard error bounce: admin balance insufficient'; + END IF; + IF no_config THEN + RAISE EXCEPTION 'TODO hard error bounce: missing conversion rate config'; END IF; RETURN NEW; END; diff --git a/integration/test/IntegrationTest.kt b/integration/test/IntegrationTest.kt @@ -72,6 +72,7 @@ class IntegrationTest { kotlin.concurrent.thread(isDaemon = true) { bankCmd.run("serve -c ../bank/conf/test.conf") } + runBlocking { val client = HttpClient(CIO) { install(HttpRequestRetry) {