commit 807eb3fa7eb4a0d555646865a370f5f66cbaa4fc
parent 9af4fd9bcb338f20e75254f1aaad9ee7b1c0df47
Author: Antoine A <>
Date: Wed, 6 Mar 2024 10:42:20 +0100
Clean and refactor code
Diffstat:
17 files changed, 1379 insertions(+), 1362 deletions(-)
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/BankIntegrationApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/BankIntegrationApi.kt
@@ -1,106 +0,0 @@
-/*
- * This file is part of LibEuFin.
- * Copyright (C) 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
- * published by the Free Software Foundation; either version 3, or
- * (at your option) any later version.
-
- * LibEuFin 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 Affero General
- * Public License for more details.
-
- * You should have received a copy of the GNU Affero General Public
- * License along with LibEuFin; see the file COPYING. If not, see
- * <http://www.gnu.org/licenses/>
- */
-
-/* This file contains the Taler Integration API endpoints,
-* that are typically requested by wallets. */
-package tech.libeufin.bank
-
-import io.ktor.http.*
-import io.ktor.server.application.*
-import io.ktor.server.request.*
-import io.ktor.server.response.*
-import io.ktor.server.routing.*
-import tech.libeufin.bank.db.AbortResult
-import tech.libeufin.bank.db.Database
-import tech.libeufin.bank.db.WithdrawalDAO.WithdrawalSelectionResult
-import tech.libeufin.common.TalerErrorCode
-
-fun Routing.bankIntegrationApi(db: Database, ctx: BankConfig) {
- get("/taler-integration/config") {
- call.respond(TalerIntegrationConfigResponse(
- currency = ctx.regionalCurrency,
- currency_specification = ctx.regionalCurrencySpec
- ))
- }
-
- // Note: wopid acts as an authentication token.
- get("/taler-integration/withdrawal-operation/{wopid}") {
- val uuid = call.uuidParameter("wopid")
- val params = StatusParams.extract(call.request.queryParameters)
- val op = db.withdrawal.pollStatus(uuid, params, ctx.wireMethod) ?: throw notFound(
- "Withdrawal operation '$uuid' not found",
- TalerErrorCode.BANK_TRANSACTION_NOT_FOUND
- )
- call.respond(op.copy(
- suggested_exchange = ctx.suggestedWithdrawalExchange,
- confirm_transfer_url = if (op.status == WithdrawalStatus.pending || op.status == WithdrawalStatus.selected) call.request.withdrawConfirmUrl(uuid) else null
- ))
- }
- post("/taler-integration/withdrawal-operation/{wopid}") {
- val uuid = call.uuidParameter("wopid")
- val req = call.receive<BankWithdrawalOperationPostRequest>()
-
- val res = db.withdrawal.setDetails(
- uuid, req.selected_exchange, req.reserve_pub
- )
- when (res) {
- is WithdrawalSelectionResult.UnknownOperation -> throw notFound(
- "Withdrawal operation '$uuid' not found",
- TalerErrorCode.BANK_TRANSACTION_NOT_FOUND
- )
- is WithdrawalSelectionResult.AlreadySelected -> throw conflict(
- "Cannot select different exchange and reserve pub. under the same withdrawal operation",
- TalerErrorCode.BANK_WITHDRAWAL_OPERATION_RESERVE_SELECTION_CONFLICT
- )
- is WithdrawalSelectionResult.RequestPubReuse -> throw conflict(
- "Reserve pub. already used",
- TalerErrorCode.BANK_DUPLICATE_RESERVE_PUB_SUBJECT
- )
- is WithdrawalSelectionResult.UnknownAccount -> throw conflict(
- "Account ${req.selected_exchange} not found",
- TalerErrorCode.BANK_UNKNOWN_ACCOUNT
- )
- is WithdrawalSelectionResult.AccountIsNotExchange -> throw conflict(
- "Account ${req.selected_exchange} is not an exchange",
- TalerErrorCode.BANK_ACCOUNT_IS_NOT_EXCHANGE
- )
- is WithdrawalSelectionResult.Success -> {
- call.respond(BankWithdrawalOperationPostResponse(
- transfer_done = res.status == WithdrawalStatus.confirmed,
- status = res.status,
- confirm_transfer_url = if (res.status == WithdrawalStatus.pending || res.status == WithdrawalStatus.selected) call.request.withdrawConfirmUrl(uuid) else null
- ))
- }
- }
- }
- post("/taler-integration/withdrawal-operation/{wopid}/abort") {
- val uuid = call.uuidParameter("wopid")
- when (db.withdrawal.abort(uuid)) {
- AbortResult.UnknownOperation -> throw notFound(
- "Withdrawal operation '$uuid' not found",
- TalerErrorCode.BANK_TRANSACTION_NOT_FOUND
- )
- AbortResult.AlreadyConfirmed -> throw conflict(
- "Cannot abort confirmed withdrawal",
- TalerErrorCode.BANK_ABORT_CONFIRM_CONFLICT
- )
- AbortResult.Success -> call.respond(HttpStatusCode.NoContent)
- }
- }
-}
-\ No newline at end of file
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/ConversionApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/ConversionApi.kt
@@ -1,116 +0,0 @@
-/*
- * This file is part of LibEuFin.
- * Copyright (C) 2023 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
- * published by the Free Software Foundation; either version 3, or
- * (at your option) any later version.
-
- * LibEuFin 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 Affero General
- * Public License for more details.
-
- * You should have received a copy of the GNU Affero General Public
- * License along with LibEuFin; see the file COPYING. If not, see
- * <http://www.gnu.org/licenses/>
- */
-package tech.libeufin.bank
-
-import io.ktor.http.*
-import io.ktor.server.application.*
-import io.ktor.server.request.*
-import io.ktor.server.response.*
-import io.ktor.server.routing.*
-import tech.libeufin.bank.auth.authAdmin
-import tech.libeufin.bank.db.ConversionDAO
-import tech.libeufin.bank.db.ConversionDAO.ConversionResult
-import tech.libeufin.bank.db.Database
-import tech.libeufin.common.TalerAmount
-import tech.libeufin.common.TalerErrorCode
-
-fun Routing.conversionApi(db: Database, ctx: BankConfig) = conditional(ctx.allowConversion) {
- get("/conversion-info/config") {
- val config = db.conversion.getConfig(ctx.regionalCurrency, ctx.fiatCurrency!!)
- if (config == null) {
- throw libeufinError(
- HttpStatusCode.NotImplemented,
- "conversion rate not configured yet",
- TalerErrorCode.END
- )
- }
- call.respond(
- ConversionConfig(
- regional_currency = ctx.regionalCurrency,
- regional_currency_specification = ctx.regionalCurrencySpec,
- fiat_currency = ctx.fiatCurrency,
- fiat_currency_specification = ctx.fiatCurrencySpec!!,
- conversion_rate = config
- )
- )
- }
- suspend fun ApplicationCall.convert(
- input: TalerAmount,
- conversion: suspend ConversionDAO.(TalerAmount) -> ConversionResult,
- output: (TalerAmount) -> ConversionResponse
- ) {
- when (val res = db.conversion.(conversion)(input)) {
- is ConversionResult.Success -> respond(output(res.converted))
- is ConversionResult.ToSmall -> throw conflict(
- "$input is too small to be converted",
- TalerErrorCode.BANK_BAD_CONVERSION
- )
- is ConversionResult.MissingConfig -> throw libeufinError(
- HttpStatusCode.NotImplemented,
- "conversion rate not configured yet",
- TalerErrorCode.END
- )
- }
- }
- get("/conversion-info/cashout-rate") {
- val params = RateParams.extract(call.request.queryParameters)
-
- params.debit?.let { ctx.checkRegionalCurrency(it) }
- params.credit?.let { ctx.checkFiatCurrency(it) }
-
- if (params.debit != null) {
- call.convert(params.debit, ConversionDAO::toCashout) {
- ConversionResponse(params.debit, it)
- }
- } else {
- call.convert(params.credit!!, ConversionDAO::fromCashout) {
- ConversionResponse(it, params.credit)
- }
- }
- }
- get("/conversion-info/cashin-rate") {
- val params = RateParams.extract(call.request.queryParameters)
-
- params.debit?.let { ctx.checkFiatCurrency(it) }
- params.credit?.let { ctx.checkRegionalCurrency(it) }
-
- if (params.debit != null) {
- call.convert(params.debit, ConversionDAO::toCashin) {
- ConversionResponse(params.debit, it)
- }
- } else {
- call.convert(params.credit!!, ConversionDAO::fromCashin) {
- ConversionResponse(it, params.credit)
- }
- }
- }
- authAdmin(db, TokenScope.readwrite) {
- 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)
- }
- for (fiatAmount in sequenceOf(req.cashout_fee, req.cashout_tiny_amount, req.cashin_min_amount)) {
- ctx.checkFiatCurrency(fiatAmount)
- }
- db.conversion.updateConfig(req)
- call.respond(HttpStatusCode.NoContent)
- }
- }
-}
-\ No newline at end of file
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt
@@ -1,742 +0,0 @@
-/*
- * This file is part of LibEuFin.
- * 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
- * published by the Free Software Foundation; either version 3, or
- * (at your option) any later version.
-
- * LibEuFin 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 Affero General
- * Public License for more details.
-
- * You should have received a copy of the GNU Affero General Public
- * License along with LibEuFin; see the file COPYING. If not, see
- * <http://www.gnu.org/licenses/>
- */
-package tech.libeufin.bank
-
-import io.ktor.http.*
-import io.ktor.server.application.*
-import io.ktor.server.request.*
-import io.ktor.server.response.*
-import io.ktor.server.routing.*
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.future.await
-import kotlinx.coroutines.withContext
-import org.slf4j.Logger
-import org.slf4j.LoggerFactory
-import tech.libeufin.bank.auth.*
-import tech.libeufin.bank.db.AbortResult
-import tech.libeufin.bank.db.AccountDAO.*
-import tech.libeufin.bank.db.CashoutDAO.CashoutCreationResult
-import tech.libeufin.bank.db.Database
-import tech.libeufin.bank.db.TanDAO.TanSendResult
-import tech.libeufin.bank.db.TanDAO.TanSolveResult
-import tech.libeufin.bank.db.TransactionDAO.BankTransactionResult
-import tech.libeufin.bank.db.WithdrawalDAO.WithdrawalConfirmationResult
-import tech.libeufin.bank.db.WithdrawalDAO.WithdrawalCreationResult
-import tech.libeufin.common.*
-import java.time.Duration
-import java.time.Instant
-import java.time.temporal.ChronoUnit
-import java.util.*
-
-private val logger: Logger = LoggerFactory.getLogger("libeufin-bank-api")
-
-fun Routing.coreBankApi(db: Database, ctx: BankConfig) {
- get("/config") {
- call.respond(
- Config(
- bank_name = ctx.name,
- currency = ctx.regionalCurrency,
- currency_specification = ctx.regionalCurrencySpec,
- allow_conversion = ctx.allowConversion,
- allow_registrations = ctx.allowRegistration,
- allow_deletions = ctx.allowAccountDeletion,
- default_debit_threshold = ctx.defaultDebtLimit,
- supported_tan_channels = ctx.tanChannels.keys,
- allow_edit_name = ctx.allowEditName,
- allow_edit_cashout_payto_uri = ctx.allowEditCashout,
- wire_type = ctx.wireMethod
- )
- )
- }
- authAdmin(db, TokenScope.readonly) {
- get("/monitor") {
- val params = MonitorParams.extract(call.request.queryParameters)
- call.respond(db.monitor(params))
- }
- }
- coreBankTokenApi(db)
- coreBankAccountsApi(db, ctx)
- coreBankTransactionsApi(db, ctx)
- coreBankWithdrawalApi(db, ctx)
- coreBankCashoutApi(db, ctx)
- coreBankTanApi(db, ctx)
-}
-
-private fun Routing.coreBankTokenApi(db: Database) {
- val TOKEN_DEFAULT_DURATION: Duration = Duration.ofDays(1L)
- auth(db, TokenScope.refreshable) {
- post("/accounts/{USERNAME}/token") {
- val existingToken = call.authToken
- val req = call.receive<TokenRequest>()
-
- if (existingToken != null) {
- // This block checks permissions ONLY IF the call was authenticated with a token
- val refreshingToken = db.token.get(existingToken) ?: throw internalServerError(
- "Token used to auth not found in the database!"
- )
- if (refreshingToken.scope == TokenScope.readonly && req.scope == TokenScope.readwrite)
- throw forbidden(
- "Cannot generate RW token from RO",
- TalerErrorCode.GENERIC_TOKEN_PERMISSION_INSUFFICIENT
- )
- }
- val token = Base32Crockford32B.rand()
- val tokenDuration: Duration = req.duration?.d_us ?: TOKEN_DEFAULT_DURATION
-
- val creationTime = Instant.now()
- val expirationTimestamp =
- if (tokenDuration == ChronoUnit.FOREVER.duration) {
- logger.debug("Creating 'forever' token.")
- Instant.MAX
- } else {
- try {
- logger.debug("Creating token with days duration: ${tokenDuration.toDays()}")
- creationTime.plus(tokenDuration)
- } catch (e: Exception) {
- throw badRequest("Bad token duration: ${e.message}")
- }
- }
- if (!db.token.create(
- login = username,
- content = token.raw,
- creationTime = creationTime,
- expirationTime = expirationTimestamp,
- scope = req.scope,
- isRefreshable = req.refreshable
- )) {
- throw internalServerError("Failed at inserting new token in the database")
- }
- call.respond(
- TokenSuccessResponse(
- access_token = token.encoded(),
- expiration = TalerProtocolTimestamp(t_s = expirationTimestamp)
- )
- )
- }
- }
- auth(db, TokenScope.readonly) {
- delete("/accounts/{USERNAME}/token") {
- val token = call.authToken ?: throw badRequest("Basic auth not supported here.")
- db.token.delete(token)
- call.respond(HttpStatusCode.NoContent)
- }
- }
-}
-
-suspend fun createAccount(
- db: Database,
- cfg: BankConfig,
- req: RegisterAccountRequest,
- isAdmin: Boolean
-): Pair<AccountCreationResult, String> {
- // Prohibit reserved usernames:
- if (RESERVED_ACCOUNTS.contains(req.username))
- throw conflict(
- "Username '${req.username}' is reserved",
- TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT
- )
-
- if (!isAdmin) {
- if (req.debit_threshold != null)
- throw conflict(
- "only admin account can choose the debit limit",
- TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT
- )
-
- if (req.tan_channel != null)
- throw conflict(
- "only admin account can enable 2fa on creation",
- TalerErrorCode.BANK_NON_ADMIN_SET_TAN_CHANNEL
- )
-
- } else if (req.tan_channel != null) {
- if (cfg.tanChannels.get(req.tan_channel) == null) {
- throw unsupportedTanChannel(req.tan_channel)
- }
- val missing = when (req.tan_channel) {
- TanChannel.sms -> req.contact_data?.phone?.get() == null
- TanChannel.email -> req.contact_data?.email?.get() == null
- }
- if (missing)
- throw conflict(
- "missing info for tan channel ${req.tan_channel}",
- TalerErrorCode.BANK_MISSING_TAN_INFO
- )
- }
-
- if (req.username == "exchange" && !req.is_taler_exchange)
- throw conflict(
- "'exchange' account must be a taler exchange account",
- TalerErrorCode.END
- )
-
- when (cfg.wireMethod) {
- WireMethod.IBAN -> {
- if (req.payto_uri != null && !(req.payto_uri is IbanPayto))
- throw badRequest("Expected an IBAN payto uri")
- 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
- )
- // Retry with new IBAN
- if (res == AccountCreationResult.PayToReuse && retry > 0) {
- retry--
- continue
- }
- return Pair(res, internalPayto.bank(req.name, cfg.payto))
- }
- }
- 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 internalPayto = XTalerBankPayto.forUsername(req.username)
-
- 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
- )
- return Pair(res, internalPayto.bank(req.name, cfg.payto))
- }
- }
-}
-
-suspend fun patchAccount(
- db: Database,
- cfg: BankConfig,
- req: AccountReconfiguration,
- username: String,
- isAdmin: Boolean,
- is2fa: Boolean,
- channel: TanChannel? = null,
- info: String? = null
-): AccountPatchResult {
- req.debit_threshold?.run { cfg.checkRegionalCurrency(this) }
-
- if (username == "admin" && req.is_public == true)
- throw conflict(
- "'admin' account cannot be public",
- TalerErrorCode.END
- )
-
- if (req.tan_channel is Option.Some && req.tan_channel.value != null && !cfg.tanChannels.contains(req.tan_channel.value)) {
- throw unsupportedTanChannel(req.tan_channel.value)
- }
-
- return db.account.reconfig(
- login = username,
- name = req.name,
- cashoutPayto = req.cashout_payto_uri,
- email = req.contact_data?.email ?: Option.None,
- phone = req.contact_data?.phone ?: Option.None,
- tan_channel = req.tan_channel,
- isPublic = req.is_public,
- debtLimit = req.debit_threshold,
- isAdmin = isAdmin,
- is2fa = is2fa,
- faChannel = channel,
- faInfo = info,
- allowEditName = cfg.allowEditName,
- allowEditCashout = cfg.allowEditCashout
- )
-}
-
-private fun Routing.coreBankAccountsApi(db: Database, ctx: BankConfig) {
- authAdmin(db, TokenScope.readwrite, !ctx.allowRegistration) {
- post("/accounts") {
- val req = call.receive<RegisterAccountRequest>()
- val (result, internalPayto) = createAccount(db, ctx, req, isAdmin)
- when (result) {
- AccountCreationResult.BonusBalanceInsufficient -> throw conflict(
- "Insufficient admin funds to grant bonus",
- TalerErrorCode.BANK_UNALLOWED_DEBIT
- )
- AccountCreationResult.LoginReuse -> throw conflict(
- "Account username reuse '${req.username}'",
- TalerErrorCode.BANK_REGISTER_USERNAME_REUSE
- )
- AccountCreationResult.PayToReuse -> throw conflict(
- "Bank internalPayToUri reuse '$internalPayto'",
- TalerErrorCode.BANK_REGISTER_PAYTO_URI_REUSE
- )
- AccountCreationResult.Success -> call.respond(RegisterAccountResponse(internalPayto))
- }
- }
- }
- auth(
- db,
- TokenScope.readwrite,
- allowAdmin = true,
- requireAdmin = !ctx.allowAccountDeletion
- ) {
- delete("/accounts/{USERNAME}") {
- val challenge = call.checkChallenge(db, Operation.account_delete)
-
- // Not deleting reserved names.
- if (RESERVED_ACCOUNTS.contains(username))
- throw conflict(
- "Cannot delete reserved accounts",
- TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT
- )
- if (username == "exchange" && ctx.allowConversion)
- throw conflict(
- "Cannot delete 'exchange' accounts when conversion is enabled",
- TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT
- )
-
- when (db.account.delete(username, isAdmin || challenge != null)) {
- AccountDeletionResult.UnknownAccount -> throw unknownAccount(username)
- AccountDeletionResult.BalanceNotZero -> throw conflict(
- "Account balance is not zero.",
- TalerErrorCode.BANK_ACCOUNT_BALANCE_NOT_ZERO
- )
- AccountDeletionResult.TanRequired -> call.respondChallenge(db, Operation.account_delete, Unit)
- AccountDeletionResult.Success -> call.respond(HttpStatusCode.NoContent)
- }
- }
- }
- auth(db, TokenScope.readwrite, allowAdmin = true) {
- patch("/accounts/{USERNAME}") {
- val (req, challenge) = call.receiveChallenge<AccountReconfiguration>(db, Operation.account_reconfig)
- val res = patchAccount(db, ctx, req, username, isAdmin, challenge != null, challenge?.channel, challenge?.info)
- when (res) {
- AccountPatchResult.Success -> call.respond(HttpStatusCode.NoContent)
- is AccountPatchResult.TanRequired -> {
- call.respondChallenge(db, Operation.account_reconfig, req, res.channel, res.info)
- }
- AccountPatchResult.UnknownAccount -> throw unknownAccount(username)
- AccountPatchResult.NonAdminName -> throw conflict(
- "non-admin user cannot change their legal name",
- TalerErrorCode.BANK_NON_ADMIN_PATCH_LEGAL_NAME
- )
- AccountPatchResult.NonAdminCashout -> throw conflict(
- "non-admin user cannot change their cashout account",
- TalerErrorCode.BANK_NON_ADMIN_PATCH_CASHOUT
- )
- AccountPatchResult.NonAdminDebtLimit -> throw conflict(
- "non-admin user cannot change their debt limit",
- TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT
- )
- AccountPatchResult.MissingTanInfo -> throw conflict(
- "missing info for tan channel ${req.tan_channel.get()}",
- TalerErrorCode.BANK_MISSING_TAN_INFO
- )
- }
- }
- patch("/accounts/{USERNAME}/auth") {
- val (req, challenge) = call.receiveChallenge<AccountPasswordChange>(db, Operation.account_auth_reconfig)
-
- if (!isAdmin && req.old_password == null) {
- throw conflict(
- "non-admin user cannot change password without providing old password",
- TalerErrorCode.BANK_NON_ADMIN_PATCH_MISSING_OLD_PASSWORD
- )
- }
- when (db.account.reconfigPassword(username, req.new_password, req.old_password, isAdmin || challenge != null)) {
- AccountPatchAuthResult.Success -> call.respond(HttpStatusCode.NoContent)
- AccountPatchAuthResult.TanRequired -> call.respondChallenge(db, Operation.account_auth_reconfig, req)
- AccountPatchAuthResult.UnknownAccount -> throw unknownAccount(username)
- AccountPatchAuthResult.OldPasswordMismatch -> throw conflict(
- "old password does not match",
- TalerErrorCode.BANK_PATCH_BAD_OLD_PASSWORD
- )
- }
- }
- }
- get("/public-accounts") {
- val params = AccountParams.extract(call.request.queryParameters)
- val publicAccounts = db.account.pagePublic(params, ctx.payto)
- if (publicAccounts.isEmpty()) {
- call.respond(HttpStatusCode.NoContent)
- } else {
- call.respond(PublicAccountsResponse(publicAccounts))
- }
- }
- authAdmin(db, TokenScope.readonly) {
- get("/accounts") {
- val params = AccountParams.extract(call.request.queryParameters)
- val accounts = db.account.pageAdmin(params, ctx.payto)
- if (accounts.isEmpty()) {
- call.respond(HttpStatusCode.NoContent)
- } else {
- call.respond(ListBankAccountsResponse(accounts))
- }
- }
- }
- auth(db, TokenScope.readonly, allowAdmin = true) {
- get("/accounts/{USERNAME}") {
- val account = db.account.get(username, ctx.payto) ?: throw unknownAccount(username)
- call.respond(account)
- }
- }
-}
-
-private fun Routing.coreBankTransactionsApi(db: Database, ctx: BankConfig) {
- auth(db, TokenScope.readonly) {
- get("/accounts/{USERNAME}/transactions") {
- val params = HistoryParams.extract(call.request.queryParameters)
- val bankAccount = call.bankInfo(db, ctx.payto)
-
- val history: List<BankAccountTransactionInfo> =
- db.transaction.pollHistory(params, bankAccount.bankAccountId, ctx.payto)
- if (history.isEmpty()) {
- call.respond(HttpStatusCode.NoContent)
- } else {
- call.respond(BankAccountTransactionsResponse(history))
- }
- }
- get("/accounts/{USERNAME}/transactions/{T_ID}") {
- val tId = call.longParameter("T_ID")
- val tx = db.transaction.get(tId, username, ctx.payto) ?: throw notFound(
- "Bank transaction '$tId' not found",
- TalerErrorCode.BANK_TRANSACTION_NOT_FOUND
- )
- call.respond(tx)
- }
- }
- auth(db, TokenScope.readwrite) {
- post("/accounts/{USERNAME}/transactions") {
- val (req, challenge) = call.receiveChallenge<TransactionCreateRequest>(db, Operation.bank_transaction)
-
- val subject = req.payto_uri.message ?: throw badRequest("Wire transfer lacks subject")
- val amount = req.payto_uri.amount ?: req.amount ?: throw badRequest("Wire transfer lacks amount")
-
- ctx.checkRegionalCurrency(amount)
-
- val res = db.transaction.create(
- creditAccountPayto = req.payto_uri,
- debitAccountUsername = username,
- subject = subject,
- amount = amount,
- timestamp = Instant.now(),
- is2fa = challenge != null
- )
- when (res) {
- BankTransactionResult.UnknownDebtor -> throw unknownAccount(username)
- BankTransactionResult.TanRequired -> {
- call.respondChallenge(db, Operation.bank_transaction, req)
- }
- BankTransactionResult.BothPartySame -> throw conflict(
- "Wire transfer attempted with credit and debit party being the same bank account",
- TalerErrorCode.BANK_SAME_ACCOUNT
- )
- BankTransactionResult.UnknownCreditor -> throw unknownCreditorAccount(req.payto_uri.canonical)
- BankTransactionResult.AdminCreditor -> throw conflict(
- "Cannot transfer money to admin account",
- TalerErrorCode.BANK_ADMIN_CREDITOR
- )
- BankTransactionResult.BalanceInsufficient -> throw conflict(
- "Insufficient funds",
- TalerErrorCode.BANK_UNALLOWED_DEBIT
- )
- is BankTransactionResult.Success -> call.respond(TransactionCreateResponse(res.id))
- }
- }
- }
-}
-
-private fun Routing.coreBankWithdrawalApi(db: Database, ctx: BankConfig) {
- auth(db, TokenScope.readwrite) {
- post("/accounts/{USERNAME}/withdrawals") {
- val req = call.receive<BankAccountCreateWithdrawalRequest>()
- ctx.checkRegionalCurrency(req.amount)
- val opId = UUID.randomUUID()
- when (db.withdrawal.create(username, opId, req.amount, Instant.now())) {
- WithdrawalCreationResult.UnknownAccount -> throw unknownAccount(username)
- WithdrawalCreationResult.AccountIsExchange -> throw conflict(
- "Exchange account cannot perform withdrawal operation",
- TalerErrorCode.BANK_ACCOUNT_IS_EXCHANGE
- )
- WithdrawalCreationResult.BalanceInsufficient -> throw conflict(
- "Insufficient funds to withdraw with Taler",
- TalerErrorCode.BANK_UNALLOWED_DEBIT
- )
- WithdrawalCreationResult.Success -> {
- call.respond(
- BankAccountCreateWithdrawalResponse(
- withdrawal_id = opId.toString(),
- taler_withdraw_uri = call.request.talerWithdrawUri(opId)
- )
- )
- }
- }
- }
- post("/accounts/{USERNAME}/withdrawals/{withdrawal_id}/confirm") {
- val id = call.uuidParameter("withdrawal_id")
- val challenge = call.checkChallenge(db, Operation.withdrawal)
- when (db.withdrawal.confirm(username, id, Instant.now(), challenge != null)) {
- WithdrawalConfirmationResult.UnknownOperation -> throw notFound(
- "Withdrawal operation $id not found",
- TalerErrorCode.BANK_TRANSACTION_NOT_FOUND
- )
- WithdrawalConfirmationResult.AlreadyAborted -> throw conflict(
- "Cannot confirm an aborted withdrawal",
- TalerErrorCode.BANK_CONFIRM_ABORT_CONFLICT
- )
- WithdrawalConfirmationResult.NotSelected -> throw conflict(
- "Cannot confirm an unselected withdrawal",
- TalerErrorCode.BANK_CONFIRM_INCOMPLETE
- )
- WithdrawalConfirmationResult.BalanceInsufficient -> throw conflict(
- "Insufficient funds",
- TalerErrorCode.BANK_UNALLOWED_DEBIT
- )
- WithdrawalConfirmationResult.UnknownExchange -> throw conflict(
- "Exchange to withdraw from not found",
- TalerErrorCode.BANK_UNKNOWN_CREDITOR
- )
- WithdrawalConfirmationResult.TanRequired -> {
- call.respondChallenge(db, Operation.withdrawal, StoredUUID(id))
- }
- WithdrawalConfirmationResult.Success -> call.respond(HttpStatusCode.NoContent)
- }
- }
- post("/accounts/{USERNAME}/withdrawals/{withdrawal_id}/abort") {
- val opId = call.uuidParameter("withdrawal_id")
- when (db.withdrawal.abort(opId)) {
- AbortResult.UnknownOperation -> throw notFound(
- "Withdrawal operation $opId not found",
- TalerErrorCode.BANK_TRANSACTION_NOT_FOUND
- )
- AbortResult.AlreadyConfirmed -> throw conflict(
- "Cannot abort confirmed withdrawal",
- TalerErrorCode.BANK_ABORT_CONFIRM_CONFLICT
- )
- AbortResult.Success -> call.respond(HttpStatusCode.NoContent)
- }
- }
- }
- get("/withdrawals/{withdrawal_id}") {
- val uuid = call.uuidParameter("withdrawal_id")
- val params = StatusParams.extract(call.request.queryParameters)
- val op = db.withdrawal.pollInfo(uuid, params) ?: throw notFound(
- "Withdrawal operation '$uuid' not found",
- TalerErrorCode.BANK_TRANSACTION_NOT_FOUND
- )
- call.respond(op)
- }
-}
-
-private fun Routing.coreBankCashoutApi(db: Database, ctx: BankConfig) = conditional(ctx.allowConversion) {
- auth(db, TokenScope.readwrite) {
- post("/accounts/{USERNAME}/cashouts") {
- val (req, challenge) = call.receiveChallenge<CashoutRequest>(db, Operation.cashout)
-
- ctx.checkRegionalCurrency(req.amount_debit)
- ctx.checkFiatCurrency(req.amount_credit)
-
- val res = db.cashout.create(
- login = username,
- requestUid = req.request_uid,
- amountDebit = req.amount_debit,
- amountCredit = req.amount_credit,
- subject = req.subject ?: "", // TODO default subject
- now = Instant.now(),
- is2fa = challenge != null
- )
- when (res) {
- CashoutCreationResult.AccountNotFound -> throw unknownAccount(username)
- CashoutCreationResult.BadConversion -> throw conflict(
- "Wrong currency conversion",
- TalerErrorCode.BANK_BAD_CONVERSION
- )
- CashoutCreationResult.AccountIsExchange -> throw conflict(
- "Exchange account cannot perform cashout operation",
- TalerErrorCode.BANK_ACCOUNT_IS_EXCHANGE
- )
- CashoutCreationResult.BalanceInsufficient -> throw conflict(
- "Insufficient funds to withdraw with Taler",
- TalerErrorCode.BANK_UNALLOWED_DEBIT
- )
- CashoutCreationResult.RequestUidReuse -> throw conflict(
- "request_uid used already",
- TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED
- )
- CashoutCreationResult.NoCashoutPayto -> throw conflict(
- "Missing cashout payto uri",
- TalerErrorCode.BANK_CONFIRM_INCOMPLETE
- )
- CashoutCreationResult.TanRequired -> {
- call.respondChallenge(db, Operation.cashout, req)
- }
- is CashoutCreationResult.Success -> call.respond(CashoutResponse(res.id))
- }
- }
- }
- auth(db, TokenScope.readonly) {
- get("/accounts/{USERNAME}/cashouts/{CASHOUT_ID}") {
- val id = call.longParameter("CASHOUT_ID")
- val cashout = db.cashout.get(id, username) ?: throw notFound(
- "Cashout operation $id not found",
- TalerErrorCode.BANK_TRANSACTION_NOT_FOUND
- )
- call.respond(cashout)
- }
- get("/accounts/{USERNAME}/cashouts") {
- val params = PageParams.extract(call.request.queryParameters)
- val cashouts = db.cashout.pageForUser(params, username)
- if (cashouts.isEmpty()) {
- call.respond(HttpStatusCode.NoContent)
- } else {
- call.respond(Cashouts(cashouts))
- }
- }
- }
- authAdmin(db, TokenScope.readonly) {
- get("/cashouts") {
- val params = PageParams.extract(call.request.queryParameters)
- val cashouts = db.cashout.pageAll(params)
- if (cashouts.isEmpty()) {
- call.respond(HttpStatusCode.NoContent)
- } else {
- call.respond(GlobalCashouts(cashouts))
- }
- }
- }
-}
-
-private fun Routing.coreBankTanApi(db: Database, ctx: BankConfig) {
- auth(db, TokenScope.readwrite) {
- post("/accounts/{USERNAME}/challenge/{CHALLENGE_ID}") {
- val id = call.longParameter("CHALLENGE_ID")
- val res = db.tan.send(
- id = id,
- login = username,
- code = Tan.genCode(),
- now = Instant.now(),
- retryCounter = TAN_RETRY_COUNTER,
- validityPeriod = TAN_VALIDITY_PERIOD
- )
- when (res) {
- TanSendResult.NotFound -> throw notFound(
- "Challenge $id not found",
- TalerErrorCode.BANK_TRANSACTION_NOT_FOUND
- )
- is TanSendResult.Success -> {
- res.tanCode?.run {
- val (tanScript, tanEnv) = ctx.tanChannels.get(res.tanChannel)
- ?: throw unsupportedTanChannel(res.tanChannel)
- val msg = "T-${res.tanCode} is your ${ctx.name} verification code"
- val exitValue = withContext(Dispatchers.IO) {
- val builder = ProcessBuilder(tanScript.toString(), res.tanInfo)
- builder.redirectErrorStream(true)
- for ((name, value) in tanEnv) {
- builder.environment()[name] = value
- }
- val process = builder.start()
- try {
- process.outputWriter().use { it.write(msg) }
- process.onExit().await()
- } catch (e: Exception) {
- process.destroy()
- }
- val exitValue = process.exitValue()
- if (exitValue != 0) {
- val out = runCatching {
- process.getInputStream().use {
- reader().readText()
- }
- }.getOrDefault("")
- if (out.isNotEmpty()) {
- logger.error("TAN ${res.tanChannel} - ${tanScript}: $out")
- }
- }
- exitValue
- }
- if (exitValue != 0) {
- throw libeufinError(
- HttpStatusCode.BadGateway,
- "Tan channel script failure with exit value $exitValue",
- TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED
- )
- }
- db.tan.markSent(id, Instant.now(), TAN_RETRANSMISSION_PERIOD)
- }
- call.respond(TanTransmission(
- tan_info = res.tanInfo,
- tan_channel = res.tanChannel
- ))
- }
- }
- }
- post("/accounts/{USERNAME}/challenge/{CHALLENGE_ID}/confirm") {
- val id = call.longParameter("CHALLENGE_ID")
- val req = call.receive<ChallengeSolve>()
- val code = req.tan.removePrefix("T-")
- val res = db.tan.solve(
- id = id,
- login = username,
- code = code,
- now = Instant.now()
- )
- when (res) {
- TanSolveResult.NotFound -> throw notFound(
- "Challenge $id not found",
- TalerErrorCode.BANK_CHALLENGE_NOT_FOUND
- )
- TanSolveResult.BadCode -> throw conflict(
- "Incorrect TAN code",
- TalerErrorCode.BANK_TAN_CHALLENGE_FAILED
- )
- TanSolveResult.NoRetry -> throw libeufinError(
- HttpStatusCode.TooManyRequests,
- "Too many failed confirmation attempt",
- TalerErrorCode.BANK_TAN_RATE_LIMITED
- )
- TanSolveResult.Expired -> throw conflict(
- "Challenge expired",
- TalerErrorCode.BANK_TAN_CHALLENGE_EXPIRED
- )
- is TanSolveResult.Success -> call.respond(HttpStatusCode.NoContent)
- }
- }
- }
-}
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Main.kt b/bank/src/main/kotlin/tech/libeufin/bank/Main.kt
@@ -62,6 +62,7 @@ import java.util.zip.Inflater
import kotlin.io.path.Path
import kotlin.io.path.exists
import kotlin.io.path.readText
+import tech.libeufin.bank.api.*
private val logger: Logger = LoggerFactory.getLogger("libeufin-bank")
// Dirty local variable to stop the server in test TODO remove this ugly hack
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Params.kt b/bank/src/main/kotlin/tech/libeufin/bank/Params.kt
@@ -1,163 +0,0 @@
-/*
- * This file is part of LibEuFin.
- * Copyright (C) 2023 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
- * published by the Free Software Foundation; either version 3, or
- * (at your option) any later version.
-
- * LibEuFin 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 Affero General
- * Public License for more details.
-
- * You should have received a copy of the GNU Affero General Public
- * License along with LibEuFin; see the file COPYING. If not, see
- * <http://www.gnu.org/licenses/>
- */
-
-package tech.libeufin.bank
-
-import io.ktor.http.*
-import tech.libeufin.common.TalerAmount
-import tech.libeufin.common.TalerErrorCode
-import java.time.OffsetDateTime
-import java.time.ZoneOffset
-import java.time.temporal.TemporalAdjusters
-
-fun Parameters.expect(name: String): String
- = get(name) ?: throw badRequest("Missing '$name' parameter", TalerErrorCode.GENERIC_PARAMETER_MISSING)
-fun Parameters.int(name: String): Int?
- = get(name)?.run { toIntOrNull() ?: throw badRequest("Param 'which' not a number", TalerErrorCode.GENERIC_PARAMETER_MALFORMED) }
-fun Parameters.expectInt(name: String): Int
- = int(name) ?: throw badRequest("Missing '$name' number parameter", TalerErrorCode.GENERIC_PARAMETER_MISSING)
-fun Parameters.long(name: String): Long?
- = get(name)?.run { toLongOrNull() ?: throw badRequest("Param 'which' not a number", TalerErrorCode.GENERIC_PARAMETER_MALFORMED) }
-fun Parameters.expectLong(name: String): Long
- = long(name) ?: throw badRequest("Missing '$name' number parameter", TalerErrorCode.GENERIC_PARAMETER_MISSING)
-fun Parameters.amount(name: String): TalerAmount?
- = get(name)?.run {
- try {
- TalerAmount(this)
- } catch (e: Exception) {
- throw badRequest("Param '$name' not a taler amount", TalerErrorCode.GENERIC_PARAMETER_MALFORMED)
- }
- }
-
-data class MonitorParams(
- val timeframe: Timeframe,
- val which: Int?
-) {
- companion object {
- val names = Timeframe.entries.map { it.name }
- val names_fmt = names.joinToString()
- fun extract(params: Parameters): MonitorParams {
- val raw = params.get("timeframe") ?: "hour"
- if (!names.contains(raw)) {
- throw badRequest("Param 'timeframe' must be one of $names_fmt", TalerErrorCode.GENERIC_PARAMETER_MALFORMED)
- }
- val timeframe = Timeframe.valueOf(raw)
- val which = params.int("which")
- if (which != null) {
- val lastDayOfMonth = OffsetDateTime.now(ZoneOffset.UTC).with(TemporalAdjusters.lastDayOfMonth()).dayOfMonth
- when {
- timeframe == Timeframe.hour && (0 > which || which > 23) ->
- throw badRequest("For hour timestamp param 'which' must be between 00 to 23", TalerErrorCode.GENERIC_PARAMETER_MALFORMED)
- timeframe == Timeframe.day && (1 > which || which > lastDayOfMonth) ->
- throw badRequest("For day timestamp param 'which' must be between 1 to $lastDayOfMonth", TalerErrorCode.GENERIC_PARAMETER_MALFORMED)
- timeframe == Timeframe.month && (1 > which || which > 12) ->
- throw badRequest("For month timestamp param 'which' must be between 1 to 12", TalerErrorCode.GENERIC_PARAMETER_MALFORMED)
- timeframe == Timeframe.year && (1 > which|| which > 9999) ->
- throw badRequest("For year timestamp param 'which' must be between 0001 to 9999", TalerErrorCode.GENERIC_PARAMETER_MALFORMED)
- else -> {}
- }
- }
- return MonitorParams(timeframe, which)
- }
- }
-}
-
-data class AccountParams(
- val page: PageParams, val loginFilter: String
-) {
- companion object {
- fun extract(params: Parameters): AccountParams {
- val loginFilter = params.get("filter_name")?.run { "%$this%" } ?: "%"
- return AccountParams(PageParams.extract(params), loginFilter)
- }
- }
-}
-
-data class PageParams(
- val delta: Int, val start: Long
-) {
- companion object {
- fun extract(params: Parameters): PageParams {
- val delta: Int = params.int("delta") ?: -20
- val start: Long = params.long("start") ?: if (delta >= 0) 0L else Long.MAX_VALUE
- if (start < 0) throw badRequest("Param 'start' must be a positive number", TalerErrorCode.GENERIC_PARAMETER_MALFORMED)
- // TODO enforce delta limit
- return PageParams(delta, start)
- }
- }
-}
-
-data class PollingParams(
- val poll_ms: Long
-) {
- companion object {
- fun extract(params: Parameters): PollingParams {
- val poll_ms: Long = params.long("long_poll_ms") ?: 0
- if (poll_ms < 0) throw badRequest("Param 'long_poll_ms' must be a positive number", TalerErrorCode.GENERIC_PARAMETER_MALFORMED)
- return PollingParams(poll_ms)
- }
- }
-}
-
-data class HistoryParams(
- val page: PageParams, val polling: PollingParams
-) {
- companion object {
- fun extract(params: Parameters): HistoryParams {
- return HistoryParams(PageParams.extract(params), PollingParams.extract(params))
- }
- }
-}
-
-data class RateParams(
- val debit: TalerAmount?, val credit: TalerAmount?
-) {
- companion object {
- fun extract(params: Parameters): RateParams {
- val debit = params.amount("amount_debit")
- val credit = params.amount("amount_credit")
- if (debit == null && credit == null) {
- throw badRequest("Either param 'amount_debit' or 'amount_credit' is required", TalerErrorCode.GENERIC_PARAMETER_MISSING)
- } else if (debit != null && credit != null) {
- throw badRequest("Cannot have both 'amount_debit' and 'amount_credit' params", TalerErrorCode.GENERIC_PARAMETER_MALFORMED)
- }
- return RateParams(debit, credit)
- }
- }
-}
-
-data class StatusParams(
- val polling: PollingParams,
- val old_state: WithdrawalStatus
-) {
- companion object {
- val names = WithdrawalStatus.entries.map { it.name }
- val names_fmt = names.joinToString()
- fun extract(params: Parameters): StatusParams {
- val old_state = params.get("old_state") ?: "pending"
- if (!names.contains(old_state)) {
- throw badRequest("Param 'old_state' must be one of $names_fmt", TalerErrorCode.GENERIC_PARAMETER_MALFORMED)
- }
- return StatusParams(
- polling = PollingParams.extract(params),
- old_state = WithdrawalStatus.valueOf(old_state)
- )
- }
- }
-}
-\ No newline at end of file
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/RevenueApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/RevenueApi.kt
@@ -1,47 +0,0 @@
-/*
- * This file is part of LibEuFin.
- * Copyright (C) 2023 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
- * published by the Free Software Foundation; either version 3, or
- * (at your option) any later version.
-
- * LibEuFin 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 Affero General
- * Public License for more details.
-
- * You should have received a copy of the GNU Affero General Public
- * License along with LibEuFin; see the file COPYING. If not, see
- * <http://www.gnu.org/licenses/>
- */
-package tech.libeufin.bank
-
-import io.ktor.http.*
-import io.ktor.server.application.*
-import io.ktor.server.response.*
-import io.ktor.server.routing.*
-import tech.libeufin.bank.auth.auth
-import tech.libeufin.bank.db.Database
-
-fun Routing.revenueApi(db: Database, ctx: BankConfig) {
- auth(db, TokenScope.readonly) {
- get("/accounts/{USERNAME}/taler-revenue/config") {
- call.respond(RevenueConfig(
- currency = ctx.regionalCurrency
- ))
- }
- get("/accounts/{USERNAME}/taler-revenue/history") {
- val params = HistoryParams.extract(context.request.queryParameters)
- val bankAccount = call.bankInfo(db, ctx.payto)
- val items = db.transaction.revenueHistory(params, bankAccount.bankAccountId, ctx.payto)
-
- if (items.isEmpty()) {
- call.respond(HttpStatusCode.NoContent)
- } else {
- call.respond(RevenueIncomingHistory(items, bankAccount.payto))
- }
- }
- }
-}
-\ No newline at end of file
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/WireGatewayApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/WireGatewayApi.kt
@@ -1,155 +0,0 @@
-/*
- * This file is part of LibEuFin.
- * Copyright (C) 2023 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
- * published by the Free Software Foundation; either version 3, or
- * (at your option) any later version.
-
- * LibEuFin 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 Affero General
- * Public License for more details.
-
- * You should have received a copy of the GNU Affero General Public
- * License along with LibEuFin; see the file COPYING. If not, see
- * <http://www.gnu.org/licenses/>
- */
-
-// This file contains the Taler Wire Gateway API handlers.
-
-package tech.libeufin.bank
-
-import io.ktor.http.*
-import io.ktor.server.application.*
-import io.ktor.server.request.*
-import io.ktor.server.response.*
-import io.ktor.server.routing.*
-import io.ktor.util.pipeline.*
-import tech.libeufin.bank.auth.auth
-import tech.libeufin.bank.auth.authAdmin
-import tech.libeufin.bank.auth.username
-import tech.libeufin.bank.db.Database
-import tech.libeufin.bank.db.ExchangeDAO
-import tech.libeufin.bank.db.ExchangeDAO.AddIncomingResult
-import tech.libeufin.bank.db.ExchangeDAO.TransferResult
-import tech.libeufin.common.BankPaytoCtx
-import tech.libeufin.common.TalerErrorCode
-import java.time.Instant
-
-
-fun Routing.wireGatewayApi(db: Database, ctx: BankConfig) {
- auth(db, TokenScope.readwrite) {
- get("/accounts/{USERNAME}/taler-wire-gateway/config") {
- call.respond(WireGatewayConfig(
- currency = ctx.regionalCurrency
- ))
- }
- post("/accounts/{USERNAME}/taler-wire-gateway/transfer") {
- val req = call.receive<TransferRequest>()
- ctx.checkRegionalCurrency(req.amount)
- val res = db.exchange.transfer(
- req = req,
- login = username,
- now = Instant.now()
- )
- when (res) {
- is TransferResult.UnknownExchange -> throw unknownAccount(username)
- is TransferResult.NotAnExchange -> throw conflict(
- "$username is not an exchange account.",
- TalerErrorCode.BANK_ACCOUNT_IS_NOT_EXCHANGE
- )
- is TransferResult.UnknownCreditor -> throw unknownCreditorAccount(req.credit_account.canonical)
- is TransferResult.BothPartyAreExchange -> throw conflict(
- "Wire transfer attempted with credit and debit party being both exchange account",
- TalerErrorCode.BANK_ACCOUNT_IS_EXCHANGE
- )
- is TransferResult.ReserveUidReuse -> throw conflict(
- "request_uid used already",
- TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED
- )
- is TransferResult.BalanceInsufficient -> throw conflict(
- "Insufficient balance for exchange",
- TalerErrorCode.BANK_UNALLOWED_DEBIT
- )
- is TransferResult.Success -> call.respond(
- TransferResponse(
- timestamp = res.timestamp,
- row_id = res.id
- )
- )
- }
- }
- }
- auth(db, TokenScope.readonly) {
- suspend fun <T> PipelineContext<Unit, ApplicationCall>.historyEndpoint(
- reduce: (List<T>, String) -> Any,
- dbLambda: suspend ExchangeDAO.(HistoryParams, Long, BankPaytoCtx) -> List<T>
- ) {
- val params = HistoryParams.extract(context.request.queryParameters)
- val bankAccount = call.bankInfo(db, ctx.payto)
-
- if (!bankAccount.isTalerExchange)
- throw conflict(
- "$username is not an exchange account.",
- TalerErrorCode.BANK_ACCOUNT_IS_NOT_EXCHANGE
- )
-
- val items = db.exchange.dbLambda(params, bankAccount.bankAccountId, ctx.payto)
-
- if (items.isEmpty()) {
- call.respond(HttpStatusCode.NoContent)
- } else {
- call.respond(reduce(items, bankAccount.payto))
- }
- }
- get("/accounts/{USERNAME}/taler-wire-gateway/history/incoming") {
- historyEndpoint(::IncomingHistory, ExchangeDAO::incomingHistory)
- }
- get("/accounts/{USERNAME}/taler-wire-gateway/history/outgoing") {
- historyEndpoint(::OutgoingHistory, ExchangeDAO::outgoingHistory)
- }
- }
- authAdmin(db, TokenScope.readwrite) {
- post("/accounts/{USERNAME}/taler-wire-gateway/admin/add-incoming") {
- val req = call.receive<AddIncomingRequest>()
- ctx.checkRegionalCurrency(req.amount)
- val timestamp = Instant.now()
- val res = db.exchange.addIncoming(
- req = req,
- login = username,
- now = timestamp
- )
- when (res) {
- is AddIncomingResult.UnknownExchange -> throw unknownAccount(username)
- is AddIncomingResult.NotAnExchange -> throw conflict(
- "$username is not an exchange account.",
- TalerErrorCode.BANK_ACCOUNT_IS_NOT_EXCHANGE
- )
- is AddIncomingResult.UnknownDebtor -> throw conflict(
- "Debtor account ${req.debit_account} was not found",
- TalerErrorCode.BANK_UNKNOWN_DEBTOR
- )
- is AddIncomingResult.BothPartyAreExchange -> throw conflict(
- "Wire transfer attempted with credit and debit party being both exchange account",
- TalerErrorCode.BANK_ACCOUNT_IS_EXCHANGE
- )
- is AddIncomingResult.ReservePubReuse -> throw conflict(
- "reserve_pub used already",
- TalerErrorCode.BANK_DUPLICATE_RESERVE_PUB_SUBJECT
- )
- is AddIncomingResult.BalanceInsufficient -> throw conflict(
- "Insufficient balance for debitor",
- TalerErrorCode.BANK_UNALLOWED_DEBIT
- )
- is AddIncomingResult.Success -> call.respond(
- AddIncomingResponse(
- timestamp = TalerProtocolTimestamp(timestamp),
- row_id = res.id
- )
- )
- }
- }
- }
-}
-\ No newline at end of file
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/api/BankIntegrationApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/api/BankIntegrationApi.kt
@@ -0,0 +1,107 @@
+/*
+ * This file is part of LibEuFin.
+ * Copyright (C) 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
+ * published by the Free Software Foundation; either version 3, or
+ * (at your option) any later version.
+
+ * LibEuFin 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 Affero General
+ * Public License for more details.
+
+ * You should have received a copy of the GNU Affero General Public
+ * License along with LibEuFin; see the file COPYING. If not, see
+ * <http://www.gnu.org/licenses/>
+ */
+
+/* This file contains the Taler Integration API endpoints,
+* that are typically requested by wallets. */
+package tech.libeufin.bank.api
+
+import io.ktor.http.*
+import io.ktor.server.application.*
+import io.ktor.server.request.*
+import io.ktor.server.response.*
+import io.ktor.server.routing.*
+import tech.libeufin.bank.*
+import tech.libeufin.bank.db.AbortResult
+import tech.libeufin.bank.db.Database
+import tech.libeufin.bank.db.WithdrawalDAO.WithdrawalSelectionResult
+import tech.libeufin.common.TalerErrorCode
+
+fun Routing.bankIntegrationApi(db: Database, ctx: BankConfig) {
+ get("/taler-integration/config") {
+ call.respond(TalerIntegrationConfigResponse(
+ currency = ctx.regionalCurrency,
+ currency_specification = ctx.regionalCurrencySpec
+ ))
+ }
+
+ // Note: wopid acts as an authentication token.
+ get("/taler-integration/withdrawal-operation/{wopid}") {
+ val uuid = call.uuidPath("wopid")
+ val params = StatusParams.extract(call.request.queryParameters)
+ val op = db.withdrawal.pollStatus(uuid, params, ctx.wireMethod) ?: throw notFound(
+ "Withdrawal operation '$uuid' not found",
+ TalerErrorCode.BANK_TRANSACTION_NOT_FOUND
+ )
+ call.respond(op.copy(
+ suggested_exchange = ctx.suggestedWithdrawalExchange,
+ confirm_transfer_url = if (op.status == WithdrawalStatus.pending || op.status == WithdrawalStatus.selected) call.request.withdrawConfirmUrl(uuid) else null
+ ))
+ }
+ post("/taler-integration/withdrawal-operation/{wopid}") {
+ val uuid = call.uuidPath("wopid")
+ val req = call.receive<BankWithdrawalOperationPostRequest>()
+
+ val res = db.withdrawal.setDetails(
+ uuid, req.selected_exchange, req.reserve_pub
+ )
+ when (res) {
+ is WithdrawalSelectionResult.UnknownOperation -> throw notFound(
+ "Withdrawal operation '$uuid' not found",
+ TalerErrorCode.BANK_TRANSACTION_NOT_FOUND
+ )
+ is WithdrawalSelectionResult.AlreadySelected -> throw conflict(
+ "Cannot select different exchange and reserve pub. under the same withdrawal operation",
+ TalerErrorCode.BANK_WITHDRAWAL_OPERATION_RESERVE_SELECTION_CONFLICT
+ )
+ is WithdrawalSelectionResult.RequestPubReuse -> throw conflict(
+ "Reserve pub. already used",
+ TalerErrorCode.BANK_DUPLICATE_RESERVE_PUB_SUBJECT
+ )
+ is WithdrawalSelectionResult.UnknownAccount -> throw conflict(
+ "Account ${req.selected_exchange} not found",
+ TalerErrorCode.BANK_UNKNOWN_ACCOUNT
+ )
+ is WithdrawalSelectionResult.AccountIsNotExchange -> throw conflict(
+ "Account ${req.selected_exchange} is not an exchange",
+ TalerErrorCode.BANK_ACCOUNT_IS_NOT_EXCHANGE
+ )
+ is WithdrawalSelectionResult.Success -> {
+ call.respond(BankWithdrawalOperationPostResponse(
+ transfer_done = res.status == WithdrawalStatus.confirmed,
+ status = res.status,
+ confirm_transfer_url = if (res.status == WithdrawalStatus.pending || res.status == WithdrawalStatus.selected) call.request.withdrawConfirmUrl(uuid) else null
+ ))
+ }
+ }
+ }
+ post("/taler-integration/withdrawal-operation/{wopid}/abort") {
+ val uuid = call.uuidPath("wopid")
+ when (db.withdrawal.abort(uuid)) {
+ AbortResult.UnknownOperation -> throw notFound(
+ "Withdrawal operation '$uuid' not found",
+ TalerErrorCode.BANK_TRANSACTION_NOT_FOUND
+ )
+ AbortResult.AlreadyConfirmed -> throw conflict(
+ "Cannot abort confirmed withdrawal",
+ TalerErrorCode.BANK_ABORT_CONFIRM_CONFLICT
+ )
+ AbortResult.Success -> call.respond(HttpStatusCode.NoContent)
+ }
+ }
+}
+\ No newline at end of file
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/api/ConversionApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/api/ConversionApi.kt
@@ -0,0 +1,117 @@
+/*
+ * This file is part of LibEuFin.
+ * 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
+ * published by the Free Software Foundation; either version 3, or
+ * (at your option) any later version.
+
+ * LibEuFin 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 Affero General
+ * Public License for more details.
+
+ * You should have received a copy of the GNU Affero General Public
+ * License along with LibEuFin; see the file COPYING. If not, see
+ * <http://www.gnu.org/licenses/>
+ */
+package tech.libeufin.bank.api
+
+import io.ktor.http.*
+import io.ktor.server.application.*
+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.db.ConversionDAO
+import tech.libeufin.bank.db.ConversionDAO.ConversionResult
+import tech.libeufin.bank.db.Database
+import tech.libeufin.common.TalerAmount
+import tech.libeufin.common.TalerErrorCode
+
+fun Routing.conversionApi(db: Database, ctx: BankConfig) = conditional(ctx.allowConversion) {
+ get("/conversion-info/config") {
+ val config = db.conversion.getConfig(ctx.regionalCurrency, ctx.fiatCurrency!!)
+ if (config == null) {
+ throw libeufinError(
+ HttpStatusCode.NotImplemented,
+ "conversion rate not configured yet",
+ TalerErrorCode.END
+ )
+ }
+ call.respond(
+ ConversionConfig(
+ regional_currency = ctx.regionalCurrency,
+ regional_currency_specification = ctx.regionalCurrencySpec,
+ fiat_currency = ctx.fiatCurrency,
+ fiat_currency_specification = ctx.fiatCurrencySpec!!,
+ conversion_rate = config
+ )
+ )
+ }
+ suspend fun ApplicationCall.convert(
+ input: TalerAmount,
+ conversion: suspend ConversionDAO.(TalerAmount) -> ConversionResult,
+ output: (TalerAmount) -> ConversionResponse
+ ) {
+ when (val res = db.conversion.(conversion)(input)) {
+ is ConversionResult.Success -> respond(output(res.converted))
+ is ConversionResult.ToSmall -> throw conflict(
+ "$input is too small to be converted",
+ TalerErrorCode.BANK_BAD_CONVERSION
+ )
+ is ConversionResult.MissingConfig -> throw libeufinError(
+ HttpStatusCode.NotImplemented,
+ "conversion rate not configured yet",
+ TalerErrorCode.END
+ )
+ }
+ }
+ get("/conversion-info/cashout-rate") {
+ val params = RateParams.extract(call.request.queryParameters)
+
+ params.debit?.let { ctx.checkRegionalCurrency(it) }
+ params.credit?.let { ctx.checkFiatCurrency(it) }
+
+ if (params.debit != null) {
+ call.convert(params.debit, ConversionDAO::toCashout) {
+ ConversionResponse(params.debit, it)
+ }
+ } else {
+ call.convert(params.credit!!, ConversionDAO::fromCashout) {
+ ConversionResponse(it, params.credit)
+ }
+ }
+ }
+ get("/conversion-info/cashin-rate") {
+ val params = RateParams.extract(call.request.queryParameters)
+
+ params.debit?.let { ctx.checkFiatCurrency(it) }
+ params.credit?.let { ctx.checkRegionalCurrency(it) }
+
+ if (params.debit != null) {
+ call.convert(params.debit, ConversionDAO::toCashin) {
+ ConversionResponse(params.debit, it)
+ }
+ } else {
+ call.convert(params.credit!!, ConversionDAO::fromCashin) {
+ ConversionResponse(it, params.credit)
+ }
+ }
+ }
+ authAdmin(db, TokenScope.readwrite) {
+ 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)
+ }
+ for (fiatAmount in sequenceOf(req.cashout_fee, req.cashout_tiny_amount, req.cashin_min_amount)) {
+ ctx.checkFiatCurrency(fiatAmount)
+ }
+ db.conversion.updateConfig(req)
+ call.respond(HttpStatusCode.NoContent)
+ }
+ }
+}
+\ 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
@@ -0,0 +1,743 @@
+/*
+ * This file is part of LibEuFin.
+ * 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
+ * published by the Free Software Foundation; either version 3, or
+ * (at your option) any later version.
+
+ * LibEuFin 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 Affero General
+ * Public License for more details.
+
+ * You should have received a copy of the GNU Affero General Public
+ * License along with LibEuFin; see the file COPYING. If not, see
+ * <http://www.gnu.org/licenses/>
+ */
+package tech.libeufin.bank.api
+
+import io.ktor.http.*
+import io.ktor.server.application.*
+import io.ktor.server.request.*
+import io.ktor.server.response.*
+import io.ktor.server.routing.*
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.future.await
+import kotlinx.coroutines.withContext
+import org.slf4j.Logger
+import org.slf4j.LoggerFactory
+import tech.libeufin.bank.auth.*
+import tech.libeufin.bank.db.AbortResult
+import tech.libeufin.bank.db.AccountDAO.*
+import tech.libeufin.bank.db.CashoutDAO.CashoutCreationResult
+import tech.libeufin.bank.db.Database
+import tech.libeufin.bank.db.TanDAO.TanSendResult
+import tech.libeufin.bank.db.TanDAO.TanSolveResult
+import tech.libeufin.bank.db.TransactionDAO.BankTransactionResult
+import tech.libeufin.bank.db.WithdrawalDAO.WithdrawalConfirmationResult
+import tech.libeufin.bank.db.WithdrawalDAO.WithdrawalCreationResult
+import tech.libeufin.common.*
+import tech.libeufin.bank.*
+import java.time.Duration
+import java.time.Instant
+import java.time.temporal.ChronoUnit
+import java.util.*
+
+private val logger: Logger = LoggerFactory.getLogger("libeufin-bank-api")
+
+fun Routing.coreBankApi(db: Database, ctx: BankConfig) {
+ get("/config") {
+ call.respond(
+ Config(
+ bank_name = ctx.name,
+ currency = ctx.regionalCurrency,
+ currency_specification = ctx.regionalCurrencySpec,
+ allow_conversion = ctx.allowConversion,
+ allow_registrations = ctx.allowRegistration,
+ allow_deletions = ctx.allowAccountDeletion,
+ default_debit_threshold = ctx.defaultDebtLimit,
+ supported_tan_channels = ctx.tanChannels.keys,
+ allow_edit_name = ctx.allowEditName,
+ allow_edit_cashout_payto_uri = ctx.allowEditCashout,
+ wire_type = ctx.wireMethod
+ )
+ )
+ }
+ authAdmin(db, TokenScope.readonly) {
+ get("/monitor") {
+ val params = MonitorParams.extract(call.request.queryParameters)
+ call.respond(db.monitor(params))
+ }
+ }
+ coreBankTokenApi(db)
+ coreBankAccountsApi(db, ctx)
+ coreBankTransactionsApi(db, ctx)
+ coreBankWithdrawalApi(db, ctx)
+ coreBankCashoutApi(db, ctx)
+ coreBankTanApi(db, ctx)
+}
+
+private fun Routing.coreBankTokenApi(db: Database) {
+ val TOKEN_DEFAULT_DURATION: Duration = Duration.ofDays(1L)
+ auth(db, TokenScope.refreshable) {
+ post("/accounts/{USERNAME}/token") {
+ val existingToken = call.authToken
+ val req = call.receive<TokenRequest>()
+
+ if (existingToken != null) {
+ // This block checks permissions ONLY IF the call was authenticated with a token
+ val refreshingToken = db.token.get(existingToken) ?: throw internalServerError(
+ "Token used to auth not found in the database!"
+ )
+ if (refreshingToken.scope == TokenScope.readonly && req.scope == TokenScope.readwrite)
+ throw forbidden(
+ "Cannot generate RW token from RO",
+ TalerErrorCode.GENERIC_TOKEN_PERMISSION_INSUFFICIENT
+ )
+ }
+ val token = Base32Crockford32B.rand()
+ val tokenDuration: Duration = req.duration?.d_us ?: TOKEN_DEFAULT_DURATION
+
+ val creationTime = Instant.now()
+ val expirationTimestamp =
+ if (tokenDuration == ChronoUnit.FOREVER.duration) {
+ logger.debug("Creating 'forever' token.")
+ Instant.MAX
+ } else {
+ try {
+ logger.debug("Creating token with days duration: ${tokenDuration.toDays()}")
+ creationTime.plus(tokenDuration)
+ } catch (e: Exception) {
+ throw badRequest("Bad token duration: ${e.message}")
+ }
+ }
+ if (!db.token.create(
+ login = username,
+ content = token.raw,
+ creationTime = creationTime,
+ expirationTime = expirationTimestamp,
+ scope = req.scope,
+ isRefreshable = req.refreshable
+ )) {
+ throw internalServerError("Failed at inserting new token in the database")
+ }
+ call.respond(
+ TokenSuccessResponse(
+ access_token = token.encoded(),
+ expiration = TalerProtocolTimestamp(t_s = expirationTimestamp)
+ )
+ )
+ }
+ }
+ auth(db, TokenScope.readonly) {
+ delete("/accounts/{USERNAME}/token") {
+ val token = call.authToken ?: throw badRequest("Basic auth not supported here.")
+ db.token.delete(token)
+ call.respond(HttpStatusCode.NoContent)
+ }
+ }
+}
+
+suspend fun createAccount(
+ db: Database,
+ cfg: BankConfig,
+ req: RegisterAccountRequest,
+ isAdmin: Boolean
+): Pair<AccountCreationResult, String> {
+ // Prohibit reserved usernames:
+ if (RESERVED_ACCOUNTS.contains(req.username))
+ throw conflict(
+ "Username '${req.username}' is reserved",
+ TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT
+ )
+
+ if (!isAdmin) {
+ if (req.debit_threshold != null)
+ throw conflict(
+ "only admin account can choose the debit limit",
+ TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT
+ )
+
+ if (req.tan_channel != null)
+ throw conflict(
+ "only admin account can enable 2fa on creation",
+ TalerErrorCode.BANK_NON_ADMIN_SET_TAN_CHANNEL
+ )
+
+ } else if (req.tan_channel != null) {
+ if (cfg.tanChannels.get(req.tan_channel) == null) {
+ throw unsupportedTanChannel(req.tan_channel)
+ }
+ val missing = when (req.tan_channel) {
+ TanChannel.sms -> req.contact_data?.phone?.get() == null
+ TanChannel.email -> req.contact_data?.email?.get() == null
+ }
+ if (missing)
+ throw conflict(
+ "missing info for tan channel ${req.tan_channel}",
+ TalerErrorCode.BANK_MISSING_TAN_INFO
+ )
+ }
+
+ if (req.username == "exchange" && !req.is_taler_exchange)
+ throw conflict(
+ "'exchange' account must be a taler exchange account",
+ TalerErrorCode.END
+ )
+
+ when (cfg.wireMethod) {
+ WireMethod.IBAN -> {
+ if (req.payto_uri != null && !(req.payto_uri is IbanPayto))
+ throw badRequest("Expected an IBAN payto uri")
+ 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
+ )
+ // Retry with new IBAN
+ if (res == AccountCreationResult.PayToReuse && retry > 0) {
+ retry--
+ continue
+ }
+ return Pair(res, internalPayto.bank(req.name, cfg.payto))
+ }
+ }
+ 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 internalPayto = XTalerBankPayto.forUsername(req.username)
+
+ 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
+ )
+ return Pair(res, internalPayto.bank(req.name, cfg.payto))
+ }
+ }
+}
+
+suspend fun patchAccount(
+ db: Database,
+ cfg: BankConfig,
+ req: AccountReconfiguration,
+ username: String,
+ isAdmin: Boolean,
+ is2fa: Boolean,
+ channel: TanChannel? = null,
+ info: String? = null
+): AccountPatchResult {
+ req.debit_threshold?.run { cfg.checkRegionalCurrency(this) }
+
+ if (username == "admin" && req.is_public == true)
+ throw conflict(
+ "'admin' account cannot be public",
+ TalerErrorCode.END
+ )
+
+ if (req.tan_channel is Option.Some && req.tan_channel.value != null && !cfg.tanChannels.contains(req.tan_channel.value)) {
+ throw unsupportedTanChannel(req.tan_channel.value)
+ }
+
+ return db.account.reconfig(
+ login = username,
+ name = req.name,
+ cashoutPayto = req.cashout_payto_uri,
+ email = req.contact_data?.email ?: Option.None,
+ phone = req.contact_data?.phone ?: Option.None,
+ tan_channel = req.tan_channel,
+ isPublic = req.is_public,
+ debtLimit = req.debit_threshold,
+ isAdmin = isAdmin,
+ is2fa = is2fa,
+ faChannel = channel,
+ faInfo = info,
+ allowEditName = cfg.allowEditName,
+ allowEditCashout = cfg.allowEditCashout
+ )
+}
+
+private fun Routing.coreBankAccountsApi(db: Database, ctx: BankConfig) {
+ authAdmin(db, TokenScope.readwrite, !ctx.allowRegistration) {
+ post("/accounts") {
+ val req = call.receive<RegisterAccountRequest>()
+ val (result, internalPayto) = createAccount(db, ctx, req, isAdmin)
+ when (result) {
+ AccountCreationResult.BonusBalanceInsufficient -> throw conflict(
+ "Insufficient admin funds to grant bonus",
+ TalerErrorCode.BANK_UNALLOWED_DEBIT
+ )
+ AccountCreationResult.LoginReuse -> throw conflict(
+ "Account username reuse '${req.username}'",
+ TalerErrorCode.BANK_REGISTER_USERNAME_REUSE
+ )
+ AccountCreationResult.PayToReuse -> throw conflict(
+ "Bank internalPayToUri reuse '$internalPayto'",
+ TalerErrorCode.BANK_REGISTER_PAYTO_URI_REUSE
+ )
+ AccountCreationResult.Success -> call.respond(RegisterAccountResponse(internalPayto))
+ }
+ }
+ }
+ auth(
+ db,
+ TokenScope.readwrite,
+ allowAdmin = true,
+ requireAdmin = !ctx.allowAccountDeletion
+ ) {
+ delete("/accounts/{USERNAME}") {
+ val challenge = call.checkChallenge(db, Operation.account_delete)
+
+ // Not deleting reserved names.
+ if (RESERVED_ACCOUNTS.contains(username))
+ throw conflict(
+ "Cannot delete reserved accounts",
+ TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT
+ )
+ if (username == "exchange" && ctx.allowConversion)
+ throw conflict(
+ "Cannot delete 'exchange' accounts when conversion is enabled",
+ TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT
+ )
+
+ when (db.account.delete(username, isAdmin || challenge != null)) {
+ AccountDeletionResult.UnknownAccount -> throw unknownAccount(username)
+ AccountDeletionResult.BalanceNotZero -> throw conflict(
+ "Account balance is not zero.",
+ TalerErrorCode.BANK_ACCOUNT_BALANCE_NOT_ZERO
+ )
+ AccountDeletionResult.TanRequired -> call.respondChallenge(db, Operation.account_delete, Unit)
+ AccountDeletionResult.Success -> call.respond(HttpStatusCode.NoContent)
+ }
+ }
+ }
+ auth(db, TokenScope.readwrite, allowAdmin = true) {
+ patch("/accounts/{USERNAME}") {
+ val (req, challenge) = call.receiveChallenge<AccountReconfiguration>(db, Operation.account_reconfig)
+ val res = patchAccount(db, ctx, req, username, isAdmin, challenge != null, challenge?.channel, challenge?.info)
+ when (res) {
+ AccountPatchResult.Success -> call.respond(HttpStatusCode.NoContent)
+ is AccountPatchResult.TanRequired -> {
+ call.respondChallenge(db, Operation.account_reconfig, req, res.channel, res.info)
+ }
+ AccountPatchResult.UnknownAccount -> throw unknownAccount(username)
+ AccountPatchResult.NonAdminName -> throw conflict(
+ "non-admin user cannot change their legal name",
+ TalerErrorCode.BANK_NON_ADMIN_PATCH_LEGAL_NAME
+ )
+ AccountPatchResult.NonAdminCashout -> throw conflict(
+ "non-admin user cannot change their cashout account",
+ TalerErrorCode.BANK_NON_ADMIN_PATCH_CASHOUT
+ )
+ AccountPatchResult.NonAdminDebtLimit -> throw conflict(
+ "non-admin user cannot change their debt limit",
+ TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT
+ )
+ AccountPatchResult.MissingTanInfo -> throw conflict(
+ "missing info for tan channel ${req.tan_channel.get()}",
+ TalerErrorCode.BANK_MISSING_TAN_INFO
+ )
+ }
+ }
+ patch("/accounts/{USERNAME}/auth") {
+ val (req, challenge) = call.receiveChallenge<AccountPasswordChange>(db, Operation.account_auth_reconfig)
+
+ if (!isAdmin && req.old_password == null) {
+ throw conflict(
+ "non-admin user cannot change password without providing old password",
+ TalerErrorCode.BANK_NON_ADMIN_PATCH_MISSING_OLD_PASSWORD
+ )
+ }
+ when (db.account.reconfigPassword(username, req.new_password, req.old_password, isAdmin || challenge != null)) {
+ AccountPatchAuthResult.Success -> call.respond(HttpStatusCode.NoContent)
+ AccountPatchAuthResult.TanRequired -> call.respondChallenge(db, Operation.account_auth_reconfig, req)
+ AccountPatchAuthResult.UnknownAccount -> throw unknownAccount(username)
+ AccountPatchAuthResult.OldPasswordMismatch -> throw conflict(
+ "old password does not match",
+ TalerErrorCode.BANK_PATCH_BAD_OLD_PASSWORD
+ )
+ }
+ }
+ }
+ get("/public-accounts") {
+ val params = AccountParams.extract(call.request.queryParameters)
+ val publicAccounts = db.account.pagePublic(params, ctx.payto)
+ if (publicAccounts.isEmpty()) {
+ call.respond(HttpStatusCode.NoContent)
+ } else {
+ call.respond(PublicAccountsResponse(publicAccounts))
+ }
+ }
+ authAdmin(db, TokenScope.readonly) {
+ get("/accounts") {
+ val params = AccountParams.extract(call.request.queryParameters)
+ val accounts = db.account.pageAdmin(params, ctx.payto)
+ if (accounts.isEmpty()) {
+ call.respond(HttpStatusCode.NoContent)
+ } else {
+ call.respond(ListBankAccountsResponse(accounts))
+ }
+ }
+ }
+ auth(db, TokenScope.readonly, allowAdmin = true) {
+ get("/accounts/{USERNAME}") {
+ val account = db.account.get(username, ctx.payto) ?: throw unknownAccount(username)
+ call.respond(account)
+ }
+ }
+}
+
+private fun Routing.coreBankTransactionsApi(db: Database, ctx: BankConfig) {
+ auth(db, TokenScope.readonly) {
+ get("/accounts/{USERNAME}/transactions") {
+ val params = HistoryParams.extract(call.request.queryParameters)
+ val bankAccount = call.bankInfo(db, ctx.payto)
+
+ val history: List<BankAccountTransactionInfo> =
+ db.transaction.pollHistory(params, bankAccount.bankAccountId, ctx.payto)
+ if (history.isEmpty()) {
+ call.respond(HttpStatusCode.NoContent)
+ } else {
+ call.respond(BankAccountTransactionsResponse(history))
+ }
+ }
+ get("/accounts/{USERNAME}/transactions/{T_ID}") {
+ val tId = call.longPath("T_ID")
+ val tx = db.transaction.get(tId, username, ctx.payto) ?: throw notFound(
+ "Bank transaction '$tId' not found",
+ TalerErrorCode.BANK_TRANSACTION_NOT_FOUND
+ )
+ call.respond(tx)
+ }
+ }
+ auth(db, TokenScope.readwrite) {
+ post("/accounts/{USERNAME}/transactions") {
+ val (req, challenge) = call.receiveChallenge<TransactionCreateRequest>(db, Operation.bank_transaction)
+
+ val subject = req.payto_uri.message ?: throw badRequest("Wire transfer lacks subject")
+ val amount = req.payto_uri.amount ?: req.amount ?: throw badRequest("Wire transfer lacks amount")
+
+ ctx.checkRegionalCurrency(amount)
+
+ val res = db.transaction.create(
+ creditAccountPayto = req.payto_uri,
+ debitAccountUsername = username,
+ subject = subject,
+ amount = amount,
+ timestamp = Instant.now(),
+ is2fa = challenge != null
+ )
+ when (res) {
+ BankTransactionResult.UnknownDebtor -> throw unknownAccount(username)
+ BankTransactionResult.TanRequired -> {
+ call.respondChallenge(db, Operation.bank_transaction, req)
+ }
+ BankTransactionResult.BothPartySame -> throw conflict(
+ "Wire transfer attempted with credit and debit party being the same bank account",
+ TalerErrorCode.BANK_SAME_ACCOUNT
+ )
+ BankTransactionResult.UnknownCreditor -> throw unknownCreditorAccount(req.payto_uri.canonical)
+ BankTransactionResult.AdminCreditor -> throw conflict(
+ "Cannot transfer money to admin account",
+ TalerErrorCode.BANK_ADMIN_CREDITOR
+ )
+ BankTransactionResult.BalanceInsufficient -> throw conflict(
+ "Insufficient funds",
+ TalerErrorCode.BANK_UNALLOWED_DEBIT
+ )
+ is BankTransactionResult.Success -> call.respond(TransactionCreateResponse(res.id))
+ }
+ }
+ }
+}
+
+private fun Routing.coreBankWithdrawalApi(db: Database, ctx: BankConfig) {
+ auth(db, TokenScope.readwrite) {
+ post("/accounts/{USERNAME}/withdrawals") {
+ val req = call.receive<BankAccountCreateWithdrawalRequest>()
+ ctx.checkRegionalCurrency(req.amount)
+ val opId = UUID.randomUUID()
+ when (db.withdrawal.create(username, opId, req.amount, Instant.now())) {
+ WithdrawalCreationResult.UnknownAccount -> throw unknownAccount(username)
+ WithdrawalCreationResult.AccountIsExchange -> throw conflict(
+ "Exchange account cannot perform withdrawal operation",
+ TalerErrorCode.BANK_ACCOUNT_IS_EXCHANGE
+ )
+ WithdrawalCreationResult.BalanceInsufficient -> throw conflict(
+ "Insufficient funds to withdraw with Taler",
+ TalerErrorCode.BANK_UNALLOWED_DEBIT
+ )
+ WithdrawalCreationResult.Success -> {
+ call.respond(
+ BankAccountCreateWithdrawalResponse(
+ withdrawal_id = opId.toString(),
+ taler_withdraw_uri = call.request.talerWithdrawUri(opId)
+ )
+ )
+ }
+ }
+ }
+ post("/accounts/{USERNAME}/withdrawals/{withdrawal_id}/confirm") {
+ val id = call.uuidPath("withdrawal_id")
+ val challenge = call.checkChallenge(db, Operation.withdrawal)
+ when (db.withdrawal.confirm(username, id, Instant.now(), challenge != null)) {
+ WithdrawalConfirmationResult.UnknownOperation -> throw notFound(
+ "Withdrawal operation $id not found",
+ TalerErrorCode.BANK_TRANSACTION_NOT_FOUND
+ )
+ WithdrawalConfirmationResult.AlreadyAborted -> throw conflict(
+ "Cannot confirm an aborted withdrawal",
+ TalerErrorCode.BANK_CONFIRM_ABORT_CONFLICT
+ )
+ WithdrawalConfirmationResult.NotSelected -> throw conflict(
+ "Cannot confirm an unselected withdrawal",
+ TalerErrorCode.BANK_CONFIRM_INCOMPLETE
+ )
+ WithdrawalConfirmationResult.BalanceInsufficient -> throw conflict(
+ "Insufficient funds",
+ TalerErrorCode.BANK_UNALLOWED_DEBIT
+ )
+ WithdrawalConfirmationResult.UnknownExchange -> throw conflict(
+ "Exchange to withdraw from not found",
+ TalerErrorCode.BANK_UNKNOWN_CREDITOR
+ )
+ WithdrawalConfirmationResult.TanRequired -> {
+ call.respondChallenge(db, Operation.withdrawal, StoredUUID(id))
+ }
+ WithdrawalConfirmationResult.Success -> call.respond(HttpStatusCode.NoContent)
+ }
+ }
+ post("/accounts/{USERNAME}/withdrawals/{withdrawal_id}/abort") {
+ val opId = call.uuidPath("withdrawal_id")
+ when (db.withdrawal.abort(opId)) {
+ AbortResult.UnknownOperation -> throw notFound(
+ "Withdrawal operation $opId not found",
+ TalerErrorCode.BANK_TRANSACTION_NOT_FOUND
+ )
+ AbortResult.AlreadyConfirmed -> throw conflict(
+ "Cannot abort confirmed withdrawal",
+ TalerErrorCode.BANK_ABORT_CONFIRM_CONFLICT
+ )
+ AbortResult.Success -> call.respond(HttpStatusCode.NoContent)
+ }
+ }
+ }
+ get("/withdrawals/{withdrawal_id}") {
+ val uuid = call.uuidPath("withdrawal_id")
+ val params = StatusParams.extract(call.request.queryParameters)
+ val op = db.withdrawal.pollInfo(uuid, params) ?: throw notFound(
+ "Withdrawal operation '$uuid' not found",
+ TalerErrorCode.BANK_TRANSACTION_NOT_FOUND
+ )
+ call.respond(op)
+ }
+}
+
+private fun Routing.coreBankCashoutApi(db: Database, ctx: BankConfig) = conditional(ctx.allowConversion) {
+ auth(db, TokenScope.readwrite) {
+ post("/accounts/{USERNAME}/cashouts") {
+ val (req, challenge) = call.receiveChallenge<CashoutRequest>(db, Operation.cashout)
+
+ ctx.checkRegionalCurrency(req.amount_debit)
+ ctx.checkFiatCurrency(req.amount_credit)
+
+ val res = db.cashout.create(
+ login = username,
+ requestUid = req.request_uid,
+ amountDebit = req.amount_debit,
+ amountCredit = req.amount_credit,
+ subject = req.subject ?: "", // TODO default subject
+ now = Instant.now(),
+ is2fa = challenge != null
+ )
+ when (res) {
+ CashoutCreationResult.AccountNotFound -> throw unknownAccount(username)
+ CashoutCreationResult.BadConversion -> throw conflict(
+ "Wrong currency conversion",
+ TalerErrorCode.BANK_BAD_CONVERSION
+ )
+ CashoutCreationResult.AccountIsExchange -> throw conflict(
+ "Exchange account cannot perform cashout operation",
+ TalerErrorCode.BANK_ACCOUNT_IS_EXCHANGE
+ )
+ CashoutCreationResult.BalanceInsufficient -> throw conflict(
+ "Insufficient funds to withdraw with Taler",
+ TalerErrorCode.BANK_UNALLOWED_DEBIT
+ )
+ CashoutCreationResult.RequestUidReuse -> throw conflict(
+ "request_uid used already",
+ TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED
+ )
+ CashoutCreationResult.NoCashoutPayto -> throw conflict(
+ "Missing cashout payto uri",
+ TalerErrorCode.BANK_CONFIRM_INCOMPLETE
+ )
+ CashoutCreationResult.TanRequired -> {
+ call.respondChallenge(db, Operation.cashout, req)
+ }
+ is CashoutCreationResult.Success -> call.respond(CashoutResponse(res.id))
+ }
+ }
+ }
+ auth(db, TokenScope.readonly) {
+ get("/accounts/{USERNAME}/cashouts/{CASHOUT_ID}") {
+ val id = call.longPath("CASHOUT_ID")
+ val cashout = db.cashout.get(id, username) ?: throw notFound(
+ "Cashout operation $id not found",
+ TalerErrorCode.BANK_TRANSACTION_NOT_FOUND
+ )
+ call.respond(cashout)
+ }
+ get("/accounts/{USERNAME}/cashouts") {
+ val params = PageParams.extract(call.request.queryParameters)
+ val cashouts = db.cashout.pageForUser(params, username)
+ if (cashouts.isEmpty()) {
+ call.respond(HttpStatusCode.NoContent)
+ } else {
+ call.respond(Cashouts(cashouts))
+ }
+ }
+ }
+ authAdmin(db, TokenScope.readonly) {
+ get("/cashouts") {
+ val params = PageParams.extract(call.request.queryParameters)
+ val cashouts = db.cashout.pageAll(params)
+ if (cashouts.isEmpty()) {
+ call.respond(HttpStatusCode.NoContent)
+ } else {
+ call.respond(GlobalCashouts(cashouts))
+ }
+ }
+ }
+}
+
+private fun Routing.coreBankTanApi(db: Database, ctx: BankConfig) {
+ auth(db, TokenScope.readwrite) {
+ post("/accounts/{USERNAME}/challenge/{CHALLENGE_ID}") {
+ val id = call.longPath("CHALLENGE_ID")
+ val res = db.tan.send(
+ id = id,
+ login = username,
+ code = Tan.genCode(),
+ now = Instant.now(),
+ retryCounter = TAN_RETRY_COUNTER,
+ validityPeriod = TAN_VALIDITY_PERIOD
+ )
+ when (res) {
+ TanSendResult.NotFound -> throw notFound(
+ "Challenge $id not found",
+ TalerErrorCode.BANK_TRANSACTION_NOT_FOUND
+ )
+ is TanSendResult.Success -> {
+ res.tanCode?.run {
+ val (tanScript, tanEnv) = ctx.tanChannels.get(res.tanChannel)
+ ?: throw unsupportedTanChannel(res.tanChannel)
+ val msg = "T-${res.tanCode} is your ${ctx.name} verification code"
+ val exitValue = withContext(Dispatchers.IO) {
+ val builder = ProcessBuilder(tanScript.toString(), res.tanInfo)
+ builder.redirectErrorStream(true)
+ for ((name, value) in tanEnv) {
+ builder.environment()[name] = value
+ }
+ val process = builder.start()
+ try {
+ process.outputWriter().use { it.write(msg) }
+ process.onExit().await()
+ } catch (e: Exception) {
+ process.destroy()
+ }
+ val exitValue = process.exitValue()
+ if (exitValue != 0) {
+ val out = runCatching {
+ process.getInputStream().use {
+ reader().readText()
+ }
+ }.getOrDefault("")
+ if (out.isNotEmpty()) {
+ logger.error("TAN ${res.tanChannel} - ${tanScript}: $out")
+ }
+ }
+ exitValue
+ }
+ if (exitValue != 0) {
+ throw libeufinError(
+ HttpStatusCode.BadGateway,
+ "Tan channel script failure with exit value $exitValue",
+ TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED
+ )
+ }
+ db.tan.markSent(id, Instant.now(), TAN_RETRANSMISSION_PERIOD)
+ }
+ call.respond(TanTransmission(
+ tan_info = res.tanInfo,
+ tan_channel = res.tanChannel
+ ))
+ }
+ }
+ }
+ post("/accounts/{USERNAME}/challenge/{CHALLENGE_ID}/confirm") {
+ val id = call.longPath("CHALLENGE_ID")
+ val req = call.receive<ChallengeSolve>()
+ val code = req.tan.removePrefix("T-")
+ val res = db.tan.solve(
+ id = id,
+ login = username,
+ code = code,
+ now = Instant.now()
+ )
+ when (res) {
+ TanSolveResult.NotFound -> throw notFound(
+ "Challenge $id not found",
+ TalerErrorCode.BANK_CHALLENGE_NOT_FOUND
+ )
+ TanSolveResult.BadCode -> throw conflict(
+ "Incorrect TAN code",
+ TalerErrorCode.BANK_TAN_CHALLENGE_FAILED
+ )
+ TanSolveResult.NoRetry -> throw libeufinError(
+ HttpStatusCode.TooManyRequests,
+ "Too many failed confirmation attempt",
+ TalerErrorCode.BANK_TAN_RATE_LIMITED
+ )
+ TanSolveResult.Expired -> throw conflict(
+ "Challenge expired",
+ TalerErrorCode.BANK_TAN_CHALLENGE_EXPIRED
+ )
+ is TanSolveResult.Success -> call.respond(HttpStatusCode.NoContent)
+ }
+ }
+ }
+}
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/api/RevenueApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/api/RevenueApi.kt
@@ -0,0 +1,48 @@
+/*
+ * This file is part of LibEuFin.
+ * 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
+ * published by the Free Software Foundation; either version 3, or
+ * (at your option) any later version.
+
+ * LibEuFin 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 Affero General
+ * Public License for more details.
+
+ * You should have received a copy of the GNU Affero General Public
+ * License along with LibEuFin; see the file COPYING. If not, see
+ * <http://www.gnu.org/licenses/>
+ */
+package tech.libeufin.bank.api
+
+import io.ktor.http.*
+import io.ktor.server.application.*
+import io.ktor.server.response.*
+import io.ktor.server.routing.*
+import tech.libeufin.bank.*
+import tech.libeufin.bank.auth.auth
+import tech.libeufin.bank.db.Database
+
+fun Routing.revenueApi(db: Database, ctx: BankConfig) {
+ auth(db, TokenScope.readonly) {
+ get("/accounts/{USERNAME}/taler-revenue/config") {
+ call.respond(RevenueConfig(
+ currency = ctx.regionalCurrency
+ ))
+ }
+ get("/accounts/{USERNAME}/taler-revenue/history") {
+ val params = HistoryParams.extract(context.request.queryParameters)
+ val bankAccount = call.bankInfo(db, ctx.payto)
+ val items = db.transaction.revenueHistory(params, bankAccount.bankAccountId, ctx.payto)
+
+ if (items.isEmpty()) {
+ call.respond(HttpStatusCode.NoContent)
+ } else {
+ call.respond(RevenueIncomingHistory(items, bankAccount.payto))
+ }
+ }
+ }
+}
+\ No newline at end of file
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/api/WireGatewayApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/api/WireGatewayApi.kt
@@ -0,0 +1,156 @@
+/*
+ * This file is part of LibEuFin.
+ * 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
+ * published by the Free Software Foundation; either version 3, or
+ * (at your option) any later version.
+
+ * LibEuFin 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 Affero General
+ * Public License for more details.
+
+ * You should have received a copy of the GNU Affero General Public
+ * License along with LibEuFin; see the file COPYING. If not, see
+ * <http://www.gnu.org/licenses/>
+ */
+
+// This file contains the Taler Wire Gateway API handlers.
+
+package tech.libeufin.bank.api
+
+import io.ktor.http.*
+import io.ktor.server.application.*
+import io.ktor.server.request.*
+import io.ktor.server.response.*
+import io.ktor.server.routing.*
+import io.ktor.util.pipeline.*
+import tech.libeufin.bank.*
+import tech.libeufin.bank.auth.auth
+import tech.libeufin.bank.auth.authAdmin
+import tech.libeufin.bank.auth.username
+import tech.libeufin.bank.db.Database
+import tech.libeufin.bank.db.ExchangeDAO
+import tech.libeufin.bank.db.ExchangeDAO.AddIncomingResult
+import tech.libeufin.bank.db.ExchangeDAO.TransferResult
+import tech.libeufin.common.BankPaytoCtx
+import tech.libeufin.common.TalerErrorCode
+import java.time.Instant
+
+
+fun Routing.wireGatewayApi(db: Database, ctx: BankConfig) {
+ auth(db, TokenScope.readwrite) {
+ get("/accounts/{USERNAME}/taler-wire-gateway/config") {
+ call.respond(WireGatewayConfig(
+ currency = ctx.regionalCurrency
+ ))
+ }
+ post("/accounts/{USERNAME}/taler-wire-gateway/transfer") {
+ val req = call.receive<TransferRequest>()
+ ctx.checkRegionalCurrency(req.amount)
+ val res = db.exchange.transfer(
+ req = req,
+ login = username,
+ now = Instant.now()
+ )
+ when (res) {
+ is TransferResult.UnknownExchange -> throw unknownAccount(username)
+ is TransferResult.NotAnExchange -> throw conflict(
+ "$username is not an exchange account.",
+ TalerErrorCode.BANK_ACCOUNT_IS_NOT_EXCHANGE
+ )
+ is TransferResult.UnknownCreditor -> throw unknownCreditorAccount(req.credit_account.canonical)
+ is TransferResult.BothPartyAreExchange -> throw conflict(
+ "Wire transfer attempted with credit and debit party being both exchange account",
+ TalerErrorCode.BANK_ACCOUNT_IS_EXCHANGE
+ )
+ is TransferResult.ReserveUidReuse -> throw conflict(
+ "request_uid used already",
+ TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED
+ )
+ is TransferResult.BalanceInsufficient -> throw conflict(
+ "Insufficient balance for exchange",
+ TalerErrorCode.BANK_UNALLOWED_DEBIT
+ )
+ is TransferResult.Success -> call.respond(
+ TransferResponse(
+ timestamp = res.timestamp,
+ row_id = res.id
+ )
+ )
+ }
+ }
+ }
+ auth(db, TokenScope.readonly) {
+ suspend fun <T> PipelineContext<Unit, ApplicationCall>.historyEndpoint(
+ reduce: (List<T>, String) -> Any,
+ dbLambda: suspend ExchangeDAO.(HistoryParams, Long, BankPaytoCtx) -> List<T>
+ ) {
+ val params = HistoryParams.extract(context.request.queryParameters)
+ val bankAccount = call.bankInfo(db, ctx.payto)
+
+ if (!bankAccount.isTalerExchange)
+ throw conflict(
+ "$username is not an exchange account.",
+ TalerErrorCode.BANK_ACCOUNT_IS_NOT_EXCHANGE
+ )
+
+ val items = db.exchange.dbLambda(params, bankAccount.bankAccountId, ctx.payto)
+
+ if (items.isEmpty()) {
+ call.respond(HttpStatusCode.NoContent)
+ } else {
+ call.respond(reduce(items, bankAccount.payto))
+ }
+ }
+ get("/accounts/{USERNAME}/taler-wire-gateway/history/incoming") {
+ historyEndpoint(::IncomingHistory, ExchangeDAO::incomingHistory)
+ }
+ get("/accounts/{USERNAME}/taler-wire-gateway/history/outgoing") {
+ historyEndpoint(::OutgoingHistory, ExchangeDAO::outgoingHistory)
+ }
+ }
+ authAdmin(db, TokenScope.readwrite) {
+ post("/accounts/{USERNAME}/taler-wire-gateway/admin/add-incoming") {
+ val req = call.receive<AddIncomingRequest>()
+ ctx.checkRegionalCurrency(req.amount)
+ val timestamp = Instant.now()
+ val res = db.exchange.addIncoming(
+ req = req,
+ login = username,
+ now = timestamp
+ )
+ when (res) {
+ is AddIncomingResult.UnknownExchange -> throw unknownAccount(username)
+ is AddIncomingResult.NotAnExchange -> throw conflict(
+ "$username is not an exchange account.",
+ TalerErrorCode.BANK_ACCOUNT_IS_NOT_EXCHANGE
+ )
+ is AddIncomingResult.UnknownDebtor -> throw conflict(
+ "Debtor account ${req.debit_account} was not found",
+ TalerErrorCode.BANK_UNKNOWN_DEBTOR
+ )
+ is AddIncomingResult.BothPartyAreExchange -> throw conflict(
+ "Wire transfer attempted with credit and debit party being both exchange account",
+ TalerErrorCode.BANK_ACCOUNT_IS_EXCHANGE
+ )
+ is AddIncomingResult.ReservePubReuse -> throw conflict(
+ "reserve_pub used already",
+ TalerErrorCode.BANK_DUPLICATE_RESERVE_PUB_SUBJECT
+ )
+ is AddIncomingResult.BalanceInsufficient -> throw conflict(
+ "Insufficient balance for debitor",
+ TalerErrorCode.BANK_UNALLOWED_DEBIT
+ )
+ is AddIncomingResult.Success -> call.respond(
+ AddIncomingResponse(
+ timestamp = TalerProtocolTimestamp(timestamp),
+ row_id = res.id
+ )
+ )
+ }
+ }
+ }
+}
+\ 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
@@ -38,7 +38,7 @@ private val AUTH_IS_ADMIN = AttributeKey<Boolean>("is_admin")
private val AUTH_TOKEN = AttributeKey<ByteArray>("auth_token")
/** Get username of the request account */
-val ApplicationCall.username: String get() = expectParameter("USERNAME")
+val ApplicationCall.username: String get() = parameters.expect("USERNAME")
/** Get username of the request account */
val PipelineContext<Unit, ApplicationCall>.username: String get() = call.username
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt b/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt
@@ -39,11 +39,23 @@ import tech.libeufin.bank.db.Database
import tech.libeufin.common.*
import java.util.*
-fun ApplicationCall.expectParameter(name: String) =
- parameters[name] ?: throw badRequest(
- "Missing '$name' param",
- TalerErrorCode.GENERIC_PARAMETER_MISSING
- )
+fun ApplicationCall.uuidPath(name: String): UUID {
+ val value = parameters[name]!!
+ try {
+ return UUID.fromString(value)
+ } catch (e: Exception) {
+ throw badRequest("UUID uri component malformed: ${e.message}", TalerErrorCode.GENERIC_PARAMETER_MALFORMED) // TODO better error ?
+ }
+}
+
+fun ApplicationCall.longPath(name: String): Long {
+ val value = parameters[name]!!
+ try {
+ return value.toLong()
+ } catch (e: Exception) {
+ throw badRequest("Long uri component malformed: ${e.message}", TalerErrorCode.GENERIC_PARAMETER_MALFORMED) // TODO better error ?
+ }
+}
/** Retrieve the bank account info for the selected username*/
suspend fun ApplicationCall.bankInfo(db: Database, ctx: BankPaytoCtx): BankInfo
@@ -82,22 +94,6 @@ fun ApplicationRequest.withdrawConfirmUrl(id: UUID) = url {
appendEncodedPathSegments("webui", "#", "operation", id.toString())
}
-fun ApplicationCall.uuidParameter(name: String): UUID {
- try {
- return UUID.fromString(expectParameter(name))
- } catch (e: Exception) {
- throw badRequest("UUID uri component malformed: ${e.message}")
- }
-}
-
-fun ApplicationCall.longParameter(name: String): Long {
- try {
- return expectParameter(name).toLong()
- } catch (e: Exception) {
- throw badRequest("Long uri component malformed: ${e.message}")
- }
-}
-
/**
* This function creates the admin account ONLY IF it was
* NOT found in the database. It sets it to a random password that
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/params.kt b/bank/src/main/kotlin/tech/libeufin/bank/params.kt
@@ -0,0 +1,176 @@
+/*
+ * This file is part of LibEuFin.
+ * Copyright (C) 2023 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
+ * published by the Free Software Foundation; either version 3, or
+ * (at your option) any later version.
+
+ * LibEuFin 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 Affero General
+ * Public License for more details.
+
+ * You should have received a copy of the GNU Affero General Public
+ * License along with LibEuFin; see the file COPYING. If not, see
+ * <http://www.gnu.org/licenses/>
+ */
+
+package tech.libeufin.bank
+
+import io.ktor.http.*
+import tech.libeufin.common.TalerAmount
+import tech.libeufin.common.TalerErrorCode
+import java.time.OffsetDateTime
+import java.time.ZoneOffset
+import java.time.temporal.TemporalAdjusters
+import java.util.UUID
+
+fun Parameters.expect(name: String): String
+ = get(name) ?: throw badRequest("Missing '$name' parameter", TalerErrorCode.GENERIC_PARAMETER_MISSING)
+fun Parameters.int(name: String): Int?
+ = get(name)?.run { toIntOrNull() ?: throw badRequest("Param '$name' not a number", TalerErrorCode.GENERIC_PARAMETER_MALFORMED) }
+fun Parameters.expectInt(name: String): Int
+ = int(name) ?: throw badRequest("Missing '$name' number parameter", TalerErrorCode.GENERIC_PARAMETER_MISSING)
+fun Parameters.long(name: String): Long?
+ = get(name)?.run { toLongOrNull() ?: throw badRequest("Param '$name' not a number", TalerErrorCode.GENERIC_PARAMETER_MALFORMED) }
+fun Parameters.expectLong(name: String): Long
+ = long(name) ?: throw badRequest("Missing '$name' number parameter", TalerErrorCode.GENERIC_PARAMETER_MISSING)
+fun Parameters.uuid(name: String): UUID? {
+ return get(name)?.run {
+ try {
+ UUID.fromString(this)
+ } catch (e: Exception) {
+ println("$this $e")
+ throw badRequest("Param '$name' not an UUID", TalerErrorCode.GENERIC_PARAMETER_MALFORMED)
+ }
+ }
+}
+fun Parameters.expectUuid(name: String): UUID
+ = uuid(name) ?: throw badRequest("Missing '$name' UUID parameter", TalerErrorCode.GENERIC_PARAMETER_MISSING)
+fun Parameters.amount(name: String): TalerAmount?
+ = get(name)?.run {
+ try {
+ TalerAmount(this)
+ } catch (e: Exception) {
+ throw badRequest("Param '$name' not a taler amount", TalerErrorCode.GENERIC_PARAMETER_MALFORMED)
+ }
+ }
+
+data class MonitorParams(
+ val timeframe: Timeframe,
+ val which: Int?
+) {
+ companion object {
+ val names = Timeframe.entries.map { it.name }
+ val names_fmt = names.joinToString()
+ fun extract(params: Parameters): MonitorParams {
+ val raw = params.get("timeframe") ?: "hour"
+ if (!names.contains(raw)) {
+ throw badRequest("Param 'timeframe' must be one of $names_fmt", TalerErrorCode.GENERIC_PARAMETER_MALFORMED)
+ }
+ val timeframe = Timeframe.valueOf(raw)
+ val which = params.int("which")
+ if (which != null) {
+ val lastDayOfMonth = OffsetDateTime.now(ZoneOffset.UTC).with(TemporalAdjusters.lastDayOfMonth()).dayOfMonth
+ when {
+ timeframe == Timeframe.hour && (0 > which || which > 23) ->
+ throw badRequest("For hour timestamp param 'which' must be between 00 to 23", TalerErrorCode.GENERIC_PARAMETER_MALFORMED)
+ timeframe == Timeframe.day && (1 > which || which > lastDayOfMonth) ->
+ throw badRequest("For day timestamp param 'which' must be between 1 to $lastDayOfMonth", TalerErrorCode.GENERIC_PARAMETER_MALFORMED)
+ timeframe == Timeframe.month && (1 > which || which > 12) ->
+ throw badRequest("For month timestamp param 'which' must be between 1 to 12", TalerErrorCode.GENERIC_PARAMETER_MALFORMED)
+ timeframe == Timeframe.year && (1 > which|| which > 9999) ->
+ throw badRequest("For year timestamp param 'which' must be between 0001 to 9999", TalerErrorCode.GENERIC_PARAMETER_MALFORMED)
+ else -> {}
+ }
+ }
+ return MonitorParams(timeframe, which)
+ }
+ }
+}
+
+data class AccountParams(
+ val page: PageParams, val loginFilter: String
+) {
+ companion object {
+ fun extract(params: Parameters): AccountParams {
+ val loginFilter = params.get("filter_name")?.run { "%$this%" } ?: "%"
+ return AccountParams(PageParams.extract(params), loginFilter)
+ }
+ }
+}
+
+data class PageParams(
+ val delta: Int, val start: Long
+) {
+ companion object {
+ fun extract(params: Parameters): PageParams {
+ val delta: Int = params.int("delta") ?: -20
+ val start: Long = params.long("start") ?: if (delta >= 0) 0L else Long.MAX_VALUE
+ if (start < 0) throw badRequest("Param 'start' must be a positive number", TalerErrorCode.GENERIC_PARAMETER_MALFORMED)
+ // TODO enforce delta limit
+ return PageParams(delta, start)
+ }
+ }
+}
+
+data class PollingParams(
+ val poll_ms: Long
+) {
+ companion object {
+ fun extract(params: Parameters): PollingParams {
+ val poll_ms: Long = params.long("long_poll_ms") ?: 0
+ if (poll_ms < 0) throw badRequest("Param 'long_poll_ms' must be a positive number", TalerErrorCode.GENERIC_PARAMETER_MALFORMED)
+ return PollingParams(poll_ms)
+ }
+ }
+}
+
+data class HistoryParams(
+ val page: PageParams, val polling: PollingParams
+) {
+ companion object {
+ fun extract(params: Parameters): HistoryParams {
+ return HistoryParams(PageParams.extract(params), PollingParams.extract(params))
+ }
+ }
+}
+
+data class RateParams(
+ val debit: TalerAmount?, val credit: TalerAmount?
+) {
+ companion object {
+ fun extract(params: Parameters): RateParams {
+ val debit = params.amount("amount_debit")
+ val credit = params.amount("amount_credit")
+ if (debit == null && credit == null) {
+ throw badRequest("Either param 'amount_debit' or 'amount_credit' is required", TalerErrorCode.GENERIC_PARAMETER_MISSING)
+ } else if (debit != null && credit != null) {
+ throw badRequest("Cannot have both 'amount_debit' and 'amount_credit' params", TalerErrorCode.GENERIC_PARAMETER_MALFORMED)
+ }
+ return RateParams(debit, credit)
+ }
+ }
+}
+
+data class StatusParams(
+ val polling: PollingParams,
+ val old_state: WithdrawalStatus
+) {
+ companion object {
+ val names = WithdrawalStatus.entries.map { it.name }
+ val names_fmt = names.joinToString()
+ fun extract(params: Parameters): StatusParams {
+ val old_state = params.get("old_state") ?: "pending"
+ if (!names.contains(old_state)) {
+ throw badRequest("Param 'old_state' must be one of $names_fmt", TalerErrorCode.GENERIC_PARAMETER_MALFORMED)
+ }
+ return StatusParams(
+ polling = PollingParams.extract(params),
+ old_state = WithdrawalStatus.valueOf(old_state)
+ )
+ }
+ }
+}
+\ No newline at end of file
diff --git a/bank/src/test/kotlin/BankIntegrationApiTest.kt b/bank/src/test/kotlin/BankIntegrationApiTest.kt
@@ -63,7 +63,7 @@ class BankIntegrationApiTest {
// Check bad UUID
client.get("/taler-integration/withdrawal-operation/chocolate")
- .assertBadRequest()
+ .assertBadRequest(TalerErrorCode.GENERIC_PARAMETER_MALFORMED)
}
// POST /taler-integration/withdrawal-operation/UUID
@@ -78,7 +78,7 @@ class BankIntegrationApiTest {
// Check bad UUID
client.post("/taler-integration/withdrawal-operation/chocolate") {
json(req)
- }.assertBadRequest()
+ }.assertBadRequest(TalerErrorCode.GENERIC_PARAMETER_MALFORMED)
// Check unknown
client.post("/taler-integration/withdrawal-operation/${UUID.randomUUID()}") {
@@ -180,7 +180,8 @@ class BankIntegrationApiTest {
}
// Check bad UUID
- client.postA("/taler-integration/withdrawal-operation/chocolate/abort").assertBadRequest()
+ client.postA("/taler-integration/withdrawal-operation/chocolate/abort")
+ .assertBadRequest(TalerErrorCode.GENERIC_PARAMETER_MALFORMED)
// Check unknown
client.postA("/taler-integration/withdrawal-operation/${UUID.randomUUID()}/abort")
diff --git a/bank/src/test/kotlin/CoreBankApiTest.kt b/bank/src/test/kotlin/CoreBankApiTest.kt
@@ -1021,7 +1021,7 @@ class CoreBankWithdrawalApiTest {
statusRoutine<WithdrawalPublicInfo>("/withdrawals") { it.status }
// Check bad UUID
- client.get("/withdrawals/chocolate").assertBadRequest()
+ client.get("/withdrawals/chocolate").assertBadRequest(TalerErrorCode.GENERIC_PARAMETER_MALFORMED)
// Check unknown
client.get("/withdrawals/${UUID.randomUUID()}")
@@ -1098,7 +1098,8 @@ class CoreBankWithdrawalApiTest {
}
// Check bad UUID
- client.postA("/accounts/merchant/withdrawals/chocolate/confirm").assertBadRequest()
+ client.postA("/accounts/merchant/withdrawals/chocolate/confirm")
+ .assertBadRequest(TalerErrorCode.GENERIC_PARAMETER_MALFORMED)
// Check unknown
client.postA("/accounts/merchant/withdrawals/${UUID.randomUUID()}/confirm")
@@ -1231,7 +1232,7 @@ class CoreBankCashoutApiTest {
// Check bad UUID
client.getA("/accounts/customer/cashouts/chocolate")
- .assertBadRequest()
+ .assertBadRequest(TalerErrorCode.GENERIC_PARAMETER_MALFORMED)
// Check unknown
client.getA("/accounts/customer/cashouts/42")