libeufin

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

commit f9022c7e371f3f841e1b3197538fdb7537d80726
parent b4043689d321034d06aa39dfe3f34e9609966943
Author: Antoine A <>
Date:   Thu, 16 Nov 2023 23:44:56 +0000

Support conversion both ways

Diffstat:
MMakefile | 4++++
Mbank/conf/test.conf | 14+++++++-------
Mbank/conf/test_no_tan.conf | 14+++++++-------
Mbank/src/main/kotlin/tech/libeufin/bank/Config.kt | 40++++++++++++++++++++--------------------
Mbank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt | 18++++++++++++++----
Mbank/src/main/kotlin/tech/libeufin/bank/db/ConversionDAO.kt | 36+++++++++++++++++++-----------------
Mbank/src/test/kotlin/AmountTest.kt | 110++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Mbank/src/test/kotlin/CoreBankApiTest.kt | 45++++++++++++++++++++++++++-------------------
Mbank/src/test/kotlin/StatsTest.kt | 1-
Mdatabase-versioning/libeufin-bank-procedures.sql | 179+++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------
10 files changed, 311 insertions(+), 150 deletions(-)

diff --git a/Makefile b/Makefile @@ -67,3 +67,7 @@ assemble: .PHONY: check check: install-bank-files ./gradlew check + +.PHONY: test +test: install-bank-files + ./gradlew test --tests $(test) -i diff --git a/bank/conf/test.conf b/bank/conf/test.conf @@ -14,10 +14,10 @@ SQL_DIR = $DATADIR/sql/ CONFIG = postgresql:///libeufincheck [libeufin-bank-conversion] -buy_ratio = 0.8 -buy_fee = 0.02 -buy_tiny_amount = KUDOS:0.01 -buy_rounding_mode = nearest -sell_ratio = 1.25 -sell_fee = 0.003 -sell_min_amount = KUDOS:0.1 +cashin_ratio = 0.8 +cashin_fee = KUDOS:0.02 +cashin_tiny_amount = KUDOS:0.01 +cashin_rounding_mode = nearest +cashout_ratio = 1.25 +cashout_fee = FIAT:0.003 +cashout_min_amount = KUDOS:0.1 diff --git a/bank/conf/test_no_tan.conf b/bank/conf/test_no_tan.conf @@ -12,10 +12,10 @@ SQL_DIR = $DATADIR/sql/ CONFIG = postgresql:///libeufincheck [libeufin-bank-conversion] -buy_ratio = 0.8 -buy_fee = 0.02 -buy_tiny_amount = KUDOS:0.01 -buy_rounding_mode = nearest -sell_ratio = 1.25 -sell_fee = 0.003 -sell_min_amount = KUDOS:0.1 +cashin_ratio = 0.8 +cashin_fee = KUDOS:0.02 +cashin_tiny_amount = KUDOS:0.01 +cashin_rounding_mode = nearest +cashout_ratio = 1.25 +cashout_fee = FIAT:0.003 +cashout_min_amount = KUDOS:0.1 diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Config.kt b/bank/src/main/kotlin/tech/libeufin/bank/Config.kt @@ -86,16 +86,16 @@ data class BankConfig( @Serializable data class ConversionInfo ( - val buy_ratio: DecimalNumber, - val buy_fee: DecimalNumber, - val buy_tiny_amount: TalerAmount, - val buy_rounding_mode: RoundingMode, - val buy_min_amount: TalerAmount, - val sell_ratio: DecimalNumber, - val sell_fee: DecimalNumber, - val sell_tiny_amount: TalerAmount, - val sell_rounding_mode: RoundingMode, - val sell_min_amount: TalerAmount, + val cashin_ratio: DecimalNumber, + val cashin_fee: TalerAmount, + val cashin_tiny_amount: TalerAmount, + val cashin_rounding_mode: RoundingMode, + val cashin_min_amount: TalerAmount, + val cashout_ratio: DecimalNumber, + val cashout_fee: TalerAmount, + val cashout_tiny_amount: TalerAmount, + val cashout_rounding_mode: RoundingMode, + val cashout_min_amount: TalerAmount, ) data class ServerConfig( @@ -156,16 +156,16 @@ fun TalerConfig.loadBankConfig(): BankConfig = catchError { private fun TalerConfig.loadConversionInfo(currency: String, fiatCurrency: String): ConversionInfo = catchError { ConversionInfo( - buy_ratio = requireDecimalNumber("libeufin-bank-conversion", "buy_ratio"), - buy_fee = requireDecimalNumber("libeufin-bank-conversion", "buy_fee"), - buy_tiny_amount = amount("libeufin-bank-conversion", "buy_tiny_amount", currency) ?: TalerAmount(0, 1, currency), - buy_rounding_mode = RoundingMode("libeufin-bank-conversion", "buy_rounding_mode") ?: RoundingMode.zero, - buy_min_amount = amount("libeufin-bank-conversion", "buy_min_amount", fiatCurrency) ?: TalerAmount(0, 0, fiatCurrency), - sell_ratio = requireDecimalNumber("libeufin-bank-conversion", "sell_ratio"), - sell_fee = requireDecimalNumber("libeufin-bank-conversion", "sell_fee"), - sell_tiny_amount = amount("libeufin-bank-conversion", "sell_tiny_amount", fiatCurrency) ?: TalerAmount(0, 1, fiatCurrency), - sell_rounding_mode = RoundingMode("libeufin-bank-conversion", "sell_rounding_mode") ?: RoundingMode.zero, - sell_min_amount = amount("libeufin-bank-conversion", "sell_min_amount", currency) ?: TalerAmount(0, 0, currency), + cashin_ratio = requireDecimalNumber("libeufin-bank-conversion", "cashin_ratio"), + cashin_fee = requireAmount("libeufin-bank-conversion", "cashin_fee", currency), + cashin_tiny_amount = amount("libeufin-bank-conversion", "cashin_tiny_amount", currency) ?: TalerAmount(0, 1, currency), + cashin_rounding_mode = RoundingMode("libeufin-bank-conversion", "cashin_rounding_mode") ?: RoundingMode.zero, + cashin_min_amount = amount("libeufin-bank-conversion", "cashin_min_amount", fiatCurrency) ?: TalerAmount(0, 0, fiatCurrency), + cashout_ratio = requireDecimalNumber("libeufin-bank-conversion", "cashout_ratio"), + cashout_fee = requireAmount("libeufin-bank-conversion", "cashout_fee", fiatCurrency), + cashout_tiny_amount = amount("libeufin-bank-conversion", "cashout_tiny_amount", fiatCurrency) ?: TalerAmount(0, 1, fiatCurrency), + cashout_rounding_mode = RoundingMode("libeufin-bank-conversion", "cashout_rounding_mode") ?: RoundingMode.zero, + cashout_min_amount = amount("libeufin-bank-conversion", "cashout_min_amount", currency) ?: TalerAmount(0, 0, currency), ) } diff --git a/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt @@ -623,14 +623,19 @@ private fun Routing.coreBankCashoutApi(db: Database, ctx: BankConfig) = conditio params.credit?.let { ctx.checkFiatCurrency(it) } if (params.debit != null) { - val credit = db.conversion.regionalToFiat(params.debit) ?: + 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)) } else { - call.respond(HttpStatusCode.NotImplemented) // TODO + 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)) } } get("/cashin-rate") { @@ -640,14 +645,19 @@ private fun Routing.coreBankCashoutApi(db: Database, ctx: BankConfig) = conditio params.credit?.let { ctx.checkRegionalCurrency(it) } if (params.debit != null) { - val credit = db.conversion.fiatToRegional(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)) } else { - call.respond(HttpStatusCode.NotImplemented) // TODO + val debit = db.conversion.fromCashin(params.credit!!) ?: + throw conflict( + "${params.debit} is too small to be converted", + TalerErrorCode.BANK_BAD_CONVERSION + ) + call.respond(ConversionResponse(debit, params.credit)) } } } diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/ConversionDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/ConversionDAO.kt @@ -30,10 +30,8 @@ class ConversionDAO(private val db: Database) { it.transaction { conn -> var stmt = conn.prepareStatement("CALL config_set_amount(?, (?, ?)::taler_amount)") for ((name, amount) in listOf( - Pair("buy_ratio", cfg.buy_ratio), - Pair("buy_fee", cfg.buy_fee), - Pair("sell_ratio", cfg.sell_ratio), - Pair("sell_fee", cfg.sell_fee), + Pair("cashin_ratio", cfg.cashin_ratio), + Pair("cashout_ratio", cfg.cashout_ratio), )) { stmt.setString(1, name) stmt.setLong(2, amount.value) @@ -41,10 +39,12 @@ class ConversionDAO(private val db: Database) { stmt.executeUpdate() } for ((name, amount) in listOf( - Pair("buy_tiny_amount", cfg.buy_tiny_amount), - Pair("buy_min_amount", cfg.buy_min_amount), - Pair("sell_tiny_amount", cfg.sell_tiny_amount), - Pair("sell_min_amount", cfg.sell_min_amount), + Pair("cashin_fee", cfg.cashin_fee), + Pair("cashin_tiny_amount", cfg.cashin_tiny_amount), + Pair("cashin_min_amount", cfg.cashin_min_amount), + Pair("cashout_fee", cfg.cashout_fee), + Pair("cashout_tiny_amount", cfg.cashout_tiny_amount), + Pair("cashout_min_amount", cfg.cashout_min_amount), )) { stmt.setString(1, name) stmt.setLong(2, amount.value) @@ -53,8 +53,8 @@ class ConversionDAO(private val db: Database) { } stmt = conn.prepareStatement("CALL config_set_rounding_mode(?, ?::rounding_mode)") for ((name, value) in listOf( - Pair("buy_rounding_mode", cfg.buy_rounding_mode), - Pair("sell_rounding_mode", cfg.sell_rounding_mode) + Pair("cashin_rounding_mode", cfg.cashin_rounding_mode), + Pair("cashout_rounding_mode", cfg.cashout_rounding_mode) )) { stmt.setString(1, name) stmt.setString(2, value.name) @@ -63,11 +63,11 @@ class ConversionDAO(private val db: Database) { } } - private suspend fun conversion(amount: TalerAmount, name: String, currency: String): TalerAmount? = db.conn { conn -> - val stmt = conn.prepareStatement("SELECT too_small, (to_amount).val AS amount_val, (to_amount).frac AS amount_frac FROM conversion_to((?, ?)::taler_amount, ?)") - stmt.setLong(1, amount.value) - stmt.setInt(2, amount.frac) - stmt.setString(3, name) + private suspend fun conversion(from: TalerAmount, direction: String, function: String, currency: 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, ?)") + stmt.setLong(1, from.value) + stmt.setInt(2, from.frac) + stmt.setString(3, direction) stmt.executeQuery().use { it.next() if (!it.getBoolean("too_small")) { @@ -82,6 +82,8 @@ class ConversionDAO(private val db: Database) { } } - suspend fun regionalToFiat(amount: TalerAmount): TalerAmount? = conversion(amount, "sell", db.fiatCurrency!!) - suspend fun fiatToRegional(amount: TalerAmount): TalerAmount? = conversion(amount, "buy", db.bankCurrency) + suspend fun toCashout(amount: TalerAmount): TalerAmount? = conversion(amount, "cashout", "conversion_to", db.fiatCurrency!!) + suspend fun toCashin(amount: TalerAmount): TalerAmount? = conversion(amount, "cashin", "conversion_to", db.bankCurrency) + suspend fun fromCashout(amount: TalerAmount): TalerAmount? = conversion(amount, "cashout", "conversion_from", db.bankCurrency) + suspend fun fromCashin(amount: TalerAmount): TalerAmount? = conversion(amount, "cashin", "conversion_from", db.fiatCurrency!!) } \ No newline at end of file diff --git a/bank/src/test/kotlin/AmountTest.kt b/bank/src/test/kotlin/AmountTest.kt @@ -204,11 +204,10 @@ class AmountTest { } @Test - fun mul() = dbSetup { db -> + fun conversionApply() = dbSetup { db -> db.conn { conn -> - val stmt = conn.prepareStatement("SELECT product.val, product.frac FROM amount_mul((?, ?)::taler_amount, (?, ?)::taler_amount, (?, ?)::taler_amount, ?::rounding_mode) as product") - - fun mul(nb: TalerAmount, times: DecimalNumber, tiny: DecimalNumber = DecimalNumber("0.00000001"), roundingMode: String = "zero"): TalerAmount? { + 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") stmt.setLong(1, nb.value) stmt.setInt(2, nb.frac) stmt.setLong(3, times.value) @@ -225,14 +224,14 @@ class AmountTest { }!! } - assertEquals(TalerAmount("EUR:30.0629"), mul(TalerAmount("EUR:6.41"), DecimalNumber("4.69"))) - assertEquals(TalerAmount("EUR:6.41000641"), mul(TalerAmount("EUR:6.41"), DecimalNumber("1.000001"))) - assertEquals(TalerAmount("EUR:2.49999997"), mul(TalerAmount("EUR:0.99999999"), DecimalNumber("2.5"))) - assertEquals(TalerAmount("EUR:${TalerAmount.MAX_VALUE}.99999999"), mul(TalerAmount("EUR:${TalerAmount.MAX_VALUE}.99999999"), DecimalNumber("1"))) - assertEquals(TalerAmount("EUR:${TalerAmount.MAX_VALUE}"), mul(TalerAmount("EUR:${TalerAmount.MAX_VALUE/4}"), DecimalNumber("4"))) - assertException("ERROR: amount value overflowed") { mul(TalerAmount(TalerAmount.MAX_VALUE/3, 0, "EUR"), DecimalNumber("3.00000001")) } - assertException("ERROR: amount value overflowed") { mul(TalerAmount((TalerAmount.MAX_VALUE+2)/2, 0, "EUR"), DecimalNumber("2")) } - assertException("ERROR: numeric field overflow") { mul(TalerAmount(Long.MAX_VALUE, 0, "EUR"), DecimalNumber("1")) } + assertEquals(TalerAmount("EUR:30.0629"), apply(TalerAmount("EUR:6.41"), DecimalNumber("4.69"))) + assertEquals(TalerAmount("EUR:6.41000641"), apply(TalerAmount("EUR:6.41"), DecimalNumber("1.000001"))) + assertEquals(TalerAmount("EUR:2.49999997"), apply(TalerAmount("EUR:0.99999999"), DecimalNumber("2.5"))) + assertEquals(TalerAmount("EUR:${TalerAmount.MAX_VALUE}.99999999"), apply(TalerAmount("EUR:${TalerAmount.MAX_VALUE}.99999999"), DecimalNumber("1"))) + assertEquals(TalerAmount("EUR:${TalerAmount.MAX_VALUE}"), apply(TalerAmount("EUR:${TalerAmount.MAX_VALUE/4}"), DecimalNumber("4"))) + assertException("ERROR: amount value overflowed") { apply(TalerAmount(TalerAmount.MAX_VALUE/3, 0, "EUR"), DecimalNumber("3.00000001")) } + assertException("ERROR: amount value overflowed") { apply(TalerAmount((TalerAmount.MAX_VALUE+2)/2, 0, "EUR"), DecimalNumber("2")) } + assertException("ERROR: numeric field overflow") { apply(TalerAmount(Long.MAX_VALUE, 0, "EUR"), DecimalNumber("1")) } // Check rounding mode for ((mode, rounding) in listOf( @@ -243,21 +242,92 @@ class AmountTest { for ((rounded, amounts) in rounding) { for (amount in amounts) { // Check euro - assertEquals(TalerAmount("EUR:0.0$rounded"), mul(TalerAmount("EUR:$amount"), DecimalNumber("0.001001"), DecimalNumber("0.01"), mode)) + assertEquals(TalerAmount("EUR:0.0$rounded"), apply(TalerAmount("EUR:$amount"), DecimalNumber("0.001"), DecimalNumber("0.01"), mode)) // Check kudos - assertEquals(TalerAmount("KUDOS:0.0000000$rounded"), mul(TalerAmount("KUDOS:0.$amount"), DecimalNumber("0.0000001"), roundingMode = mode)) + assertEquals(TalerAmount("KUDOS:0.0000000$rounded"), apply(TalerAmount("KUDOS:0.$amount"), DecimalNumber("0.0000001"), roundingMode = mode)) } } } // Check hungarian rounding - for ((rounded, amounts) in listOf( - Pair(10, listOf(10, 11, 12)), - Pair(15, listOf(13, 14, 15, 16, 17)), - Pair(20, listOf(18, 19)), + for ((mode, rounding) in listOf( + Pair("zero", listOf(Pair(10, listOf(10, 11, 12, 13, 14)), Pair(15, listOf(15, 16, 17, 18, 19)))), + Pair("up", listOf(Pair(10, listOf(10)), Pair(15, listOf(11, 12, 13, 14, 15)), Pair(20, listOf(16, 17, 18, 19)))), + Pair("nearest", listOf(Pair(10, listOf(10, 11, 12)), Pair(15, listOf(13, 14, 15, 16, 17)), Pair(20, listOf(18, 19)))) )) { - for (amount in amounts) { - assertEquals(TalerAmount("HUF:$rounded"), mul(TalerAmount("HUF:$amount"), DecimalNumber("1.01"), DecimalNumber("5"), "nearest")) + for ((rounded, amounts) in rounding) { + for (amount in amounts) { + assertEquals(TalerAmount("HUF:$rounded"), apply(TalerAmount("HUF:$amount"), DecimalNumber("1"), DecimalNumber("5"), mode)) + } + } + } + } + } + + @Test + fun conversionRevert() = dbSetup { db -> + db.conn { conn -> + fun TalerAmount.apply(ratio: 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") + stmt.setLong(1, this.value) + stmt.setInt(2, this.frac) + stmt.setLong(3, ratio.value) + stmt.setInt(4, ratio.frac) + stmt.setLong(5, tiny.value) + stmt.setInt(6, tiny.frac) + stmt.setString(7, roundingMode) + return stmt.oneOrNull { + TalerAmount( + it.getLong(1), + it.getInt(2), + currency + ) + }!! + } + + fun TalerAmount.revert(ratio: DecimalNumber, tiny: DecimalNumber = DecimalNumber("0.00000001"), roundingMode: String = "zero"): TalerAmount? { + val stmt = conn.prepareStatement("SELECT amount.val, amount.frac FROM conversion_revert_ratio((?, ?)::taler_amount, (?, ?)::taler_amount, (?, ?)::taler_amount, ?::rounding_mode) as amount") + stmt.setLong(1, this.value) + stmt.setInt(2, this.frac) + stmt.setLong(3, ratio.value) + stmt.setInt(4, ratio.frac) + stmt.setLong(5, tiny.value) + stmt.setInt(6, tiny.frac) + stmt.setString(7, roundingMode) + return stmt.oneOrNull { + TalerAmount( + it.getLong(1), + it.getInt(2), + currency + ) + }!! + } + + 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:${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("0.341", "0.00000001")) { + val tiny = DecimalNumber(tiny) + val ratio = DecimalNumber(ratio) + val base = TalerAmount("EUR:$amount") + // Apply ratio + val rounded = base.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) + } + } } } } diff --git a/bank/src/test/kotlin/CoreBankApiTest.kt b/bank/src/test/kotlin/CoreBankApiTest.kt @@ -1134,16 +1134,16 @@ class CoreBankCashoutApiTest { val id = it.cashout_id db.conversion.updateConfig(ConversionInfo( - buy_ratio = DecimalNumber("1"), - buy_fee = DecimalNumber("1"), - buy_tiny_amount = TalerAmount("KUDOS:0.0001"), - buy_rounding_mode = RoundingMode.nearest, - buy_min_amount = TalerAmount("FIAT:0.0001"), - sell_ratio = DecimalNumber("1"), - sell_fee = DecimalNumber("1"), - sell_tiny_amount = TalerAmount("FIAT:0.0001"), - sell_rounding_mode = RoundingMode.nearest, - sell_min_amount = TalerAmount("KUDOS:0.0001"), + cashin_ratio = DecimalNumber("1"), + cashin_fee = TalerAmount("KUDOS:0.1"), + cashin_tiny_amount = TalerAmount("KUDOS:0.0001"), + cashin_rounding_mode = RoundingMode.nearest, + cashin_min_amount = TalerAmount("FIAT:0.0001"), + cashout_ratio = DecimalNumber("1"), + cashout_fee = TalerAmount("FIAT:0.1"), + cashout_tiny_amount = TalerAmount("FIAT:0.0001"), + cashout_rounding_mode = RoundingMode.nearest, + cashout_min_amount = TalerAmount("KUDOS:0.0001"), )) client.postA("/accounts/customer/cashouts/$id/confirm"){ @@ -1334,13 +1334,16 @@ class CoreBankCashoutApiTest { // GET /cashout-rate @Test fun cashoutRate() = bankSetup { _ -> - // Check conversion + // Check conversion to client.get("/cashout-rate?amount_debit=KUDOS:1").assertOkJson<ConversionResponse> { + assertEquals(TalerAmount("KUDOS:1"), it.amount_debit) + assertEquals(TalerAmount("FIAT:1.247"), it.amount_credit) + } + // Check conversion from + client.get("/cashout-rate?amount_credit=FIAT:1.247").assertOkJson<ConversionResponse> { + assertEquals(TalerAmount("KUDOS:1"), it.amount_debit) assertEquals(TalerAmount("FIAT:1.247"), it.amount_credit) } - // Not implemented (yet) - client.get("/cashout-rate?amount_credit=FIAT:1") - .assertNotImplemented() // Too small client.get("/cashout-rate?amount_debit=KUDOS:0.08") @@ -1366,17 +1369,21 @@ class CoreBankCashoutApiTest { // GET /cashin-rate @Test fun cashinRate() = bankSetup { _ -> - // Check conversion + for ((amount, converted) in listOf( - Pair(0.75, 0.58), Pair(0.33, 0.24), Pair(0.66, 0.51) + Pair(0.75, 0.58), Pair(0.32, 0.24), Pair(0.66, 0.51) )) { + // Check conversion to client.get("/cashin-rate?amount_debit=FIAT:$amount").assertOkJson<ConversionResponse> { assertEquals(TalerAmount("KUDOS:$converted"), it.amount_credit) + assertEquals(TalerAmount("FIAT:$amount"), it.amount_debit) + } + // Check conversion from + client.get("/cashin-rate?amount_credit=KUDOS:$converted").assertOkJson<ConversionResponse> { + assertEquals(TalerAmount("KUDOS:$converted"), it.amount_credit) + assertEquals(TalerAmount("FIAT:$amount"), it.amount_debit) } } - // Not implemented (yet) - client.get("/cashin-rate?amount_credit=KUDOS:1") - .assertNotImplemented() // No amount client.get("/cashin-rate") diff --git a/bank/src/test/kotlin/StatsTest.kt b/bank/src/test/kotlin/StatsTest.kt @@ -161,7 +161,6 @@ class StatsTest { frac = it.getInt("taler_out_volume_frac"), currency = "KUDOS" ) - println("$timeframe $talerOutCount $talerOutVolume") assertEquals(count, talerOutCount) assertEquals(amount, talerOutVolume) }!! diff --git a/database-versioning/libeufin-bank-procedures.sql b/database-versioning/libeufin-bank-procedures.sql @@ -31,44 +31,6 @@ END $$; COMMENT ON FUNCTION amount_add IS 'Returns the normalized sum of two amounts. It raises an exception when the resulting .val is larger than 2^52'; -CREATE OR REPLACE FUNCTION amount_mul( - IN a taler_amount - ,IN b taler_amount - ,IN tiny taler_amount -- Product is rounded around the tiny amount - ,IN rounding rounding_mode - ,OUT product taler_amount -) -LANGUAGE plpgsql AS $$ -DECLARE - product_numeric NUMERIC(33, 8); -- 16 digit for val, 8 for frac and 1 for rounding error - tiny_numeric NUMERIC(24); - rounding_error int2; -BEGIN - -- Perform multiplication using big numbers - product_numeric = (a.val::numeric(24) * 100000000 + a.frac::numeric(24)) * (b.val::numeric(24, 8) + b.frac::numeric(24, 8) / 100000000); - - -- 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 * 10 % 10)::int2; - product_numeric = trunc(product_numeric) * tiny_numeric; - - -- Apply rounding mode - IF (rounding = 'nearest'::rounding_mode AND rounding_error >= 5) - OR (rounding = 'up'::rounding_mode AND rounding_error > 0) THEN - product_numeric = product_numeric + tiny_numeric; - END IF; - - -- Extract product parts - product = (trunc(product_numeric / 100000000)::int8, (product_numeric % 100000000)::int4); - - IF (product.val > 1::bigint<<52) THEN - RAISE EXCEPTION 'amount value overflowed'; - END IF; -END $$; -COMMENT ON FUNCTION amount_mul - IS 'Returns the product of two amounts. It raises an exception when the resulting .val is larger than 2^52'; - CREATE OR REPLACE FUNCTION amount_left_minus_right( IN l taler_amount ,IN r taler_amount @@ -1040,9 +1002,9 @@ SELECT bank_account_id WHERE login = 'admin'; -- Perform conversion -SELECT (to_amount).val, (to_amount).frac, too_small +SELECT (converted).val, (converted).frac, too_small INTO converted_amount.val, converted_amount.frac, out_too_small - FROM conversion_to(in_amount, 'buy'::text); + FROM conversion_to(in_amount, 'cashin'::text); IF out_too_small THEN RETURN; END IF; @@ -1098,7 +1060,7 @@ account_id BIGINT; challenge_id BIGINT; BEGIN -- check conversion -SELECT too_small OR in_amount_credit!=to_amount INTO out_bad_conversion FROM conversion_to(in_amount_debit, 'sell'::text); +SELECT too_small 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; @@ -1220,7 +1182,7 @@ ELSIF already_confirmed OR out_aborted OR out_no_cashout_payto THEN END IF; -- check conversion -SELECT too_small OR amount_credit_local!=to_amount INTO out_bad_conversion FROM conversion_to(amount_debit_local, 'sell'::text); +SELECT too_small 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; @@ -1474,10 +1436,86 @@ LANGUAGE sql AS $$ ON CONFLICT (key) DO UPDATE SET value = excluded.value $$; +CREATE OR REPLACE FUNCTION conversion_apply_ratio( + IN amount taler_amount + ,IN ratio taler_amount + ,IN tiny taler_amount -- Result is rounded to this amount + ,IN rounding rounding_mode -- With this rounding mode + ,OUT result taler_amount +) +LANGUAGE plpgsql AS $$ +DECLARE + product_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); + + -- 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; + + -- 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; + END IF; + + -- Extract product parts + result = (trunc(product_numeric / 100000000)::int8, (product_numeric % 100000000)::int4); + + IF (result.val > 1::bigint<<52) THEN + RAISE EXCEPTION 'amount value overflowed'; + END IF; +END $$; +COMMENT ON FUNCTION conversion_apply_ratio + IS 'Apply a ratio to an amount rouding the result to a tiny amount following a rounding mode. It raises an exception when the resulting .val is larger than 2^52'; + +CREATE OR REPLACE FUNCTION conversion_revert_ratio( + IN amount taler_amount + ,IN ratio taler_amount + ,IN tiny taler_amount -- Result is rounded to this amount + ,IN rounding rounding_mode -- With this rounding mode + ,OUT result taler_amount +) +LANGUAGE plpgsql AS $$ +DECLARE + fraction_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 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); + + -- 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; + END IF; + + -- Extract division parts + result = (trunc(fraction_numeric / 100000000)::int8, (fraction_numeric % 100000000)::int4); + + IF (result.val > 1::bigint<<52) THEN + RAISE EXCEPTION 'amount value overflowed'; + END IF; +END $$; +COMMENT ON FUNCTION conversion_revert_ratio + IS 'Revert the application of a ratio. This function does not always return the smallest possible amount. It raises an exception when the resulting .val is larger than 2^52'; + + CREATE OR REPLACE FUNCTION conversion_to( - IN from_amount taler_amount, - IN name TEXT, - OUT to_amount taler_amount, + IN amount taler_amount, + IN direction TEXT, + OUT converted taler_amount, OUT too_small BOOLEAN ) LANGUAGE plpgsql AS $$ @@ -1489,24 +1527,55 @@ DECLARE mode rounding_mode; BEGIN -- Check min amount - SELECT value['val']::int8, value['frac']::int4 INTO min_amount.val, min_amount.frac FROM config WHERE key=name||'_min_amount'; - SELECT NOT ok INTO too_small FROM amount_left_minus_right(from_amount, min_amount); + SELECT value['val']::int8, value['frac']::int4 INTO min_amount.val, min_amount.frac FROM config WHERE key=direction||'_min_amount'; + SELECT NOT ok INTO too_small FROM amount_left_minus_right(amount, min_amount); IF too_small THEN - to_amount = (0, 0); + converted = (0, 0); RETURN; END IF; -- Perform conversion - SELECT value['val']::int8, value['frac']::int4 INTO at_ratio.val, at_ratio.frac FROM config WHERE key=name||'_ratio'; - SELECT value['val']::int8, value['frac']::int4 INTO out_fee.val, out_fee.frac FROM config WHERE key=name||'_fee'; - SELECT value['val']::int8, value['frac']::int4 INTO tiny_amount.val, tiny_amount.frac FROM config WHERE key=name||'_tiny_amount'; - SELECT (value->>'mode')::rounding_mode INTO mode FROM config WHERE key=name||'_rounding_mode'; + SELECT value['val']::int8, value['frac']::int4 INTO at_ratio.val, at_ratio.frac FROM config WHERE key=direction||'_ratio'; + 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'; + + 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; +END $$; - SELECT product.val, product.frac INTO to_amount.val, to_amount.frac FROM amount_mul(from_amount, at_ratio, tiny_amount, mode) as product; - SELECT (diff).val, (diff).frac, NOT ok INTO to_amount.val, to_amount.frac, too_small FROM amount_left_minus_right(to_amount, out_fee); +CREATE OR REPLACE FUNCTION conversion_from( + IN amount taler_amount, + IN direction TEXT, + OUT converted taler_amount, + OUT too_small BOOLEAN +) +LANGUAGE plpgsql AS $$ +DECLARE + at_ratio taler_amount; + out_fee taler_amount; + tiny_amount taler_amount; + min_amount taler_amount; + mode rounding_mode; +BEGIN + -- Perform conversion + SELECT value['val']::int8, value['frac']::int4 INTO at_ratio.val, at_ratio.frac FROM config WHERE key=direction||'_ratio'; + 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'; + 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; + + -- Check min amount + SELECT value['val']::int8, value['frac']::int4 INTO min_amount.val, min_amount.frac FROM config WHERE key=direction||'_min_amount'; + SELECT NOT ok INTO too_small FROM amount_left_minus_right(converted, min_amount); IF too_small THEN - to_amount = (0, 0); + converted = (0, 0); END IF; END $$;