libeufin

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

commit d42eb3fd52ce410442a8eb7900cbc9b81dd42362
parent 2af30b7202a4ce6630be6b49a7027d8dce326a96
Author: Antoine A <>
Date:   Thu, 15 Aug 2024 12:55:38 +0200

common: more conversion check and fix conversion logic

Diffstat:
Mbank/src/main/kotlin/tech/libeufin/bank/api/ConversionApi.kt | 9+++++----
Mbank/src/test/kotlin/AmountTest.kt | 22++++++++++------------
Mbank/src/test/kotlin/ConversionApiTest.kt | 38+++++++++++++++++++++++++++++++++++++-
Mbank/src/test/kotlin/StatsTest.kt | 8++++----
Mbank/src/test/kotlin/helpers.kt | 2+-
Mcommon/src/main/kotlin/TalerCommon.kt | 5+++++
Mcommon/src/test/kotlin/AmountTest.kt | 12+++++++++++-
Mdatabase-versioning/libeufin-bank-procedures.sql | 63+++++++++++++++++++++++++++++++++++++--------------------------
Mnexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt | 18++++--------------
Mtestbench/src/test/kotlin/IntegrationTest.kt | 4++--
10 files changed, 116 insertions(+), 65 deletions(-)

diff --git a/bank/src/main/kotlin/tech/libeufin/bank/api/ConversionApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/api/ConversionApi.kt @@ -28,10 +28,7 @@ import tech.libeufin.bank.auth.authAdmin import tech.libeufin.bank.db.ConversionDAO import tech.libeufin.bank.db.ConversionDAO.ConversionResult import tech.libeufin.bank.db.Database -import tech.libeufin.common.TalerAmount -import tech.libeufin.common.TalerErrorCode -import tech.libeufin.common.apiError -import tech.libeufin.common.conflict +import tech.libeufin.common.* fun Routing.conversionApi(db: Database, ctx: BankConfig) = conditional(ctx.allowConversion) { get("/conversion-info/config") { @@ -110,6 +107,10 @@ fun Routing.conversionApi(db: Database, ctx: BankConfig) = conditional(ctx.allow for (fiatAmount in sequenceOf(req.cashout_fee, req.cashout_tiny_amount, req.cashin_min_amount)) { ctx.checkFiatCurrency(fiatAmount) } + // cashout conversion tiny amount must >= 0.01 as it is what EBICS supports + if (req.cashout_tiny_amount.isSubCent()) { + throw badRequest("Sub-cent amounts no supported by cashout, cashout_tiny_amount must be >= 0.01") + } db.conversion.updateConfig(req) call.respond(HttpStatusCode.NoContent) } diff --git a/bank/src/test/kotlin/AmountTest.kt b/bank/src/test/kotlin/AmountTest.kt @@ -248,7 +248,7 @@ class AmountTest { fun conversionApply() = dbSetup { db -> db.conn { conn -> fun apply(nb: TalerAmount, times: DecimalNumber, tiny: DecimalNumber = DecimalNumber("0.00000001"), roundingMode: String = "zero"): TalerAmount { - val stmt = conn.prepareStatement("SELECT amount.val, amount.frac FROM conversion_apply_ratio((?, ?)::taler_amount, (?, ?)::taler_amount, (?, ?)::taler_amount, ?::rounding_mode) as amount") + val stmt = conn.prepareStatement("SELECT (result).val, (result).frac FROM conversion_apply_ratio((?, ?)::taler_amount, (?, ?)::taler_amount, (0, 0)::taler_amount, (?, ?)::taler_amount, ?::rounding_mode)") stmt.setLong(1, nb.value) stmt.setInt(2, nb.frac) stmt.setLong(3, times.value) @@ -311,7 +311,7 @@ class AmountTest { @Test fun conversionRevert() = dbSetup { db -> db.conn { conn -> - val applyStmt = conn.prepareStatement("SELECT amount.val, amount.frac FROM conversion_apply_ratio((?, ?)::taler_amount, (?, ?)::taler_amount, (?, ?)::taler_amount, ?::rounding_mode) as amount") + val applyStmt = conn.prepareStatement("SELECT (result).val, (result).frac FROM conversion_apply_ratio((?, ?)::taler_amount, (?, ?)::taler_amount, (0, 0)::taler_amount, (?, ?)::taler_amount, ?::rounding_mode)") fun TalerAmount.apply(ratio: DecimalNumber, tiny: DecimalNumber = DecimalNumber("0.00000001"), roundingMode: String = "zero"): TalerAmount { applyStmt.setLong(1, this.value) applyStmt.setInt(2, this.frac) @@ -329,7 +329,7 @@ class AmountTest { } } - val revertStmt = conn.prepareStatement("SELECT amount.val, amount.frac FROM conversion_revert_ratio((?, ?)::taler_amount, (?, ?)::taler_amount, (?, ?)::taler_amount, ?::rounding_mode) as amount") + val revertStmt = conn.prepareStatement("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 { revertStmt.setLong(1, this.value) revertStmt.setInt(2, this.frac) @@ -349,22 +349,20 @@ 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.49999997").revert(DecimalNumber("2.5"))) + assertEquals(TalerAmount("EUR:0.99999999"), 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")) } assertException("ERROR: amount value overflowed") { TalerAmount((TalerAmount.MAX_VALUE+2)/2, 0, "EUR").revert(DecimalNumber("0.5")) } assertException("ERROR: numeric field overflow") { TalerAmount(Long.MAX_VALUE, 0, "EUR").revert(DecimalNumber("1")) } - for (mode in listOf("zero", "up", "nearest")) { - for (amount in listOf(10, 11, 12, 12, 14, 15, 16, 17, 18, 19)) { - for (tiny in listOf("0.01", "0.00000001", "5")) { - for (ratio in listOf("1", "0.341", "0.00000001")) { - val tiny = DecimalNumber(tiny) - val ratio = DecimalNumber(ratio) - val base = TalerAmount("EUR:$amount") + + for (mode in sequenceOf("zero", "up", "nearest")) { + for (tiny in sequenceOf("0.01", "0.00000001", "1", "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 = base.apply(ratio, tiny, mode) + val rounded = amount.apply(ratio, tiny, mode) // Revert ratio val revert = rounded.revert(ratio, tiny, mode) // Check applying ratio again give the same result diff --git a/bank/src/test/kotlin/ConversionApiTest.kt b/bank/src/test/kotlin/ConversionApiTest.kt @@ -29,6 +29,42 @@ class ConversionApiTest { fun config() = bankSetup { client.get("/conversion-info/config").assertOk() } + + // POST /conversion-info/conversion-rate + @Test + fun conversionRate() = bankSetup { + val ok = obj { + "cashin_ratio" to "0.8" + "cashin_fee" to "KUDOS:0.02" + "cashin_tiny_amount" to "KUDOS:0.01" + "cashin_rounding_mode" to "nearest" + "cashin_min_amount" to "EUR:0" + "cashout_ratio" to "1.25" + "cashout_fee" to "EUR:0.003" + "cashout_tiny_amount" to "EUR:0.01" + "cashout_rounding_mode" to "zero" + "cashout_min_amount" to "KUDOS:0.1" + } + // Good rates + client.post("/conversion-info/conversion-rate") { + pwAuth("admin") + json(ok) + }.assertNoContent() + // Bad currency + client.post("/conversion-info/conversion-rate") { + pwAuth("admin") + json(ok) { + "cashout_fee" to "CHF:0.003" + } + }.assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH) + // Subcent cashout tiny amount + client.post("/conversion-info/conversion-rate") { + pwAuth("admin") + json(ok) { + "cashout_tiny_amount" to "EUR:0.0001" + } + }.assertBadRequest(TalerErrorCode.GENERIC_JSON_INVALID) + } // GET /conversion-info/cashout-rate @Test @@ -36,7 +72,7 @@ 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.247"), it.amount_credit) + assertEquals(TalerAmount("EUR:1.24"), it.amount_credit) } // Check conversion from client.get("/conversion-info/cashout-rate?amount_credit=EUR:1.247").assertOkJson<ConversionResponse> { 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.747") + monitorCashout(1, "KUDOS:3", "EUR:3.74") cashout("KUDOS:7.6") - monitorCashout(2, "KUDOS:10.6", "EUR:13.244") + monitorCashout(2, "KUDOS:10.6", "EUR:13.23") cashout("KUDOS:12.3") - monitorCashout(3, "KUDOS:22.9", "EUR:28.616") + monitorCashout(3, "KUDOS:22.9", "EUR:28.6") monitorTalerIn(6, "KUDOS:78.84") monitorTalerOut(3, "KUDOS:82.5") monitorCashin(3, "KUDOS:55.94", "EUR:70") - monitorCashout(3, "KUDOS:22.9", "EUR:28.616") + monitorCashout(3, "KUDOS:22.9", "EUR:28.6") } @Test diff --git a/bank/src/test/kotlin/helpers.kt b/bank/src/test/kotlin/helpers.kt @@ -152,7 +152,7 @@ fun bankSetup( "cashin_min_amount" to "EUR:0" "cashout_ratio" to "1.25" "cashout_fee" to "EUR:0.003" - "cashout_tiny_amount" to "EUR:0.00000001" + "cashout_tiny_amount" to "EUR:0.01" "cashout_rounding_mode" to "zero" "cashout_min_amount" to "KUDOS:0.1" } diff --git a/common/src/main/kotlin/TalerCommon.kt b/common/src/main/kotlin/TalerCommon.kt @@ -201,12 +201,16 @@ class TalerAmount { repeat(8 - frac.length) { tmp *= 10 } + tmp } } fun number(): DecimalNumber = DecimalNumber(value, frac) + /* Check is amount has fractional amount < 0.01 */ + fun isSubCent(): Boolean = (frac % CENT_FRACTION) > 0 + override fun equals(other: Any?): Boolean { return other is TalerAmount && other.value == this.value && @@ -238,6 +242,7 @@ class TalerAmount { companion object { const val FRACTION_BASE = 100000000 + const val CENT_FRACTION = 1000000 const val MAX_VALUE = 4503599627370496L // 2^52 private val PATTERN = Regex("([A-Z]{1,11}):([0-9]+)(?:\\.([0-9]{1,8}))?") diff --git a/common/src/test/kotlin/AmountTest.kt b/common/src/test/kotlin/AmountTest.kt @@ -44,11 +44,21 @@ class AmountTest { @Test fun parseRoundTrip() { - for (amount in listOf("EUR:4", "EUR:0.02", "EUR:4.12")) { + for (amount in sequenceOf("EUR:4", "EUR:0.02", "EUR:4.12")) { assertEquals(amount, TalerAmount(amount).toString()) } } + @Test + fun subCent() { + for (ok in sequenceOf("EUR:1", "EUR:0.1", "EUR:0.01", "EUR:1.23")) { + assert(!TalerAmount(ok).isSubCent()) + } + for (subCent in sequenceOf("EUR:0.001", "EUR:1.001", "EUR:99.991", "EUR:0.0000012")) { + assert(TalerAmount(subCent).isSubCent()) + } + } + fun assertException(msg: String, lambda: () -> Unit) { try { lambda() diff --git a/database-versioning/libeufin-bank-procedures.sql b/database-versioning/libeufin-bank-procedures.sql @@ -1634,33 +1634,43 @@ LANGUAGE sql AS $$ SELECT (value->>'mode')::rounding_mode FROM config WHERE key= CREATE FUNCTION conversion_apply_ratio( IN amount taler_amount ,IN ratio taler_amount + ,IN fee taler_amount ,IN tiny taler_amount -- Result is rounded to this amount ,IN rounding rounding_mode -- With this rounding mode ,OUT result taler_amount + ,OUT out_too_small BOOLEAN ) LANGUAGE plpgsql AS $$ DECLARE - product_numeric NUMERIC(33, 8); -- 16 digit for val, 8 for frac and 1 for rounding error + 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 - product_numeric = (amount.val::numeric(24) * 100000000 + amount.frac::numeric(24)) * (ratio.val::numeric(24, 8) + ratio.frac::numeric(24, 8) / 100000000); + amount_numeric = (amount.val::numeric(24) * 100000000 + amount.frac::numeric(24)) * (ratio.val::numeric(24, 8) + ratio.frac::numeric(24, 8) / 100000000); + + -- Apply fees + amount_numeric = amount_numeric - (fee.val::numeric(24) * 100000000 + fee.frac::numeric(24)); + IF (sign(amount_numeric) != 1) THEN + out_too_small = TRUE; + result = (0, 0); + RETURN; + END IF; -- Round to tiny amounts tiny_numeric = (tiny.val::numeric(24) * 100000000 + tiny.frac::numeric(24)); - product_numeric = product_numeric / tiny_numeric; - rounding_error = (product_numeric % 1)::real; - product_numeric = trunc(product_numeric) * tiny_numeric; + 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 - product_numeric = product_numeric + tiny_numeric; + amount_numeric = amount_numeric + tiny_numeric; END IF; -- Extract product parts - result = (trunc(product_numeric / 100000000)::int8, (product_numeric % 100000000)::int4); + result = (trunc(amount_numeric / 100000000)::int8, (amount_numeric % 100000000)::int4); IF (result.val > 1::INT8<<52) THEN RAISE EXCEPTION 'amount value overflowed'; @@ -1672,32 +1682,37 @@ COMMENT ON FUNCTION conversion_apply_ratio CREATE FUNCTION conversion_revert_ratio( IN amount taler_amount ,IN ratio taler_amount + ,IN fee taler_amount ,IN tiny taler_amount -- Result is rounded to this amount ,IN rounding rounding_mode -- With this rounding mode ,OUT result taler_amount + ,OUT bad_value BOOLEAN ) LANGUAGE plpgsql AS $$ DECLARE - fraction_numeric NUMERIC(33, 8); -- 16 digit for val, 8 for frac and 1 for rounding error + 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 + -- Apply fees + 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 - fraction_numeric = (amount.val::numeric(24) * 100000000 + amount.frac::numeric(24)) / (ratio.val::numeric(24, 8) + ratio.frac::numeric(24, 8) / 100000000); + 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)); - fraction_numeric = fraction_numeric / tiny_numeric; - rounding_error = (fraction_numeric % 1)::real; - fraction_numeric = trunc(fraction_numeric) * tiny_numeric; - - -- Recover potentially lost tiny amount - IF (rounding = 'zero'::rounding_mode AND rounding_error > 0) THEN - fraction_numeric = fraction_numeric + tiny_numeric; + 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; + + -- Recover potentially lost tiny amount during rounding + IF (rounding = 'zero'::rounding_mode AND rounding_error > 0.0) THEN + amount_numeric = amount_numeric + tiny_numeric; END IF; -- Extract division parts - result = (trunc(fraction_numeric / 100000000)::int8, (fraction_numeric % 100000000)::int4); + result = (trunc(amount_numeric / 100000000)::int8, (amount_numeric % 100000000)::int4); IF (result.val > 1::INT8<<52) THEN RAISE EXCEPTION 'amount value overflowed'; @@ -1747,12 +1762,8 @@ BEGIN 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'; - SELECT (diff).val, (diff).frac, NOT ok INTO converted.val, converted.frac, too_small - FROM amount_left_minus_right(conversion_apply_ratio(amount, at_ratio, tiny_amount, mode), out_fee); - - IF too_small THEN - converted = (0, 0); - END IF; + SELECT (result).val, (result).frac, out_too_small INTO converted.val, converted.frac, too_small + FROM conversion_apply_ratio(amount, at_ratio, out_fee, tiny_amount, mode); END $$; CREATE FUNCTION conversion_from( @@ -1781,8 +1792,8 @@ BEGIN 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; + SELECT (result).val, (result).frac INTO converted.val, converted.frac + FROM conversion_revert_ratio(amount, at_ratio, out_fee, tiny_amount, mode); -- Check min amount SELECT value['val']::int8, value['frac']::int4 INTO min_amount.val, min_amount.frac FROM config WHERE key=direction||'_min_amount'; diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt @@ -28,22 +28,12 @@ import java.time.ZoneOffset import java.time.ZonedDateTime import java.time.format.DateTimeFormatter -/** - * Gets the amount number, also converting it from the - * Taler-friendly 8 fractional digits to the more bank - * friendly with 2. - * - * @param amount the Taler amount where to extract the number - * @return [String] of the amount number without the currency. - */ +/** String representation of a Taler [amount] compatible with EBICS */ fun getAmountNoCurrency(amount: TalerAmount): String { - if (amount.frac == 0) { - return amount.value.toString() - } else { - val fractionFormat = amount.frac.toString().padStart(8, '0').dropLastWhile { it == '0' } - if (fractionFormat.length > 2) throw Exception("Sub-cent amounts not supported") - return "${amount.value}.${fractionFormat}" + if (amount.isSubCent()) { + throw Exception("Sub-cent amounts not supported") } + return amount.number().toString() } /** Create a pain.001 XML document valid for [dialect] */ diff --git a/testbench/src/test/kotlin/IntegrationTest.kt b/testbench/src/test/kotlin/IntegrationTest.kt @@ -206,7 +206,7 @@ class IntegrationTest { "cashin_min_amount" to "EUR:0" "cashout_ratio" to "1.25" "cashout_fee" to "EUR:0.003" - "cashout_tiny_amount" to "EUR:0.00000001" + "cashout_tiny_amount" to "EUR:0.01" "cashout_rounding_mode" to "zero" "cashout_min_amount" to "KUDOS:0.1" } @@ -295,7 +295,7 @@ class IntegrationTest { "cashin_min_amount" to "EUR:0" "cashout_ratio" to "1.25" "cashout_fee" to "EUR:0.003" - "cashout_tiny_amount" to "EUR:0.00000001" + "cashout_tiny_amount" to "EUR:0.01" "cashout_rounding_mode" to "zero" "cashout_min_amount" to "KUDOS:0.1" }