libeufin

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

commit dedac1f5c5404a68bec600aa49030851e2ef13a0
parent 844be31478752257916a9c2edca232854f225965
Author: Antoine A <>
Date:   Fri, 24 Nov 2023 15:05:08 +0000

Set conversion rates through REST API

Diffstat:
Mbank/conf/test.conf | 9---------
Mbank/conf/test_no_tan.conf | 9---------
Mbank/src/main/kotlin/tech/libeufin/bank/Config.kt | 19-------------------
Mbank/src/main/kotlin/tech/libeufin/bank/ConversionApi.kt | 17++++++++++++++++-
Mbank/src/main/kotlin/tech/libeufin/bank/Main.kt | 12------------
Mbank/src/main/kotlin/tech/libeufin/bank/TalerCommon.kt | 4++++
Mbank/src/main/kotlin/tech/libeufin/bank/db/ConversionDAO.kt | 30++++++++++++++++++++++++++++++
Mbank/src/test/kotlin/CoreBankApiTest.kt | 30++++++++++++++++--------------
Mbank/src/test/kotlin/DatabaseTest.kt | 9+--------
Mbank/src/test/kotlin/helpers.kt | 19++++++++++++++++++-
Mdatabase-versioning/libeufin-bank-procedures.sql | 14++++++++++++++
Mintegration/test/IntegrationTest.kt | 18++++++++++++++++++
12 files changed, 117 insertions(+), 73 deletions(-)

