libeufin

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

commit 9738c420533c7eafad8d28a5154b925d7265d1bb
parent 7c4f92ff090440b0b595ac8eee05cd5969aec9be
Author: Antoine A <>
Date:   Sat,  7 Jun 2025 15:06:34 +0200

common: fix conversion revert logic

Diffstat:
Mbank/src/main/kotlin/tech/libeufin/bank/Config.kt | 2+-
Mbank/src/main/kotlin/tech/libeufin/bank/db/ConversionDAO.kt | 29+++++++++++++++++++++++------
Mbank/src/main/kotlin/tech/libeufin/bank/db/Database.kt | 2++
Mbank/src/test/kotlin/AmountTest.kt | 28++++++++++++++++------------
Mbank/src/test/kotlin/ConversionApiTest.kt | 8++++----
Mbank/src/test/kotlin/StatsTest.kt | 8++++----
Mbank/src/test/kotlin/helpers.kt | 2+-
Mcommon/src/main/kotlin/db/statement.kt | 19++++++++++++-------
Mdatabase-versioning/libeufin-bank-procedures.sql | 44++++++++++++++++++++++----------------------
9 files changed, 85 insertions(+), 57 deletions(-)

diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Config.kt b/bank/src/main/kotlin/tech/libeufin/bank/Config.kt @@ -88,7 +88,7 @@ fun bankConfig(configPath: Path?): BankConfig = BANK_CONFIG_SOURCE.fromFile(conf /** Run [lambda] with access to a database conn pool */ suspend fun BankConfig.withDb(lambda: suspend (Database, BankConfig) -> Unit) { - Database(dbCfg, regionalCurrency, fiatCurrency, payto).use { lambda(it, this) } + Database(dbCfg, regionalCurrency, fiatCurrency, regionalCurrencySpec, fiatCurrencySpec, payto).use { lambda(it, this) } } private fun TalerConfig.loadBankConfig(): BankConfig = section("libeufin-bank").run { diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/ConversionDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/ConversionDAO.kt @@ -113,8 +113,8 @@ class ConversionDAO(private val db: Database) { } /** Perform [direction] conversion of [amount] using in-db [function] */ - private suspend fun conversion(amount: TalerAmount, direction: String, function: String): ConversionResult = db.serializable( - "SELECT too_small, no_config, (converted).val AS amount_val, (converted).frac AS amount_frac FROM $function((?, ?)::taler_amount, ?, (0, 0)::taler_amount)" + private suspend fun conversionTo(amount: TalerAmount, direction: String): ConversionResult = db.serializable( + "SELECT too_small, no_config, (converted).val AS amount_val, (converted).frac AS amount_frac FROM conversion_to((?, ?)::taler_amount, ?, (0, 0)::taler_amount)" ) { bind(amount) bind(direction) @@ -128,13 +128,30 @@ class ConversionDAO(private val db: Database) { } } } + + private suspend fun conversionFrom(amount: TalerAmount, direction: String): ConversionResult = db.serializable( + "SELECT too_small, no_config, (converted).val AS amount_val, (converted).frac AS amount_frac FROM conversion_from((?, ?)::taler_amount, ?, (0, 0)::taler_amount, ?)" + ) { + bind(amount) + bind(direction) + bind((if (amount.currency == db.bankCurrency) db.fiatCurrencySpec!! else db.bankCurrencySpec).num_fractional_input_digits) + one { + when { + 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): ConversionResult = conversion(regional, "cashout", "conversion_to") + suspend fun toCashout(regional: TalerAmount): ConversionResult = conversionTo(regional, "cashout") /** Convert [fiat] amount to regional using cashin rate */ - suspend fun toCashin(fiat: TalerAmount): ConversionResult = conversion(fiat, "cashin", "conversion_to") + suspend fun toCashin(fiat: TalerAmount): ConversionResult = conversionTo(fiat, "cashin") /** Convert [fiat] amount to regional using inverse cashout rate */ - suspend fun fromCashout(fiat: TalerAmount): ConversionResult = conversion(fiat, "cashout", "conversion_from") + suspend fun fromCashout(fiat: TalerAmount): ConversionResult = conversionFrom(fiat, "cashout") /** Convert [regional] amount to fiat using inverse cashin rate */ - suspend fun fromCashin(regional: TalerAmount): ConversionResult = conversion(regional, "cashin", "conversion_from") + suspend fun fromCashin(regional: TalerAmount): ConversionResult = conversionFrom(regional, "cashin") } \ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/Database.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/Database.kt @@ -34,6 +34,8 @@ class Database( dbConfig: DatabaseConfig, internal val bankCurrency: String, internal val fiatCurrency: String?, + internal val bankCurrencySpec: CurrencySpecification, + internal val fiatCurrencySpec: CurrencySpecification?, internal val ctx: BankPaytoCtx ): DbPool(dbConfig, "libeufin-bank") { // DAOs diff --git a/bank/src/test/kotlin/AmountTest.kt b/bank/src/test/kotlin/AmountTest.kt @@ -339,12 +339,13 @@ class AmountTest { } } - val revertStmt = conn.talerStatement("SELECT (result).val, (result).frac FROM conversion_revert_ratio((?, ?)::taler_amount, (?, ?)::taler_amount, (0, 0)::taler_amount, (?, ?)::taler_amount, ?::rounding_mode)") - fun TalerAmount.revert(ratio: DecimalNumber, tiny: DecimalNumber = DecimalNumber("0.00000001"), roundingMode: String = "zero"): TalerAmount { + val revertStmt = conn.talerStatement("SELECT (result).val, (result).frac FROM conversion_revert_ratio((?, ?)::taler_amount, (?, ?)::taler_amount, (0, 0)::taler_amount, (?, ?)::taler_amount, ?::rounding_mode, ?)") + fun TalerAmount.revert(ratio: DecimalNumber, tiny: DecimalNumber = DecimalNumber("0.00000001"), roundingMode: String = "zero", digits: Int = 8): TalerAmount { revertStmt.bind(this) revertStmt.bind(ratio) revertStmt.bind(tiny) revertStmt.bind(roundingMode) + revertStmt.bind(digits) return revertStmt.one { TalerAmount( it.getLong(1), @@ -356,7 +357,7 @@ class AmountTest { assertEquals(TalerAmount("EUR:6.41"), TalerAmount("EUR:30.0629").revert(DecimalNumber("4.69"))) assertEquals(TalerAmount("EUR:6.41"), TalerAmount("EUR:6.41000641").revert(DecimalNumber("1.000001"))) - assertEquals(TalerAmount("EUR:0.99999999"), TalerAmount("EUR:2.49999998").revert(DecimalNumber("2.5"))) + assertEquals(TalerAmount("EUR:1"), TalerAmount("EUR:2.49999998").revert(DecimalNumber("2.5"))) assertEquals(TalerAmount("EUR:${TalerAmount.MAX_VALUE}.99999999"), TalerAmount("EUR:${TalerAmount.MAX_VALUE}.99999999").revert(DecimalNumber("1"))) assertEquals(TalerAmount("EUR:${TalerAmount.MAX_VALUE}"), TalerAmount("EUR:${TalerAmount.MAX_VALUE/4}").revert(DecimalNumber("0.25"))) assertException("ERROR: amount value overflowed") { TalerAmount(TalerAmount.MAX_VALUE/4, 0, "EUR").revert(DecimalNumber("0.24999999")) } @@ -365,16 +366,19 @@ class AmountTest { for (mode in sequenceOf("zero", "up", "nearest")) { - for (tiny in sequenceOf("0.01", "0.00000001", "1", "5").map(::DecimalNumber)) { + for (tiny in sequenceOf("0.01", "0.00000001", "1", "2", "3", "5").map(::DecimalNumber)) { for (amount in sequenceOf(10, 11, 12, 12, 14, 15, 16, 17, 18, 19).map { TalerAmount("EUR:$it") }) { - for (ratio in sequenceOf("1", "0.01", "0.001", "0.00000001").map(::DecimalNumber)) { - // Apply ratio - val rounded = amount.apply(ratio, tiny, mode) - // Revert ratio - val revert = rounded.revert(ratio, tiny, mode) - // Check applying ratio again give the same result - val check = revert.apply(ratio, tiny, mode) - assertEquals(rounded, check) + for (ratio in sequenceOf("1", "1.25", "1.26", "0.01", "0.001", "0.00000001").map(::DecimalNumber)) { + for (digits in sequenceOf(8, 2, 0)) { + // Apply ratio + val rounded = amount.apply(ratio, tiny, mode) + // Revert ratio + val revert = rounded.revert(ratio, tiny, mode, digits) + // Check applying ratio again give the same result + val check = revert.apply(ratio, tiny, mode) + println("$amount $rounded $revert $check $ratio $tiny $mode") + assertEquals(rounded, check) + } } } } diff --git a/bank/src/test/kotlin/ConversionApiTest.kt b/bank/src/test/kotlin/ConversionApiTest.kt @@ -70,12 +70,12 @@ class ConversionApiTest { // Check conversion to client.get("/conversion-info/cashout-rate?amount_debit=KUDOS:1").assertOkJson<ConversionResponse> { assertEquals(TalerAmount("KUDOS:1"), it.amount_debit) - assertEquals(TalerAmount("EUR:1.24"), it.amount_credit) + assertEquals(TalerAmount("EUR:1.25"), it.amount_credit) } // Check conversion from - client.get("/conversion-info/cashout-rate?amount_credit=EUR:1.247").assertOkJson<ConversionResponse> { - assertEquals(TalerAmount("KUDOS:1"), it.amount_debit) - assertEquals(TalerAmount("EUR:1.247"), it.amount_credit) + client.get("/conversion-info/cashout-rate?amount_credit=EUR:1.257").assertOkJson<ConversionResponse> { + assertEquals(TalerAmount("KUDOS:1.01"), it.amount_debit) + assertEquals(TalerAmount("EUR:1.257"), it.amount_credit) } // Too small diff --git a/bank/src/test/kotlin/StatsTest.kt b/bank/src/test/kotlin/StatsTest.kt @@ -114,16 +114,16 @@ class StatsTest { monitorTalerIn(6, "KUDOS:78.84") cashout("KUDOS:3") - monitorCashout(1, "KUDOS:3", "EUR:3.74") + monitorCashout(1, "KUDOS:3", "EUR:3.77") cashout("KUDOS:7.6") - monitorCashout(2, "KUDOS:10.6", "EUR:13.23") + monitorCashout(2, "KUDOS:10.6", "EUR:13.34") cashout("KUDOS:12.3") - monitorCashout(3, "KUDOS:22.9", "EUR:28.6") + monitorCashout(3, "KUDOS:22.9", "EUR:28.83") monitorTalerIn(6, "KUDOS:78.84") monitorTalerOut(3, "KUDOS:82.5") monitorCashin(3, "KUDOS:55.94", "EUR:70") - monitorCashout(3, "KUDOS:22.9", "EUR:28.6") + monitorCashout(3, "KUDOS:22.9", "EUR:28.83") } @Test diff --git a/bank/src/test/kotlin/helpers.kt b/bank/src/test/kotlin/helpers.kt @@ -148,7 +148,7 @@ fun bankSetup( "cashin_tiny_amount" to "KUDOS:0.01" "cashin_rounding_mode" to "nearest" "cashin_min_amount" to "EUR:0" - "cashout_ratio" to "1.25" + "cashout_ratio" to "1.26" "cashout_fee" to "EUR:0.003" "cashout_tiny_amount" to "EUR:0.01" "cashout_rounding_mode" to "zero" diff --git a/common/src/main/kotlin/db/statement.kt b/common/src/main/kotlin/db/statement.kt @@ -31,14 +31,21 @@ internal val logger: Logger = LoggerFactory.getLogger("libeufin-db") class TalerStatement(internal val stmt: PreparedStatement): java.io.Closeable { override fun close() { + // Close inner statement + stmt.close() + } + + private fun consume() { // Log warnings var current = stmt.getWarnings() while (current != null) { - //logger.warning(current.message) + logger.warn(current.message) current = current.getNextWarning() } - // Close inner statement - stmt.close() + + // Reset params + stmt.clearParameters() + idx=1 } /* ----- Bindings helpers ----- */ @@ -117,8 +124,7 @@ class TalerStatement(internal val stmt: PreparedStatement): java.io.Closeable { return try { stmt.executeQuery() } finally { - stmt.clearParameters() - idx=1 + consume() } } @@ -126,8 +132,7 @@ class TalerStatement(internal val stmt: PreparedStatement): java.io.Closeable { return try { stmt.executeUpdate() } finally { - stmt.clearParameters() - idx=1 + consume() } } diff --git a/database-versioning/libeufin-bank-procedures.sql b/database-versioning/libeufin-bank-procedures.sql @@ -1838,7 +1838,6 @@ LANGUAGE plpgsql AS $$ DECLARE amount_numeric NUMERIC(33, 8); -- 16 digit for val, 8 for frac and 1 for rounding error tiny_numeric NUMERIC(24); - rounding_error real; BEGIN -- Perform multiplication using big numbers amount_numeric = (amount.val::numeric(24) * 100000000 + amount.frac::numeric(24)) * (ratio.val::numeric(24, 8) + ratio.frac::numeric(24, 8) / 100000000); @@ -1853,15 +1852,11 @@ BEGIN -- Round to tiny amounts tiny_numeric = (tiny.val::numeric(24) * 100000000 + tiny.frac::numeric(24)); - amount_numeric = amount_numeric / tiny_numeric; - rounding_error = (amount_numeric % 1)::real; - amount_numeric = trunc(amount_numeric) * tiny_numeric; - - -- Apply rounding mode - IF (rounding = 'nearest'::rounding_mode AND rounding_error >= 0.5) - OR (rounding = 'up'::rounding_mode AND rounding_error > 0.0) THEN - amount_numeric = amount_numeric + tiny_numeric; - END IF; + case rounding + when 'zero' then amount_numeric = trunc(amount_numeric / tiny_numeric) * tiny_numeric; + when 'up' then amount_numeric = ceil(amount_numeric / tiny_numeric) * tiny_numeric; + when 'nearest' then amount_numeric = round(amount_numeric / tiny_numeric) * tiny_numeric; + end case; -- Extract product parts result = (trunc(amount_numeric / 100000000)::int8, (amount_numeric % 100000000)::int4); @@ -1879,6 +1874,7 @@ CREATE FUNCTION conversion_revert_ratio( ,IN fee taler_amount ,IN tiny taler_amount -- Result is rounded to this amount ,IN rounding rounding_mode -- With this rounding mode + ,IN frac_digits INT ,OUT result taler_amount ,OUT bad_value BOOLEAN ) @@ -1886,28 +1882,31 @@ LANGUAGE plpgsql AS $$ DECLARE amount_numeric NUMERIC(33, 8); -- 16 digit for val, 8 for frac and 1 for rounding error tiny_numeric NUMERIC(24); - rounding_error real; + roundtrip BOOLEAN; BEGIN -- Apply fees - amount_numeric = (amount.val::numeric(24) * 100000000 + amount.frac::numeric(24)) + (fee.val::numeric(24) * 100000000 + fee.frac::numeric(24)); + amount_numeric = (amount.val::numeric(24) * 100000000 + amount.frac::numeric(24)) + (fee.val::numeric(24) * 100000000 + fee.frac::numeric(24)); -- Perform division using big numbers amount_numeric = amount_numeric / (ratio.val::numeric(24, 8) + ratio.frac::numeric(24, 8) / 100000000); - -- Round to tiny amounts - tiny_numeric = (tiny.val::numeric(24) * 100000000 - tiny.frac::numeric(24)); - amount_numeric = amount_numeric / tiny_numeric; - rounding_error = (amount_numeric % 1)::real; - amount_numeric = trunc(amount_numeric) * tiny_numeric; + -- Round to input digits + tiny_numeric = power(10::numeric, 8 - frac_digits); + amount_numeric = trunc(amount_numeric / tiny_numeric) * tiny_numeric; + + -- Extract division parts + result = (trunc(amount_numeric / 100000000)::int8, (amount_numeric % 100000000)::int4); -- Recover potentially lost tiny amount during rounding - IF (rounding = 'zero'::rounding_mode AND rounding_error > 0.0) THEN + -- There must be a clever way to compute this but I am a little limited with math + -- and revert ratio computation is not a hot function so I just use the apply ratio + -- function to be conservative and correct + SELECT ok INTO roundtrip FROM amount_left_minus_right((SELECT conversion_apply_ratio.result FROM conversion_apply_ratio(result, ratio, fee, tiny, rounding)), amount); + IF NOT roundtrip THEN amount_numeric = amount_numeric + tiny_numeric; + result = (trunc(amount_numeric / 100000000)::int8, (amount_numeric % 100000000)::int4); END IF; - -- Extract division parts - result = (trunc(amount_numeric / 100000000)::int8, (amount_numeric % 100000000)::int4); - IF (result.val > 1::INT8<<52) THEN RAISE EXCEPTION 'amount value overflowed'; END IF; @@ -1964,6 +1963,7 @@ CREATE FUNCTION conversion_from( IN amount taler_amount, IN direction TEXT, IN custom_min_amount taler_amount, + IN frac_digits INT, OUT converted taler_amount, OUT too_small BOOLEAN, OUT under_min BOOLEAN, @@ -1987,7 +1987,7 @@ BEGIN RETURN; END IF; SELECT (result).val, (result).frac INTO converted.val, converted.frac - FROM conversion_revert_ratio(amount, at_ratio, out_fee, tiny_amount, mode); + FROM conversion_revert_ratio(amount, at_ratio, out_fee, tiny_amount, mode, frac_digits); -- Check min amount SELECT value['val']::int8, value['frac']::int4 INTO min_amount.val, min_amount.frac FROM config WHERE key=direction||'_min_amount';