libeufin

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

commit 3de9d180d8212718a7a150f2a42854d9b3eed16d
parent 53bfb4d6ce0d6a23af150947dd5123e45bc0cb12
Author: Antoine A <>
Date:   Wed, 16 Jul 2025 13:53:00 +0200

bank: conversion rate class support

Diffstat:
Mbank/src/main/kotlin/tech/libeufin/bank/Config.kt | 2+-
Mbank/src/main/kotlin/tech/libeufin/bank/Constants.kt | 4++--
Mbank/src/main/kotlin/tech/libeufin/bank/Error.kt | 56+++++++++++++++++++++++++++++++-------------------------
Mbank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt | 72+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mbank/src/main/kotlin/tech/libeufin/bank/api/ConversionApi.kt | 199++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Mbank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt | 95+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mbank/src/main/kotlin/tech/libeufin/bank/auth/auth.kt | 4+++-
Mbank/src/main/kotlin/tech/libeufin/bank/cli/CreateAccount.kt | 8+++-----
Mbank/src/main/kotlin/tech/libeufin/bank/cli/DbInit.kt | 6++++--
Mbank/src/main/kotlin/tech/libeufin/bank/cli/EditAccount.kt | 22++++++----------------
Mbank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt | 205+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
Mbank/src/main/kotlin/tech/libeufin/bank/db/ConversionDAO.kt | 462++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Mbank/src/main/kotlin/tech/libeufin/bank/db/Database.kt | 2--
Mbank/src/main/kotlin/tech/libeufin/bank/helpers.kt | 4++--
Mbank/src/main/kotlin/tech/libeufin/bank/params.kt | 20+++++++++++++++++---
Mbank/src/test/kotlin/AmountTest.kt | 10+++++-----
Mbank/src/test/kotlin/ConversionApiTest.kt | 218++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
Mbank/src/test/kotlin/CoreBankApiTest.kt | 210+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mbank/src/test/kotlin/bench.kt | 45+++++++++++++++++++++++++++++++++++++++++++--
Mbank/src/test/kotlin/helpers.kt | 21++++++++++++++++++---
Mbank/src/test/kotlin/routines.kt | 15+++++++++------
Mcommon/src/main/kotlin/TalerCommon.kt | 1+
Mcommon/src/main/kotlin/TalerErrorCode.kt | 54+++++++++++++++++++++++++++++++++++++++---------------
Mcommon/src/main/kotlin/api/server.kt | 2+-
Mcommon/src/main/kotlin/db/statement.kt | 8++++++--
Mcommon/src/main/kotlin/db/transaction.kt | 8+++-----
Mcommon/src/main/kotlin/db/types.kt | 20++++++++++++++++++++
Adatabase-versioning/libeufin-bank-0013.sql | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdatabase-versioning/libeufin-bank-procedures.sql | 263++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
Mtestbench/src/main/kotlin/Main.kt | 4++--
Mtestbench/src/test/kotlin/MigrationTest.kt | 90++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
31 files changed, 1708 insertions(+), 524 deletions(-)

diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Config.kt b/bank/src/main/kotlin/tech/libeufin/bank/Config.kt @@ -88,7 +88,7 @@ fun bankConfig(configPath: Path?): BankConfig = BANK_CONFIG_SOURCE.fromFile(conf /** Run [lambda] with access to a database conn pool */ suspend fun BankConfig.withDb(lambda: suspend (Database, BankConfig) -> Unit) { - Database(dbCfg, regionalCurrency, fiatCurrency, regionalCurrencySpec, fiatCurrencySpec, payto).use { lambda(it, this) } + Database(dbCfg, regionalCurrency, fiatCurrency, payto).use { lambda(it, this) } } private fun TalerConfig.loadBankConfig(): BankConfig = section("libeufin-bank").run { diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Constants.kt b/bank/src/main/kotlin/tech/libeufin/bank/Constants.kt @@ -39,6 +39,6 @@ const val MAX_TOKEN_CREATION_ATTEMPTS: Int = 5 const val MAX_ACTIVE_CHALLENGES: Int = 5 // API version -const val COREBANK_API_VERSION: String = "8:1:6" -const val CONVERSION_API_VERSION: String = "0:1:0" +const val COREBANK_API_VERSION: String = "9:0:0" +const val CONVERSION_API_VERSION: String = "2:0:1" const val INTEGRATION_API_VERSION: String = "5:0:5" diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Error.kt b/bank/src/main/kotlin/tech/libeufin/bank/Error.kt @@ -36,32 +36,38 @@ fun BankConfig.checkFiatCurrency(amount: TalerAmount) { ) } +fun BankConfig.checkCurrency(input: ConversionRateClassInput) { + for (regionalAmount in sequenceOf(input.cashin_fee, input.cashout_min_amount).filterNotNull()) { + this.checkRegionalCurrency(regionalAmount) + } + for (fiatAmount in sequenceOf(input.cashout_fee, input.cashin_min_amount).filterNotNull()) { + this.checkFiatCurrency(fiatAmount) + } +} + /* ----- Common errors ----- */ -fun unknownAccount(id: String): ApiException { - return notFound( - "Account '$id' not found", - TalerErrorCode.BANK_UNKNOWN_ACCOUNT - ) -} +fun unknownAccount(id: String) = notFound( + "Account '$id' not found", + TalerErrorCode.BANK_UNKNOWN_ACCOUNT +) -fun unknownCreditorAccount(id: String): ApiException { - return conflict( - "Creditor account '$id' not found", - TalerErrorCode.BANK_UNKNOWN_CREDITOR - ) -} +fun unknownCreditorAccount(id: String) = conflict( + "Creditor account '$id' not found", + TalerErrorCode.BANK_UNKNOWN_CREDITOR +) -fun unsupportedTanChannel(channel: TanChannel): ApiException { - return conflict( - "Unsupported tan channel $channel", - TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED - ) -} +fun unsupportedTanChannel(channel: TanChannel) = conflict( + "Unsupported tan channel $channel", + TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED +) -fun notExchange(username: String): ApiException { - return conflict( - "Account '$username' is not an exchange account.", - TalerErrorCode.BANK_ACCOUNT_IS_NOT_EXCHANGE - ) -} -\ No newline at end of file +fun notExchange(username: String): ApiException = conflict( + "Account '$username' is not an exchange account.", + TalerErrorCode.BANK_ACCOUNT_IS_NOT_EXCHANGE +) + +fun unknownConversionClass(id: Long?): ApiException = conflict( + "Unknown conversion rate class $id", + TalerErrorCode.BANK_CONVERSION_RATE_CLASS_UNKNOWN +) +\ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt b/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt @@ -194,8 +194,8 @@ data class RegisterAccountRequest( val cashout_payto_uri: IbanPayto? = null, val payto_uri: Payto? = null, val debit_threshold: TalerAmount? = null, - val min_cashout: TalerAmount? = null, val tan_channel: TanChannel? = null, + val conversion_rate_class_id: Long? = null ) { init { if (!USERNAME_REGEX.matches(username)) @@ -222,9 +222,9 @@ data class AccountReconfiguration( val name: String? = null, val is_public: Boolean? = null, val debit_threshold: TalerAmount? = null, - val min_cashout: Option<TalerAmount?> = Option.None, val tan_channel: Option<TanChannel?> = Option.None, val is_taler_exchange: Boolean? = null, + val conversion_rate_class_id: Option<Long?> = Option.None ) /** @@ -342,6 +342,8 @@ data class TokenInfos ( data class Config( val currency: String, val currency_specification: CurrencySpecification, + val fiat_currency: String? = null, + val fiat_currency_specification: CurrencySpecification? = null, val base_url: BaseURL?, val bank_name: String, val allow_conversion: Boolean, @@ -401,12 +403,13 @@ data class AccountMinimalData( val payto_uri: String, val balance: Balance, val debit_threshold: TalerAmount, - val min_cashout: TalerAmount? = null, val is_public: Boolean, val is_taler_exchange: Boolean, val is_locked: Boolean, val row_id: Long, - val status: AccountStatus + val status: AccountStatus, + val conversion_rate_class_id: Long? = null, + val conversion_rate: ConversionRate? = null ) /** @@ -423,17 +426,18 @@ data class ListBankAccountsResponse( @Serializable data class AccountData( val name: String, - val balance: Balance, val payto_uri: String, + val balance: Balance, val debit_threshold: TalerAmount, - val min_cashout: TalerAmount? = null, val contact_data: ChallengeContactData? = null, val cashout_payto_uri: String? = null, val tan_channel: TanChannel? = null, val is_public: Boolean, val is_taler_exchange: Boolean, val is_locked: Boolean, - val status: AccountStatus + val status: AccountStatus, + val conversion_rate_class_id: Long? = null, + val conversion_rate: ConversionRate? = null ) @Serializable @@ -641,4 +645,55 @@ data class AccountPasswordChange( @Serializable data class BankAccountConfirmWithdrawalRequest( val amount: TalerAmount? = null -) -\ No newline at end of file +) + +/** +* Request POST /conversion-rate-classes +* Request PATCH /conversion-rate-classes/{CLASS_ID} +*/ +@Serializable +data class ConversionRateClassInput( + val name: String, + val description: String? = null, + val cashin_ratio: DecimalNumber? = null, + val cashin_fee: TalerAmount? = null, + val cashin_rounding_mode: RoundingMode? = null, + val cashin_min_amount: TalerAmount? = null, + val cashout_ratio: DecimalNumber? = null, + val cashout_fee: TalerAmount? = null, + val cashout_rounding_mode: RoundingMode? = null, + val cashout_min_amount: TalerAmount? = null +) + +/** Response POST /conversion-rate-classes */ +@Serializable +data class ConversionRateClassResponse( + val conversion_rate_class_id: Long, +) + +/** +* Response GET /conversion-rate-classes/{CLASS_ID} +*/ +@Serializable +data class ConversionRateClass( + val conversion_rate_class_id: Long, + val name: String, + val description: String? = null, + val num_users: Int, + val cashin_ratio: DecimalNumber? = null, + val cashin_fee: TalerAmount? = null, + val cashin_rounding_mode: RoundingMode? = null, + val cashin_min_amount: TalerAmount? = null, + val cashout_ratio: DecimalNumber? = null, + val cashout_fee: TalerAmount? = null, + val cashout_rounding_mode: RoundingMode? = null, + val cashout_min_amount: TalerAmount? = null +) + +/** +* Response GET /conversion-rate-classes +*/ +@Serializable +data class ConversionRateClasses( + val classes: List<ConversionRateClass> +) diff --git a/bank/src/main/kotlin/tech/libeufin/bank/api/ConversionApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/api/ConversionApi.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2023-2024 Taler Systems S.A. + * Copyright (C) 2023-2025 Taler Systems S.A. * LibEuFin is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -24,29 +24,52 @@ import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* import tech.libeufin.bank.* -import tech.libeufin.bank.auth.authAdmin +import tech.libeufin.bank.auth.* import tech.libeufin.bank.db.ConversionDAO import tech.libeufin.bank.db.ConversionDAO.ConversionResult import tech.libeufin.bank.db.Database import tech.libeufin.common.* -fun Routing.conversionApi(db: Database, ctx: BankConfig) = conditional(ctx.allowConversion) { - get("/conversion-info/config") { - val config = db.conversion.getConfig(ctx.regionalCurrency, ctx.fiatCurrency!!) - ?: throw apiError( - 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_rate = config - ) +private suspend fun ApplicationCall.config(db: Database, cfg: BankConfig) { + val rate = db.conversion.getDefaultRate() + ?: throw apiError( + HttpStatusCode.NotImplemented, + "conversion rate not configured yet", + TalerErrorCode.END ) + respond( + ConversionConfig( + regional_currency = cfg.regionalCurrency, + regional_currency_specification = cfg.regionalCurrencySpec, + fiat_currency = cfg.fiatCurrency!!, + fiat_currency_specification = cfg.fiatCurrencySpec!!, + conversion_rate = rate + ) + ) +} +private suspend fun ApplicationCall.setGlobal(db: Database, cfg: BankConfig) { + val req = receive<ConversionRate>() + for (regionalAmount in sequenceOf(req.cashin_fee, req.cashin_tiny_amount, req.cashout_min_amount)) { + cfg.checkRegionalCurrency(regionalAmount) + } + for (fiatAmount in sequenceOf(req.cashout_fee, req.cashout_tiny_amount, req.cashin_min_amount)) { + cfg.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) + respond(HttpStatusCode.NoContent) +} +fun Routing.conversionApi(db: Database, cfg: BankConfig) = conditional(cfg.allowConversion) { + get("/conversion-info/config") { call.config(db, cfg) } + get("/conversion-rate-classes/{CLASS_ID}/conversion-info/config") { call.config(db, cfg) } + get("/accounts/{USERNAME}/conversion-info/config") { call.config(db, cfg) } + authAdmin(db, cfg.pwCrypto, TokenLogicalScope.readwrite, cfg.basicAuthCompat) { + post("/conversion-info/conversion-rate") { call.setGlobal(db, cfg) } + post("/accounts/{USERNAME}/conversion-info/conversion-rate") { call.setGlobal(db, cfg) } + post("/conversion-rate-classes/{CLASS_ID}/conversion-info/conversion-rate") { call.setGlobal(db, cfg) } } suspend fun ApplicationCall.convert( input: TalerAmount, @@ -55,29 +78,46 @@ fun Routing.conversionApi(db: Database, ctx: BankConfig) = conditional(ctx.allow ) { when (val res = db.conversion.(conversion)(input)) { is ConversionResult.Success -> respond(output(res.converted)) - is ConversionResult.ToSmall -> throw conflict( + ConversionResult.ToSmall -> throw conflict( "$input is too small to be converted", TalerErrorCode.BANK_BAD_CONVERSION ) - is ConversionResult.MissingConfig -> throw apiError( - HttpStatusCode.NotImplemented, + ConversionResult.MissingConfig -> throw apiError( + HttpStatusCode.NotImplemented, "conversion rate not configured yet", TalerErrorCode.END ) + ConversionResult.IsExchange -> throw conflict( + "exchange accounts cannot cashout", + TalerErrorCode.BANK_ACCOUNT_IS_EXCHANGE + ) + ConversionResult.NotExchange -> throw conflict( + "only exchange accounts can cashin", + TalerErrorCode.BANK_ACCOUNT_IS_NOT_EXCHANGE + ) } } + get("/conversion-info/rate") { + val rate = db.conversion.getDefaultRate() + ?: throw apiError( + HttpStatusCode.NotImplemented, + "conversion rate not configured yet", + TalerErrorCode.END + ) + call.respond(rate) + } get("/conversion-info/cashout-rate") { val params = RateParams.extract(call.request.queryParameters) - params.debit?.let { ctx.checkRegionalCurrency(it) } - params.credit?.let { ctx.checkFiatCurrency(it) } + params.debit?.let { cfg.checkRegionalCurrency(it) } + params.credit?.let { cfg.checkFiatCurrency(it) } if (params.debit != null) { - call.convert(params.debit, ConversionDAO::toCashout) { + call.convert(params.debit, ConversionDAO::defaultToCashout) { ConversionResponse(params.debit, it) } } else { - call.convert(params.credit!!, ConversionDAO::fromCashout) { + call.convert(params.credit!!, ConversionDAO::defaultFromCashout) { ConversionResponse(it, params.credit) } } @@ -85,34 +125,111 @@ fun Routing.conversionApi(db: Database, ctx: BankConfig) = conditional(ctx.allow get("/conversion-info/cashin-rate") { val params = RateParams.extract(call.request.queryParameters) - params.debit?.let { ctx.checkFiatCurrency(it) } - params.credit?.let { ctx.checkRegionalCurrency(it) } + params.debit?.let { cfg.checkFiatCurrency(it) } + params.credit?.let { cfg.checkRegionalCurrency(it) } if (params.debit != null) { - call.convert(params.debit, ConversionDAO::toCashin) { + call.convert(params.debit, ConversionDAO::defaultToCashin) { ConversionResponse(params.debit, it) } } else { - call.convert(params.credit!!, ConversionDAO::fromCashin) { + call.convert(params.credit!!, ConversionDAO::defaultFromCashin) { ConversionResponse(it, params.credit) } } } - authAdmin(db, ctx.pwCrypto, TokenLogicalScope.readwrite, ctx.basicAuthCompat) { - post("/conversion-info/conversion-rate") { - val req = call.receive<ConversionRate>() - for (regionalAmount in sequenceOf(req.cashin_fee, req.cashin_tiny_amount, req.cashout_min_amount)) { - ctx.checkRegionalCurrency(regionalAmount) + authAdmin(db, cfg.pwCrypto, TokenLogicalScope.readonly, cfg.basicAuthCompat) { + get("/conversion-rate-classes/{CLASS_ID}/conversion-info/rate") { + val id = call.longPath("CLASS_ID") + val rate = db.conversion.getClassRate(id) + ?: throw apiError( + HttpStatusCode.NotImplemented, + "conversion rate not configured yet", + TalerErrorCode.END + ) + call.respond(rate) + } + get("/conversion-rate-classes/{CLASS_ID}/conversion-info/cashout-rate") { + val id = call.longPath("CLASS_ID") + val params = RateParams.extract(call.request.queryParameters) + + params.debit?.let { cfg.checkRegionalCurrency(it) } + params.credit?.let { cfg.checkFiatCurrency(it) } + + if (params.debit != null) { + call.convert(params.debit, { classToCashout(id, it) }) { + ConversionResponse(params.debit, it) + } + } else { + call.convert(params.credit!!, { classFromCashout(id, it) }) { + ConversionResponse(it, params.credit) + } + } + } + get("/conversion-rate-classes/{CLASS_ID}/conversion-info/cashin-rate") { + val id = call.longPath("CLASS_ID") + val params = RateParams.extract(call.request.queryParameters) + + params.debit?.let { cfg.checkFiatCurrency(it) } + params.credit?.let { cfg.checkRegionalCurrency(it) } + + if (params.debit != null) { + call.convert(params.debit, { classToCashin(id, it) }) { + ConversionResponse(params.debit, it) + } + } else { + call.convert(params.credit!!, { classFromCashin(id, it) }) { + ConversionResponse(it, params.credit) + } + } + } + } + get("/accounts/{USERNAME}/conversion-info/cashin-rate") { + val params = RateParams.extract(call.request.queryParameters) + + params.debit?.let { cfg.checkFiatCurrency(it) } + params.credit?.let { cfg.checkRegionalCurrency(it) } + + if (params.debit != null) { + call.convert(params.debit, { userToCashin(call.username, it) }) { + ConversionResponse(params.debit, it) } - for (fiatAmount in sequenceOf(req.cashout_fee, req.cashout_tiny_amount, req.cashin_min_amount)) { - ctx.checkFiatCurrency(fiatAmount) + } else { + call.convert(params.credit!!, { userFromCashin(call.username, it) }) { + ConversionResponse(it, params.credit) } - // 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") + } + } + optAuth(db, cfg.pwCrypto, TokenLogicalScope.readonly, cfg.basicAuthCompat, allowAdmin = true) { + get("/accounts/{USERNAME}/conversion-info/rate") { + val (isExchange, rate) = db.conversion.getUserRate(call.username) + ?: throw apiError( + HttpStatusCode.NotImplemented, + "conversion rate not configured yet", + TalerErrorCode.END + ) + if (!isExchange && !call.isAuthenticated) { + throw forbidden("Non exchange account rates are private") } - db.conversion.updateConfig(req) - call.respond(HttpStatusCode.NoContent) + call.respond(rate) } } + auth(db, cfg.pwCrypto, TokenLogicalScope.readonly, cfg.basicAuthCompat, allowAdmin = true) { + get("/accounts/{USERNAME}/conversion-info/cashout-rate") { + val params = RateParams.extract(call.request.queryParameters) + + params.debit?.let { cfg.checkRegionalCurrency(it) } + params.credit?.let { cfg.checkFiatCurrency(it) } + + if (params.debit != null) { + call.convert(params.debit, { userToCashout(call.username, it) }) { + ConversionResponse(params.debit, it) + } + } else { + call.convert(params.credit!!, { userFromCashout(call.username, it) }) { + ConversionResponse(it, params.credit) + } + } + } + } } \ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt @@ -40,6 +40,7 @@ import tech.libeufin.bank.db.TokenDAO.TokenCreationResult import tech.libeufin.bank.db.TransactionDAO.BankTransactionResult import tech.libeufin.bank.db.WithdrawalDAO.WithdrawalConfirmationResult import tech.libeufin.bank.db.WithdrawalDAO.WithdrawalCreationResult +import tech.libeufin.bank.db.ConversionDAO.* import tech.libeufin.common.* import tech.libeufin.common.crypto.* import java.time.Duration @@ -57,6 +58,8 @@ fun Routing.coreBankApi(db: Database, cfg: BankConfig) { base_url = cfg.baseUrl, currency = cfg.regionalCurrency, currency_specification = cfg.regionalCurrencySpec, + fiat_currency = cfg.fiatCurrency, + fiat_currency_specification = cfg.fiatCurrencySpec, allow_conversion = cfg.allowConversion, allow_registrations = cfg.allowRegistration, allow_deletions = cfg.allowAccountDeletion, @@ -83,6 +86,7 @@ fun Routing.coreBankApi(db: Database, cfg: BankConfig) { coreBankWithdrawalApi(db, cfg) coreBankCashoutApi(db, cfg) coreBankTanApi(db, cfg) + coreBankConversionApi(db, cfg) } private fun Routing.coreBankTokenApi(db: Database, cfg: BankConfig) { @@ -190,10 +194,10 @@ suspend fun createAccount( TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT ) - if (req.min_cashout != null) + if (req.conversion_rate_class_id != null) throw conflict( - "only admin account can choose the minimum cashout amount", - TalerErrorCode.BANK_NON_ADMIN_SET_MIN_CASHOUT + "only admin account can choose the conversion rate class", + TalerErrorCode.BANK_NON_ADMIN_SET_CONVERSION_RATE_CLASS ) if (req.tan_channel != null) @@ -240,8 +244,8 @@ suspend fun createAccount( else TalerAmount(0, 0, cfg.regionalCurrency), tanChannel = req.tan_channel, checkPaytoIdempotent = req.payto_uri != null, - minCashout = req.min_cashout, - pwCrypto = cfg.pwCrypto + pwCrypto = cfg.pwCrypto, + conversionRateClassId = req.conversion_rate_class_id ) when (cfg.wireMethod) { @@ -310,13 +314,13 @@ suspend fun patchAccount( tan_channel = req.tan_channel, isPublic = req.is_public, debtLimit = req.debit_threshold, - minCashout = req.min_cashout, isAdmin = isAdmin, is2fa = is2fa, faChannel = channel, faInfo = info, allowEditName = cfg.allowEditName, - allowEditCashout = cfg.allowEditCashout + allowEditCashout = cfg.allowEditCashout, + conversionRateClassId = req.conversion_rate_class_id ) } @@ -337,8 +341,10 @@ private fun Routing.coreBankAccountsApi(db: Database, cfg: BankConfig) { "Bank internalPayToUri reuse", TalerErrorCode.BANK_REGISTER_PAYTO_URI_REUSE ) + AccountCreationResult.UnknownConversionClass -> + throw unknownConversionClass(req.conversion_rate_class_id) is AccountCreationResult.Success -> call.respond(RegisterAccountResponse(result.payto)) - } + } } } auth( @@ -397,10 +403,12 @@ private fun Routing.coreBankAccountsApi(db: Database, cfg: BankConfig) { "non-admin user cannot change their debt limit", TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT ) - AccountPatchResult.NonAdminMinCashout -> throw conflict( - "non-admin user cannot change their min cashout amount", - TalerErrorCode.BANK_NON_ADMIN_SET_MIN_CASHOUT + AccountPatchResult.NonAdminConversionRateClass -> throw conflict( + "non-admin user cannot change their conversion rate class", + TalerErrorCode.BANK_NON_ADMIN_SET_CONVERSION_RATE_CLASS ) + AccountPatchResult.UnknownConversionClass -> + throw unknownConversionClass(req.conversion_rate_class_id.get()) AccountPatchResult.MissingTanInfo -> throw conflict( "missing info for tan channel ${req.tan_channel.get()}", TalerErrorCode.BANK_MISSING_TAN_INFO @@ -844,3 +852,67 @@ private fun Routing.coreBankTanApi(db: Database, cfg: BankConfig) { } } } + +private fun Routing.coreBankConversionApi(db: Database, cfg: BankConfig) = conditional(cfg.allowConversion) { + authAdmin(db, cfg.pwCrypto, TokenLogicalScope.readwrite, cfg.basicAuthCompat) { + post("/conversion-rate-classes") { + val req = call.receive<ConversionRateClassInput>() + cfg.checkCurrency(req) + when (val res = db.conversion.createClass(req)) { + is ClassCreateResult.Success -> call.respond(ConversionRateClassResponse(res.id)) + ClassCreateResult.NameReuse -> throw conflict( + "Conversion rate class name '${req.name}' already use", + TalerErrorCode.BANK_NAME_REUSE + ) + } + + } + patch("/conversion-rate-classes/{CLASS_ID}") { + val id = call.longPath("CLASS_ID") + val req = call.receive<ConversionRateClassInput>() + cfg.checkCurrency(req) + when (val res = db.conversion.patchClass(id, req)) { + ClassPatchResult.Success -> call.respond(HttpStatusCode.NoContent) + ClassPatchResult.Unknown -> throw notFound( + "Conversion rate class '$id' not found", + TalerErrorCode.BANK_TRANSACTION_NOT_FOUND + ) + ClassPatchResult.NameReuse -> throw conflict( + "Conversion rate class name '${req.name}' already use", + TalerErrorCode.BANK_NAME_REUSE + ) + } + } + delete("/conversion-rate-classes/{CLASS_ID}") { + val id = call.longPath("CLASS_ID") + if (db.conversion.deleteClass(id)) { + call.respond(HttpStatusCode.NoContent) + } else { + throw notFound( + "Conversion rate class '$id' not found", + TalerErrorCode.BANK_TRANSACTION_NOT_FOUND + ) + } + } + } + authAdmin(db, cfg.pwCrypto, TokenLogicalScope.readonly, cfg.basicAuthCompat) { + get("/conversion-rate-classes/{CLASS_ID}") { + val id = call.longPath("CLASS_ID") + val cashout = db.conversion.getClass(id) ?: throw notFound( + "Conversion rate class $id not found", + TalerErrorCode.BANK_TRANSACTION_NOT_FOUND + ) + call.respond(cashout) + } + get("/conversion-rate-classes") { + val params = ClassParams.extract(call.request.queryParameters) + + val page = db.conversion.pageClass(params) + if (page.isEmpty()) { + call.respond(HttpStatusCode.NoContent) + } else { + call.respond(ConversionRateClasses(page)) + } + } + } +} +\ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/auth/auth.kt b/bank/src/main/kotlin/tech/libeufin/bank/auth/auth.kt @@ -129,12 +129,14 @@ fun Route.optAuth( pwCrypto: PwCrypto, scope: TokenLogicalScope, compatPw: Boolean, + allowAdmin: Boolean = false, callback: Route.() -> Unit ): Route = intercept("Auth", callback) { val header = request.headers[HttpHeaders.Authorization] if (header != null) { val authUsername = this.authenticateBankRequest(db, pwCrypto, scope, false, compatPw) - if (authUsername != username) { + val hasRight = authUsername == username || (allowAdmin && authUsername == "admin") + if (!hasRight) { throw forbidden("Customer $authUsername have no right on $username account") } } diff --git a/bank/src/main/kotlin/tech/libeufin/bank/cli/CreateAccount.kt b/bank/src/main/kotlin/tech/libeufin/bank/cli/CreateAccount.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2024 Taler Systems S.A. + * Copyright (C) 2024-2025 Taler Systems S.A. * LibEuFin is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -65,9 +65,6 @@ class CreateAccountOption: OptionGroup() { val debit_threshold: TalerAmount? by option( help = "Max debit allowed for this account" ).convert { TalerAmount(it) } - val min_cashout: TalerAmount? by option( - help = "Custom minimum cashout amount for this account" - ).convert { TalerAmount(it) } val tan_channel: TanChannel? by option( help = "Enables 2FA and set the TAN channel used for challenges" ).convert { TanChannel.valueOf(it) } @@ -96,7 +93,6 @@ class CreateAccount : CliktCommand("create-account") { cashout_payto_uri = cashout_payto_uri, payto_uri = payto_uri, debit_threshold = debit_threshold, - min_cashout = min_cashout, tan_channel = tan_channel ) } @@ -108,6 +104,8 @@ class CreateAccount : CliktCommand("create-account") { throw Exception("Account username reuse '${req.username}'") AccountCreationResult.PayToReuse -> throw Exception("Bank internalPayToUri reuse") + AccountCreationResult.UnknownConversionClass -> + throw Exception("Unknown conversion class ${req.conversion_rate_class_id}") is AccountCreationResult.Success -> { logger.info("Account '${req.username}' created") println(result.payto) diff --git a/bank/src/main/kotlin/tech/libeufin/bank/cli/DbInit.kt b/bank/src/main/kotlin/tech/libeufin/bank/cli/DbInit.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2024 Taler Systems S.A. + * Copyright (C) 2024-2025 Taler Systems S.A. * LibEuFin is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -51,8 +51,10 @@ class DbInit : CliktCommand("dbinit") { // Create admin account if missing val res = createAdminAccount(db, cfg) when (res) { - AccountCreationResult.BonusBalanceInsufficient -> {} AccountCreationResult.UsernameReuse -> {} + AccountCreationResult.BonusBalanceInsufficient, + AccountCreationResult.UnknownConversionClass -> + throw IllegalStateException("Those error can never happen $res") AccountCreationResult.PayToReuse -> throw Exception("Failed to create admin's account") is AccountCreationResult.Success -> diff --git a/bank/src/main/kotlin/tech/libeufin/bank/cli/EditAccount.kt b/bank/src/main/kotlin/tech/libeufin/bank/cli/EditAccount.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2024 Taler Systems S.A. + * Copyright (C) 2024-2025 Taler Systems S.A. * LibEuFin is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -58,15 +58,6 @@ class EditAccount : CliktCommand("edit-account") { private val debit_threshold: TalerAmount? by option( help = "Max debit allowed for this account" ).convert { TalerAmount(it) } - private val min_cashout: Option<TalerAmount>? by option( - help = "Custom minimum cashout amount for this account" - ).convert { - if (it == "") { - Option.None - } else { - Option.Some(TalerAmount(it)) - } - } private val tan_channel: Option<TanChannel>? by option( help = "Enables 2FA and set the TAN channel used for challenges" ).convert { @@ -90,10 +81,9 @@ class EditAccount : CliktCommand("edit-account") { ), cashout_payto_uri = Option.Some(cashout_payto_uri), debit_threshold = debit_threshold, - min_cashout = Option.invert(min_cashout), tan_channel = Option.invert(tan_channel) ) - when (patchAccount(db, cfg, req, username, true, true)) { + when (val res = patchAccount(db, cfg, req, username, true, true)) { AccountPatchResult.Success -> logger.info("Account '$username' edited") AccountPatchResult.UnknownAccount -> @@ -103,10 +93,10 @@ class EditAccount : CliktCommand("edit-account") { AccountPatchResult.NonAdminName, AccountPatchResult.NonAdminCashout, AccountPatchResult.NonAdminDebtLimit, - AccountPatchResult.NonAdminMinCashout, - is AccountPatchResult.TanRequired -> { - // Unreachable as we edit account as admin - } + AccountPatchResult.NonAdminConversionRateClass, + AccountPatchResult.UnknownConversionClass, + is AccountPatchResult.TanRequired -> + throw IllegalStateException("Those error can never happen $res") } } } diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt @@ -24,6 +24,8 @@ import tech.libeufin.common.* import tech.libeufin.common.crypto.* import tech.libeufin.common.db.* import java.time.Instant +import java.sql.SQLException +import org.postgresql.util.PSQLState /** Data access logic for accounts */ class AccountDAO(private val db: Database) { @@ -32,6 +34,7 @@ class AccountDAO(private val db: Database) { data class Success(val payto: String): AccountCreationResult data object UsernameReuse: AccountCreationResult data object PayToReuse: AccountCreationResult + data object UnknownConversionClass: AccountCreationResult data object BonusBalanceInsufficient: AccountCreationResult } @@ -47,12 +50,12 @@ class AccountDAO(private val db: Database) { isPublic: Boolean, isTalerExchange: Boolean, maxDebt: TalerAmount, - minCashout: TalerAmount?, bonus: TalerAmount, tanChannel: TanChannel?, // Whether to check [internalPaytoUri] for idempotency checkPaytoIdempotent: Boolean, - pwCrypto: PwCrypto + pwCrypto: PwCrypto, + conversionRateClassId: Long? ): AccountCreationResult = db.serializableTransaction { conn -> val timestamp = Instant.now() val idempotent = conn.withStatement(""" @@ -65,7 +68,7 @@ class AccountDAO(private val db: Database) { AND is_public=? AND is_taler_exchange=? AND max_debt=(?,?)::taler_amount - AND min_cashout IS NOT DISTINCT FROM ${optAmount(minCashout)} + AND conversion_rate_class_id IS NOT DISTINCT FROM ? ,internal_payto, name FROM customers JOIN bank_accounts @@ -82,7 +85,7 @@ class AccountDAO(private val db: Database) { bind(isPublic) bind(isTalerExchange) bind(maxDebt) - bind(minCashout) + bind(conversionRateClassId) bind(username) oneOrNull { Pair( @@ -144,18 +147,27 @@ class AccountDAO(private val db: Database) { ,is_public ,is_taler_exchange ,max_debt - ,min_cashout - ) VALUES (?, ?, ?, ?, (?, ?)::taler_amount, ${optAmount(minCashout)}) + ,conversion_rate_class_id + ) VALUES (?, ?, ?, ?, (?, ?)::taler_amount, ?) """) { bind(internalPayto.canonical) bind(customerId) bind(isPublic) bind(isTalerExchange) bind(maxDebt) - bind(minCashout) - if (!executeUpdateViolation()) { - conn.rollback() - return@serializableTransaction AccountCreationResult.PayToReuse + bind(conversionRateClassId) + try { + executeUpdate() + } catch (e: SQLException) { + logger.debug(e.message) + if (e.sqlState == PSQLState.UNIQUE_VIOLATION.state) { + conn.rollback() + return@serializableTransaction AccountCreationResult.PayToReuse + } else if (e.sqlState == PSQLState.FOREIGN_KEY_VIOLATION.state) { + conn.rollback() + return@serializableTransaction AccountCreationResult.UnknownConversionClass + } + throw e } } @@ -223,7 +235,8 @@ class AccountDAO(private val db: Database) { data object NonAdminName: AccountPatchResult data object NonAdminCashout: AccountPatchResult data object NonAdminDebtLimit: AccountPatchResult - data object NonAdminMinCashout: AccountPatchResult + data object NonAdminConversionRateClass: AccountPatchResult + data object UnknownConversionClass: AccountPatchResult data object MissingTanInfo: AccountPatchResult data class TanRequired(val channel: TanChannel?, val info: String?): AccountPatchResult data object Success: AccountPatchResult @@ -239,18 +252,18 @@ class AccountDAO(private val db: Database) { tan_channel: Option<TanChannel?>, isPublic: Boolean?, debtLimit: TalerAmount?, - minCashout: Option<TalerAmount?>, isAdmin: Boolean, is2fa: Boolean, faChannel: TanChannel?, faInfo: String?, allowEditName: Boolean, allowEditCashout: Boolean, + conversionRateClassId: Option<Long?> ): AccountPatchResult = db.serializableTransaction { conn -> val checkName = !isAdmin && !allowEditName && name != null val checkCashout = !isAdmin && !allowEditCashout && cashoutPayto.isSome() val checkDebtLimit = !isAdmin && debtLimit != null - val checkMinCashout = !isAdmin && minCashout.isSome() + val checkConversionRateClass = !isAdmin && conversionRateClassId.isSome() data class CurrentAccount( val id: Long, @@ -260,7 +273,7 @@ class AccountDAO(private val db: Database) { val name: String, val cashoutPayTo: String?, val debtLimit: TalerAmount, - val minCashout: TalerAmount? + val conversionRateClassId: Long? ) // Get user ID and current data @@ -269,8 +282,7 @@ class AccountDAO(private val db: Database) { customer_id, tan_channel, phone, email, name, cashout_payto ,(max_debt).val AS max_debt_val ,(max_debt).frac AS max_debt_frac - ,(min_cashout).val AS min_cashout_val - ,(min_cashout).frac AS min_cashout_frac + ,conversion_rate_class_id FROM customers JOIN bank_accounts ON customer_id=owning_customer_id @@ -286,7 +298,7 @@ class AccountDAO(private val db: Database) { name = it.getString("name"), cashoutPayTo = it.getOptIbanPayto("cashout_payto")?.simple(), debtLimit = it.getAmount("max_debt", db.bankCurrency), - minCashout = it.getOptAmount("min_cashout", db.bankCurrency), + conversionRateClassId = it.getOptLong("conversion_rate_class_id"), ) } ?: return@serializableTransaction AccountPatchResult.UnknownAccount } @@ -317,8 +329,8 @@ class AccountDAO(private val db: Database) { return@serializableTransaction AccountPatchResult.NonAdminCashout if (checkDebtLimit && debtLimit != curr.debtLimit) return@serializableTransaction AccountPatchResult.NonAdminDebtLimit - if (checkMinCashout && minCashout.get() != curr.minCashout) - return@serializableTransaction AccountPatchResult.NonAdminMinCashout + if (checkConversionRateClass && conversionRateClassId.get() != curr.conversionRateClassId) + return@serializableTransaction AccountPatchResult.NonAdminConversionRateClass if (patchChannel != null && newInfo == null) return@serializableTransaction AccountPatchResult.MissingTanInfo @@ -343,33 +355,30 @@ class AccountDAO(private val db: Database) { } } - // Update bank info - conn.dynamicUpdate( - "bank_accounts", - sequence { - if (isPublic != null) yield("is_public=?") - if (debtLimit != null) yield("max_debt=(?, ?)::taler_amount") - minCashout.some { - if (it != null) { - yield("min_cashout=(?, ?)::taler_amount") - } else { - yield("min_cashout=null") - } - } - }, - "WHERE owning_customer_id = ?", - sequence { - isPublic?.let { yield(it) } - debtLimit?.let { yield(it.value); yield(it.frac) } - minCashout.some { - if (it != null) { - yield(it.value) - yield(it.frac) - } - } - yield(curr.id) + try { + // Update bank info + conn.dynamicUpdate( + "bank_accounts", + sequence { + if (isPublic != null) yield("is_public=?") + if (debtLimit != null) yield("max_debt=(?, ?)::taler_amount") + conversionRateClassId.some { yield("conversion_rate_class_id=?") } + }, + "WHERE owning_customer_id = ?" + ) { + isPublic?.let { bind(it) } + debtLimit?.let { bind(it) } + conversionRateClassId?.some { bind(it) } + bind(curr.id) } - ) + } catch (e: SQLException) { + logger.debug(e.message) + if (e.sqlState == PSQLState.FOREIGN_KEY_VIOLATION.state) { + conn.rollback() + return@serializableTransaction AccountPatchResult.UnknownConversionClass + } + throw e + } // Update customer info conn.dynamicUpdate( @@ -381,16 +390,15 @@ class AccountDAO(private val db: Database) { tan_channel.some { yield("tan_channel=?::tan_enum") } name?.let { yield("name=?") } }, - "WHERE customer_id = ?", - sequence { - cashoutPayto.some { yield(simpleCashoutPayto) } - phone.some { yield(it) } - email.some { yield(it) } - tan_channel.some { yield(it?.name) } - name?.let { yield(it) } - yield(curr.id) - } - ) + "WHERE customer_id = ?" + ) { + cashoutPayto.some { bind(simpleCashoutPayto) } + phone.some { bind(it) } + email.some { bind(it) } + tan_channel.some { bind(it?.name) } + name?.let { bind(it) } + bind(curr.id) + } AccountPatchResult.Success } @@ -522,7 +530,7 @@ class AccountDAO(private val db: Database) { suspend fun get(username: String): AccountData? = db.serializable( """ SELECT - name + customers.name ,email ,phone ,tan_channel @@ -533,18 +541,27 @@ class AccountDAO(private val db: Database) { ,has_debt ,(max_debt).val AS max_debt_val ,(max_debt).frac AS max_debt_frac - ,(min_cashout).val AS min_cashout_val - ,(min_cashout).frac AS min_cashout_frac ,is_public ,is_taler_exchange ,CASE WHEN deleted_at IS NOT NULL THEN 'deleted' WHEN token_creation_counter > ? THEN 'locked' ELSE 'active' - END as status + END as status, + conversion_rate_class_id, + (cashin_ratio).val as cashin_ratio_val, (cashin_ratio).frac as cashin_ratio_frac, + (cashin_fee).val as cashin_fee_val, (cashin_fee).frac as cashin_fee_frac, + (cashin_tiny_amount).val as cashin_tiny_amount_val, (cashin_tiny_amount).frac as cashin_tiny_amount_frac, + (cashin_min_amount).val as cashin_min_amount_val, (cashin_min_amount).frac as cashin_min_amount_frac, + cashin_rounding_mode, + (cashout_ratio).val as cashout_ratio_val, (cashout_ratio).frac as cashout_ratio_frac, + (cashout_fee).val as cashout_fee_val, (cashout_fee).frac as cashout_fee_frac, + (cashout_tiny_amount).val as cashout_tiny_amount_val, (cashout_tiny_amount).frac as cashout_tiny_amount_frac, + (cashout_min_amount).val as cashout_min_amount_val, (cashout_min_amount).frac as cashout_min_amount_frac, + cashout_rounding_mode FROM customers - JOIN bank_accounts - ON customer_id=owning_customer_id + JOIN bank_accounts ON customer_id=owning_customer_id + LEFT JOIN LATERAL get_conversion_class_rate(conversion_rate_class_id) on true WHERE username=? """ ) { @@ -553,6 +570,7 @@ class AccountDAO(private val db: Database) { oneOrNull { val name = it.getString("name") val status: AccountStatus = it.getEnum("status") + val isTalerExchange = it.getBoolean("is_taler_exchange") AccountData( name = name, contact_data = ChallengeContactData( @@ -572,11 +590,12 @@ class AccountDAO(private val db: Database) { } ), debit_threshold = it.getAmount("max_debt", db.bankCurrency), - min_cashout = it.getOptAmount("min_cashout", db.bankCurrency), is_public = it.getBoolean("is_public"), - is_taler_exchange = it.getBoolean("is_taler_exchange"), + is_taler_exchange = isTalerExchange, status = status, - is_locked = status == AccountStatus.locked + is_locked = status == AccountStatus.locked, + conversion_rate = ConversionDAO.userRate(db, it, username,isTalerExchange), + conversion_rate_class_id = it.getOptLong("conversion_rate_class_id") ) } } @@ -599,7 +618,7 @@ class AccountDAO(private val db: Database) { FROM bank_accounts JOIN customers ON owning_customer_id = customer_id WHERE is_public=true AND - ${if (params.usernameFilter != null) "name LIKE ? AND" else ""} + ${if (params.usernameFilter != null) "name ILIKE ? AND" else ""} deleted_at IS NULL AND """, { @@ -631,15 +650,13 @@ class AccountDAO(private val db: Database) { "bank_account_id", """ SELECT - username, - name, - (balance).val AS balance_val, - (balance).frac AS balance_frac, - has_debt AS balance_has_debt, - (max_debt).val as max_debt_val, - (max_debt).frac as max_debt_frac - ,(min_cashout).val AS min_cashout_val - ,(min_cashout).frac AS min_cashout_frac + username + ,name + ,(balance).val AS balance_val + ,(balance).frac AS balance_frac + ,has_debt AS balance_has_debt + ,(max_debt).val as max_debt_val + ,(max_debt).frac as max_debt_frac ,is_public ,is_taler_exchange ,internal_payto @@ -648,21 +665,44 @@ class AccountDAO(private val db: Database) { WHEN deleted_at IS NOT NULL THEN 'deleted' WHEN token_creation_counter > ? THEN 'locked' ELSE 'active' - END as status - FROM bank_accounts JOIN customers - ON owning_customer_id = customer_id - WHERE ${if (params.usernameFilter != null) "name LIKE ? AND" else ""} + END as status, + conversion_rate_class_id, + (cashin_ratio).val as cashin_ratio_val, (cashin_ratio).frac as cashin_ratio_frac, + (cashin_fee).val as cashin_fee_val, (cashin_fee).frac as cashin_fee_frac, + (cashin_tiny_amount).val as cashin_tiny_amount_val, (cashin_tiny_amount).frac as cashin_tiny_amount_frac, + (cashin_min_amount).val as cashin_min_amount_val, (cashin_min_amount).frac as cashin_min_amount_frac, + cashin_rounding_mode, + (cashout_ratio).val as cashout_ratio_val, (cashout_ratio).frac as cashout_ratio_frac, + (cashout_fee).val as cashout_fee_val, (cashout_fee).frac as cashout_fee_frac, + (cashout_tiny_amount).val as cashout_tiny_amount_val, (cashout_tiny_amount).frac as cashout_tiny_amount_frac, + (cashout_min_amount).val as cashout_min_amount_val, (cashout_min_amount).frac as cashout_min_amount_frac, + cashout_rounding_mode + FROM bank_accounts + JOIN customers ON owning_customer_id = customer_id + LEFT JOIN LATERAL get_conversion_class_rate(conversion_rate_class_id) on true + WHERE + ${if (params.usernameFilter != null) "name ILIKE ? AND" else ""} + ${when (params.conversionRateClassId) { + null -> "" + 0L -> "conversion_rate_class_id IS NULL AND" + else -> "conversion_rate_class_id=? AND" + }} """, { bind(MAX_TOKEN_CREATION_ATTEMPTS) if (params.usernameFilter != null) { bind(params.usernameFilter) } + if (params.conversionRateClassId != null && params.conversionRateClassId != 0L) { + bind(params.conversionRateClassId) + } } ) { val status: AccountStatus = it.getEnum("status") + val isTalerExchange = it.getBoolean("is_taler_exchange") + val username = it.getString("username") AccountMinimalData( - username = it.getString("username"), + username = username, row_id = it.getLong("bank_account_id"), name = it.getString("name"), balance = Balance( @@ -674,12 +714,13 @@ class AccountDAO(private val db: Database) { } ), debit_threshold = it.getAmount("max_debt", db.bankCurrency), - min_cashout = it.getOptAmount("min_cashout", db.bankCurrency), is_public = it.getBoolean("is_public"), - is_taler_exchange = it.getBoolean("is_taler_exchange"), + is_taler_exchange = isTalerExchange, payto_uri = it.getBankPayto("internal_payto", "name", db.ctx), status = status, - is_locked = status == AccountStatus.locked + is_locked = status == AccountStatus.locked, + conversion_rate = ConversionDAO.userRate(db, it, username, isTalerExchange), + conversion_rate_class_id = it.getOptLong("conversion_rate_class_id") ) } } \ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/ConversionDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/ConversionDAO.kt @@ -19,83 +19,170 @@ package tech.libeufin.bank.db -import tech.libeufin.bank.ConversionRate -import tech.libeufin.bank.RoundingMode -import tech.libeufin.common.DecimalNumber -import tech.libeufin.common.TalerAmount +import tech.libeufin.bank.* +import tech.libeufin.common.* import tech.libeufin.common.db.* +import java.sql.* +import org.postgresql.util.PSQLState /** Data access logic for conversion */ class ConversionDAO(private val db: Database) { - /** Update in-db conversion config */ - suspend fun updateConfig(cfg: ConversionRate) = db.serializableTransaction { conn -> - conn.withStatement("CALL config_set_amount(?, (?, ?)::taler_amount)") { - for ((name, amount) in listOf( - Pair("cashin_ratio", cfg.cashin_ratio), - Pair("cashout_ratio", cfg.cashout_ratio), - )) { - bind(name) - bind(amount) - executeUpdate() - } - for ((name, amount) in listOf( - 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), - )) { - bind(name) - bind(amount) - executeUpdate() - } - } - - conn.withStatement("CALL config_set_rounding_mode(?, ?::rounding_mode)") { - for ((name, value) in listOf( - Pair("cashin_rounding_mode", cfg.cashin_rounding_mode), - Pair("cashout_rounding_mode", cfg.cashout_rounding_mode) - )) { - bind(name) - bind(value) - executeUpdate() + companion object { + fun userRate(db: Database, it: ResultSet, username: String, isTalerExchange: Boolean): ConversionRate? { + val hasRate = it.getObject("cashin_rounding_mode") != null + return if (!hasRate || db.fiatCurrency == null) { + null + } else if (username == "admin") { + ConversionRate( + cashin_ratio = DecimalNumber.ZERO, + cashin_fee = TalerAmount.zero(db.bankCurrency), + cashin_tiny_amount = TalerAmount.zero(db.bankCurrency), + cashin_rounding_mode = RoundingMode.zero, + cashin_min_amount = TalerAmount.zero(db.fiatCurrency!!), + cashout_ratio = DecimalNumber.ZERO, + cashout_fee = TalerAmount.zero(db.fiatCurrency), + cashout_tiny_amount = TalerAmount.zero(db.fiatCurrency), + cashout_rounding_mode = RoundingMode.zero, + cashout_min_amount = TalerAmount.zero(db.bankCurrency), + ) + } else if (isTalerExchange) { + ConversionRate( + cashin_ratio = it.getDecimal("cashin_ratio"), + cashin_fee = it.getAmount("cashin_fee", db.bankCurrency), + cashin_tiny_amount = it.getAmount("cashin_tiny_amount", db.bankCurrency), + cashin_rounding_mode = it.getEnum("cashin_rounding_mode"), + cashin_min_amount = it.getAmount("cashin_min_amount", db.fiatCurrency!!), + cashout_ratio = DecimalNumber.ZERO, + cashout_fee = TalerAmount.zero(db.fiatCurrency), + cashout_tiny_amount = TalerAmount.zero(db.fiatCurrency), + cashout_rounding_mode = RoundingMode.zero, + cashout_min_amount = TalerAmount.zero(db.bankCurrency), + ) + } else { + ConversionRate( + cashin_ratio = DecimalNumber.ZERO, + cashin_fee = TalerAmount.zero(db.bankCurrency), + cashin_tiny_amount = TalerAmount.zero(db.bankCurrency), + cashin_rounding_mode = RoundingMode.zero, + cashin_min_amount = TalerAmount.zero(db.fiatCurrency!!), + cashout_ratio = it.getDecimal("cashout_ratio"), + cashout_fee = it.getAmount("cashout_fee", db.fiatCurrency), + cashout_tiny_amount = it.getAmount("cashout_tiny_amount", db.fiatCurrency), + cashout_rounding_mode = it.getEnum("cashout_rounding_mode"), + cashout_min_amount = it.getAmount("cashout_min_amount", db.bankCurrency), + ) } } } - /** Get in-db conversion config */ - suspend fun getConfig(regional: String, fiat: String): ConversionRate? = db.serializableTransaction { conn -> - val check = conn.withStatement("select exists(select 1 from config where key='cashin_ratio')") { - one { it.getBoolean(1) } + /** Update in-db conversion config */ + suspend fun updateConfig(cfg: ConversionRate) = db.serializable(""" + CALL config_set_conversion_rate( + (?, ?)::taler_amount, (?, ?)::taler_amount, (?, ?)::taler_amount, (?, ?)::taler_amount, ?::rounding_mode, + (?, ?)::taler_amount, (?, ?)::taler_amount, (?, ?)::taler_amount, (?, ?)::taler_amount, ?::rounding_mode + ) + """) { + bind(cfg.cashin_ratio) + bind(cfg.cashin_fee) + bind(cfg.cashin_tiny_amount) + bind(cfg.cashin_min_amount) + bind(cfg.cashin_rounding_mode) + bind(cfg.cashout_ratio) + bind(cfg.cashout_fee) + bind(cfg.cashout_tiny_amount) + bind(cfg.cashout_min_amount) + bind(cfg.cashout_rounding_mode) + executeUpdate() + } + + /** Get default conversion rate */ + suspend fun getDefaultRate(): ConversionRate? = db.serializable(""" + SELECT + (cashin_ratio).val as cashin_ratio_val, (cashin_ratio).frac as cashin_ratio_frac, + (cashin_fee).val as cashin_fee_val, (cashin_fee).frac as cashin_fee_frac, + (cashin_tiny_amount).val as cashin_tiny_amount_val, (cashin_tiny_amount).frac as cashin_tiny_amount_frac, + (cashin_min_amount).val as cashin_min_amount_val, (cashin_min_amount).frac as cashin_min_amount_frac, + cashin_rounding_mode, + (cashout_ratio).val as cashout_ratio_val, (cashout_ratio).frac as cashout_ratio_frac, + (cashout_fee).val as cashout_fee_val, (cashout_fee).frac as cashout_fee_frac, + (cashout_tiny_amount).val as cashout_tiny_amount_val, (cashout_tiny_amount).frac as cashout_tiny_amount_frac, + (cashout_min_amount).val as cashout_min_amount_val, (cashout_min_amount).frac as cashout_min_amount_frac, + cashout_rounding_mode + FROM config_get_conversion_rate() + """) { + oneOrNull { + ConversionRate( + cashin_ratio = it.getDecimal("cashin_ratio"), + cashin_fee = it.getAmount("cashin_fee", db.bankCurrency), + cashin_tiny_amount = it.getAmount("cashin_tiny_amount", db.bankCurrency), + cashin_rounding_mode = it.getEnum("cashin_rounding_mode"), + cashin_min_amount = it.getAmount("cashin_min_amount", db.fiatCurrency!!), + cashout_ratio = it.getDecimal("cashout_ratio"), + cashout_fee = it.getAmount("cashout_fee", db.fiatCurrency), + cashout_tiny_amount = it.getAmount("cashout_tiny_amount", db.fiatCurrency), + cashout_rounding_mode = it.getEnum("cashout_rounding_mode"), + cashout_min_amount = it.getAmount("cashout_min_amount", db.bankCurrency), + ) } - if (!check) return@serializableTransaction null - val amount = conn.talerStatement("SELECT (amount).val as amount_val, (amount).frac as amount_frac FROM config_get_amount(?) as amount") - val roundingMode = conn.talerStatement("SELECT config_get_rounding_mode(?)") - fun getAmount(name: String, currency: String): TalerAmount { - amount.bind(name) - return amount.one { it.getAmount("amount", currency) } + } + + /** Get conversion class rate */ + suspend fun getClassRate(conversionRateClassId: Long): ConversionRate? = db.serializable(""" + SELECT + (cashin_ratio).val as cashin_ratio_val, (cashin_ratio).frac as cashin_ratio_frac, + (cashin_fee).val as cashin_fee_val, (cashin_fee).frac as cashin_fee_frac, + (cashin_tiny_amount).val as cashin_tiny_amount_val, (cashin_tiny_amount).frac as cashin_tiny_amount_frac, + (cashin_min_amount).val as cashin_min_amount_val, (cashin_min_amount).frac as cashin_min_amount_frac, + cashin_rounding_mode, + (cashout_ratio).val as cashout_ratio_val, (cashout_ratio).frac as cashout_ratio_frac, + (cashout_fee).val as cashout_fee_val, (cashout_fee).frac as cashout_fee_frac, + (cashout_tiny_amount).val as cashout_tiny_amount_val, (cashout_tiny_amount).frac as cashout_tiny_amount_frac, + (cashout_min_amount).val as cashout_min_amount_val, (cashout_min_amount).frac as cashout_min_amount_frac, + cashout_rounding_mode + FROM get_conversion_class_rate(?) + """) { + bind(conversionRateClassId) + oneOrNull { + ConversionRate( + cashin_ratio = it.getDecimal("cashin_ratio"), + cashin_fee = it.getAmount("cashin_fee", db.bankCurrency), + cashin_tiny_amount = it.getAmount("cashin_tiny_amount", db.bankCurrency), + cashin_rounding_mode = it.getEnum("cashin_rounding_mode"), + cashin_min_amount = it.getAmount("cashin_min_amount", db.fiatCurrency!!), + cashout_ratio = it.getDecimal("cashout_ratio"), + cashout_fee = it.getAmount("cashout_fee", db.fiatCurrency), + cashout_tiny_amount = it.getAmount("cashout_tiny_amount", db.fiatCurrency), + cashout_rounding_mode = it.getEnum("cashout_rounding_mode"), + cashout_min_amount = it.getAmount("cashout_min_amount", db.bankCurrency), + ) } - fun getRatio(name: String): DecimalNumber = getAmount(name, "").run { DecimalNumber(value, frac) } - fun getMode(name: String): RoundingMode { - roundingMode.bind(name) - return roundingMode.one { it.getEnum<RoundingMode>(1) } + } + + /** Get user rate */ + suspend fun getUserRate(username: String): Pair<Boolean, ConversionRate>? = db.serializable(""" + SELECT + (cashin_ratio).val as cashin_ratio_val, (cashin_ratio).frac as cashin_ratio_frac, + (cashin_fee).val as cashin_fee_val, (cashin_fee).frac as cashin_fee_frac, + (cashin_tiny_amount).val as cashin_tiny_amount_val, (cashin_tiny_amount).frac as cashin_tiny_amount_frac, + (cashin_min_amount).val as cashin_min_amount_val, (cashin_min_amount).frac as cashin_min_amount_frac, + cashin_rounding_mode, + (cashout_ratio).val as cashout_ratio_val, (cashout_ratio).frac as cashout_ratio_frac, + (cashout_fee).val as cashout_fee_val, (cashout_fee).frac as cashout_fee_frac, + (cashout_tiny_amount).val as cashout_tiny_amount_val, (cashout_tiny_amount).frac as cashout_tiny_amount_frac, + (cashout_min_amount).val as cashout_min_amount_val, (cashout_min_amount).frac as cashout_min_amount_frac, + cashout_rounding_mode, + is_taler_exchange + FROM bank_accounts + JOIN customers ON customer_id=owning_customer_id + CROSS JOIN LATERAL get_conversion_class_rate(conversion_rate_class_id) + WHERE username=? + """) { + bind(username) + oneOrNull { + val isTalerExchange = it.getBoolean("is_taler_exchange") + val rate = ConversionDAO.userRate(db, it, username,isTalerExchange)!! + Pair(isTalerExchange, rate) } - val rate = ConversionRate( - cashin_ratio = getRatio("cashin_ratio"), - cashin_fee = getAmount("cashin_fee", regional), - cashin_tiny_amount = getAmount("cashin_tiny_amount", regional), - cashin_rounding_mode = getMode("cashin_rounding_mode"), - cashin_min_amount = getAmount("cashin_min_amount", fiat), - cashout_ratio = getRatio("cashout_ratio"), - cashout_fee = getAmount("cashout_fee", fiat), - cashout_tiny_amount = getAmount("cashout_tiny_amount", fiat), - cashout_rounding_mode = getMode("cashout_rounding_mode"), - cashout_min_amount = getAmount("cashout_min_amount", regional), - ) - amount.close() - roundingMode.close() - rate } /** Clear in-db conversion config */ @@ -110,14 +197,17 @@ class ConversionDAO(private val db: Database) { data class Success(val converted: TalerAmount): ConversionResult data object ToSmall: ConversionResult data object MissingConfig: ConversionResult + data object IsExchange: ConversionResult + data object NotExchange: ConversionResult } /** Perform [direction] conversion of [amount] using in-db [function] */ - private suspend fun conversionTo(amount: TalerAmount, direction: String): ConversionResult = db.serializable( - "SELECT too_small, no_config, (converted).val AS amount_val, (converted).frac AS amount_frac FROM conversion_to((?, ?)::taler_amount, ?, (0, 0)::taler_amount)" + private suspend fun conversion(amount: TalerAmount, function: String, direction: String, conversionRateClassId: Long?): ConversionResult = db.serializable( + "SELECT too_small, no_config, (converted).val AS amount_val, (converted).frac AS amount_frac FROM conversion_$function((?, ?)::taler_amount, ?, ?)" ) { bind(amount) bind(direction) + bind(conversionRateClassId) one { when { it.getBoolean("no_config") -> ConversionResult.MissingConfig @@ -128,16 +218,29 @@ class ConversionDAO(private val db: Database) { } } } - - private suspend fun conversionFrom(amount: TalerAmount, direction: String): ConversionResult = db.serializable( - "SELECT too_small, no_config, (converted).val AS amount_val, (converted).frac AS amount_frac FROM conversion_from((?, ?)::taler_amount, ?, (0, 0)::taler_amount, ?)" + private suspend fun userConversion(amount: TalerAmount, function: String, direction: String, username: String): ConversionResult = db.serializable( + """ + SELECT + is_taler_exchange, + too_small, + no_config, + (converted).val AS amount_val, + (converted).frac AS amount_frac + FROM bank_accounts + JOIN customers ON customer_id=owning_customer_id, + LATERAL conversion_$function((?, ?)::taler_amount, ?, conversion_rate_class_id) + WHERE username=? + """ ) { bind(amount) bind(direction) - bind((if (amount.currency == db.bankCurrency) db.fiatCurrencySpec!! else db.bankCurrencySpec).num_fractional_input_digits) + bind(username) one { + val isExchange = it.getBoolean("is_taler_exchange") when { it.getBoolean("no_config") -> ConversionResult.MissingConfig + direction == "cashout" && isExchange -> ConversionResult.IsExchange + direction == "cashin" && !isExchange -> ConversionResult.NotExchange it.getBoolean("too_small") -> ConversionResult.ToSmall else -> ConversionResult.Success( it.getAmount("amount", if (amount.currency == db.bankCurrency) db.fiatCurrency!! else db.bankCurrency) @@ -147,11 +250,218 @@ class ConversionDAO(private val db: Database) { } /** Convert [regional] amount to fiat using cashout rate */ - suspend fun toCashout(regional: TalerAmount): ConversionResult = conversionTo(regional, "cashout") + suspend fun defaultToCashout(regional: TalerAmount): ConversionResult = conversion(regional, "to", "cashout", null) + suspend fun classToCashout(id: Long, regional: TalerAmount): ConversionResult = conversion(regional, "to", "cashout", id) + suspend fun userToCashout(username: String, regional: TalerAmount): ConversionResult = userConversion(regional, "to", "cashout", username) /** Convert [fiat] amount to regional using cashin rate */ - suspend fun toCashin(fiat: TalerAmount): ConversionResult = conversionTo(fiat, "cashin") + suspend fun defaultToCashin(fiat: TalerAmount): ConversionResult = conversion(fiat, "to", "cashin", null) + suspend fun classToCashin(id: Long, fiat: TalerAmount): ConversionResult = conversion(fiat, "to", "cashin", id) + suspend fun userToCashin(username: String, fiat: TalerAmount): ConversionResult = userConversion(fiat, "to", "cashin", username) /** Convert [fiat] amount to regional using inverse cashout rate */ - suspend fun fromCashout(fiat: TalerAmount): ConversionResult = conversionFrom(fiat, "cashout") + suspend fun defaultFromCashout(fiat: TalerAmount): ConversionResult = conversion(fiat, "from", "cashout", null) + suspend fun classFromCashout(id: Long, fiat: TalerAmount): ConversionResult = conversion(fiat, "from", "cashout", id) + suspend fun userFromCashout(username: String, fiat: TalerAmount): ConversionResult = userConversion(fiat, "from", "cashout", username) /** Convert [regional] amount to fiat using inverse cashin rate */ - suspend fun fromCashin(regional: TalerAmount): ConversionResult = conversionFrom(regional, "cashin") + suspend fun defaultFromCashin(regional: TalerAmount): ConversionResult = conversion(regional, "from", "cashin", null) + suspend fun classFromCashin(id: Long, regional: TalerAmount): ConversionResult = conversion(regional, "from", "cashin", id) + suspend fun userFromCashin(username: String, regional: TalerAmount): ConversionResult = userConversion(regional, "from", "cashin", username) + + /** Result status of conversion rate class creation */ + sealed interface ClassCreateResult { + data class Success(val id: Long): ClassCreateResult + data object NameReuse: ClassCreateResult + } + + /** Create a new conversion rate class */ + suspend fun createClass( + input: ConversionRateClassInput + ): ClassCreateResult = db.serializable( + """ + INSERT INTO conversion_rate_classes ( + name + ,description + ,cashin_ratio + ,cashin_fee + ,cashin_min_amount + ,cashin_rounding_mode + ,cashout_ratio + ,cashout_fee + ,cashout_min_amount + ,cashout_rounding_mode + ) VALUES ( + ?, ?, + ${optDecimal(input.cashin_ratio)}, + ${optAmount(input.cashin_fee)}, + ${optAmount(input.cashin_min_amount)}, + ?::rounding_mode, + ${optDecimal(input.cashout_ratio)}, + ${optAmount(input.cashout_fee)}, + ${optAmount(input.cashout_min_amount)}, + ?::rounding_mode + ) + RETURNING conversion_rate_class_id + """ + ) { + bind(input.name) + bind(input.description) + bind(input.cashin_ratio) + bind(input.cashin_fee) + bind(input.cashin_min_amount) + bind(input.cashin_rounding_mode) + bind(input.cashout_ratio) + bind(input.cashout_fee) + bind(input.cashout_min_amount) + bind(input.cashout_rounding_mode) + try { + one { + ClassCreateResult.Success(it.getLong("conversion_rate_class_id")) + } + } catch (e: SQLException) { + if (e.sqlState == PSQLState.UNIQUE_VIOLATION.state) ClassCreateResult.NameReuse + else throw e + } + + } + + /** Result status of conversion rate class patching */ + enum class ClassPatchResult { + Success, + Unknown, + NameReuse + } + + /** Patch a conversion rate class */ + suspend fun patchClass( + id: Long, + input: ConversionRateClassInput + ): ClassPatchResult = db.serializable( + """ + UPDATE conversion_rate_classes SET + name=? + ,description=? + ,cashin_ratio=${optDecimal(input.cashin_ratio)} + ,cashin_fee=${optAmount(input.cashin_fee)} + ,cashin_min_amount=${optAmount(input.cashin_min_amount)} + ,cashin_rounding_mode=?::rounding_mode + ,cashout_ratio=${optDecimal(input.cashout_ratio)} + ,cashout_fee=${optAmount(input.cashout_fee)} + ,cashout_min_amount=${optAmount(input.cashout_min_amount)} + ,cashout_rounding_mode=?::rounding_mode + WHERE conversion_rate_class_id=? + """ + ) { + bind(input.name) + bind(input.description) + bind(input.cashin_ratio) + bind(input.cashin_fee) + bind(input.cashin_min_amount) + bind(input.cashin_rounding_mode) + bind(input.cashout_ratio) + bind(input.cashout_fee) + bind(input.cashout_min_amount) + bind(input.cashout_rounding_mode) + bind(id) + try { + if (executeUpdateCheck()) { + ClassPatchResult.Success + } else { + ClassPatchResult.Unknown + } + } catch (e: SQLException) { + if (e.sqlState == PSQLState.UNIQUE_VIOLATION.state) ClassPatchResult.NameReuse + else throw e + } + } + + /** Delete a conversion rate class */ + suspend fun deleteClass( + id: Long + ): Boolean = db.serializable( + "DELETE FROM conversion_rate_classes WHERE conversion_rate_class_id=?" + ) { + bind(id) + executeUpdateCheck() + } + + /** Get conversion rate class [id] */ + suspend fun getClass(id: Long): ConversionRateClass? = db.serializable( + """ + SELECT + name + ,description + ,(cashin_ratio).val as cashin_ratio_val, (cashin_ratio).frac as cashin_ratio_frac + ,(cashin_fee).val as cashin_fee_val, (cashin_fee).frac as cashin_fee_frac + ,(cashin_min_amount).val as cashin_min_amount_val, (cashin_min_amount).frac as cashin_min_amount_frac + ,cashin_rounding_mode + ,(cashout_ratio).val as cashout_ratio_val, (cashout_ratio).frac as cashout_ratio_frac + ,(cashout_fee).val as cashout_fee_val, (cashout_fee).frac as cashout_fee_frac + ,(cashout_min_amount).val as cashout_min_amount_val, (cashout_min_amount).frac as cashout_min_amount_frac + ,cashout_rounding_mode + ,(SELECT count(*) FROM bank_accounts WHERE bank_accounts.conversion_rate_class_id=conversion_rate_classes.conversion_rate_class_id) as num_users + FROM conversion_rate_classes + WHERE conversion_rate_class_id=? + """ + ) { + bind(id) + oneOrNull { + ConversionRateClass( + name = it.getString("name"), + description = it.getString("description"), + conversion_rate_class_id = id, + num_users = it.getInt("num_users"), + cashin_ratio = it.getOptDecimal("cashin_ratio"), + cashin_fee = it.getOptAmount("cashin_fee", db.bankCurrency), + cashin_rounding_mode = it.getOptEnum<RoundingMode>("cashin_rounding_mode"), + cashin_min_amount = it.getOptAmount("cashin_min_amount", db.fiatCurrency!!), + cashout_ratio = it.getOptDecimal("cashout_ratio"), + cashout_fee = it.getOptAmount("cashout_fee", db.fiatCurrency), + cashout_rounding_mode = it.getOptEnum<RoundingMode>("cashout_rounding_mode"), + cashout_min_amount = it.getOptAmount("cashout_min_amount", db.bankCurrency), + ) + } + } + + /** Get a page of conversion rate classes */ + suspend fun pageClass(params: ClassParams): List<ConversionRateClass> + = db.page( + params.page, + "conversion_rate_class_id", + """ + SELECT + name + ,description + ,(cashin_ratio).val as cashin_ratio_val, (cashin_ratio).frac as cashin_ratio_frac + ,(cashin_fee).val as cashin_fee_val, (cashin_fee).frac as cashin_fee_frac + ,(cashin_min_amount).val as cashin_min_amount_val, (cashin_min_amount).frac as cashin_min_amount_frac + ,cashin_rounding_mode + ,(cashout_ratio).val as cashout_ratio_val, (cashout_ratio).frac as cashout_ratio_frac + ,(cashout_fee).val as cashout_fee_val, (cashout_fee).frac as cashout_fee_frac + ,(cashout_min_amount).val as cashout_min_amount_val, (cashout_min_amount).frac as cashout_min_amount_frac + ,cashout_rounding_mode + ,(SELECT count(*) FROM bank_accounts WHERE bank_accounts.conversion_rate_class_id=conversion_rate_classes.conversion_rate_class_id) as num_users + ,conversion_rate_class_id + FROM conversion_rate_classes + WHERE ${if (params.nameFilter != null) "name ILIKE ? AND" else ""} + """, + { + if (params.nameFilter != null) { + bind(params.nameFilter) + } + } + ) { + ConversionRateClass( + name = it.getString("name"), + description = it.getString("description"), + conversion_rate_class_id = it.getLong("conversion_rate_class_id"), + num_users = it.getInt("num_users"), + cashin_ratio = it.getOptDecimal("cashin_ratio"), + cashin_fee = it.getOptAmount("cashin_fee", db.bankCurrency), + cashin_rounding_mode = it.getOptEnum<RoundingMode>("cashin_rounding_mode"), + cashin_min_amount = it.getOptAmount("cashin_min_amount", db.fiatCurrency!!), + cashout_ratio = it.getOptDecimal("cashout_ratio"), + cashout_fee = it.getOptAmount("cashout_fee", db.fiatCurrency), + cashout_rounding_mode = it.getOptEnum<RoundingMode>("cashout_rounding_mode"), + cashout_min_amount = it.getOptAmount("cashout_min_amount", db.bankCurrency), + ) + } } \ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/Database.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/Database.kt @@ -34,8 +34,6 @@ class Database( dbConfig: DatabaseConfig, internal val bankCurrency: String, internal val fiatCurrency: String?, - internal val bankCurrencySpec: CurrencySpecification, - internal val fiatCurrencySpec: CurrencySpecification?, internal val ctx: BankPaytoCtx ): DbPool(dbConfig, "libeufin-bank") { // DAOs diff --git a/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt b/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt @@ -105,8 +105,8 @@ suspend fun createAdminAccount(db: Database, cfg: BankConfig, pw: String? = null phone = null, cashoutPayto = null, tanChannel = null, - minCashout = null, - pwCrypto = cfg.pwCrypto + pwCrypto = cfg.pwCrypto, + conversionRateClassId = null ) } diff --git a/bank/src/main/kotlin/tech/libeufin/bank/params.kt b/bank/src/main/kotlin/tech/libeufin/bank/params.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2023 Taler Systems S.A. + * Copyright (C) 2023-2025 Taler Systems S.A. * LibEuFin is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -83,12 +83,26 @@ data class MonitorParams( } data class AccountParams( - val page: PageParams, val usernameFilter: String? + val page: PageParams, + val usernameFilter: String?, + val conversionRateClassId: Long? ) { companion object { fun extract(params: Parameters): AccountParams { val usernameFilter = params["filter_name"]?.run { "%$this%" } - return AccountParams(PageParams.extract(params), usernameFilter) + val conversionRateClassId = params.long("conversion_rate_class_id") + return AccountParams(PageParams.extract(params), usernameFilter, conversionRateClassId) + } + } +} + +data class ClassParams( + val page: PageParams, val nameFilter: String? +) { + companion object { + fun extract(params: Parameters): ClassParams { + val usernameFilter = params["filter_name"]?.run { "%$this%" } + return ClassParams(PageParams.extract(params), usernameFilter) } } } diff --git a/bank/src/test/kotlin/AmountTest.kt b/bank/src/test/kotlin/AmountTest.kt @@ -339,13 +339,13 @@ class AmountTest { } } - val revertStmt = conn.talerStatement("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", digits: Int = 8): TalerAmount { + val revertStmt = conn.talerStatement("SELECT (result).val, (result).frac FROM conversion_revert_ratio((?, ?)::taler_amount, (?, ?)::taler_amount, (0, 0)::taler_amount, (?, ?)::taler_amount, ?::rounding_mode, (?, ?)::taler_amount)") + fun TalerAmount.revert(ratio: DecimalNumber, tiny: DecimalNumber = DecimalNumber("0.00000001"), roundingMode: String = "zero", reverseTiny: DecimalNumber = DecimalNumber("0.00000001")): TalerAmount { revertStmt.bind(this) revertStmt.bind(ratio) revertStmt.bind(tiny) revertStmt.bind(roundingMode) - revertStmt.bind(digits) + revertStmt.bind(reverseTiny) return revertStmt.one { TalerAmount( it.getLong(1), @@ -369,11 +369,11 @@ class AmountTest { for (tiny in sequenceOf("0.01", "0.00000001", "1", "2", "3", "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", "1.25", "1.26", "0.01", "0.001", "0.00000001").map(::DecimalNumber)) { - for (digits in sequenceOf(8, 2, 0)) { + for (reverseTiny in sequenceOf("0.01", "0.00000001", "1").map(::DecimalNumber)) { // Apply ratio val rounded = amount.apply(ratio, tiny, mode) // Revert ratio - val revert = rounded.revert(ratio, tiny, mode, digits) + val revert = rounded.revert(ratio, tiny, mode, reverseTiny) // Check applying ratio again give the same result val check = revert.apply(ratio, tiny, mode) println("$amount $rounded $revert $check $ratio $tiny $mode") diff --git a/bank/src/test/kotlin/ConversionApiTest.kt b/bank/src/test/kotlin/ConversionApiTest.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2023 Taler Systems S.A. + * Copyright (C) 2023-2025 Taler Systems S.A. * LibEuFin is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -18,8 +18,9 @@ */ import io.ktor.client.request.* +import io.ktor.http.* import org.junit.Test -import tech.libeufin.bank.ConversionResponse +import tech.libeufin.bank.* import tech.libeufin.common.* import tech.libeufin.common.test.* import kotlin.test.assertEquals @@ -29,6 +30,8 @@ class ConversionApiTest { @Test fun config() = bankSetup { client.get("/conversion-info/config").assertOk() + client.get("/conversion-rate-classes/1/conversion-info/config").assertOk() + client.get("/accounts/merchant/conversion-info/config").assertOk() } // POST /conversion-info/conversion-rate @@ -46,115 +49,148 @@ class ConversionApiTest { "cashout_rounding_mode" to "zero" "cashout_min_amount" to "KUDOS:0.1" } - // Good rates - client.postAdmin("/conversion-info/conversion-rate") { - json(ok) - }.assertNoContent() - // Bad currency - client.postAdmin("/conversion-info/conversion-rate") { - json(ok) { - "cashout_fee" to "CHF:0.003" - } - }.assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH) - // Subcent cashout tiny amount - client.postAdmin("/conversion-info/conversion-rate") { - json(ok) { - "cashout_tiny_amount" to "EUR:0.0001" - } - }.assertBadRequest(TalerErrorCode.GENERIC_JSON_INVALID) + authRoutine(HttpMethod.Post, "/conversion-info/conversion-rate", requireAdmin = true) + authRoutine(HttpMethod.Post, "/accounts/merchant/conversion-info/conversion-rate", requireAdmin = true) + authRoutine(HttpMethod.Post, "/conversion-rate-classes/1/conversion-info/conversion-rate", requireAdmin = true) + for (prefix in sequenceOf("", "/conversion-rate-classes/1", "/accounts/merchant")) { + // Good rates + client.postAdmin("$prefix/conversion-info/conversion-rate") { + json(ok) + }.assertNoContent() + // Bad currency + client.postAdmin("$prefix/conversion-info/conversion-rate") { + json(ok) { + "cashout_fee" to "CHF:0.003" + } + }.assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH) + // Subcent cashout tiny amount + client.postAdmin("$prefix/conversion-info/conversion-rate") { + json(ok) { + "cashout_tiny_amount" to "EUR:0.0001" + } + }.assertBadRequest(TalerErrorCode.GENERIC_JSON_INVALID) + } + } + + // GET /conversion-info/rate + @Test + fun userRate() = bankSetup { + authRoutine(HttpMethod.Get, "/accounts/merchant/conversion-info/rate", allowAdmin = true, optional = true) + authRoutine(HttpMethod.Get, "/conversion-rate-classes/1/conversion-info/rate", requireAdmin = true) + client.get("/conversion-info/rate").assertOkJson<ConversionRate>() + client.getA("/accounts/merchant/conversion-info/rate").assertOkJson<ConversionRate>() + client.get("/accounts/exchange/conversion-info/rate").assertOkJson<ConversionRate>() + client.getAdmin("/conversion-rate-classes/1/conversion-info/rate").assertOkJson<ConversionRate>() } // GET /conversion-info/cashout-rate @Test fun cashoutRate() = bankSetup { - // 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.25"), it.amount_credit) - } - // Check conversion from - client.get("/conversion-info/cashout-rate?amount_credit=EUR:1.257").assertOkJson<ConversionResponse> { - assertEquals(TalerAmount("KUDOS:1.01"), it.amount_debit) - assertEquals(TalerAmount("EUR:1.257"), it.amount_credit) - } + authRoutine(HttpMethod.Get, "/accounts/merchant/conversion-info/cashout-rate?amount_debit=KUDOS:1", allowAdmin = true) + authRoutine(HttpMethod.Get, "/conversion-rate-classes/1/conversion-info/cashout-rate?amount_debit=KUDOS:1", requireAdmin = true) + for (prefix in sequenceOf("", "/conversion-rate-classes/1", "/accounts/merchant")) { + // Check conversion to + client.getAdmin("$prefix/conversion-info/cashout-rate?amount_debit=KUDOS:1").assertOkJson<ConversionResponse> { + assertEquals(TalerAmount("KUDOS:1"), it.amount_debit) + assertEquals(TalerAmount("EUR:1.25"), it.amount_credit) + } + // Check conversion from + client.getAdmin("$prefix/conversion-info/cashout-rate?amount_credit=EUR:1.257").assertOkJson<ConversionResponse> { + assertEquals(TalerAmount("KUDOS:1.01"), it.amount_debit) + assertEquals(TalerAmount("EUR:1.257"), it.amount_credit) + } - // Too small - client.get("/conversion-info/cashout-rate?amount_debit=KUDOS:0.0008") - .assertConflict(TalerErrorCode.BANK_BAD_CONVERSION) - // No amount - client.get("/conversion-info/cashout-rate") - .assertBadRequest(TalerErrorCode.GENERIC_PARAMETER_MISSING) - // Both amount - client.get("/conversion-info/cashout-rate?amount_debit=EUR:1&amount_credit=KUDOS:1") - .assertBadRequest(TalerErrorCode.GENERIC_PARAMETER_MALFORMED) - // Wrong format - client.get("/conversion-info/cashout-rate?amount_debit=1") - .assertBadRequest(TalerErrorCode.GENERIC_PARAMETER_MALFORMED) - client.get("/conversion-info/cashout-rate?amount_credit=1") - .assertBadRequest(TalerErrorCode.GENERIC_PARAMETER_MALFORMED) - // Wrong currency - client.get("/conversion-info/cashout-rate?amount_debit=EUR:1") - .assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH) - client.get("/conversion-info/cashout-rate?amount_credit=KUDOS:1") - .assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH) + // Too small + client.getAdmin("$prefix/conversion-info/cashout-rate?amount_debit=KUDOS:0.0008") + .assertConflict(TalerErrorCode.BANK_BAD_CONVERSION) + // No amount + client.getAdmin("$prefix/conversion-info/cashout-rate") + .assertBadRequest(TalerErrorCode.GENERIC_PARAMETER_MISSING) + // Both amount + client.getAdmin("$prefix/conversion-info/cashout-rate?amount_debit=EUR:1&amount_credit=KUDOS:1") + .assertBadRequest(TalerErrorCode.GENERIC_PARAMETER_MALFORMED) + // Wrong format + client.getAdmin("$prefix/conversion-info/cashout-rate?amount_debit=1") + .assertBadRequest(TalerErrorCode.GENERIC_PARAMETER_MALFORMED) + client.getAdmin("$prefix/conversion-info/cashout-rate?amount_credit=1") + .assertBadRequest(TalerErrorCode.GENERIC_PARAMETER_MALFORMED) + // Wrong currency + client.getAdmin("$prefix/conversion-info/cashout-rate?amount_debit=EUR:1") + .assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH) + client.getAdmin("$prefix/conversion-info/cashout-rate?amount_credit=KUDOS:1") + .assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH) + } + client.getA("/accounts/exchange/conversion-info/cashout-rate?amount_debit=KUDOS:1") + .assertConflict(TalerErrorCode.BANK_ACCOUNT_IS_EXCHANGE) } // GET /conversion-info/cashin-rate @Test fun cashinRate() = bankSetup { - for ((amount, converted) in listOf( - Pair(0.75, 0.58), Pair(0.32, 0.24), Pair(0.66, 0.51) - )) { - // Check conversion to - client.get("/conversion-info/cashin-rate?amount_debit=EUR:$amount").assertOkJson<ConversionResponse> { - assertEquals(TalerAmount("KUDOS:$converted"), it.amount_credit) - assertEquals(TalerAmount("EUR:$amount"), it.amount_debit) + authRoutine(HttpMethod.Get, "/conversion-rate-classes/1/conversion-info/cashin-rate?amount_debit=EUR:1", requireAdmin = true) + for (prefix in sequenceOf("", "/conversion-rate-classes/1", "/accounts/exchange")) { + for ((amount, converted) in listOf( + Pair(0.75, 0.58), Pair(0.32, 0.24), Pair(0.66, 0.51) + )) { + // Check conversion to + client.getAdmin("$prefix/conversion-info/cashin-rate?amount_debit=EUR:$amount").assertOkJson<ConversionResponse> { + assertEquals(TalerAmount("KUDOS:$converted"), it.amount_credit) + assertEquals(TalerAmount("EUR:$amount"), it.amount_debit) + } + // Check conversion from + client.getAdmin("$prefix/conversion-info/cashin-rate?amount_credit=KUDOS:$converted").assertOkJson<ConversionResponse> { + assertEquals(TalerAmount("KUDOS:$converted"), it.amount_credit) + assertEquals(TalerAmount("EUR:$amount"), it.amount_debit) + } } - // Check conversion from - client.get("/conversion-info/cashin-rate?amount_credit=KUDOS:$converted").assertOkJson<ConversionResponse> { - assertEquals(TalerAmount("KUDOS:$converted"), it.amount_credit) - assertEquals(TalerAmount("EUR:$amount"), it.amount_debit) - } - } - // No amount - client.get("/conversion-info/cashin-rate") - .assertBadRequest(TalerErrorCode.GENERIC_PARAMETER_MISSING) - // Both amount - client.get("/conversion-info/cashin-rate?amount_debit=KUDOS:1&amount_credit=EUR:1") - .assertBadRequest(TalerErrorCode.GENERIC_PARAMETER_MALFORMED) - // Wrong format - client.get("/conversion-info/cashin-rate?amount_debit=1") - .assertBadRequest(TalerErrorCode.GENERIC_PARAMETER_MALFORMED) - client.get("/conversion-info/cashin-rate?amount_credit=1") - .assertBadRequest(TalerErrorCode.GENERIC_PARAMETER_MALFORMED) - // Wrong currency - client.get("/conversion-info/cashin-rate?amount_debit=KUDOS:1") - .assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH) - client.get("/conversion-info/cashin-rate?amount_credit=EUR:1") - .assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH) + // No amount + client.getAdmin("$prefix/conversion-info/cashin-rate") + .assertBadRequest(TalerErrorCode.GENERIC_PARAMETER_MISSING) + // Both amount + client.getAdmin("$prefix/conversion-info/cashin-rate?amount_debit=KUDOS:1&amount_credit=EUR:1") + .assertBadRequest(TalerErrorCode.GENERIC_PARAMETER_MALFORMED) + // Wrong format + client.getAdmin("$prefix/conversion-info/cashin-rate?amount_debit=1") + .assertBadRequest(TalerErrorCode.GENERIC_PARAMETER_MALFORMED) + client.getAdmin("$prefix/conversion-info/cashin-rate?amount_credit=1") + .assertBadRequest(TalerErrorCode.GENERIC_PARAMETER_MALFORMED) + // Wrong currency + client.getAdmin("$prefix/conversion-info/cashin-rate?amount_debit=KUDOS:1") + .assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH) + client.getAdmin("$prefix/conversion-info/cashin-rate?amount_credit=EUR:1") + .assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH) + } + client.get("/accounts/merchant/conversion-info/cashin-rate?amount_debit=EUR:1") + .assertConflict(TalerErrorCode.BANK_ACCOUNT_IS_NOT_EXCHANGE) } @Test fun noRate() = bankSetup { db -> - db.conversion.clearConfig() - client.get("/conversion-info/config") - .assertNotImplemented() - client.get("/conversion-info/cashin-rate") - .assertBadRequest(TalerErrorCode.GENERIC_PARAMETER_MISSING) - client.get("/conversion-info/cashout-rate") - .assertBadRequest(TalerErrorCode.GENERIC_PARAMETER_MISSING) - client.get("/conversion-info/cashin-rate?amount_credit=KUDOS:1") - .assertNotImplemented() - client.get("/conversion-info/cashout-rate?amount_credit=EUR:1") - .assertNotImplemented() + db.serializable("DELETE FROM config WHERE key='conversion_rate'") { + executeUpdate() + } + for (prefix in sequenceOf("", "/conversion-rate-classes/1", "/accounts/merchant")) { + client.getAdmin("$prefix/conversion-info/config") + .assertNotImplemented() + client.getAdmin("$prefix/conversion-info/cashin-rate") + .assertBadRequest(TalerErrorCode.GENERIC_PARAMETER_MISSING) + client.getAdmin("$prefix/conversion-info/cashout-rate") + .assertBadRequest(TalerErrorCode.GENERIC_PARAMETER_MISSING) + client.getAdmin("$prefix/conversion-info/cashin-rate?amount_credit=KUDOS:1") + .assertNotImplemented() + client.getAdmin("$prefix/conversion-info/cashout-rate?amount_credit=EUR:1") + .assertNotImplemented() + } } @Test fun notImplemented() = bankSetup("test_no_conversion.conf") { - client.get("/conversion-info/cashin-rate") - .assertNotImplemented() - client.get("/conversion-info/cashout-rate") - .assertNotImplemented() + for (prefix in sequenceOf("", "/conversion-rate-classes/1", "/accounts/merchant")) { + client.get("$prefix/conversion-info/cashin-rate") + .assertNotImplemented() + client.get("$prefix/conversion-info/cashout-rate") + .assertNotImplemented() + } } } \ No newline at end of file diff --git a/bank/src/test/kotlin/CoreBankApiTest.kt b/bank/src/test/kotlin/CoreBankApiTest.kt @@ -456,16 +456,17 @@ class CoreBankAccountsApiTest { }.assertOk() } - // Check admin only min_cashout + // Check admin only conversion_rate_class_id + createConversionRateClass() obj { "username" to "bat2" "password" to "password" "name" to "Bat" - "min_cashout" to "KUDOS:42" + "conversion_rate_class_id" to 1 }.let { req -> client.post("/accounts") { json(req) - }.assertConflict(TalerErrorCode.BANK_NON_ADMIN_SET_MIN_CASHOUT) + }.assertConflict(TalerErrorCode.BANK_NON_ADMIN_SET_CONVERSION_RATE_CLASS) client.postAdmin("/accounts") { json(req) }.assertOk() @@ -501,6 +502,16 @@ class CoreBankAccountsApiTest { }.assertConflict(TalerErrorCode.BANK_MISSING_TAN_INFO) } + // Check unknown conversion rate class + client.postAdmin("/accounts") { + json { + "username" to "new_account" + "password" to "password" + "name" to "New Account" + "conversion_rate_class_id" to 42 + } + }.assertConflict(TalerErrorCode.BANK_CONVERSION_RATE_CLASS_UNKNOWN) + // Reserved account RESERVED_ACCOUNTS.forEach { client.post("/accounts") { @@ -872,10 +883,16 @@ class CoreBankAccountsApiTest { obj(req) { "debit_threshold" to "KUDOS:100" }, TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT ) + createConversionRateClass() checkAdminOnly( - obj(req) { "min_cashout" to "KUDOS:100" }, - TalerErrorCode.BANK_NON_ADMIN_SET_MIN_CASHOUT + obj(req) { "conversion_rate_class_id" to 1 }, + TalerErrorCode.BANK_NON_ADMIN_SET_CONVERSION_RATE_CLASS ) + + // Check unknown conversion rate class + client.patchAdmin("/accounts/merchant") { + json(req) { "conversion_rate_class_id" to 42} + }.assertConflict(TalerErrorCode.BANK_CONVERSION_RATE_CLASS_UNKNOWN) // Check currency client.patchAdmin("/accounts/merchant") { @@ -1106,6 +1123,7 @@ class CoreBankAccountsApiTest { } client.getAdmin("/accounts").assertOkJson<ListBankAccountsResponse> { for (account in it.accounts) { + assertNull(account.conversion_rate) if (defaultAccounts.contains(account.username)) { assertEquals(AccountStatus.deleted, account.status) } else { @@ -1117,15 +1135,25 @@ class CoreBankAccountsApiTest { // Check error when no public accounts client.get("/public-accounts").assertNoContent() client.getAdmin("/accounts").assertOkJson<ListBankAccountsResponse>() + } + + @Test + fun listConversionClass() = bankSetup(conf = "test.conf") { db -> + repeat(3) { + createConversionRateClass() + } // Gen some public and private accounts repeat(5) { - client.post("/accounts") { + client.postAdmin("/accounts") { + val mod = it%3 + val rateClassId = if (mod in 1..3) mod else null json { "username" to "$it" "password" to "password" - "name" to "Mr $it" + "name" to "Mr 1$it" "is_public" to (it%2 == 0) + "conversion_rate_class_id" to rateClassId } }.assertOk() } @@ -1133,24 +1161,41 @@ class CoreBankAccountsApiTest { client.get("/public-accounts").assertOkJson<PublicAccountsResponse> { assertEquals(3, it.public_accounts.size) it.public_accounts.forEach { - assertEquals(0, it.username.toInt() % 2) + assertEquals(0, (it.username.toInt() - 10) % 2) } } - // All accounts - client.getAdmin("/accounts?limit=10").assertOkJson<ListBankAccountsResponse> { - assertEquals(6, it.accounts.size) - it.accounts.forEachIndexed { idx, it -> - if (idx == 0) { - assertEquals("admin", it.username) - } else { - assertEquals(idx - 1, it.username.toInt()) - } + // Conversion rate + client.getAdmin("/accounts").assertOkJson<ListBankAccountsResponse> { + for (account in it.accounts) { + val rate = client.getAdmin("/accounts/${account.username}/conversion-info/rate").assertOkJson<ConversionRate>() + assertEquals(account.conversion_rate, rate) } } // Filtering - client.getAdmin("/accounts?filter_name=3").assertOkJson<ListBankAccountsResponse> { - assertEquals(1, it.accounts.size) - assertEquals("3", it.accounts[0].username) + suspend fun checkIds(query: String, vararg ids: String) { + val res = client.getAdmin("/accounts?$query") + val list = listOf(*ids) + if (list.isEmpty()) { + res.assertNoContent() + } else { + res.assertOkJson<ListBankAccountsResponse> { + assertEquals(list, it.accounts.map { it.username }) + } + } + } + checkIds("", "4", "3", "2", "1", "0", "admin", "customer", "exchange", "merchant") + checkIds("filter_name=1", "4", "3", "2", "1", "0") + checkIds("filter_name=3", "3") + checkIds("conversion_rate_class_id=1", "4", "1") + checkIds("conversion_rate_class_id=2", "2") + checkIds("conversion_rate_class_id=3") + checkIds("conversion_rate_class_id=4") + checkIds("conversion_rate_class_id=0", "3", "0", "admin", "customer", "exchange", "merchant") + checkIds("conversion_rate_class_id=0&filter_name=1", "3", "0") + for ((id, num) in mapOf(1 to 2, 2 to 1, 3 to 0)) { + client.getAdmin("/conversion-rate-classes/$id").assertOkJson<ConversionRateClass> { + assertEquals(it.num_users, num) + } } } @@ -1865,28 +1910,26 @@ class CoreBankCashoutApiTest { // Check min amount client.postA("/accounts/customer/cashouts") { json(req) { - "request_uid" to ShortHashCode.rand() "amount_debit" to "KUDOS:0.09" - "amount_credit" to convert("KUDOS:0.09") } }.assertConflict(TalerErrorCode.BANK_CONVERSION_AMOUNT_TO_SMALL) // Check custom min account + createConversionRateClass(cashout_min_amount = TalerAmount("KUDOS:10")) client.patchAdmin("/accounts/customer") { json { - "min_cashout" to "KUDOS:10" + "conversion_rate_class_id" to 1 } }.assertNoContent() client.postA("/accounts/customer/cashouts") { json(req) { - "request_uid" to ShortHashCode.rand() "amount_debit" to "KUDOS:5" "amount_credit" to convert("KUDOS:5") } }.assertConflict(TalerErrorCode.BANK_CONVERSION_AMOUNT_TO_SMALL) client.patchAdmin("/accounts/customer") { json { - "min_cashout" to (null as String?) + "conversion_rate_class_id" to (null as Long?) } }.assertNoContent() @@ -2255,4 +2298,121 @@ class CoreBankTanApiTest { }.assertNoContent() } } +} + +class CoreBankConversionApiTest { + // POST /conversion-rate-classes + // GET /conversion-rate-classes + // GET /conversion-rate-classes/{CLASS_ID} + @Test + fun classes() = bankSetup() { + authRoutine(HttpMethod.Post, "/conversion-rate-classes", requireAdmin = true) + authRoutine(HttpMethod.Get, "/conversion-rate-classes", requireAdmin = true) + authRoutine(HttpMethod.Get, "/conversion-rate-classes/1", requireAdmin = true) + + val fullInput = obj { + "description" to "A nice little class" + "cashin_ratio" to "0.1" + "cashin_fee" to "KUDOS:0.2" + "cashin_tiny_amount" to "KUDOS:0.3" + "cashin_rounding_mode" to "nearest" + "cashin_min_amount" to "EUR:0" + "cashout_ratio" to "0.4" + "cashout_fee" to "EUR:0.5" + "cashout_tiny_amount" to "EUR:0.6" + "cashout_rounding_mode" to "zero" + "cashout_min_amount" to "KUDOS:0.7" + } + + // Check no classes + client.getAdmin("/conversion-rate-classes").assertNoContent() + client.getAdmin("/conversion-rate-classes/1").assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) + client.patchAdmin("/conversion-rate-classes/1") { + json(fullInput) { + "name" to "Class" + } + }.assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) + client.deleteAdmin("/conversion-rate-classes/1").assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) + + // Create full + val full = client.postAdmin("/conversion-rate-classes") { + json(fullInput) { + "name" to "Class n°1" + } + }.assertOkJson<ConversionRateClassResponse> { + assertEquals(it.conversion_rate_class_id, 1) + val rate = client.getAdmin("/conversion-rate-classes/${it.conversion_rate_class_id}").assertOkJson<ConversionRateClass>() + client.patchAdmin("/conversion-rate-classes/${it.conversion_rate_class_id}") { + json { + "name" to "Class n°1" + } + }.assertNoContent() + it.conversion_rate_class_id + } + // Create empty + val empty = client.postAdmin("/conversion-rate-classes") { + json { + "name" to "Class n°2" + } + }.assertOkJson<ConversionRateClassResponse> { + assertEquals(it.conversion_rate_class_id, 2) + val rate = client.getAdmin("/conversion-rate-classes/${it.conversion_rate_class_id}").assertOkJson<ConversionRateClass>() + client.patchAdmin("/conversion-rate-classes/${it.conversion_rate_class_id}") { + json(fullInput) { + "name" to "Class n°2" + } + }.assertNoContent() + it.conversion_rate_class_id + } + + // Bad currency + client.postAdmin("/conversion-rate-classes") { + json(fullInput) { + "name" to "Bad currency" + "cashout_fee" to "CHF:0.003" + } + }.assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH) + + // Name reuse currency + client.postAdmin("/conversion-rate-classes") { + json(fullInput) { + "name" to "Class n°1" + } + }.assertConflict(TalerErrorCode.BANK_NAME_REUSE) + client.patchAdmin("/conversion-rate-classes/2") { + json(fullInput) { + "name" to "Class n°1" + } + }.assertConflict(TalerErrorCode.BANK_NAME_REUSE) + client.patchAdmin("/conversion-rate-classes/1") { + json(fullInput) { + "name" to "Class n°1" + } + }.assertNoContent() + + // Page + client.getAdmin("/conversion-rate-classes").assertOkJson<ConversionRateClasses> { + assertEquals(it.classes.size, 2) + } + val generated = (0 until 5).map { createConversionRateClass() } + client.getAdmin("/conversion-rate-classes").assertOkJson<ConversionRateClasses> { + assertEquals(it.classes.size, 7) + } + client.getAdmin("/conversion-rate-classes?filter_name=Gen").assertOkJson<ConversionRateClasses> { + assertEquals(it.classes.size, 5) + } + + // Delete all + for (id in listOf(full.conversion_rate_class_id, empty.conversion_rate_class_id) + generated) { + client.deleteAdmin("/conversion-rate-classes/$id").assertNoContent() + client.deleteAdmin("/conversion-rate-classes/$id").assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) + } + client.getAdmin("/conversion-rate-classes").assertNoContent() + } + + @Test + fun notImplemented() = bankSetup("test_no_conversion.conf") { + client.getAdmin("conversion-rate-classes/1").assertNotImplemented() + client.getAdmin("conversion-rate-classes").assertNotImplemented() + } } \ No newline at end of file diff --git a/bank/src/test/kotlin/bench.kt b/bank/src/test/kotlin/bench.kt @@ -54,8 +54,17 @@ class Bench { "customers(username, name, password_hash, cashout_payto)" to { "account_$it\t$password\tMr n°$it\t$unknownPayto\n" }, - "bank_accounts(internal_payto, owning_customer_id, is_public)" to { - "payto://x-taler-bank/localhost/account_$it\t${it+skipAccount}\t${it%3==0}\n" + "conversion_rate_classes(name)" to { + "Class n0$it\n" + }, + "bank_accounts(internal_payto, owning_customer_id, is_public,conversion_rate_class_id)" to { + val conversionId = when (it%5) { + 0 -> "\\N" + 1, 2 -> "1" + 3 -> "2" + else -> it%10 + } + "payto://x-taler-bank/localhost/account_$it\t${it+skipAccount}\t${it%3==0}\t$conversionId\n" }, "bearer_tokens(content, creation_time, expiration_time, scope, is_refreshable, bank_customer, description, last_access)" to { val account = if (it > mid) customerAccount else it+4 @@ -154,6 +163,12 @@ class Bench { measureAction("account_list") { client.getAdmin("/accounts").assertOk() } + measureAction("account_list_class") { + client.getAdmin("/accounts?conversion_rate_class=${it%10}").assertOk() + } + measureAction("account_list_name") { + client.getAdmin("/accounts?name=Mr").assertOk() + } measureAction("account_list_public") { client.get("/public-accounts").assertOk() } @@ -185,6 +200,32 @@ class Bench { }.assertNoContent() } + // Conversion rate classes + val classes = measureAction("class_create") { + client.postAdmin("/conversion-rate-classes") { + json { + "name" to "Gen class $it" + } + }.assertOkJson<ConversionRateClassResponse>().conversion_rate_class_id + } + measureAction("class_patch") { + client.patchAdmin("/conversion-rate-classes/${classes[it]}") { + json { + "name" to "Gen class $it" + "description" to "test $it" + } + }.assertNoContent() + } + measureAction("class_get") { + client.getAdmin("/conversion-rate-classes/${classes[it]}").assertOk() + } + measureAction("class_list") { + client.getAdmin("/conversion-rate-classes").assertOk() + } + measureAction("class_delete") { + client.deleteAdmin("/conversion-rate-classes/${classes[it]}").assertNoContent() + } + // Transaction val transactions = measureAction("transaction_create") { client.postA("/accounts/customer/transactions") { diff --git a/bank/src/test/kotlin/helpers.kt b/bank/src/test/kotlin/helpers.kt @@ -96,7 +96,7 @@ fun bankSetup( phone = null, cashoutPayto = null, tanChannel = null, - minCashout = null, + conversionRateClassId = null, pwCrypto = cfg.pwCrypto )) assertIs<AccountCreationResult.Success>(db.account.create( @@ -113,7 +113,7 @@ fun bankSetup( phone = null, cashoutPayto = null, tanChannel = null, - minCashout = null, + conversionRateClassId = null, pwCrypto = cfg.pwCrypto )) assertIs<AccountCreationResult.Success>(db.account.create( @@ -130,7 +130,7 @@ fun bankSetup( phone = null, cashoutPayto = null, tanChannel = null, - minCashout = null, + conversionRateClassId = null, pwCrypto = cfg.pwCrypto )) // Create admin account @@ -305,6 +305,21 @@ suspend fun ApplicationTestBuilder.withdrawalSelect(uuid: String) { }.assertOk() } +private var nbClass = 0; + +suspend fun ApplicationTestBuilder.createConversionRateClass( + cashout_min_amount: TalerAmount? = null +): Long { + nbClass += 1 + return client.postAdmin("/conversion-rate-classes") { + json { + "name" to "Gen class $nbClass" + "cashout_min_amount" to cashout_min_amount + } + }.assertOkJson<ConversionRateClassResponse>().conversion_rate_class_id +} + + suspend fun ApplicationTestBuilder.convert(amount: String): TalerAmount { return client.get("/conversion-info/cashout-rate?amount_debit=$amount") .assertOkJson<ConversionResponse>().amount_credit diff --git a/bank/src/test/kotlin/routines.kt b/bank/src/test/kotlin/routines.kt @@ -37,15 +37,18 @@ suspend fun ApplicationTestBuilder.authRoutine( body: JsonObject? = null, requireExchange: Boolean = false, requireAdmin: Boolean = false, - allowAdmin: Boolean = false + allowAdmin: Boolean = false, + optional: Boolean = false ) { // No body when authentication must happen before parsing the body - // No header - client.request(path) { - this.method = method - if (body != null) json(body) - }.assertUnauthorized(TalerErrorCode.GENERIC_PARAMETER_MISSING) + if (!optional) { + // No header + client.request(path) { + this.method = method + if (body != null) json(body) + }.assertUnauthorized(TalerErrorCode.GENERIC_PARAMETER_MISSING) + } // Bad header client.request(path) { diff --git a/common/src/main/kotlin/TalerCommon.kt b/common/src/main/kotlin/TalerCommon.kt @@ -180,6 +180,7 @@ class DecimalNumber { } companion object { + val ZERO = DecimalNumber(0, 0) private val PATTERN = Regex("([0-9]+)(?:\\.([0-9]{1,8}))?") } } diff --git a/common/src/main/kotlin/TalerErrorCode.kt b/common/src/main/kotlin/TalerErrorCode.kt @@ -1,24 +1,24 @@ /* - This file is part of GNU Taler - Copyright (C) 2012-2025 Taler Systems SA + *This file is part of GNU Taler + * Copyright (C) 2012-2025 Taler Systems SA - GNU Taler is free software: you can redistribute it and/or modify it - under the terms of the GNU Lesser General Public License as published - by the Free Software Foundation, either version 3 of the License, - or (at your option) any later version. + * GNU Taler is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published + * by the Free Software Foundation, either version 3 of the License, + * or (at your option) any later version. - GNU Taler is distributed in the hope that it will be useful, but - WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - Lesser General Public License for more details. + * GNU Taler is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. - You should have received a copy of the GNU Lesser General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. - SPDX-License-Identifier: LGPL3.0-or-later + * SPDX-License-Identifier: LGPL3.0-or-later - Note: the LGPL does not apply to all components of GNU Taler, - but it does apply to this file. + * Note: the LGPL does not apply to all components of GNU Taler, + * but it does apply to this file. */ package tech.libeufin.common @@ -1530,6 +1530,15 @@ enum class TalerErrorCode(val code: Int, val status: Int, val description: Strin /** The wtid for a request to transfer funds has already been used, but with a different request unpaid. */ BANK_TRANSFER_WTID_REUSED(5154, 409, "The wtid for a request to transfer funds has already been used, but with a different request unpaid."), + /** A non-admin user has tried to set their conversion rate class */ + BANK_NON_ADMIN_SET_CONVERSION_RATE_CLASS(5155, 409, "A non-admin user has tried to set their conversion rate class"), + + /** The referenced conversion rate class was not found */ + BANK_CONVERSION_RATE_CLASS_UNKNOWN(5156, 409, "The referenced conversion rate class was not found"), + + /** The client tried to use an already taken name. */ + BANK_NAME_REUSE(5157, 409, "The client tried to use an already taken name."), + /** The sync service failed find the account in its database. */ SYNC_ACCOUNT_UNKNOWN(6100, 404, "The sync service failed find the account in its database."), @@ -1710,6 +1719,18 @@ enum class TalerErrorCode(val code: Int, val status: Int, val description: Strin /** A payment was attempted, but the merchant claims the order is gone (likely expired). */ WALLET_PAY_MERCHANT_ORDER_GONE(7044, 0, "A payment was attempted, but the merchant claims the order is gone (likely expired)."), + /** The wallet does not have an entry for the requested exchange. */ + WALLET_EXCHANGE_ENTRY_NOT_FOUND(7045, 0, "The wallet does not have an entry for the requested exchange."), + + /** The wallet is not able to process the request due to the transaction's state. */ + WALLET_REQUEST_TRANSACTION_STATE_UNSUPPORTED(7046, 0, "The wallet is not able to process the request due to the transaction's state."), + + /** A transaction could not be processed due to an unrecoverable protocol violation. */ + WALLET_TRANSACTION_PROTOCOL_VIOLATION(7047, 0, "A transaction could not be processed due to an unrecoverable protocol violation."), + + /** A parameter in the request is malformed or missing. */ + WALLET_CORE_API_BAD_REQUEST(7048, 0, "A parameter in the request is malformed or missing."), + /** We encountered a timeout with our payment backend. */ ANASTASIS_GENERIC_BACKEND_TIMEOUT(8000, 504, "We encountered a timeout with our payment backend."), @@ -1989,6 +2010,9 @@ enum class TalerErrorCode(val code: Int, val status: Int, val description: Strin /** The token cannot be valid as no address was ever provided by the client. */ CHALLENGER_MISSING_ADDRESS(9759, 409, "The token cannot be valid as no address was ever provided by the client."), + /** The client is not allowed to change the address being validated. */ + CHALLENGER_CLIENT_FORBIDDEN_READ_ONLY(9760, 403, "The client is not allowed to change the address being validated."), + /** End of error code range. */ END(9999, 0, "End of error code range."), diff --git a/common/src/main/kotlin/api/server.kt b/common/src/main/kotlin/api/server.kt @@ -184,7 +184,7 @@ fun Application.talerApi(logger: Logger, routes: Routing.() -> Unit) { ) } exception<Exception> { call, cause -> - logger.debug("failure", cause) + logger.debug("", cause) when (cause) { is ApiException -> call.err(cause, null) is SQLException -> { diff --git a/common/src/main/kotlin/db/statement.kt b/common/src/main/kotlin/db/statement.kt @@ -62,8 +62,12 @@ class TalerStatement(internal val stmt: PreparedStatement): java.io.Closeable { idx+=1; } - fun bind(nb: Long) { - stmt.setLong(idx, nb) + fun bind(nb: Long?) { + if (nb != null) { + stmt.setLong(idx, nb) + } else { + stmt.setNull(idx, Types.INTEGER) + } idx+=1; } diff --git a/common/src/main/kotlin/db/transaction.kt b/common/src/main/kotlin/db/transaction.kt @@ -73,14 +73,12 @@ fun PgConnection.dynamicUpdate( table: String, fields: Sequence<String>, filter: String, - bind: Sequence<Any?>, + bind: TalerStatement.() -> Unit, ) { val sql = fields.joinToString() if (sql.isEmpty()) return withStatement("UPDATE $table SET $sql $filter") { - for ((idx, value) in bind.withIndex()) { - stmt.setObject(idx + 1, value) - } - stmt.executeUpdate() + bind() + executeUpdate() } } \ No newline at end of file diff --git a/common/src/main/kotlin/db/types.kt b/common/src/main/kotlin/db/types.kt @@ -32,6 +32,14 @@ fun optAmount(amount: TalerAmount?): String { } } +fun optDecimal(nb: DecimalNumber?): String { + if (nb != null) { + return "(?,?)::taler_amount" + } else { + return "NULL" + } +} + inline fun <reified T : Enum<T>> ResultSet.getEnum(name: String): T = java.lang.Enum.valueOf(T::class.java, getString(name)) inline fun <reified T : Enum<T>> ResultSet.getEnum(idx: Int): T @@ -40,6 +48,12 @@ inline fun <reified T : Enum<T>> ResultSet.getEnum(idx: Int): T inline fun <reified T : Enum<T>> ResultSet.getOptEnum(name: String): T? = getString(name)?.run { java.lang.Enum.valueOf(T::class.java, this) } +fun ResultSet.getOptLong(name: String): Long? { + val nb = getLong(name) + if (wasNull()) return null + return nb +} + fun ResultSet.getAmount(name: String, currency: String): TalerAmount { return TalerAmount( getLong("${name}_val"), @@ -61,6 +75,12 @@ fun ResultSet.getDecimal(name: String): DecimalNumber { ) } +fun ResultSet.getOptDecimal(name: String): DecimalNumber? { + val amount = getDecimal(name) + if (wasNull()) return null + return amount +} + fun ResultSet.getTalerTimestamp(name: String): TalerProtocolTimestamp{ return TalerProtocolTimestamp(getLong(name).asInstant()) } diff --git a/database-versioning/libeufin-bank-0013.sql b/database-versioning/libeufin-bank-0013.sql @@ -0,0 +1,102 @@ +-- +-- This file is part of TALER +-- Copyright (C) 2025 Taler Systems SA +-- +-- TALER is free software; you can redistribute it and/or modify it under the +-- terms of the GNU General Public License as published by the Free Software +-- Foundation; either version 3, or (at your option) any later version. +-- +-- TALER is distributed in the hope that it will be useful, but WITHOUT ANY +-- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +-- A PARTICULAR PURPOSE. See the GNU General Public License for more details. +-- +-- You should have received a copy of the GNU General Public License along with +-- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + +BEGIN; + +SELECT _v.register_patch('libeufin-bank-0013', NULL, NULL); + +SET search_path TO libeufin_bank; +-- Remove all existing functions +DO +$do$ +BEGIN + IF EXISTS (SELECT FROM config WHERE key LIKE 'cashin_%') THEN + INSERT INTO config (key, value) VALUES ('conversion_rate', jsonb_build_object( + 'cashin', jsonb_build_object( + 'ratio', jsonb_build_object( + 'val', (SELECT value->'val' FROM config WHERE key='cashin_ratio'), + 'frac', (SELECT value->'frac' FROM config WHERE key='cashin_ratio') + ), + 'fee', jsonb_build_object( + 'val', (SELECT value->'val' FROM config WHERE key='cashin_fee'), + 'frac', (SELECT value->'frac' FROM config WHERE key='cashin_fee') + ), + 'tiny_amount', jsonb_build_object( + 'val', (SELECT value->'val' FROM config WHERE key='cashin_tiny_amount'), + 'frac', (SELECT value->'frac' FROM config WHERE key='cashin_tiny_amount') + ), + 'min_amount', jsonb_build_object( + 'val', (SELECT value->'val' FROM config WHERE key='cashin_min_amount'), + 'frac', (SELECT value->'frac' FROM config WHERE key='cashin_min_amount') + ), + 'rounding_mode', (SELECT value->'mode' FROM config WHERE key='cashin_rounding_mode') + ), + 'cashout', jsonb_build_object( + 'ratio', jsonb_build_object( + 'val', (SELECT value->'val' FROM config WHERE key='cashout_ratio'), + 'frac', (SELECT value->'frac' FROM config WHERE key='cashout_ratio') + ), + 'fee', jsonb_build_object( + 'val', (SELECT value->'val' FROM config WHERE key='cashout_fee'), + 'frac', (SELECT value->'frac' FROM config WHERE key='cashout_fee') + ), + 'tiny_amount', jsonb_build_object( + 'val', (SELECT value->'val' FROM config WHERE key='cashout_tiny_amount'), + 'frac', (SELECT value->'frac' FROM config WHERE key='cashout_tiny_amount') + ), + 'min_amount', jsonb_build_object( + 'val', (SELECT value->'val' FROM config WHERE key='cashout_min_amount'), + 'frac', (SELECT value->'frac' FROM config WHERE key='cashout_min_amount') + ), + 'rounding_mode', (SELECT value->'mode' FROM config WHERE key='cashout_rounding_mode') + ) + ) + ); + DELETE FROM config WHERE key LIKE 'cashin_%' OR key LIKE 'cashout_%'; + END IF; +END +$do$; + +CREATE TABLE conversion_rate_classes + ( conversion_rate_class_id INT8 GENERATED BY DEFAULT AS IDENTITY UNIQUE + ,name TEXT NOT NULL UNIQUE + ,description TEXT + ,cashin_ratio taler_amount + ,cashin_fee taler_amount + ,cashin_min_amount taler_amount + ,cashin_rounding_mode rounding_mode + ,cashout_ratio taler_amount + ,cashout_fee taler_amount + ,cashout_min_amount taler_amount + ,cashout_rounding_mode rounding_mode + ); +COMMENT ON TABLE conversion_rate_classes IS 'TODO'; +ALTER TABLE bank_accounts ADD COLUMN conversion_rate_class_id INT4 REFERENCES conversion_rate_classes(conversion_rate_class_id); + +-- Migrate existing user config +INSERT INTO conversion_rate_classes(name, cashout_min_amount) +SELECT format('migrated min_cashout=%s.%s', (min_cashout).val, TRIM(TRAILING '0' FROM LPAD((min_cashout).frac::text, 8, '0'))), min_cashout +FROM bank_accounts +WHERE min_cashout IS NOT NULL +GROUP BY min_cashout; +UPDATE bank_accounts SET conversion_rate_class_id=( + SELECT conversion_rate_class_id FROM conversion_rate_classes WHERE cashout_min_amount=min_cashout +) WHERE min_cashout IS NOT NULL; +ALTER TABLE bank_accounts DROP COLUMN min_cashout; + +CREATE INDEX accounts_conversion_rate_class_id ON bank_accounts (conversion_rate_class_id); +COMMENT ON INDEX accounts_conversion_rate_class_id + IS 'link accounts to their conversion rate class'; +COMMIT; diff --git a/database-versioning/libeufin-bank-procedures.sql b/database-versioning/libeufin-bank-procedures.sql @@ -108,7 +108,7 @@ CREATE FUNCTION account_balance_is_sufficient( OUT out_balance_insufficient BOOLEAN, OUT out_bad_amount BOOLEAN ) -LANGUAGE plpgsql AS $$ +LANGUAGE plpgsql STABLE AS $$ DECLARE account_has_debt BOOLEAN; account_balance taler_amount; @@ -183,7 +183,7 @@ CREATE FUNCTION account_max_amount( IN in_max_amount taler_amount, OUT out_max_amount taler_amount ) -LANGUAGE plpgsql AS $$ +LANGUAGE plpgsql STABLE AS $$ BEGIN -- add balance and max_debt WITH computed AS ( @@ -1276,13 +1276,14 @@ DECLARE converted_amount taler_amount; admin_account_id INT8; exchange_account_id INT8; + exchange_conversion_rate_class_id INT8; tx_row_id INT8; BEGIN -- TODO check reserve_pub reuse ? -- Recover exchange account info -SELECT bank_account_id - INTO exchange_account_id +SELECT bank_account_id, conversion_rate_class_id + INTO exchange_account_id, exchange_conversion_rate_class_id FROM bank_accounts JOIN customers ON customer_id=owning_customer_id @@ -1303,7 +1304,7 @@ SELECT bank_account_id -- Perform conversion SELECT (converted).val, (converted).frac, too_small, no_config INTO converted_amount.val, converted_amount.frac, out_too_small, out_no_config - FROM conversion_to(in_amount, 'cashin'::text, null); + FROM conversion_to(in_amount, 'cashin'::text, exchange_conversion_rate_class_id); IF out_too_small OR out_no_config THEN RETURN; END IF; @@ -1362,19 +1363,17 @@ CREATE FUNCTION cashout_create( LANGUAGE plpgsql AS $$ DECLARE account_id INT8; +account_conversion_rate_class_id INT8; admin_account_id INT8; tx_id INT8; -custom_min_cashout taler_amount; BEGIN -- Check account exists, has all info and if 2FA is required SELECT - bank_account_id, is_taler_exchange, - (min_cashout).val, (min_cashout).frac, + bank_account_id, is_taler_exchange, conversion_rate_class_id, cashout_payto IS NULL, (NOT in_is_tan AND tan_channel IS NOT NULL) INTO - account_id, out_account_is_exchange, - custom_min_cashout.val, custom_min_cashout.frac, + account_id, out_account_is_exchange, account_conversion_rate_class_id, out_no_cashout_payto, out_tan_required FROM bank_accounts JOIN customers ON owning_customer_id=customer_id @@ -1387,12 +1386,9 @@ ELSIF out_account_is_exchange OR out_no_cashout_payto THEN END IF; -- check conversion -IF custom_min_cashout.val IS NULL THEN - custom_min_cashout = NULL; -END IF; SELECT under_min, too_small OR no_config OR in_amount_credit!=converted INTO out_under_min, out_bad_conversion - FROM conversion_to(in_amount_debit, 'cashout'::text, custom_min_cashout); + FROM conversion_to(in_amount_debit, 'cashout'::text, account_conversion_rate_class_id); IF out_bad_conversion THEN RETURN; END IF; @@ -1793,38 +1789,6 @@ BEGIN END IF; END $$; -CREATE PROCEDURE config_set_amount( - IN name TEXT, - IN amount taler_amount -) -LANGUAGE sql AS $$ - INSERT INTO config (key, value) VALUES (name, jsonb_build_object('val', amount.val, 'frac', amount.frac)) - ON CONFLICT (key) DO UPDATE SET value = excluded.value -$$; - -CREATE PROCEDURE config_set_rounding_mode( - IN name TEXT, - IN mode rounding_mode -) -LANGUAGE sql AS $$ - INSERT INTO config (key, value) VALUES (name, jsonb_build_object('mode', mode::text)) - ON CONFLICT (key) DO UPDATE SET value = excluded.value -$$; - -CREATE 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 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 FUNCTION conversion_apply_ratio( IN amount taler_amount ,IN ratio taler_amount @@ -1834,7 +1798,7 @@ CREATE FUNCTION conversion_apply_ratio( ,OUT result taler_amount ,OUT out_too_small BOOLEAN ) -LANGUAGE plpgsql AS $$ +LANGUAGE plpgsql IMMUTABLE AS $$ DECLARE amount_numeric NUMERIC(33, 8); -- 16 digit for val, 8 for frac and 1 for rounding error tiny_numeric NUMERIC(24); @@ -1874,11 +1838,11 @@ CREATE FUNCTION conversion_revert_ratio( ,IN fee taler_amount ,IN tiny taler_amount -- Result is rounded to this amount ,IN rounding rounding_mode -- With this rounding mode - ,IN frac_digits INT + ,IN reverse_tiny taler_amount ,OUT result taler_amount ,OUT bad_value BOOLEAN ) -LANGUAGE plpgsql AS $$ +LANGUAGE plpgsql IMMUTABLE AS $$ DECLARE amount_numeric NUMERIC(33, 8); -- 16 digit for val, 8 for frac and 1 for rounding error tiny_numeric NUMERIC(24); @@ -1891,7 +1855,7 @@ BEGIN amount_numeric = amount_numeric / (ratio.val::numeric(24, 8) + ratio.frac::numeric(24, 8) / 100000000); -- Round to input digits - tiny_numeric = power(10::numeric, 8 - frac_digits); + tiny_numeric = (reverse_tiny.val::numeric(24) * 100000000 + reverse_tiny.frac::numeric(24)); amount_numeric = trunc(amount_numeric / tiny_numeric) * tiny_numeric; -- Extract division parts @@ -1918,13 +1882,13 @@ COMMENT ON FUNCTION conversion_revert_ratio CREATE FUNCTION conversion_to( IN amount taler_amount, IN direction TEXT, - IN custom_min_amount taler_amount, + IN conversion_rate_class_id INT8, OUT converted taler_amount, OUT too_small BOOLEAN, OUT under_min BOOLEAN, OUT no_config BOOLEAN ) -LANGUAGE plpgsql AS $$ +LANGUAGE plpgsql STABLE AS $$ DECLARE at_ratio taler_amount; out_fee taler_amount; @@ -1932,16 +1896,40 @@ DECLARE min_amount taler_amount; 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=direction||'_min_amount'; - IF NOT FOUND THEN - no_config = true; - RETURN; - END IF; - IF custom_min_amount IS NOT NULL THEN - min_amount = custom_min_amount; + -- Load rate + IF direction='cashin' THEN + SELECT + (cashin_ratio).val, (cashin_ratio).frac, + (cashin_fee).val, (cashin_fee).frac, + (cashin_tiny_amount).val, (cashin_tiny_amount).frac, + (cashin_min_amount).val, (cashin_min_amount).frac, + cashin_rounding_mode + INTO + at_ratio.val, at_ratio.frac, + out_fee.val, out_fee.frac, + tiny_amount.val, tiny_amount.frac, + min_amount.val, min_amount.frac, + mode + FROM get_conversion_class_rate(conversion_rate_class_id); + ELSE + SELECT + (cashout_ratio).val, (cashout_ratio).frac, + (cashout_fee).val, (cashout_fee).frac, + (cashout_tiny_amount).val, (cashout_tiny_amount).frac, + (cashout_min_amount).val, (cashout_min_amount).frac, + cashout_rounding_mode + INTO + at_ratio.val, at_ratio.frac, + out_fee.val, out_fee.frac, + tiny_amount.val, tiny_amount.frac, + min_amount.val, min_amount.frac, + mode + FROM get_conversion_class_rate(conversion_rate_class_id); END IF; - + no_config = NOT FOUND; + IF no_config THEN RETURN; END IF; + + -- Check min amount SELECT NOT ok INTO too_small FROM amount_left_minus_right(amount, min_amount); IF too_small THEN under_min = true; @@ -1950,11 +1938,6 @@ BEGIN END IF; -- 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, out_too_small INTO converted.val, converted.frac, too_small FROM conversion_apply_ratio(amount, at_ratio, out_fee, tiny_amount, mode); END $$; @@ -1962,38 +1945,63 @@ END $$; CREATE FUNCTION conversion_from( IN amount taler_amount, IN direction TEXT, - IN custom_min_amount taler_amount, - IN frac_digits INT, + IN conversion_rate_class_id INT8, OUT converted taler_amount, OUT too_small BOOLEAN, OUT under_min BOOLEAN, OUT no_config BOOLEAN ) -LANGUAGE plpgsql AS $$ +LANGUAGE plpgsql STABLE AS $$ DECLARE at_ratio taler_amount; out_fee taler_amount; tiny_amount taler_amount; + reverse_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'; - IF NOT FOUND THEN - no_config = true; - RETURN; + -- Load rate + IF direction='cashin' THEN + SELECT + (cashin_ratio).val, (cashin_ratio).frac, + (cashin_fee).val, (cashin_fee).frac, + (cashin_tiny_amount).val, (cashin_tiny_amount).frac, + (cashout_tiny_amount).val, (cashout_tiny_amount).frac, + (cashin_min_amount).val, (cashin_min_amount).frac, + cashin_rounding_mode + INTO + at_ratio.val, at_ratio.frac, + out_fee.val, out_fee.frac, + tiny_amount.val, tiny_amount.frac, + reverse_tiny_amount.val, reverse_tiny_amount.frac, + min_amount.val, min_amount.frac, + mode + FROM get_conversion_class_rate(conversion_rate_class_id); + ELSE + SELECT + (cashout_ratio).val, (cashout_ratio).frac, + (cashout_fee).val, (cashout_fee).frac, + (cashout_tiny_amount).val, (cashout_tiny_amount).frac, + (cashin_tiny_amount).val, (cashin_tiny_amount).frac, + (cashout_min_amount).val, (cashout_min_amount).frac, + cashout_rounding_mode + INTO + at_ratio.val, at_ratio.frac, + out_fee.val, out_fee.frac, + tiny_amount.val, tiny_amount.frac, + reverse_tiny_amount.val, reverse_tiny_amount.frac, + min_amount.val, min_amount.frac, + mode + FROM get_conversion_class_rate(conversion_rate_class_id); END IF; + no_config = NOT FOUND; + IF no_config THEN RETURN; END IF; + + -- Perform conversion SELECT (result).val, (result).frac INTO converted.val, converted.frac - FROM conversion_revert_ratio(amount, at_ratio, out_fee, tiny_amount, mode, frac_digits); + FROM conversion_revert_ratio(amount, at_ratio, out_fee, tiny_amount, mode, reverse_tiny_amount); -- Check min amount - SELECT value['val']::int8, value['frac']::int4 INTO min_amount.val, min_amount.frac FROM config WHERE key=direction||'_min_amount'; - IF custom_min_amount IS NOT NULL THEN - min_amount = custom_min_amount; - END IF; SELECT NOT ok INTO too_small FROM amount_left_minus_right(converted, min_amount); IF too_small THEN under_min = true; @@ -2001,4 +2009,95 @@ BEGIN END IF; END $$; +CREATE FUNCTION config_get_conversion_rate() +RETURNS TABLE ( + cashin_ratio taler_amount, + cashin_fee taler_amount, + cashin_tiny_amount taler_amount, + cashin_min_amount taler_amount, + cashin_rounding_mode rounding_mode, + cashout_ratio taler_amount, + cashout_fee taler_amount, + cashout_tiny_amount taler_amount, + cashout_min_amount taler_amount, + cashout_rounding_mode rounding_mode +) +LANGUAGE sql STABLE AS $$ + SELECT + (value->'cashin'->'ratio'->'val', value->'cashin'->'ratio'->'frac')::taler_amount, + (value->'cashin'->'fee'->'val', value->'cashin'->'fee'->'frac')::taler_amount, + (value->'cashin'->'tiny_amount'->'val', value->'cashin'->'tiny_amount'->'frac')::taler_amount, + (value->'cashin'->'min_amount'->'val', value->'cashin'->'min_amount'->'frac')::taler_amount, + (value->'cashin'->>'rounding_mode')::rounding_mode, + (value->'cashout'->'ratio'->'val', value->'cashout'->'ratio'->'frac')::taler_amount, + (value->'cashout'->'fee'->'val', value->'cashout'->'fee'->'frac')::taler_amount, + (value->'cashout'->'tiny_amount'->'val', value->'cashout'->'tiny_amount'->'frac')::taler_amount, + (value->'cashout'->'min_amount'->'val', value->'cashout'->'min_amount'->'frac')::taler_amount, + (value->'cashout'->>'rounding_mode')::rounding_mode + FROM config WHERE key='conversion_rate' +$$; + +CREATE FUNCTION get_conversion_class_rate( + IN in_conversion_rate_class_id INT8 +) +RETURNS TABLE ( + cashin_ratio taler_amount, + cashin_fee taler_amount, + cashin_tiny_amount taler_amount, + cashin_min_amount taler_amount, + cashin_rounding_mode rounding_mode, + cashout_ratio taler_amount, + cashout_fee taler_amount, + cashout_tiny_amount taler_amount, + cashout_min_amount taler_amount, + cashout_rounding_mode rounding_mode +) +LANGUAGE sql STABLE AS $$ + SELECT + COALESCE(class.cashin_ratio, cfg.cashin_ratio), + COALESCE(class.cashin_fee, cfg.cashin_fee), + cashin_tiny_amount, + COALESCE(class.cashin_min_amount, cfg.cashin_min_amount), + COALESCE(class.cashin_rounding_mode, cfg.cashin_rounding_mode), + COALESCE(class.cashout_ratio, cfg.cashout_ratio), + COALESCE(class.cashout_fee, cfg.cashout_fee), + cashout_tiny_amount, + COALESCE(class.cashout_min_amount, cfg.cashout_min_amount), + COALESCE(class.cashout_rounding_mode, cfg.cashout_rounding_mode) + FROM config_get_conversion_rate() as cfg + LEFT JOIN conversion_rate_classes as class + ON (conversion_rate_class_id=in_conversion_rate_class_id) +$$; + +CREATE PROCEDURE config_set_conversion_rate( + IN cashin_ratio taler_amount, + IN cashin_fee taler_amount, + IN cashin_tiny_amount taler_amount, + IN cashin_min_amount taler_amount, + IN cashin_rounding_mode rounding_mode, + IN cashout_ratio taler_amount, + IN cashout_fee taler_amount, + IN cashout_tiny_amount taler_amount, + IN cashout_min_amount taler_amount, + IN cashout_rounding_mode rounding_mode +) +LANGUAGE sql AS $$ + INSERT INTO config (key, value) VALUES ('conversion_rate', jsonb_build_object( + 'cashin', jsonb_build_object( + 'ratio', jsonb_build_object('val', cashin_ratio.val, 'frac', cashin_ratio.frac), + 'fee', jsonb_build_object('val', cashin_fee.val, 'frac', cashin_fee.frac), + 'tiny_amount', jsonb_build_object('val', cashin_tiny_amount.val, 'frac', cashin_tiny_amount.frac), + 'min_amount', jsonb_build_object('val', cashin_min_amount.val, 'frac', cashin_min_amount.frac), + 'rounding_mode', cashin_rounding_mode + ), + 'cashout', jsonb_build_object( + 'ratio', jsonb_build_object('val', cashout_ratio.val, 'frac', cashout_ratio.frac), + 'fee', jsonb_build_object('val', cashout_fee.val, 'frac', cashout_fee.frac), + 'tiny_amount', jsonb_build_object('val', cashout_tiny_amount.val, 'frac', cashout_tiny_amount.frac), + 'min_amount', jsonb_build_object('val', cashout_min_amount.val, 'frac', cashout_min_amount.frac), + 'rounding_mode', cashout_rounding_mode + ) + )) ON CONFLICT (key) DO UPDATE SET value = excluded.value +$$; + COMMIT; \ No newline at end of file diff --git a/testbench/src/main/kotlin/Main.kt b/testbench/src/main/kotlin/Main.kt @@ -110,11 +110,11 @@ class Cli : CliktCommand() { LIBEUFIN_NEXUS_HOME = test/$platform [nexus-fetch] - FREQUENCY = 5m + FREQUENCY = 1h CHECKPOINT_TIME_OF_DAY = 16:52 [nexus-submit] - FREQUENCY = 5m + FREQUENCY = 1h [libeufin-nexusdb-postgres] CONFIG = postgres:///libeufintestbench diff --git a/testbench/src/test/kotlin/MigrationTest.kt b/testbench/src/test/kotlin/MigrationTest.kt @@ -19,11 +19,11 @@ import kotlinx.coroutines.runBlocking import org.junit.Test -import tech.libeufin.common.db.pgConnection -import tech.libeufin.common.db.pgDataSource +import tech.libeufin.common.db.* import kotlin.io.path.Path import kotlin.io.path.readText import java.util.UUID +import kotlin.test.assertTrue class MigrationTest { @Test @@ -38,13 +38,19 @@ class MigrationTest { conn.execSQLUpdate(Path("../database-versioning/libeufin-bank-0001.sql").readText()) conn.execSQLUpdate(""" INSERT INTO customers (login, password_hash) VALUES - ('account_0', 'fack_hash'), ('account_1', 'fack_hash'); + ('account_1', 'fack_hash'), + ('account_2', 'fack_hash'), + ('account_3', 'fack_hash'), + ('account_4', 'fack_hash'); INSERT INTO bank_accounts (internal_payto_uri, owning_customer_id) VALUES - ('payto_0', 1), ('payto_1', 2); + ('payto_1', 1), + ('payto_2', 2), + ('payto_3', 3), + ('payto_4', 4); INSERT INTO bank_account_transactions(creditor_payto_uri, creditor_name, debtor_payto_uri, debtor_name, subject, amount, transaction_date, direction, bank_account_id) VALUES - ('payto_0', 'account_0', 'payto_1', 'account_1', 'subject', (0, 0)::taler_amount, 42, 'credit'::direction_enum, 1), - ('payto_0', 'account_0', 'payto_1', 'account_1', 'subject', (0, 0)::taler_amount, 42, 'credit'::direction_enum, 1), - ('payto_0', 'account_0', 'payto_1', 'account_1', 'subject', (0, 0)::taler_amount, 42, 'credit'::direction_enum, 1); + ('payto_1', 'account_1', 'payto_2', 'account_2', 'subject', (0, 0)::taler_amount, 42, 'credit'::direction_enum, 1), + ('payto_1', 'account_1', 'payto_2', 'account_2', 'subject', (0, 0)::taler_amount, 42, 'credit'::direction_enum, 1), + ('payto_1', 'account_1', 'payto_2', 'account_2', 'subject', (0, 0)::taler_amount, 42, 'credit'::direction_enum, 1); INSERT INTO taler_exchange_incoming(reserve_pub, bank_transaction) VALUES ('\x6ca1ab1a76a484d7424064c51c49c1947405f42f7d185d052dbf6718d845ec6b'::bytea, 1), ('\xa605637a4852684e4957e6177f41311eacf8661a6a74b90178c487fe347b9918'::bytea, 2); @@ -68,6 +74,11 @@ class MigrationTest { // libeufin-bank-0004 conn.execSQLUpdate(Path("../database-versioning/libeufin-bank-0004.sql").readText()) + conn.execSQLUpdate(""" + UPDATE bank_accounts SET min_cashout=(0, 1) WHERE bank_account_id=2; + UPDATE bank_accounts SET min_cashout=(2, 300) WHERE bank_account_id IN (3, 4); + """) + // libeufin-bank-0005 conn.execSQLUpdate(Path("../database-versioning/libeufin-bank-0005.sql").readText()) @@ -92,6 +103,71 @@ class MigrationTest { // libeufin-bank-0012 conn.execSQLUpdate(Path("../database-versioning/libeufin-bank-0012.sql").readText()) + conn.execSQLUpdate(""" + INSERT INTO config(key, value) VALUES + ('cashin_ratio', '{"val": 1, "frac": 2}'::jsonb), + ('cashin_fee', '{"val": 3, "frac": 4}'::jsonb), + ('cashin_tiny_amount', '{"val": 5, "frac": 6}'::jsonb), + ('cashin_min_amount', '{"val": 7, "frac": 8}'::jsonb), + ('cashin_rounding_mode', '{"mode": "zero"}'::jsonb), + ('cashout_ratio', '{"val": 9, "frac": 10}'::jsonb), + ('cashout_fee', '{"val": 11, "frac": 12}'::jsonb), + ('cashout_tiny_amount', '{"val": 13, "frac": 14}'::jsonb), + ('cashout_min_amount', '{"val": 15, "frac": 16}'::jsonb), + ('cashout_rounding_mode', '{"mode": "nearest"}'::jsonb); + """) + + // libeufin-bank-0013 + conn.execSQLUpdate(Path("../database-versioning/libeufin-bank-0013.sql").readText()) + conn.withStatement( + """ + SELECT value='{ + "cashin": { + "fee": { + "val": 3, + "frac": 4 + }, + "ratio": { + "val": 1, + "frac": 2 + }, + "min_amount": { + "val": 7, + "frac": 8 + }, + "tiny_amount": { + "val": 5, + "frac": 6 + }, + "rounding_mode": "zero" + }, + "cashout": { + "fee": { + "val": 11, + "frac": 12 + }, + "ratio": { + "val": 9, + "frac": 10 + }, + "min_amount": { + "val": 15, + "frac": 16 + }, + "tiny_amount": { + "val": 13, + "frac": 14 + }, + "rounding_mode": "nearest" + } + }'::jsonb FROM libeufin_bank.config WHERE key='conversion_rate' + """ + ) { + one { + assertTrue(it.getBoolean(1)) + } + } + // libeufin-nexus-0001 conn.execSQLUpdate(Path("../database-versioning/libeufin-nexus-0001.sql").readText()) conn.execSQLUpdate("""