diff --git a/bank/conf/test.conf b/bank/conf/test.conf @@ -13,15 +13,6 @@ spa = $DATADIR/spa/ SQL_DIR = $DATADIR/sql/ CONFIG = postgresql:///libeufincheck -[libeufin-bank-conversion] -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 = EUR:0.003 -cashout_min_amount = KUDOS:0.1 - [nexus-ebics] currency = EUR diff --git a/bank/conf/test_no_tan.conf b/bank/conf/test_no_tan.conf @@ -10,14 +10,5 @@ allow_conversion = YES SQL_DIR = $DATADIR/sql/ CONFIG = postgresql:///libeufincheck -[libeufin-bank-conversion] -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 = EUR:0.003 -cashout_min_amount = KUDOS:0.1 - [nexus-ebics] currency = EUR diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Config.kt b/bank/src/main/kotlin/tech/libeufin/bank/Config.kt @@ -79,7 +79,6 @@ data class BankConfig( val allowConversion: Boolean, val fiatCurrency: String?, val fiatCurrencySpec: CurrencySpecification?, - val conversionInfo: ConversionInfo?, val tanSms: String?, val tanEmail: String?, val spaPath: String? @@ -128,12 +127,10 @@ fun TalerConfig.loadBankConfig(): BankConfig = catchError { val regionalCurrency = requireString("libeufin-bank", "currency") var fiatCurrency: String? = null; var fiatCurrencySpec: CurrencySpecification? = null - var conversionInfo: ConversionInfo? = null; val allowConversion = lookupBoolean("libeufin-bank", "allow_conversion") ?: false; if (allowConversion) { fiatCurrency = requireString("nexus-ebics", "currency"); fiatCurrencySpec = currencySpecificationFor(fiatCurrency) - conversionInfo = loadConversionInfo(regionalCurrency, fiatCurrency) } BankConfig( regionalCurrency = regionalCurrency, @@ -150,7 +147,6 @@ fun TalerConfig.loadBankConfig(): BankConfig = catchError { allowConversion = allowConversion, fiatCurrency = fiatCurrency, fiatCurrencySpec = fiatCurrencySpec, - conversionInfo = conversionInfo, tanSms = lookupPath("libeufin-bank", "tan_sms"), tanEmail = lookupPath("libeufin-bank", "tan_email"), ) @@ -162,21 +158,6 @@ fun TalerConfig.currencySpecificationFor(currency: String): CurrencySpecificatio }?.let { loadCurrencySpecification(it) } ?: throw TalerConfigError("missing currency specification for $currency") } -private fun TalerConfig.loadConversionInfo(currency: String, fiatCurrency: String): ConversionInfo = catchError { - ConversionInfo( - 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), - ) -} - private fun TalerConfig.loadCurrencySpecification(section: String): CurrencySpecification = catchError { CurrencySpecification( name = requireString(section, "name"), diff --git a/bank/src/main/kotlin/tech/libeufin/bank/ConversionApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/ConversionApi.kt @@ -30,13 +30,21 @@ import net.taler.common.errorcodes.TalerErrorCode fun Routing.conversionApi(db: Database, ctx: BankConfig) = conditional(ctx.allowConversion) { get("/conversion-info/config") { + val config = db.conversion.getConfig(ctx.regionalCurrency, ctx.fiatCurrency!!) + if (config == null) { + throw libeufinError( + HttpStatusCode.NotImplemented, + "conversion rate not configured yet", + TalerErrorCode.END + ) + } call.respond( ConversionConfig( regional_currency = ctx.regionalCurrency, regional_currency_specification = ctx.regionalCurrencySpec, fiat_currency = ctx.fiatCurrency!!, fiat_currency_specification = ctx.fiatCurrencySpec!!, - conversion_info = ctx.conversionInfo!! + conversion_info = config ) ) } @@ -90,4 +98,11 @@ fun Routing.conversionApi(db: Database, ctx: BankConfig) = conditional(ctx.allow } } } + authAdmin(db, TokenScope.readwrite) { + post("/conversion-info/conversion-rate") { + val req = call.receive<ConversionInfo>() + db.conversion.updateConfig(req); + call.respond(HttpStatusCode.NoContent) + } + } } \ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Main.kt b/bank/src/main/kotlin/tech/libeufin/bank/Main.kt @@ -316,12 +316,6 @@ class BankDbInit : CliktCommand("Initialize the libeufin-bank database", name = logger.info("Admin's account created") } } - - // Load conversion config - ctx.conversionInfo?.run { - logger.info("load conversion config in DB") - db.conversion.updateConfig(this) - } } } } @@ -361,12 +355,6 @@ class ServeBank : CliktCommand("Run libeufin-bank HTTP server", name = "serve") pgDataSource(dbCfg.dbConnStr).pgConnection().execSQLUpdate(sqlProcedures.readText()) // Remove conversion info from the database ? } - - // Load conversion config - ctx.conversionInfo?.run { - logger.info("loading conversion config in DB") - db.conversion.updateConfig(this) - } } embeddedServer(Netty, port = serverCfg.port) { corebankWebApp(db, ctx) diff --git a/bank/src/main/kotlin/tech/libeufin/bank/TalerCommon.kt b/bank/src/main/kotlin/tech/libeufin/bank/TalerCommon.kt @@ -300,6 +300,10 @@ class DecimalNumber { val value: Long val frac: Int + constructor(value: Long, frac: Int) { + this.value = value + this.frac = frac + } constructor(encoded: String) { val match = PATTERN.matchEntire(encoded) ?: throw badRequest("Invalid decimal number format"); val (value, frac) = match.destructured diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/ConversionDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/ConversionDAO.kt @@ -20,6 +20,7 @@ package tech.libeufin.bank import tech.libeufin.util.* +import tech.libeufin.bank.* /** Data access logic for conversion */ class ConversionDAO(private val db: Database) { @@ -61,6 +62,35 @@ class ConversionDAO(private val db: Database) { } } + /** Get in-db conversion config */ + suspend fun getConfig(regional: String, fiat: String): ConversionInfo? = db.conn { + it.transaction { conn -> + val amount = conn.prepareStatement("SELECT (amount).val as amount_val, (amount).frac as amount_frac FROM config_get_amount(?) as amount"); + val roundingMode = conn.prepareStatement("SELECT config_get_rounding_mode(?)"); + fun getAmount(name: String, currency: String): TalerAmount? { + amount.setString(1, name) + return amount.oneOrNull { it.getAmount("amount", currency) } + } + fun getRatio(name: String): DecimalNumber? = getAmount(name, "")?.run { DecimalNumber(value, frac) } + fun getMode(name: String): RoundingMode? { + roundingMode.setString(1, name) + return roundingMode.oneOrNull { RoundingMode.valueOf(it.getString(1)) } + } + ConversionInfo( + cashin_ratio = getRatio("cashin_ratio") ?: return@transaction null, + cashin_fee = getAmount("cashin_fee", regional) ?: return@transaction null, + cashin_tiny_amount = getAmount("cashin_tiny_amount", regional) ?: return@transaction null, + cashin_rounding_mode = getMode("cashin_rounding_mode") ?: return@transaction null, + cashin_min_amount = getAmount("cashin_min_amount", fiat) ?: return@transaction null, + cashout_ratio = getRatio("cashout_ratio") ?: return@transaction null, + cashout_fee = getAmount("cashout_fee", fiat) ?: return@transaction null, + cashout_tiny_amount = getAmount("cashout_tiny_amount", fiat) ?: return@transaction null, + cashout_rounding_mode = getMode("cashout_rounding_mode") ?: return@transaction null, + cashout_min_amount = getAmount("cashout_min_amount", regional) ?: return@transaction null, + ) + } + } + /** Clear in-db conversion config */ suspend fun clearConfig() = db.serializable { conn -> conn.prepareStatement("DELETE FROM config WHERE key LIKE 'cashin%' OR key like 'cashout%'").executeUpdate() diff --git a/bank/src/test/kotlin/CoreBankApiTest.kt b/bank/src/test/kotlin/CoreBankApiTest.kt @@ -1020,7 +1020,7 @@ class CoreBankCashoutApiTest { // POST /accounts/{USERNAME}/cashouts/{CASHOUT_ID}/confirm @Test - fun confirm() = bankSetup { db -> + fun confirm() = bankSetup { _ -> authRoutine(HttpMethod.Post, "/accounts/merchant/cashouts/42/confirm") client.patchA("/accounts/customer") { @@ -1086,19 +1086,21 @@ class CoreBankCashoutApiTest { json(req) { "request_uid" to randShortHashCode() } }.assertOkJson<CashoutPending> { val id = it.cashout_id - - db.conversion.updateConfig(ConversionInfo( - 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("EUR:0.0001"), - cashout_ratio = DecimalNumber("1"), - cashout_fee = TalerAmount("EUR:0.1"), - cashout_tiny_amount = TalerAmount("EUR:0.0001"), - cashout_rounding_mode = RoundingMode.nearest, - cashout_min_amount = TalerAmount("KUDOS:0.0001"), - )) + client.post("/conversion-info/conversion-rate") { + pwAuth("admin") + json { + "cashin_ratio" to "1" + "cashin_fee" to "KUDOS:0.1" + "cashin_tiny_amount" to "KUDOS:0.0001" + "cashin_rounding_mode" to "nearest" + "cashin_min_amount" to "EUR:0.0001" + "cashout_ratio" to "1" + "cashout_fee" to "EUR:0.1" + "cashout_tiny_amount" to "EUR:0.0001" + "cashout_rounding_mode" to "nearest" + "cashout_min_amount" to "KUDOS:0.0001" + } + }.assertNoContent() client.postA("/accounts/customer/cashouts/$id/confirm"){ json { "tan" to smsCode("+99") } diff --git a/bank/src/test/kotlin/DatabaseTest.kt b/bank/src/test/kotlin/DatabaseTest.kt @@ -33,14 +33,7 @@ import tech.libeufin.bank.AccountDAO.* import tech.libeufin.util.* class DatabaseTest { - // Testing the helper that update conversion config - @Test - fun conversionConfig() = setup { db, ctx -> - // Check idempotent - db.conversion.updateConfig(ctx.conversionInfo!!) - db.conversion.updateConfig(ctx.conversionInfo!!) - } - + // Testing the helper that creates the admin account. @Test fun createAdmin() = setup { db, ctx -> diff --git a/bank/src/test/kotlin/helpers.kt b/bank/src/test/kotlin/helpers.kt @@ -57,7 +57,6 @@ fun setup( val ctx = config.loadBankConfig() Database(dbCfg.dbConnStr, ctx.regionalCurrency, ctx.fiatCurrency).use { runBlocking { - ctx.conversionInfo?.run { it.conversion.updateConfig(this) } lambda(it, ctx) } } @@ -105,6 +104,24 @@ fun bankSetup( application { corebankWebApp(db, ctx) } + if (ctx.allowConversion) { + // Set conversion rates + client.post("/conversion-info/conversion-rate") { + pwAuth("admin") + json { + "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.00000001" + "cashout_rounding_mode" to "zero" + "cashout_min_amount" to "KUDOS:0.1" + } + }.assertNoContent() + } lambda(db) } } diff --git a/database-versioning/libeufin-bank-procedures.sql b/database-versioning/libeufin-bank-procedures.sql @@ -1438,6 +1438,20 @@ LANGUAGE sql AS $$ ON CONFLICT (key) DO UPDATE SET value = excluded.value $$; +CREATE OR REPLACE FUNCTION config_get_amount( + IN name TEXT, + OUT amount taler_amount +) +LANGUAGE sql AS $$ + SELECT (value['val']::int8, value['frac']::int4)::taler_amount FROM config WHERE key=name +$$; + +CREATE OR REPLACE FUNCTION config_get_rounding_mode( + IN name TEXT, + OUT mode rounding_mode +) +LANGUAGE sql AS $$ SELECT (value->>'mode')::rounding_mode FROM config WHERE key=name $$; + CREATE OR REPLACE FUNCTION conversion_apply_ratio( IN amount taler_amount ,IN ratio taler_amount diff --git a/integration/test/IntegrationTest.kt b/integration/test/IntegrationTest.kt @@ -69,6 +69,7 @@ class IntegrationTest { nexusCmd.run("dbinit -c ../bank/conf/test.conf -r") val bankCmd = LibeufinBankCommand(); bankCmd.run("dbinit -c ../bank/conf/test.conf -r") + bankCmd.run("passwd admin password -c ../bank/conf/test.conf") kotlin.concurrent.thread(isDaemon = true) { bankCmd.run("serve -c ../bank/conf/test.conf") } @@ -98,6 +99,23 @@ class IntegrationTest { } }.assertCreated() + // Set conversion rates + client.post("http://0.0.0.0:8080/conversion-info/conversion-rate") { + basicAuth("admin", "password") + json { + "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.00000001" + "cashout_rounding_mode" to "zero" + "cashout_min_amount" to "KUDOS:0.1" + } + }.assertNoContent() + // Cashin repeat(3) { i -> val reservePub = randBytes(32);