diff options
Diffstat (limited to 'bank/src')
20 files changed, 204 insertions, 376 deletions
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Config.kt b/bank/src/main/kotlin/tech/libeufin/bank/Config.kt index 9cdfcf0f..17fef641 100644 --- a/bank/src/main/kotlin/tech/libeufin/bank/Config.kt +++ b/bank/src/main/kotlin/tech/libeufin/bank/Config.kt @@ -68,11 +68,6 @@ data class ConversionRate ( val cashout_min_amount: TalerAmount, ) -sealed interface ServerConfig { - data class Unix(val path: String, val mode: Int): ServerConfig - data class Tcp(val addr: String, val port: Int): ServerConfig -} - fun talerConfig(configPath: Path?): TalerConfig = BANK_CONFIG_SOURCE.fromFile(configPath) fun TalerConfig.loadDbConfig(): DatabaseConfig { @@ -82,14 +77,6 @@ fun TalerConfig.loadDbConfig(): DatabaseConfig { ) } -fun TalerConfig.loadServerConfig(): ServerConfig { - return when (val method = requireString("libeufin-bank", "serve")) { - "tcp" -> ServerConfig.Tcp(lookupString("libeufin-bank", "address") ?: requireString("libeufin-bank", "bind_to"), requireNumber("libeufin-bank", "port")) - "unix" -> ServerConfig.Unix(requireString("libeufin-bank", "unixpath"), requireNumber("libeufin-bank", "unixpath_mode")) - else -> throw TalerConfigError.invalid("server method", "libeufin-bank", "serve", "expected 'tcp' or 'unix' got '$method'") - } -} - fun TalerConfig.loadBankConfig(): BankConfig { val regionalCurrency = requireString("libeufin-bank", "currency") var fiatCurrency: String? = null diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Constants.kt b/bank/src/main/kotlin/tech/libeufin/bank/Constants.kt index d91d779d..209ea700 100644 --- a/bank/src/main/kotlin/tech/libeufin/bank/Constants.kt +++ b/bank/src/main/kotlin/tech/libeufin/bank/Constants.kt @@ -37,7 +37,6 @@ val RESERVED_ACCOUNTS = setOf("admin", "bank") const val IBAN_ALLOCATION_RETRY_COUNTER: Int = 5 // API version -const val COREBANK_API_VERSION: String = "4:7:0" -const val CONVERSION_API_VERSION: String = "0:0:0" +const val COREBANK_API_VERSION: String = "4:8:0" +const val CONVERSION_API_VERSION: String = "0:1:0" const val INTEGRATION_API_VERSION: String = "2:0:2" -const val REVENUE_API_VERSION: String = "0:0:0"
\ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Main.kt b/bank/src/main/kotlin/tech/libeufin/bank/Main.kt index 8733b907..1f3ebbc8 100644 --- a/bank/src/main/kotlin/tech/libeufin/bank/Main.kt +++ b/bank/src/main/kotlin/tech/libeufin/bank/Main.kt @@ -62,8 +62,7 @@ import kotlin.io.path.exists import kotlin.io.path.readText private val logger: Logger = LoggerFactory.getLogger("libeufin-bank") -// Dirty local variable to stop the server in test TODO remove this ugly hack -var engine: ApplicationEngine? = null + /** @@ -117,7 +116,7 @@ class ServeBank : CliktCommand("Run libeufin-bank HTTP server", name = "serve") val cfg = talerConfig(common.config) val ctx = cfg.loadBankConfig() val dbCfg = cfg.loadDbConfig() - val serverCfg = cfg.loadServerConfig() + val serverCfg = cfg.loadServerConfig("libeufin-bank") Database(dbCfg, ctx.regionalCurrency, ctx.fiatCurrency).use { db -> if (ctx.allowConversion) { logger.info("Ensure exchange account exists") @@ -142,25 +141,9 @@ class ServeBank : CliktCommand("Run libeufin-bank HTTP server", name = "serve") db.conn { it.execSQLUpdate(sqlProcedures.readText()) } // Remove conversion info from the database ? } - - val env = applicationEngineEnvironment { - when (serverCfg) { - is ServerConfig.Tcp -> { - for (addr in InetAddress.getAllByName(serverCfg.addr)) { - connector { - port = serverCfg.port - host = addr.hostAddress - } - } - } - is ServerConfig.Unix -> - throw Exception("Can only serve libeufin-bank via TCP") - } - module { corebankWebApp(db, ctx) } + serve(serverCfg) { + corebankWebApp(db, ctx) } - val local = embeddedServer(Netty, env) - engine = local - local.start(wait = true) } } } @@ -216,7 +199,14 @@ class EditAccount : CliktCommand( private val tan_channel: String? by option(help = "which channel TAN challenges should be sent to") private val cashout_payto_uri: IbanPayto? by option(help = "Payto URI of a fiant account who receive cashout amount").convert { Payto.parse(it).expectIban() } 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)) + } + } + override fun run() = cliCmd(logger, common.log) { val cfg = talerConfig(common.config) val ctx = cfg.loadBankConfig() @@ -232,7 +222,12 @@ class EditAccount : CliktCommand( phone = if (phone == null) Option.None else Option.Some(if (phone != "") phone else null), ), cashout_payto_uri = Option.Some(cashout_payto_uri), - debit_threshold = debit_threshold + debit_threshold = debit_threshold, + min_cashout = when (val tmp = min_cashout) { + null -> Option.None + is Option.None -> Option.Some(null) + is Option.Some -> Option.Some(tmp.value) + } ) when (patchAccount(db, ctx, req, username, true, true)) { AccountPatchResult.Success -> @@ -244,6 +239,7 @@ class EditAccount : CliktCommand( AccountPatchResult.NonAdminName, AccountPatchResult.NonAdminCashout, AccountPatchResult.NonAdminDebtLimit, + AccountPatchResult.NonAdminMinCashout, is AccountPatchResult.TanRequired -> { // Unreachable as we edit account as admin } @@ -282,6 +278,10 @@ 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) } + } class CreateAccount : CliktCommand( @@ -312,7 +312,8 @@ class CreateAccount : CliktCommand( ), cashout_payto_uri = cashout_payto_uri, payto_uri = payto_uri, - debit_threshold = debit_threshold + debit_threshold = debit_threshold, + min_cashout = min_cashout ) } req?.let { diff --git a/bank/src/main/kotlin/tech/libeufin/bank/TalerCommon.kt b/bank/src/main/kotlin/tech/libeufin/bank/TalerCommon.kt index 5966cba9..57c3fda8 100644 --- a/bank/src/main/kotlin/tech/libeufin/bank/TalerCommon.kt +++ b/bank/src/main/kotlin/tech/libeufin/bank/TalerCommon.kt @@ -37,65 +37,6 @@ import java.time.Instant import java.time.temporal.ChronoUnit import java.util.concurrent.TimeUnit -@Serializable(with = DecimalNumber.Serializer::class) -class DecimalNumber { - val value: Long - val frac: Int - - constructor(value: Long, frac: Int) { - this.value = value - this.frac = frac - } - constructor(encoded: String) { - val match = PATTERN.matchEntire(encoded) ?: throw badRequest("Invalid decimal number format") - val (value, frac) = match.destructured - this.value = value.toLongOrNull() ?: throw badRequest("Invalid value") - if (this.value > TalerAmount.MAX_VALUE) throw badRequest("Value specified in decimal number is too large") - this.frac = if (frac.isEmpty()) { - 0 - } else { - var tmp = frac.toIntOrNull() ?: throw badRequest("Invalid fractional value") - repeat(8 - frac.length) { - tmp *= 10 - } - tmp - } - } - - override fun equals(other: Any?): Boolean { - return other is DecimalNumber && - other.value == this.value && - other.frac == this.frac - } - - override fun toString(): String { - if (frac == 0) { - return "$value" - } else { - return "$value.${frac.toString().padStart(8, '0')}" - .dropLastWhile { it == '0' } // Trim useless fractional trailing 0 - } - } - - internal object Serializer : KSerializer<DecimalNumber> { - override val descriptor: SerialDescriptor = - PrimitiveSerialDescriptor("DecimalNumber", PrimitiveKind.STRING) - - override fun serialize(encoder: Encoder, value: DecimalNumber) { - encoder.encodeString(value.toString()) - } - - override fun deserialize(decoder: Decoder): DecimalNumber { - return DecimalNumber(decoder.decodeString()) - } - } - - companion object { - private val PATTERN = Regex("([0-9]+)(?:\\.([0-9]{1,8}))?") - } -} - - /** * Internal representation of relative times. The * "forever" case is represented with Long.MAX_VALUE. diff --git a/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt b/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt index 2527bae6..cca56e57 100644 --- a/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt +++ b/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt @@ -182,6 +182,7 @@ 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, ) { init { @@ -209,6 +210,7 @@ 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, ) @@ -327,14 +329,6 @@ data class TalerIntegrationConfigResponse( val version: String = INTEGRATION_API_VERSION } -@Serializable -data class RevenueConfig( - val currency: String -) { - val name: String = "taler-revenue" - val version: String = REVENUE_API_VERSION -} - enum class CreditDebitInfo { credit, debit } @@ -355,6 +349,7 @@ 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 row_id: Long, @@ -378,6 +373,7 @@ data class AccountData( val balance: Balance, val payto_uri: String, 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, @@ -546,21 +542,6 @@ data class ConversionResponse( val amount_credit: TalerAmount, ) -@Serializable -data class RevenueIncomingHistory( - val incoming_transactions : List<RevenueIncomingBankTransaction>, - val credit_account: String -) - -@Serializable -data class RevenueIncomingBankTransaction( - val row_id: Long, - val date: TalerProtocolTimestamp, - val amount: TalerAmount, - val debit_account: String, - val subject: String -) - /** * Response to GET /public-accounts */ diff --git a/bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt index c659b0f0..d57c504d 100644 --- a/bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt +++ b/bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt @@ -126,7 +126,7 @@ private fun Routing.coreBankTokenApi(db: Database) { } call.respond( TokenSuccessResponse( - access_token = token.encoded(), + access_token = "$TOKEN_PREFIX$token", expiration = TalerProtocolTimestamp(t_s = expirationTimestamp) ) ) @@ -160,6 +160,12 @@ suspend fun createAccount( "only admin account can choose the debit limit", TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT ) + + if (req.min_cashout != null) + throw conflict( + "only admin account can choose the minimum cashout amount", + TalerErrorCode.BANK_NON_ADMIN_SET_MIN_CASHOUT + ) if (req.tan_channel != null) throw conflict( @@ -188,31 +194,33 @@ suspend fun createAccount( TalerErrorCode.END ) + suspend fun doDb(internalPayto: Payto) = db.account.create( + login = req.username, + name = req.name, + email = req.contact_data?.email?.get(), + phone = req.contact_data?.phone?.get(), + cashoutPayto = req.cashout_payto_uri, + password = req.password, + internalPayto = internalPayto, + isPublic = req.is_public, + isTalerExchange = req.is_taler_exchange, + maxDebt = req.debit_threshold ?: cfg.defaultDebtLimit, + bonus = if (!req.is_taler_exchange) cfg.registrationBonus + else TalerAmount(0, 0, cfg.regionalCurrency), + tanChannel = req.tan_channel, + checkPaytoIdempotent = req.payto_uri != null, + ctx = cfg.payto, + minCashout = req.min_cashout + ) + when (cfg.wireMethod) { WireMethod.IBAN -> { - if (req.payto_uri != null && !(req.payto_uri is IbanPayto)) - throw badRequest("Expected an IBAN payto uri") + req.payto_uri?.expectRequestIban() var retry = if (req.payto_uri == null) IBAN_ALLOCATION_RETRY_COUNTER else 0 while (true) { val internalPayto = req.payto_uri ?: IbanPayto.rand() as Payto - val res = db.account.create( - login = req.username, - name = req.name, - email = req.contact_data?.email?.get(), - phone = req.contact_data?.phone?.get(), - cashoutPayto = req.cashout_payto_uri, - password = req.password, - internalPayto = internalPayto, - isPublic = req.is_public, - isTalerExchange = req.is_taler_exchange, - maxDebt = req.debit_threshold ?: cfg.defaultDebtLimit, - bonus = if (!req.is_taler_exchange) cfg.registrationBonus - else TalerAmount(0, 0, cfg.regionalCurrency), - tanChannel = req.tan_channel, - checkPaytoIdempotent = req.payto_uri != null, - ctx = cfg.payto - ) + val res = doDb(internalPayto) // Retry with new IBAN if (res == AccountCreationResult.PayToReuse && retry > 0) { retry-- @@ -223,31 +231,13 @@ suspend fun createAccount( } WireMethod.X_TALER_BANK -> { if (req.payto_uri != null) { - if (!(req.payto_uri is XTalerBankPayto)) - throw badRequest("Expected an IBAN payto uri") - else if (req.payto_uri.username != req.username) - throw badRequest("Expected a payto uri for '${req.username}' got one for '${req.payto_uri.username}'") + val payto = req.payto_uri.expectRequestXTalerBank() + if (payto.username != req.username) + throw badRequest("Expected a payto uri for '${req.username}' got one for '${payto.username}'") } val internalPayto = XTalerBankPayto.forUsername(req.username) - - return db.account.create( - login = req.username, - name = req.name, - email = req.contact_data?.email?.get(), - phone = req.contact_data?.phone?.get(), - cashoutPayto = req.cashout_payto_uri, - password = req.password, - internalPayto = internalPayto, - isPublic = req.is_public, - isTalerExchange = req.is_taler_exchange, - maxDebt = req.debit_threshold ?: cfg.defaultDebtLimit, - bonus = if (!req.is_taler_exchange) cfg.registrationBonus - else TalerAmount(0, 0, cfg.regionalCurrency), - tanChannel = req.tan_channel, - checkPaytoIdempotent = req.payto_uri != null, - ctx = cfg.payto - ) + return doDb(internalPayto) } } } @@ -283,6 +273,7 @@ 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, @@ -367,6 +358,10 @@ private fun Routing.coreBankAccountsApi(db: Database, ctx: 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.MissingTanInfo -> throw conflict( "missing info for tan channel ${req.tan_channel.get()}", TalerErrorCode.BANK_MISSING_TAN_INFO @@ -595,6 +590,10 @@ private fun Routing.coreBankCashoutApi(db: Database, ctx: BankConfig) = conditio "Wrong currency conversion", TalerErrorCode.BANK_BAD_CONVERSION ) + CashoutCreationResult.UnderMin -> throw conflict( + "Amount of currency conversion it less than the minimum allowed", + TalerErrorCode.BANK_CONVERSION_AMOUNT_TO_SMALL + ) CashoutCreationResult.AccountIsExchange -> throw conflict( "Exchange account cannot perform cashout operation", TalerErrorCode.BANK_ACCOUNT_IS_EXCHANGE diff --git a/bank/src/main/kotlin/tech/libeufin/bank/auth/auth.kt b/bank/src/main/kotlin/tech/libeufin/bank/auth/auth.kt index c0ed1100..56bdba2d 100644 --- a/bank/src/main/kotlin/tech/libeufin/bank/auth/auth.kt +++ b/bank/src/main/kotlin/tech/libeufin/bank/auth/auth.kt @@ -28,6 +28,7 @@ import io.ktor.util.pipeline.* import tech.libeufin.bank.* import tech.libeufin.bank.db.Database import tech.libeufin.common.* +import tech.libeufin.common.api.* import tech.libeufin.common.crypto.PwCrypto import java.time.Instant @@ -37,6 +38,8 @@ private val AUTH_IS_ADMIN = AttributeKey<Boolean>("is_admin") /** Used to store used auth token */ private val AUTH_TOKEN = AttributeKey<ByteArray>("auth_token") +const val TOKEN_PREFIX = "secret-token:" + /** Get username of the request account */ val ApplicationCall.username: String get() = parameters.expect("USERNAME") /** Get username of the request account */ @@ -155,7 +158,7 @@ private suspend fun ApplicationCall.doTokenAuth( bearer: String, requiredScope: TokenScope, ): String { - if (!bearer.startsWith("secret-token:")) throw badRequest( + if (!bearer.startsWith(TOKEN_PREFIX)) throw badRequest( "Bearer token malformed", TalerErrorCode.GENERIC_HTTP_HEADERS_MALFORMED ) diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt index 036ec116..97ca874b 100644 --- a/bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt +++ b/bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt @@ -47,6 +47,7 @@ class AccountDAO(private val db: Database) { isPublic: Boolean, isTalerExchange: Boolean, maxDebt: TalerAmount, + minCashout: TalerAmount?, bonus: TalerAmount, tanChannel: TanChannel?, // Whether to check [internalPaytoUri] for idempotency @@ -70,7 +71,7 @@ class AccountDAO(private val db: Database) { ON customer_id=owning_customer_id WHERE login=? """).run { - // TODO check max debt + // TODO check max debt and min checkout ? setString(1, name) setString(2, email) setString(3, phone) @@ -141,7 +142,8 @@ class AccountDAO(private val db: Database) { ,is_public ,is_taler_exchange ,max_debt - ) VALUES (?, ?, ?, ?, (?, ?)::taler_amount) + ,min_cashout + ) VALUES (?, ?, ?, ?, (?, ?)::taler_amount, ${if (minCashout == null) "NULL" else "(?, ?)::taler_amount"}) """).run { setString(1, internalPayto.canonical) setLong(2, customerId) @@ -149,6 +151,10 @@ class AccountDAO(private val db: Database) { setBoolean(4, isTalerExchange) setLong(5, maxDebt.value) setInt(6, maxDebt.frac) + if (minCashout != null) { + setLong(7, minCashout.value) + setInt(8, minCashout.frac) + } if (!executeUpdateViolation()) { conn.rollback() return@transaction AccountCreationResult.PayToReuse @@ -223,6 +229,7 @@ class AccountDAO(private val db: Database) { data object NonAdminName: AccountPatchResult data object NonAdminCashout: AccountPatchResult data object NonAdminDebtLimit: AccountPatchResult + data object NonAdminMinCashout: AccountPatchResult data object MissingTanInfo: AccountPatchResult data class TanRequired(val channel: TanChannel?, val info: String?): AccountPatchResult data object Success: AccountPatchResult @@ -238,6 +245,7 @@ class AccountDAO(private val db: Database) { tan_channel: Option<TanChannel?>, isPublic: Boolean?, debtLimit: TalerAmount?, + minCashout: Option<TalerAmount?>, isAdmin: Boolean, is2fa: Boolean, faChannel: TanChannel?, @@ -248,6 +256,7 @@ class AccountDAO(private val db: Database) { val checkName = !isAdmin && !allowEditName && name != null val checkCashout = !isAdmin && !allowEditCashout && cashoutPayto.isSome() val checkDebtLimit = !isAdmin && debtLimit != null + val checkMinCashout = !isAdmin && minCashout.isSome() data class CurrentAccount( val id: Long, @@ -257,6 +266,7 @@ class AccountDAO(private val db: Database) { val name: String, val cashoutPayTo: String?, val debtLimit: TalerAmount, + val minCashout: TalerAmount? ) // Get user ID and current data @@ -265,6 +275,8 @@ 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 FROM customers JOIN bank_accounts ON customer_id=owning_customer_id @@ -280,6 +292,7 @@ class AccountDAO(private val db: Database) { name = it.getString("name"), cashoutPayTo = it.getString("cashout_payto"), debtLimit = it.getAmount("max_debt", db.bankCurrency), + minCashout = it.getOptAmount("min_cashout", db.bankCurrency), ) } ?: return@transaction AccountPatchResult.UnknownAccount } @@ -310,10 +323,11 @@ class AccountDAO(private val db: Database) { return@transaction AccountPatchResult.NonAdminCashout if (checkDebtLimit && debtLimit != curr.debtLimit) return@transaction AccountPatchResult.NonAdminDebtLimit + if (checkMinCashout && minCashout.get() != curr.minCashout) + return@transaction AccountPatchResult.NonAdminMinCashout if (patchChannel != null && newInfo == null) return@transaction AccountPatchResult.MissingTanInfo - // Tan channel verification if (!isAdmin) { // Check performed 2fa check @@ -340,11 +354,24 @@ class AccountDAO(private val db: Database) { 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) } ) @@ -464,6 +491,8 @@ 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 @@ -496,6 +525,7 @@ 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"), status = AccountStatus.valueOf(it.getString("status")) @@ -557,6 +587,8 @@ class AccountDAO(private val db: Database) { 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 ,is_public ,is_taler_exchange ,internal_payto_uri @@ -587,6 +619,7 @@ 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"), payto_uri = it.getBankPayto("internal_payto_uri", "name", ctx), diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/CashoutDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/CashoutDAO.kt index 799d466a..d8779613 100644 --- a/bank/src/main/kotlin/tech/libeufin/bank/db/CashoutDAO.kt +++ b/bank/src/main/kotlin/tech/libeufin/bank/db/CashoutDAO.kt @@ -29,6 +29,7 @@ class CashoutDAO(private val db: Database) { /** Result of cashout operation creation */ sealed interface CashoutCreationResult { data class Success(val id: Long): CashoutCreationResult + data object UnderMin: CashoutCreationResult data object BadConversion: CashoutCreationResult data object AccountNotFound: CashoutCreationResult data object AccountIsExchange: CashoutCreationResult @@ -57,7 +58,8 @@ class CashoutDAO(private val db: Database) { out_request_uid_reuse, out_no_cashout_payto, out_tan_required, - out_cashout_id + out_cashout_id, + out_under_min FROM cashout_create(?,?,(?,?)::taler_amount,(?,?)::taler_amount,?,?,?) """) stmt.setString(1, login) @@ -73,6 +75,7 @@ class CashoutDAO(private val db: Database) { when { !it.next() -> throw internalServerError("No result from DB procedure cashout_create") + it.getBoolean("out_under_min") -> CashoutCreationResult.UnderMin it.getBoolean("out_bad_conversion") -> CashoutCreationResult.BadConversion it.getBoolean("out_account_not_found") -> CashoutCreationResult.AccountNotFound it.getBoolean("out_account_is_exchange") -> CashoutCreationResult.AccountIsExchange diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/ConversionDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/ConversionDAO.kt index 04317b68..8afd4f7f 100644 --- a/bank/src/main/kotlin/tech/libeufin/bank/db/ConversionDAO.kt +++ b/bank/src/main/kotlin/tech/libeufin/bank/db/ConversionDAO.kt @@ -20,7 +20,6 @@ package tech.libeufin.bank.db import tech.libeufin.bank.ConversionRate -import tech.libeufin.bank.DecimalNumber import tech.libeufin.bank.RoundingMode import tech.libeufin.common.* import tech.libeufin.common.db.* @@ -110,7 +109,7 @@ class ConversionDAO(private val db: Database) { /** Perform [direction] conversion of [amount] using in-db [function] */ private suspend fun conversion(amount: TalerAmount, direction: String, function: String): ConversionResult = db.conn { conn -> - val stmt = conn.prepareStatement("SELECT too_small, no_config, (converted).val AS amount_val, (converted).frac AS amount_frac FROM $function((?, ?)::taler_amount, ?)") + val stmt = conn.prepareStatement("SELECT too_small, no_config, (converted).val AS amount_val, (converted).frac AS amount_frac FROM $function((?, ?)::taler_amount, ?, (0, 0)::taler_amount)") stmt.setLong(1, amount.value) stmt.setInt(2, amount.frac) stmt.setString(3, direction) diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/Database.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/Database.kt index 9efb821a..1236b1ad 100644 --- a/bank/src/main/kotlin/tech/libeufin/bank/db/Database.kt +++ b/bank/src/main/kotlin/tech/libeufin/bank/db/Database.kt @@ -96,13 +96,13 @@ class Database(dbConfig: DatabaseConfig, internal val bankCurrency: String, inte /** Listen for new bank transactions for [account] */ suspend fun <R> listenBank(account: Long, lambda: suspend (Flow<Long>) -> R): R = listen(bankTxFlows, account, lambda) - /** Listen for new taler outgoing transactions from [account] */ + /** Listen for new taler outgoing transactions from [exchange] */ suspend fun <R> listenOutgoing(exchange: Long, lambda: suspend (Flow<Long>) -> R): R = listen(outgoingTxFlows, exchange, lambda) - /** Listen for new taler incoming transactions to [account] */ + /** Listen for new taler incoming transactions to [exchange] */ suspend fun <R> listenIncoming(exchange: Long, lambda: suspend (Flow<Long>) -> R): R = listen(incomingTxFlows, exchange, lambda) - /** Listen for new taler outgoing transactions to [account] */ + /** Listen for new incoming transactions to [merchant] */ suspend fun <R> listenRevenue(merchant: Long, lambda: suspend (Flow<Long>) -> R): R = listen(revenueTxFlows, merchant, lambda) /** Listen for new withdrawal confirmations */ @@ -163,8 +163,4 @@ enum class AbortResult { Success, UnknownOperation, AlreadyConfirmed -} - -fun ResultSet.getTalerTimestamp(name: String): TalerProtocolTimestamp{ - return TalerProtocolTimestamp(getLong(name).asInstant()) }
\ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt b/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt index 094b7996..8c7bd21b 100644 --- a/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt +++ b/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt @@ -37,6 +37,7 @@ import tech.libeufin.bank.auth.username import tech.libeufin.bank.db.AccountDAO.AccountCreationResult import tech.libeufin.bank.db.Database import tech.libeufin.common.* +import tech.libeufin.common.api.* import java.util.* fun ApplicationCall.uuidPath(name: String): UUID { @@ -128,24 +129,11 @@ suspend fun createAdminAccount(db: Database, cfg: BankConfig, pw: String? = null phone = null, cashoutPayto = null, tanChannel = null, + minCashout = null, ctx = cfg.payto ) } -fun Route.intercept(callback: Route.() -> Unit, interceptor: suspend PipelineContext<Unit, ApplicationCall>.() -> Unit): Route { - val subRoute = createChild(object : RouteSelector() { - override fun evaluate(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation = - RouteSelectorEvaluation.Constant - }) - subRoute.intercept(ApplicationCallPipeline.Plugins) { - interceptor() - proceed() - } - - callback(subRoute) - return subRoute -} - fun Route.conditional(implemented: Boolean, callback: Route.() -> Unit): Route = intercept(callback) { if (!implemented) { diff --git a/bank/src/test/kotlin/AmountTest.kt b/bank/src/test/kotlin/AmountTest.kt index 9f7b14aa..a93a10ba 100644 --- a/bank/src/test/kotlin/AmountTest.kt +++ b/bank/src/test/kotlin/AmountTest.kt @@ -18,7 +18,6 @@ */ import org.junit.Test -import tech.libeufin.bank.DecimalNumber import tech.libeufin.bank.db.TransactionDAO.BankTransactionResult import tech.libeufin.bank.db.WithdrawalDAO.WithdrawalCreationResult import tech.libeufin.common.* diff --git a/bank/src/test/kotlin/ConversionApiTest.kt b/bank/src/test/kotlin/ConversionApiTest.kt index 0954d3fa..c4aa0096 100644 --- a/bank/src/test/kotlin/ConversionApiTest.kt +++ b/bank/src/test/kotlin/ConversionApiTest.kt @@ -45,7 +45,7 @@ class ConversionApiTest { } // Too small - client.get("/conversion-info/cashout-rate?amount_debit=KUDOS:0.08") + client.get("/conversion-info/cashout-rate?amount_debit=KUDOS:0.0008") .assertConflict(TalerErrorCode.BANK_BAD_CONVERSION) // No amount client.get("/conversion-info/cashout-rate") diff --git a/bank/src/test/kotlin/CoreBankApiTest.kt b/bank/src/test/kotlin/CoreBankApiTest.kt index 6e8af912..43a65985 100644 --- a/bank/src/test/kotlin/CoreBankApiTest.kt +++ b/bank/src/test/kotlin/CoreBankApiTest.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2023 Taler Systems S.A. + * Copyright (C) 2023-2024 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,6 +24,7 @@ import io.ktor.server.testing.* import kotlinx.serialization.json.JsonElement import org.junit.Test import tech.libeufin.bank.* +import tech.libeufin.bank.auth.* import tech.libeufin.common.* import java.time.Duration import java.time.Instant @@ -64,7 +65,7 @@ class CoreBankTokenApiTest { json { "scope" to "readonly" } }.assertOkJson<TokenSuccessResponse> { // Checking that the token lifetime defaulted to 24 hours. - val token = db.token.get(Base32Crockford.decode(it.access_token)) + val token = db.token.get(Base32Crockford.decode(it.access_token.removePrefix(TOKEN_PREFIX))) val lifeTime = Duration.between(token!!.creationTime, token.expirationTime) assertEquals(Duration.ofDays(1), lifeTime) } @@ -74,7 +75,7 @@ class CoreBankTokenApiTest { json { "scope" to "readonly" } }.assertOkJson<TokenSuccessResponse> { // Checking that the token lifetime defaulted to 24 hours. - val token = db.token.get(Base32Crockford.decode(it.access_token)) + val token = db.token.get(Base32Crockford.decode(it.access_token.removePrefix(TOKEN_PREFIX))) val lifeTime = Duration.between(token!!.creationTime, token.expirationTime) assertEquals(Duration.ofDays(1), lifeTime) } @@ -88,7 +89,7 @@ class CoreBankTokenApiTest { }.assertOkJson<TokenSuccessResponse> { val token = it.access_token client.post("/accounts/merchant/token") { - headers["Authorization"] = "Bearer secret-token:$token" + headers["Authorization"] = "Bearer $token" json { "scope" to "readonly" } }.assertOk() } @@ -142,11 +143,11 @@ class CoreBankTokenApiTest { }.assertOkJson<TokenSuccessResponse>().access_token // Check OK client.delete("/accounts/merchant/token") { - headers["Authorization"] = "Bearer secret-token:$token" + headers["Authorization"] = "Bearer $token" }.assertNoContent() // Check token no longer work client.delete("/accounts/merchant/token") { - headers["Authorization"] = "Bearer secret-token:$token" + headers["Authorization"] = "Bearer $token" }.assertUnauthorized() // Checking merchant can still be served by basic auth, after token deletion. @@ -226,11 +227,27 @@ class CoreBankAccountsApiTest { }.assertOk() } - // Check admin only tan_channel + // Check admin only min_cashout obj { "username" to "bat2" "password" to "password" "name" to "Bat" + "min_cashout" to "KUDOS:42" + }.let { req -> + client.post("/accounts") { + json(req) + }.assertConflict(TalerErrorCode.BANK_NON_ADMIN_SET_MIN_CASHOUT) + client.post("/accounts") { + json(req) + pwAuth("admin") + }.assertOk() + } + + // Check admin only tan_channel + obj { + "username" to "bat3" + "password" to "password" + "name" to "Bat" "contact_data" to obj { "phone" to "+456" } @@ -459,8 +476,6 @@ class CoreBankAccountsApiTest { }.assertNotFound(TalerErrorCode.BANK_UNKNOWN_ACCOUNT) } - - @Test fun softDelete() = bankSetup { db -> // Create all kind of operations @@ -501,7 +516,7 @@ class CoreBankAccountsApiTest { // Check account can no longer login client.delete("/accounts/customer/token") { - headers["Authorization"] = "Bearer secret-token:$token" + headers["Authorization"] = "Bearer $token" }.assertUnauthorized() client.getA("/accounts/customer/transactions/$tx_id").assertUnauthorized() client.getA("/accounts/customer/cashouts/$cashout_id").assertUnauthorized() @@ -600,6 +615,10 @@ class CoreBankAccountsApiTest { obj(req) { "debit_threshold" to "KUDOS:100" }, TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT ) + checkAdminOnly( + obj(req) { "min_cashout" to "KUDOS:100" }, + TalerErrorCode.BANK_NON_ADMIN_SET_MIN_CASHOUT + ) // Check currency client.patch("/accounts/merchant") { @@ -1319,6 +1338,36 @@ class CoreBankCashoutApiTest { } }.assertConflict(TalerErrorCode.BANK_BAD_CONVERSION) + // 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 + client.patch("/accounts/customer") { + pwAuth("admin") + json { + "min_cashout" to "KUDOS:10" + } + }.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.patch("/accounts/customer") { + pwAuth("admin") + json { + "min_cashout" to (null as String?) + } + }.assertNoContent() + // Check wrong currency client.postA("/accounts/customer/cashouts") { json(req) { diff --git a/bank/src/test/kotlin/RevenueApiTest.kt b/bank/src/test/kotlin/RevenueApiTest.kt index 6c694842..3c692f90 100644 --- a/bank/src/test/kotlin/RevenueApiTest.kt +++ b/bank/src/test/kotlin/RevenueApiTest.kt @@ -19,7 +19,6 @@ import io.ktor.http.* import org.junit.Test -import tech.libeufin.bank.RevenueIncomingHistory import tech.libeufin.common.* class RevenueApiTest { diff --git a/bank/src/test/kotlin/StatsTest.kt b/bank/src/test/kotlin/StatsTest.kt index f5ec3161..240c23a4 100644 --- a/bank/src/test/kotlin/StatsTest.kt +++ b/bank/src/test/kotlin/StatsTest.kt @@ -23,10 +23,8 @@ import tech.libeufin.bank.MonitorParams import tech.libeufin.bank.MonitorResponse import tech.libeufin.bank.MonitorWithConversion import tech.libeufin.bank.Timeframe -import tech.libeufin.common.ShortHashCode -import tech.libeufin.common.TalerAmount import tech.libeufin.common.db.executeQueryCheck -import tech.libeufin.common.micros +import tech.libeufin.common.* import java.time.Instant import java.time.LocalDateTime import java.time.ZoneOffset diff --git a/bank/src/test/kotlin/WireGatewayApiTest.kt b/bank/src/test/kotlin/WireGatewayApiTest.kt index 1c6c60ef..10482bdb 100644 --- a/bank/src/test/kotlin/WireGatewayApiTest.kt +++ b/bank/src/test/kotlin/WireGatewayApiTest.kt @@ -30,7 +30,7 @@ class WireGatewayApiTest { client.getA("/accounts/merchant/taler-wire-gateway/config").assertOk() } - // Testing the POST /transfer call from the TWG API. + // POST /accounts/{USERNAME}/taler-wire-gateway/transfer @Test fun transfer() = bankSetup { _ -> val valid_req = obj { @@ -121,9 +121,7 @@ class WireGatewayApiTest { }.assertBadRequest() } - /** - * Testing the /history/incoming call from the TWG API. - */ + // GET /accounts/{USERNAME}/taler-wire-gateway/history/incoming @Test fun historyIncoming() = bankSetup { // Give Foo reasonable debt allowance: @@ -159,10 +157,7 @@ class WireGatewayApiTest { ) } - - /** - * Testing the /history/outgoing call from the TWG API. - */ + // GET /accounts/{USERNAME}/taler-wire-gateway/history/outgoing @Test fun historyOutgoing() = bankSetup { setMaxDebt("exchange", "KUDOS:1000000") @@ -193,7 +188,7 @@ class WireGatewayApiTest { ) } - // Testing the /admin/add-incoming call from the TWG API. + // POST /accounts/{USERNAME}/taler-wire-gateway/admin/add-incoming @Test fun addIncoming() = bankSetup { _ -> val valid_req = obj { diff --git a/bank/src/test/kotlin/helpers.kt b/bank/src/test/kotlin/helpers.kt index 73240c15..0a253929 100644 --- a/bank/src/test/kotlin/helpers.kt +++ b/bank/src/test/kotlin/helpers.kt @@ -96,6 +96,7 @@ fun bankSetup( phone = null, cashoutPayto = null, tanChannel = null, + minCashout = null, ctx = cfg.payto )) assertIs<AccountCreationResult.Success>(db.account.create( @@ -112,6 +113,7 @@ fun bankSetup( phone = null, cashoutPayto = null, tanChannel = null, + minCashout = null, ctx = cfg.payto )) assertIs<AccountCreationResult.Success>(db.account.create( @@ -128,6 +130,7 @@ fun bankSetup( phone = null, cashoutPayto = null, tanChannel = null, + minCashout = null, ctx = cfg.payto )) // Create admin account @@ -343,15 +346,6 @@ suspend fun HttpResponse.assertChallenge( } } -suspend fun assertTime(min: Int, max: Int, lambda: suspend () -> Unit) { - val start = System.currentTimeMillis() - lambda() - val end = System.currentTimeMillis() - val time = end - start - assert(time >= min) { "Expected to last at least $min ms, lasted $time" } - assert(time <= max) { "Expected to last at most $max ms, lasted $time" } -} - fun assertException(msg: String, lambda: () -> Unit) { try { lambda() @@ -361,47 +355,6 @@ fun assertException(msg: String, lambda: () -> Unit) { } } -suspend inline fun <reified B> HttpResponse.assertHistoryIds(size: Int, ids: (B) -> List<Long>): B { - assertOk() - val body = json<B>() - val history = ids(body) - val params = PageParams.extract(call.request.url.parameters) - - // testing the size is like expected. - assertEquals(size, history.size, "bad history length: $history") - if (params.delta < 0) { - // testing that the first id is at most the 'start' query param. - assert(history[0] <= params.start) { "bad history start: $params $history" } - // testing that the id decreases. - if (history.size > 1) - assert(history.windowed(2).all { (a, b) -> a > b }) { "bad history order: $history" } - } else { - // testing that the first id is at least the 'start' query param. - assert(history[0] >= params.start) { "bad history start: $params $history" } - // testing that the id increases. - if (history.size > 1) - assert(history.windowed(2).all { (a, b) -> a < b }) { "bad history order: $history" } - } - - return body -} - -/* ----- Body helper ----- */ - -suspend inline fun <reified B> HttpResponse.assertOkJson(lambda: (B) -> Unit = {}): B { - assertOk() - val body = json<B>() - lambda(body) - return body -} - -suspend inline fun <reified B> HttpResponse.assertAcceptedJson(lambda: (B) -> Unit = {}): B { - assertAccepted() - val body = json<B>() - lambda(body) - return body -} - /* ----- Auth ----- */ /** Auto auth get request */ @@ -446,7 +399,6 @@ fun HttpRequestBuilder.pwAuth(username: String? = null) { val login = url.pathSegments[2] basicAuth("$login", "$login-password") } - } /* ----- Random data generation ----- */ diff --git a/bank/src/test/kotlin/routines.kt b/bank/src/test/kotlin/routines.kt index d49a3324..93e619ed 100644 --- a/bank/src/test/kotlin/routines.kt +++ b/bank/src/test/kotlin/routines.kt @@ -28,6 +28,7 @@ import kotlinx.serialization.json.JsonObject import tech.libeufin.bank.BankAccountCreateWithdrawalResponse import tech.libeufin.bank.WithdrawalStatus import tech.libeufin.common.* +import tech.libeufin.common.test.* import kotlin.test.assertEquals // Test endpoint is correctly authenticated @@ -40,6 +41,17 @@ suspend fun ApplicationTestBuilder.authRoutine( allowAdmin: Boolean = false ) { // No body when authentication must happen before parsing the body + + // No header + client.request(path) { + this.method = method + }.assertUnauthorized(TalerErrorCode.GENERIC_PARAMETER_MISSING) + + // Bad header + client.request(path) { + this.method = method + headers["Authorization"] = "WTF" + }.assertBadRequest(TalerErrorCode.GENERIC_HTTP_HEADERS_MALFORMED) // Unknown account client.request(path) { @@ -60,7 +72,7 @@ suspend fun ApplicationTestBuilder.authRoutine( }.assertUnauthorized() if (requireAdmin) { - // Not exchange account + // Not exchange account client.request(path) { this.method = method pwAuth("merchant") @@ -91,117 +103,11 @@ suspend inline fun <reified B> ApplicationTestBuilder.historyRoutine( polling: Boolean = true, auth: String? = null ) { - // Get history - val history: suspend (String) -> HttpResponse = { params: String -> + abstractHistoryRoutine(ids, registered, ignored, polling) { params: String -> client.get("$url?$params") { pwAuth(auth) } } - // Check history is following specs - val assertHistory: suspend HttpResponse.(Int) -> Unit = { size: Int -> - assertHistoryIds<B>(size, ids) - } - // Get latest registered id - val latestId: suspend () -> Long = { - history("delta=-1").assertOkJson<B>().run { ids(this)[0] } - } - - // Check error when no transactions - history("delta=7").assertNoContent() - - // Run interleaved registered and ignore transactions - val registered_iter = registered.iterator() - val ignored_iter = ignored.iterator() - while (registered_iter.hasNext() || ignored_iter.hasNext()) { - if (registered_iter.hasNext()) registered_iter.next()() - if (ignored_iter.hasNext()) ignored_iter.next()() - } - - - val nbRegistered = registered.size - val nbIgnored = ignored.size - val nbTotal = nbRegistered + nbIgnored - - // Check ignored - history("delta=$nbTotal").assertHistory(nbRegistered) - // Check skip ignored - history("delta=$nbRegistered").assertHistory(nbRegistered) - - if (polling) { - // Check no polling when we cannot have more transactions - assertTime(0, 100) { - history("delta=-${nbRegistered+1}&long_poll_ms=1000") - .assertHistory(nbRegistered) - } - // Check no polling when already find transactions even if less than delta - assertTime(0, 100) { - history("delta=${nbRegistered+1}&long_poll_ms=1000") - .assertHistory(nbRegistered) - } - - // Check polling - coroutineScope { - val id = latestId() - launch { // Check polling succeed - assertTime(100, 200) { - history("delta=2&start=$id&long_poll_ms=1000") - .assertHistory(1) - } - } - launch { // Check polling timeout - assertTime(200, 300) { - history("delta=1&start=${id+nbTotal*3}&long_poll_ms=200") - .assertNoContent() - } - } - delay(100) - registered[0]() - } - - // Test triggers - for (register in registered) { - coroutineScope { - val id = latestId() - launch { - assertTime(100, 200) { - history("delta=7&start=$id&long_poll_ms=1000") - .assertHistory(1) - } - } - delay(100) - register() - } - } - - // Test doesn't trigger - coroutineScope { - val id = latestId() - launch { - assertTime(200, 300) { - history("delta=7&start=$id&long_poll_ms=200") - .assertNoContent() - } - } - delay(100) - for (ignore in ignored) { - ignore() - } - } - } - - // Testing ranges. - repeat(20) { - registered[0]() - } - val id = latestId() - // Default - history("").assertHistory(20) - // forward range: - history("delta=10").assertHistory(10) - history("delta=10&start=4").assertHistory(10) - // backward range: - history("delta=-10").assertHistory(10) - history("delta=-10&start=${id-4}").assertHistory(10) } suspend inline fun <reified B> ApplicationTestBuilder.statusRoutine( |