commit f9022c7e371f3f841e1b3197538fdb7537d80726
parent b4043689d321034d06aa39dfe3f34e9609966943
Author: Antoine A <>
Date: Thu, 16 Nov 2023 23:44:56 +0000
Support conversion both ways
Diffstat:
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 $$;