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:
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"
}