commit ffb74ead5035f3218f137b855e85edc91a8e4552 parent 752ec1fde3afdaf52ae7db7f20045844aff83e0c Author: Antoine A <> Date: Fri, 17 Nov 2023 15:23:06 +0000 Clean code and improve documentation Diffstat:
33 files changed, 1400 insertions(+), 1472 deletions(-)
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Authentication.kt b/bank/src/main/kotlin/tech/libeufin/bank/Authentication.kt @@ -21,15 +21,13 @@ package tech.libeufin.bank import io.ktor.http.* import io.ktor.server.application.* import io.ktor.server.routing.Route -import io.ktor.server.routing.RouteSelector -import io.ktor.server.routing.RoutingResolveContext -import io.ktor.server.routing.RouteSelectorEvaluation import io.ktor.util.AttributeKey import io.ktor.util.pipeline.PipelineContext +import java.time.Instant import net.taler.common.errorcodes.TalerErrorCode -import tech.libeufin.util.* import net.taler.wallet.crypto.Base32Crockford -import java.time.Instant +import tech.libeufin.bank.AccountDAO.* +import tech.libeufin.util.* private val AUTH_IS_ADMIN = AttributeKey<Boolean>("is_admin"); @@ -136,7 +134,7 @@ private suspend fun doBasicAuth(db: Database, encodedCredentials: String): Strin TalerErrorCode.GENERIC_HTTP_HEADERS_MALFORMED ) val (login, plainPassword) = userAndPassSplit - val passwordHash = db.customerPasswordHashFromLogin(login) ?: throw unauthorized() + val passwordHash = db.account.passwordHash(login) ?: throw unauthorized() if (!CryptoUtil.checkpw(plainPassword, passwordHash)) return null return login } @@ -171,7 +169,7 @@ private suspend fun doTokenAuth( e.message, TalerErrorCode.GENERIC_HTTP_HEADERS_MALFORMED ) } - val maybeToken: BearerToken? = db.bearerTokenGet(tokenBytes) + val maybeToken: BearerToken? = db.token.get(tokenBytes) if (maybeToken == null) { logger.error("Auth token not found") return null @@ -189,7 +187,7 @@ private suspend fun doTokenAuth( return null } // Getting the related username. - return db.customerLoginFromId(maybeToken.bankCustomer) ?: throw libeufinError( + return db.account.login(maybeToken.bankCustomer) ?: throw libeufinError( HttpStatusCode.InternalServerError, "Customer not found, despite token mentions it.", TalerErrorCode.GENERIC_INTERNAL_INVARIANT_FAILURE diff --git a/bank/src/main/kotlin/tech/libeufin/bank/BankIntegrationApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/BankIntegrationApi.kt @@ -25,9 +25,9 @@ import io.ktor.server.application.* import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* -import net.taler.common.errorcodes.TalerErrorCode import java.util.* -import tech.libeufin.bank.PollingParams +import net.taler.common.errorcodes.TalerErrorCode +import tech.libeufin.bank.WithdrawalDAO.* fun Routing.bankIntegrationApi(db: Database, ctx: BankConfig) { get("/taler-integration/config") { @@ -59,39 +59,39 @@ fun Routing.bankIntegrationApi(db: Database, ctx: BankConfig) { val opId = call.uuidUriComponent("wopid") val req = call.receive<BankWithdrawalOperationPostRequest>() - val (result, confirmationDone) = db.withdrawal.setDetails( + val res = db.withdrawal.setDetails( opId, req.selected_exchange, req.reserve_pub ) - when (result) { - WithdrawalSelectionResult.OP_NOT_FOUND -> throw notFound( + when (res) { + is WithdrawalSelectionResult.UnknownOperation -> throw notFound( "Withdrawal operation $opId not found", TalerErrorCode.BANK_TRANSACTION_NOT_FOUND ) - WithdrawalSelectionResult.ALREADY_SELECTED -> throw conflict( + is WithdrawalSelectionResult.AlreadySelected -> throw conflict( "Cannot select different exchange and reserve pub. under the same withdrawal operation", TalerErrorCode.BANK_WITHDRAWAL_OPERATION_RESERVE_SELECTION_CONFLICT ) - WithdrawalSelectionResult.RESERVE_PUB_REUSE -> throw conflict( + is WithdrawalSelectionResult.RequestPubReuse -> throw conflict( "Reserve pub. already used", TalerErrorCode.BANK_DUPLICATE_RESERVE_PUB_SUBJECT ) - WithdrawalSelectionResult.ACCOUNT_NOT_FOUND -> throw conflict( + is WithdrawalSelectionResult.UnknownAccount -> throw conflict( "Account ${req.selected_exchange.canonical} not found", TalerErrorCode.BANK_UNKNOWN_ACCOUNT ) - WithdrawalSelectionResult.ACCOUNT_IS_NOT_EXCHANGE -> throw conflict( + is WithdrawalSelectionResult.AccountIsNotExchange -> throw conflict( "Account ${req.selected_exchange.canonical} is not an exchange", TalerErrorCode.BANK_ACCOUNT_IS_NOT_EXCHANGE ) - WithdrawalSelectionResult.SUCCESS -> { - val confirmUrl: String? = if (ctx.spaCaptchaURL !== null && !confirmationDone) { + is WithdrawalSelectionResult.Success -> { + val confirmUrl: String? = if (ctx.spaCaptchaURL !== null && !res.confirmed) { getWithdrawalConfirmUrl( baseUrl = ctx.spaCaptchaURL, wopId = opId ) } else null call.respond(BankWithdrawalOperationPostResponse( - transfer_done = confirmationDone, confirm_transfer_url = confirmUrl + transfer_done = res.confirmed, confirm_transfer_url = confirmUrl )) } } diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Config.kt b/bank/src/main/kotlin/tech/libeufin/bank/Config.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2019 Stanisci and Dold. + * 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 @@ -21,10 +21,10 @@ package tech.libeufin.bank import ConfigSource import TalerConfig import TalerConfigError +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json import org.slf4j.Logger import org.slf4j.LoggerFactory -import kotlinx.serialization.json.Json -import kotlinx.serialization.Serializable import tech.libeufin.util.DatabaseConfig private val logger: Logger = LoggerFactory.getLogger("tech.libeufin.bank.Config") diff --git a/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt @@ -27,17 +27,20 @@ import java.time.Duration import java.time.Instant import java.time.temporal.ChronoUnit import java.util.* -import java.util.concurrent.TimeUnit -import java.io.File import kotlin.random.Random +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.future.await +import kotlinx.coroutines.withContext import net.taler.common.errorcodes.TalerErrorCode import net.taler.wallet.crypto.Base32Crockford import org.slf4j.Logger import org.slf4j.LoggerFactory +import tech.libeufin.bank.AccountDAO.* +import tech.libeufin.bank.CashoutDAO.* +import tech.libeufin.bank.ExchangeDAO.* +import tech.libeufin.bank.TransactionDAO.* +import tech.libeufin.bank.WithdrawalDAO.* import tech.libeufin.util.* -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import kotlinx.coroutines.future.await private val logger: Logger = LoggerFactory.getLogger("tech.libeufin.bank.accountsMgmtHandlers") @@ -80,7 +83,7 @@ private fun Routing.coreBankTokenApi(db: Database) { if (maybeAuthToken != null) { val tokenBytes = Base32Crockford.decode(maybeAuthToken) val refreshingToken = - db.bearerTokenGet(tokenBytes) + db.token.get(tokenBytes) ?: throw internalServerError( "Token used to auth not found in the database!" ) @@ -107,7 +110,7 @@ private fun Routing.coreBankTokenApi(db: Database) { throw badRequest("Bad token duration: ${e.message}") } } - if (!db.bearerTokenCreate( + if (!db.token.create( login = username, content = tokenBytes, creationTime = creationTime, @@ -128,17 +131,7 @@ private fun Routing.coreBankTokenApi(db: Database) { auth(db, TokenScope.readonly) { delete("/accounts/{USERNAME}/token") { val token = call.getAuthToken() ?: throw badRequest("Basic auth not supported here.") - - /** - * Not sanity-checking the token, as it was used by the authentication already. If harder - * errors happen, then they'll get Ktor respond with 500. - */ - db.bearerTokenDelete(Base32Crockford.decode(token)) - /** - * Responding 204 regardless of it being actually deleted or not. If it wasn't found, then - * it must have been deleted before we reached here, but the token was valid as it served - * the authentication => no reason to fail the request. - */ + db.token.delete(Base32Crockford.decode(token)) call.respond(HttpStatusCode.NoContent) } } @@ -156,7 +149,7 @@ private fun Routing.coreBankAccountsApi(db: Database, ctx: BankConfig) { ) val internalPayto = req.internal_payto_uri ?: IbanPayTo(genIbanPaytoUri()) - val result = db.accountCreate( + val result = db.account.create( login = req.username, name = req.name, email = req.challenge_contact_data?.email, @@ -172,19 +165,19 @@ private fun Routing.coreBankAccountsApi(db: Database, ctx: BankConfig) { ) when (result) { - CustomerCreationResult.BALANCE_INSUFFICIENT -> throw conflict( + AccountCreationResult.BonusBalanceInsufficient -> throw conflict( "Insufficient admin funds to grant bonus", TalerErrorCode.BANK_UNALLOWED_DEBIT ) - CustomerCreationResult.CONFLICT_LOGIN -> throw conflict( + AccountCreationResult.LoginReuse -> throw conflict( "Customer username reuse '${req.username}'", TalerErrorCode.BANK_REGISTER_USERNAME_REUSE ) - CustomerCreationResult.CONFLICT_PAY_TO -> throw conflict( + AccountCreationResult.PayToReuse -> throw conflict( "Bank internalPayToUri reuse '${internalPayto.canonical}'", TalerErrorCode.BANK_REGISTER_PAYTO_URI_REUSE ) - CustomerCreationResult.SUCCESS -> call.respond(HttpStatusCode.Created) + AccountCreationResult.Success -> call.respond(HttpStatusCode.Created) } } } @@ -202,16 +195,16 @@ private fun Routing.coreBankAccountsApi(db: Database, ctx: BankConfig) { TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT ) - when (db.customerDeleteIfBalanceIsZero(username)) { - CustomerDeletionResult.CUSTOMER_NOT_FOUND -> throw notFound( + when (db.account.delete(username)) { + AccountDeletionResult.UnknownAccount -> throw notFound( "Account '$username' not found", TalerErrorCode.BANK_UNKNOWN_ACCOUNT ) - CustomerDeletionResult.BALANCE_NOT_ZERO -> throw conflict( + AccountDeletionResult.BalanceNotZero -> throw conflict( "Account balance is not zero.", TalerErrorCode.BANK_ACCOUNT_BALANCE_NOT_ZERO ) - CustomerDeletionResult.SUCCESS -> call.respond(HttpStatusCode.NoContent) + AccountDeletionResult.Success -> call.respond(HttpStatusCode.NoContent) } } } @@ -226,7 +219,7 @@ private fun Routing.coreBankAccountsApi(db: Database, ctx: BankConfig) { TalerErrorCode.BANK_PATCH_ADMIN_EXCHANGE ) - val res = db.accountReconfig( + val res = db.account.reconfig( login = username, name = req.name, cashoutPayto = req.cashout_payto_uri, @@ -237,16 +230,16 @@ private fun Routing.coreBankAccountsApi(db: Database, ctx: BankConfig) { isAdmin = isAdmin ) when (res) { - CustomerPatchResult.SUCCESS -> call.respond(HttpStatusCode.NoContent) - CustomerPatchResult.ACCOUNT_NOT_FOUND -> throw notFound( + AccountPatchResult.Success -> call.respond(HttpStatusCode.NoContent) + AccountPatchResult.UnknownAccount -> throw notFound( "Account '$username' not found", TalerErrorCode.BANK_UNKNOWN_ACCOUNT ) - CustomerPatchResult.CONFLICT_LEGAL_NAME -> throw conflict( + AccountPatchResult.NonAdminLegalName -> throw conflict( "non-admin user cannot change their legal name", TalerErrorCode.BANK_NON_ADMIN_PATCH_LEGAL_NAME ) - CustomerPatchResult.CONFLICT_DEBT_LIMIT -> throw conflict( + AccountPatchResult.NonAdminDebtLimit -> throw conflict( "non-admin user cannot change their debt limit", TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT ) @@ -260,13 +253,13 @@ private fun Routing.coreBankAccountsApi(db: Database, ctx: BankConfig) { TalerErrorCode.BANK_NON_ADMIN_PATCH_MISSING_OLD_PASSWORD ) } - when (db.accountReconfigPassword(username, req.new_password, req.old_password)) { - CustomerPatchAuthResult.SUCCESS -> call.respond(HttpStatusCode.NoContent) - CustomerPatchAuthResult.ACCOUNT_NOT_FOUND -> throw notFound( + when (db.account.reconfigPassword(username, req.new_password, req.old_password)) { + AccountPatchAuthResult.Success -> call.respond(HttpStatusCode.NoContent) + AccountPatchAuthResult.UnknownAccount -> throw notFound( "Account '$username' not found", TalerErrorCode.BANK_UNKNOWN_ACCOUNT ) - CustomerPatchAuthResult.CONFLICT_BAD_PASSWORD -> throw conflict( + AccountPatchAuthResult.OldPasswordMismatch -> throw conflict( "old password does not match", TalerErrorCode.BANK_PATCH_BAD_OLD_PASSWORD ) @@ -275,7 +268,7 @@ private fun Routing.coreBankAccountsApi(db: Database, ctx: BankConfig) { } get("/public-accounts") { val params = AccountParams.extract(call.request.queryParameters) - val publicAccounts = db.accountsGetPublic(params) + val publicAccounts = db.account.pagePublic(params) if (publicAccounts.isEmpty()) { call.respond(HttpStatusCode.NoContent) } else { @@ -285,7 +278,7 @@ private fun Routing.coreBankAccountsApi(db: Database, ctx: BankConfig) { authAdmin(db, TokenScope.readonly) { get("/accounts") { val params = AccountParams.extract(call.request.queryParameters) - val accounts = db.accountsGetForAdmin(params) + val accounts = db.account.pageAdmin(params) if (accounts.isEmpty()) { call.respond(HttpStatusCode.NoContent) } else { @@ -295,7 +288,7 @@ private fun Routing.coreBankAccountsApi(db: Database, ctx: BankConfig) { } auth(db, TokenScope.readonly, allowAdmin = true) { get("/accounts/{USERNAME}") { - val account = db.accountDataFromLogin(username) ?: throw notFound( + val account = db.account.get(username) ?: throw notFound( "Account '$username' not found.", TalerErrorCode.BANK_UNKNOWN_ACCOUNT ) @@ -308,10 +301,10 @@ 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.bankAccount(db) + val bankAccount = call.bankInfo(db) val history: List<BankAccountTransactionInfo> = - db.bankPoolHistory(params, bankAccount.bankAccountId) + db.transaction.pollHistory(params, bankAccount.bankAccountId) if (history.isEmpty()) { call.respond(HttpStatusCode.NoContent) } else { @@ -320,7 +313,7 @@ private fun Routing.coreBankTransactionsApi(db: Database, ctx: BankConfig) { } get("/accounts/{USERNAME}/transactions/{T_ID}") { val tId = call.longUriComponent("T_ID") - val tx = db.bankTransactionGetFromInternalId(tId, username) ?: throw notFound( + val tx = db.transaction.get(tId, username) ?: throw notFound( "Bank transaction '$tId' not found", TalerErrorCode.BANK_TRANSACTION_NOT_FOUND ) @@ -334,31 +327,31 @@ private fun Routing.coreBankTransactionsApi(db: Database, ctx: BankConfig) { val amount = tx.payto_uri.amount ?: tx.amount ?: throw badRequest("Wire transfer lacks amount") ctx.checkRegionalCurrency(amount) - val (result, id) = db.bankTransaction( + val res = db.transaction.create( creditAccountPayto = tx.payto_uri, debitAccountUsername = username, subject = subject, amount = amount, timestamp = Instant.now(), ) - when (result) { - BankTransactionResult.NO_DEBTOR -> throw notFound( + when (res) { + is BankTransactionResult.UnknownDebtor -> throw notFound( "Account '$username' not found", TalerErrorCode.BANK_UNKNOWN_ACCOUNT ) - BankTransactionResult.SAME_ACCOUNT -> throw conflict( + is BankTransactionResult.BothPartySame -> throw conflict( "Wire transfer attempted with credit and debit party being the same bank account", TalerErrorCode.BANK_SAME_ACCOUNT ) - BankTransactionResult.NO_CREDITOR -> throw conflict( + is BankTransactionResult.UnknownCreditor -> throw conflict( "Creditor account was not found", TalerErrorCode.BANK_UNKNOWN_CREDITOR ) - BankTransactionResult.BALANCE_INSUFFICIENT -> throw conflict( + is BankTransactionResult.BalanceInsufficient -> throw conflict( "Insufficient funds", TalerErrorCode.BANK_UNALLOWED_DEBIT ) - BankTransactionResult.SUCCESS -> call.respond(TransactionCreateResponse(id!!)) + is BankTransactionResult.Success -> call.respond(TransactionCreateResponse(res.id)) } } } @@ -371,19 +364,19 @@ private fun Routing.coreBankWithdrawalApi(db: Database, ctx: BankConfig) { ctx.checkRegionalCurrency(req.amount) val opId = UUID.randomUUID() when (db.withdrawal.create(username, opId, req.amount)) { - WithdrawalCreationResult.ACCOUNT_NOT_FOUND -> throw notFound( + WithdrawalCreationResult.UnknownAccount -> throw notFound( "Account '$username' not found", TalerErrorCode.BANK_UNKNOWN_ACCOUNT ) - WithdrawalCreationResult.ACCOUNT_IS_EXCHANGE -> throw conflict( + WithdrawalCreationResult.AccountIsExchange -> throw conflict( "Exchange account cannot perform withdrawal operation", TalerErrorCode.BANK_ACCOUNT_IS_EXCHANGE ) - WithdrawalCreationResult.BALANCE_INSUFFICIENT -> throw conflict( + WithdrawalCreationResult.BalanceInsufficient -> throw conflict( "Insufficient funds to withdraw with Taler", TalerErrorCode.BANK_UNALLOWED_DEBIT ) - WithdrawalCreationResult.SUCCESS -> { + WithdrawalCreationResult.Success -> { val bankBaseUrl = call.request.getBaseUrl() ?: throw internalServerError("Bank could not find its own base URL") call.respond( @@ -407,41 +400,41 @@ private fun Routing.coreBankWithdrawalApi(db: Database, ctx: BankConfig) { post("/withdrawals/{withdrawal_id}/abort") { val opId = call.uuidUriComponent("withdrawal_id") when (db.withdrawal.abort(opId)) { - AbortResult.NOT_FOUND -> throw notFound( + AbortResult.UnknownOperation -> throw notFound( "Withdrawal operation $opId not found", TalerErrorCode.BANK_TRANSACTION_NOT_FOUND ) - AbortResult.CONFIRMED -> throw conflict( + AbortResult.AlreadyConfirmed -> throw conflict( "Cannot abort confirmed withdrawal", TalerErrorCode.BANK_ABORT_CONFIRM_CONFLICT ) - AbortResult.SUCCESS -> call.respond(HttpStatusCode.NoContent) + AbortResult.Success -> call.respond(HttpStatusCode.NoContent) } } post("/withdrawals/{withdrawal_id}/confirm") { val opId = call.uuidUriComponent("withdrawal_id") when (db.withdrawal.confirm(opId, Instant.now())) { - WithdrawalConfirmationResult.OP_NOT_FOUND -> throw notFound( + WithdrawalConfirmationResult.UnknownOperation -> throw notFound( "Withdrawal operation $opId not found", TalerErrorCode.BANK_TRANSACTION_NOT_FOUND ) - WithdrawalConfirmationResult.ABORTED -> throw conflict( + WithdrawalConfirmationResult.AlreadyAborted -> throw conflict( "Cannot confirm an aborted withdrawal", TalerErrorCode.BANK_CONFIRM_ABORT_CONFLICT ) - WithdrawalConfirmationResult.NOT_SELECTED -> throw conflict( + WithdrawalConfirmationResult.NotSelected -> throw conflict( "Cannot confirm an unselected withdrawal", TalerErrorCode.BANK_CONFIRM_INCOMPLETE ) - WithdrawalConfirmationResult.BALANCE_INSUFFICIENT -> throw conflict( + WithdrawalConfirmationResult.BalanceInsufficient -> throw conflict( "Insufficient funds", TalerErrorCode.BANK_UNALLOWED_DEBIT ) - WithdrawalConfirmationResult.EXCHANGE_NOT_FOUND -> throw conflict( + WithdrawalConfirmationResult.UnknownExchange -> throw conflict( "Exchange to withdraw from not found", TalerErrorCode.BANK_UNKNOWN_CREDITOR ) - WithdrawalConfirmationResult.SUCCESS -> call.respond(HttpStatusCode.NoContent) + WithdrawalConfirmationResult.Success -> call.respond(HttpStatusCode.NoContent) } } } @@ -469,7 +462,7 @@ private fun Routing.coreBankCashoutApi(db: Database, ctx: BankConfig) = conditio ) val res = db.cashout.create( - accountUsername = username, + login = username, requestUid = req.request_uid, amountDebit = req.amount_debit, amountCredit = req.amount_credit, @@ -480,32 +473,32 @@ private fun Routing.coreBankCashoutApi(db: Database, ctx: BankConfig) = conditio retryCounter = TAN_RETRY_COUNTER, validityPeriod = TAN_VALIDITY_PERIOD ) - when (res.status) { - CashoutCreationResult.ACCOUNT_NOT_FOUND -> throw notFound( + when (res) { + is CashoutCreationResult.AccountNotFound -> throw notFound( "Account '$username' not found", TalerErrorCode.BANK_UNKNOWN_ACCOUNT ) - CashoutCreationResult.BAD_CONVERSION -> throw conflict( + is CashoutCreationResult.BadConversion -> throw conflict( "Wrong currency conversion", TalerErrorCode.BANK_BAD_CONVERSION ) - CashoutCreationResult.ACCOUNT_IS_EXCHANGE -> throw conflict( + is CashoutCreationResult.AccountIsExchange -> throw conflict( "Exchange account cannot perform cashout operation", TalerErrorCode.BANK_ACCOUNT_IS_EXCHANGE ) - CashoutCreationResult.BALANCE_INSUFFICIENT -> throw conflict( + is CashoutCreationResult.BalanceInsufficient -> throw conflict( "Insufficient funds to withdraw with Taler", TalerErrorCode.BANK_UNALLOWED_DEBIT ) - CashoutCreationResult.MISSING_TAN_INFO -> throw conflict( + is CashoutCreationResult.MissingTanInfo -> throw conflict( "Account '$username' missing info for tan channel ${req.tan_channel}", TalerErrorCode.BANK_MISSING_TAN_INFO ) - CashoutCreationResult.REQUEST_UID_REUSE -> throw conflict( + is CashoutCreationResult.RequestUidReuse -> throw conflict( "request_uid used already", TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED ) - CashoutCreationResult.SUCCESS -> { + is CashoutCreationResult.Success -> { res.tanCode?.run { val exitValue = withContext(Dispatchers.IO) { val process = ProcessBuilder(tanScript, res.tanInfo).start() @@ -524,24 +517,24 @@ private fun Routing.coreBankCashoutApi(db: Database, ctx: BankConfig) = conditio TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED ) } - db.cashout.markSent(res.id!!, Instant.now(), TAN_RETRANSMISSION_PERIOD) + db.cashout.markSent(res.id, Instant.now(), TAN_RETRANSMISSION_PERIOD) } - call.respond(CashoutPending(res.id!!)) + call.respond(CashoutPending(res.id)) } } } post("/accounts/{USERNAME}/cashouts/{CASHOUT_ID}/abort") { val id = call.longUriComponent("CASHOUT_ID") when (db.cashout.abort(id, username)) { - AbortResult.NOT_FOUND -> throw notFound( + AbortResult.UnknownOperation -> throw notFound( "Cashout operation $id not found", TalerErrorCode.BANK_TRANSACTION_NOT_FOUND ) - AbortResult.CONFIRMED -> throw conflict( + AbortResult.AlreadyConfirmed -> throw conflict( "Cannot abort confirmed cashout", TalerErrorCode.BANK_ABORT_CONFIRM_CONFLICT ) - AbortResult.SUCCESS -> call.respond(HttpStatusCode.NoContent) + AbortResult.Success -> call.respond(HttpStatusCode.NoContent) } } post("/accounts/{USERNAME}/cashouts/{CASHOUT_ID}/confirm") { diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Error.kt b/bank/src/main/kotlin/tech/libeufin/bank/Error.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2023 Stanisci and Dold. + * 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 @@ -18,11 +18,10 @@ */ package tech.libeufin.bank +import io.ktor.http.* +import kotlinx.serialization.Serializable import net.taler.common.errorcodes.TalerErrorCode import tech.libeufin.util.* -import kotlinx.serialization.Serializable -import io.ktor.http.* - /** * Convenience type to throw errors along the bank activity * and that is meant to be caught by Ktor and responded to the diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Main.kt b/bank/src/main/kotlin/tech/libeufin/bank/Main.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2019 Stanisci and Dold. + * 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 @@ -21,7 +21,6 @@ package tech.libeufin.bank import com.github.ajalt.clikt.core.CliktCommand -import com.github.ajalt.clikt.core.context import com.github.ajalt.clikt.core.subcommands import com.github.ajalt.clikt.parameters.arguments.argument import com.github.ajalt.clikt.parameters.options.flag @@ -42,27 +41,22 @@ import io.ktor.server.response.* import io.ktor.server.routing.* import io.ktor.utils.io.* import io.ktor.utils.io.jvm.javaio.* +import java.time.Duration +import java.util.zip.DataFormatException +import java.util.zip.Inflater +import kotlin.system.exitProcess import kotlinx.coroutines.* import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.KSerializer import kotlinx.serialization.descriptors.* import kotlinx.serialization.json.* import net.taler.common.errorcodes.TalerErrorCode import org.slf4j.Logger import org.slf4j.LoggerFactory import org.slf4j.event.Level -import tech.libeufin.util.CryptoUtil +import tech.libeufin.bank.AccountDAO.* import tech.libeufin.util.getVersion import tech.libeufin.util.initializeDatabaseTables import tech.libeufin.util.resetDatabaseTables -import tech.libeufin.bank.libeufinError -import java.time.Duration -import java.time.Instant -import java.time.temporal.ChronoUnit -import java.util.zip.InflaterInputStream -import java.util.zip.Inflater -import java.util.zip.DataFormatException -import kotlin.system.exitProcess // GLOBALS private val logger: Logger = LoggerFactory.getLogger("tech.libeufin.bank.Main") @@ -332,7 +326,7 @@ class ChangePw : CliktCommand("Change account password", name = "passwd") { if (!maybeCreateAdminAccount(db, ctx)) // logs provided by the helper exitProcess(1) - if (db.accountReconfigPassword(account, password, null) != CustomerPatchAuthResult.SUCCESS) { + if (db.account.reconfigPassword(account, password, null) != AccountPatchAuthResult.Success) { println("password change failed") exitProcess(1) } else { diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Metadata.kt b/bank/src/main/kotlin/tech/libeufin/bank/Metadata.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2019 Stanisci and Dold. + * 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 diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Params.kt b/bank/src/main/kotlin/tech/libeufin/bank/Params.kt @@ -24,16 +24,11 @@ import io.ktor.server.application.* import io.ktor.server.plugins.* import io.ktor.server.request.* import io.ktor.server.util.* -import io.ktor.util.valuesOf -import net.taler.common.errorcodes.TalerErrorCode -import net.taler.wallet.crypto.Base32Crockford -import org.slf4j.Logger -import org.slf4j.LoggerFactory -import tech.libeufin.util.* -import java.net.URL import java.time.* import java.time.temporal.* import java.util.* +import net.taler.common.errorcodes.TalerErrorCode +import tech.libeufin.util.* fun Parameters.expect(name: String): String = get(name) ?: throw badRequest("Missing '$name' parameter", TalerErrorCode.GENERIC_PARAMETER_MISSING) diff --git a/bank/src/main/kotlin/tech/libeufin/bank/RevenueApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/RevenueApi.kt @@ -23,27 +23,14 @@ import io.ktor.server.application.* import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* -import java.time.Duration -import java.time.Instant -import java.time.temporal.ChronoUnit import java.util.* -import java.util.concurrent.TimeUnit -import java.io.File -import kotlin.random.Random -import net.taler.common.errorcodes.TalerErrorCode -import net.taler.wallet.crypto.Base32Crockford -import org.slf4j.Logger -import org.slf4j.LoggerFactory import tech.libeufin.util.* -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import kotlinx.coroutines.future.await fun Routing.revenueApi(db: Database) { auth(db, TokenScope.readonly) { get("/accounts/{USERNAME}/taler-revenue/history") { val params = HistoryParams.extract(context.request.queryParameters) - val bankAccount = call.bankAccount(db) + val bankAccount = call.bankInfo(db) val items = db.exchange.revenueHistory(params, bankAccount.bankAccountId); if (items.isEmpty()) { diff --git a/bank/src/main/kotlin/tech/libeufin/bank/TalerCommon.kt b/bank/src/main/kotlin/tech/libeufin/bank/TalerCommon.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2019 Stanisci and Dold. + * 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 @@ -20,26 +20,24 @@ package tech.libeufin.bank import io.ktor.http.* +import io.ktor.serialization.kotlinx.json.* import io.ktor.server.application.* -import kotlinx.serialization.Serializable -import net.taler.wallet.crypto.Base32Crockford -import net.taler.wallet.crypto.EncodingException +import java.net.* import java.time.Duration import java.time.Instant import java.time.temporal.ChronoUnit -import java.util.concurrent.TimeUnit import java.util.* -import java.math.BigInteger -import java.net.* +import java.util.concurrent.TimeUnit import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable import kotlinx.serialization.descriptors.* import kotlinx.serialization.encoding.* -import io.ktor.serialization.kotlinx.json.* import kotlinx.serialization.json.* import net.taler.common.errorcodes.TalerErrorCode +import net.taler.wallet.crypto.Base32Crockford +import net.taler.wallet.crypto.EncodingException import org.slf4j.Logger import org.slf4j.LoggerFactory -import org.slf4j.event.Level private val logger: Logger = LoggerFactory.getLogger("tech.libeufin.bank.TalerCommon") const val MAX_SAFE_INTEGER = 9007199254740991L; // 2^53 - 1 diff --git a/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt b/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt @@ -157,10 +157,10 @@ data class MonitorWithConversion( ) : MonitorResponse() /** - * Convenience type to get and set bank account information + * Convenience type to get bank account information * from/to the database. */ -data class BankAccount( +data class BankInfo( val internalPaytoUri: String, val bankAccountId: Long, val isTalerExchange: Boolean, diff --git a/bank/src/main/kotlin/tech/libeufin/bank/WireGatewayApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/WireGatewayApi.kt @@ -27,12 +27,11 @@ import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* import io.ktor.util.pipeline.PipelineContext +import java.time.Instant import net.taler.common.errorcodes.TalerErrorCode import org.slf4j.Logger import org.slf4j.LoggerFactory -import tech.libeufin.util.extractReservePubFromSubject -import java.time.Instant -import kotlin.math.abs +import tech.libeufin.bank.ExchangeDAO.* private val logger: Logger = LoggerFactory.getLogger("tech.libeufin.nexus") @@ -45,40 +44,40 @@ fun Routing.wireGatewayApi(db: Database, ctx: BankConfig) { post("/accounts/{USERNAME}/taler-wire-gateway/transfer") { val req = call.receive<TransferRequest>() ctx.checkRegionalCurrency(req.amount) - val dbRes = db.exchange.transfer( + val res = db.exchange.transfer( req = req, - username = username, - timestamp = Instant.now() + login = username, + now = Instant.now() ) - when (dbRes.txResult) { - TalerTransferResult.NO_DEBITOR -> throw notFound( + when (res) { + is TransferResult.UnknownExchange -> throw notFound( "Account '$username' not found", TalerErrorCode.BANK_UNKNOWN_ACCOUNT ) - TalerTransferResult.NOT_EXCHANGE -> throw conflict( + is TransferResult.NotAnExchange -> throw conflict( "$username is not an exchange account.", TalerErrorCode.BANK_ACCOUNT_IS_NOT_EXCHANGE ) - TalerTransferResult.NO_CREDITOR -> throw conflict( + is TransferResult.UnknownCreditor -> throw conflict( "Creditor account was not found", TalerErrorCode.BANK_UNKNOWN_CREDITOR ) - TalerTransferResult.BOTH_EXCHANGE -> throw conflict( + is TransferResult.BothPartyAreExchange -> throw conflict( "Wire transfer attempted with credit and debit party being both exchange account", TalerErrorCode.BANK_ACCOUNT_IS_EXCHANGE ) - TalerTransferResult.REQUEST_UID_REUSE -> throw conflict( + is TransferResult.ReserveUidReuse -> throw conflict( "request_uid used already", TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED ) - TalerTransferResult.BALANCE_INSUFFICIENT -> throw conflict( + is TransferResult.BalanceInsufficient -> throw conflict( "Insufficient balance for exchange", TalerErrorCode.BANK_UNALLOWED_DEBIT ) - TalerTransferResult.SUCCESS -> call.respond( + is TransferResult.Success -> call.respond( TransferResponse( - timestamp = dbRes.timestamp!!, - row_id = dbRes.txRowId!! + timestamp = res.timestamp, + row_id = res.id ) ) } @@ -90,7 +89,7 @@ fun Routing.wireGatewayApi(db: Database, ctx: BankConfig) { dbLambda: suspend ExchangeDAO.(HistoryParams, Long) -> List<T> ) { val params = HistoryParams.extract(context.request.queryParameters) - val bankAccount = call.bankAccount(db) + val bankAccount = call.bankInfo(db) if (!bankAccount.isTalerExchange) throw conflict( @@ -118,40 +117,40 @@ fun Routing.wireGatewayApi(db: Database, ctx: BankConfig) { val req = call.receive<AddIncomingRequest>() ctx.checkRegionalCurrency(req.amount) val timestamp = Instant.now() - val dbRes = db.exchange.addIncoming( + val res = db.exchange.addIncoming( req = req, - username = username, - timestamp = timestamp + login = username, + now = timestamp ) - when (dbRes.txResult) { - TalerAddIncomingResult.NO_CREDITOR -> throw notFound( + when (res) { + is AddIncomingResult.UnknownExchange -> throw notFound( "Account '$username' not found", TalerErrorCode.BANK_UNKNOWN_ACCOUNT ) - TalerAddIncomingResult.NOT_EXCHANGE -> throw conflict( + is AddIncomingResult.NotAnExchange -> throw conflict( "$username is not an exchange account.", TalerErrorCode.BANK_ACCOUNT_IS_NOT_EXCHANGE ) - TalerAddIncomingResult.NO_DEBITOR -> throw conflict( + is AddIncomingResult.UnknownDebtor -> throw conflict( "Debtor account was not found", TalerErrorCode.BANK_UNKNOWN_DEBTOR ) - TalerAddIncomingResult.BOTH_EXCHANGE -> throw conflict( + is AddIncomingResult.BothPartyAreExchange -> throw conflict( "Wire transfer attempted with credit and debit party being both exchange account", TalerErrorCode.BANK_ACCOUNT_IS_EXCHANGE ) - TalerAddIncomingResult.RESERVE_PUB_REUSE -> throw conflict( + is AddIncomingResult.ReservePubReuse -> throw conflict( "reserve_pub used already", TalerErrorCode.BANK_DUPLICATE_RESERVE_PUB_SUBJECT ) - TalerAddIncomingResult.BALANCE_INSUFFICIENT -> throw conflict( + is AddIncomingResult.BalanceInsufficient -> throw conflict( "Insufficient balance for debitor", TalerErrorCode.BANK_UNALLOWED_DEBIT ) - TalerAddIncomingResult.SUCCESS -> call.respond( + is AddIncomingResult.Success -> call.respond( AddIncomingResponse( timestamp = TalerProtocolTimestamp(timestamp), - row_id = dbRes.txRowId!! + row_id = res.id ) ) } diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt @@ -0,0 +1,438 @@ +/* + * 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 tech.libeufin.util.* +import java.time.* +import java.sql.Types + +/** Data access logic for accounts */ +class AccountDAO(private val db: Database) { + /** Result status of account creation */ + enum class AccountCreationResult { + Success, + LoginReuse, + PayToReuse, + BonusBalanceInsufficient, + } + + /** Create new account */ + suspend fun create( + login: String, + password: String, + name: String, + email: String? = null, + phone: String? = null, + cashoutPayto: IbanPayTo? = null, + internalPaytoUri: IbanPayTo, + isPublic: Boolean, + isTalerExchange: Boolean, + maxDebt: TalerAmount, + bonus: TalerAmount? + ): AccountCreationResult = db.serializable { it -> + val now = Instant.now().toDbMicros() ?: throw faultyTimestampByBank(); + it.transaction { conn -> + val idempotent = conn.prepareStatement(""" + SELECT password_hash, name=? + AND email IS NOT DISTINCT FROM ? + AND phone IS NOT DISTINCT FROM ? + AND cashout_payto IS NOT DISTINCT FROM ? + AND internal_payto_uri=? + AND is_public=? + AND is_taler_exchange=? + FROM customers + JOIN bank_accounts + ON customer_id=owning_customer_id + WHERE login=? + """).run { + setString(1, name) + setString(2, email) + setString(3, phone) + setString(4, cashoutPayto?.canonical) + setString(5, internalPaytoUri.canonical) + setBoolean(6, isPublic) + setBoolean(7, isTalerExchange) + setString(8, login) + oneOrNull { + CryptoUtil.checkpw(password, it.getString(1)) && it.getBoolean(2) + } + } + if (idempotent != null) { + if (idempotent) { + AccountCreationResult.Success + } else { + AccountCreationResult.LoginReuse + } + } else { + val customerId = conn.prepareStatement(""" + INSERT INTO customers ( + login + ,password_hash + ,name + ,email + ,phone + ,cashout_payto + ) VALUES (?, ?, ?, ?, ?, ?) + RETURNING customer_id + """ + ).run { + setString(1, login) + setString(2, CryptoUtil.hashpw(password)) + setString(3, name) + setString(4, email) + setString(5, phone) + setString(6, cashoutPayto?.canonical) + oneOrNull { it.getLong("customer_id") }!! + } + + conn.prepareStatement(""" + INSERT INTO iban_history( + iban + ,creation_time + ) VALUES (?, ?) + """).run { + setString(1, internalPaytoUri.iban) + setLong(2, now) + if (!executeUpdateViolation()) { + conn.rollback() + return@transaction AccountCreationResult.PayToReuse + } + } + + conn.prepareStatement(""" + INSERT INTO bank_accounts( + internal_payto_uri + ,owning_customer_id + ,is_public + ,is_taler_exchange + ,max_debt + ) VALUES (?, ?, ?, ?, (?, ?)::taler_amount) + """).run { + setString(1, internalPaytoUri.canonical) + setLong(2, customerId) + setBoolean(3, isPublic) + setBoolean(4, isTalerExchange) + setLong(5, maxDebt.value) + setInt(6, maxDebt.frac) + if (!executeUpdateViolation()) { + conn.rollback() + return@transaction AccountCreationResult.PayToReuse + } + } + + if (bonus != null) { + conn.prepareStatement(""" + SELECT out_balance_insufficient + FROM bank_transaction(?,'admin','bonus',(?,?)::taler_amount,?) + """).run { + setString(1, internalPaytoUri.canonical) + setLong(2, bonus.value) + setInt(3, bonus.frac) + setLong(4, now) + executeQuery().use { + when { + !it.next() -> throw internalServerError("Bank transaction didn't properly return") + it.getBoolean("out_balance_insufficient") -> { + conn.rollback() + AccountCreationResult.BonusBalanceInsufficient + } + else -> AccountCreationResult.Success + } + } + } + } else { + AccountCreationResult.Success + } + } + } + } + + /** Result status of account deletion */ + enum class AccountDeletionResult { + Success, + UnknownAccount, + BalanceNotZero + } + + /** Delete account [login] */ + suspend fun delete(login: String): AccountDeletionResult = db.serializable { conn -> + val stmt = conn.prepareStatement(""" + SELECT + out_nx_customer, + out_balance_not_zero + FROM customer_delete(?); + """) + stmt.setString(1, login) + stmt.executeQuery().use { + when { + !it.next() -> throw internalServerError("Deletion returned nothing.") + it.getBoolean("out_nx_customer") -> AccountDeletionResult.UnknownAccount + it.getBoolean("out_balance_not_zero") -> AccountDeletionResult.BalanceNotZero + else -> AccountDeletionResult.Success + } + } + } + + /** Result status of customer account patch */ + enum class AccountPatchResult { + UnknownAccount, + NonAdminLegalName, + NonAdminDebtLimit, + Success + } + + /** Change account [login] informations */ + suspend fun reconfig( + login: String, + name: String?, + cashoutPayto: IbanPayTo?, + phoneNumber: String?, + emailAddress: String?, + isTalerExchange: Boolean?, + debtLimit: TalerAmount?, + isAdmin: Boolean + ): AccountPatchResult = db.serializable { conn -> + val stmt = conn.prepareStatement(""" + SELECT + out_not_found, + out_legal_name_change, + out_debt_limit_change + FROM account_reconfig(?, ?, ?, ?, ?, ?, (?, ?)::taler_amount, ?) + """) + stmt.setString(1, login) + stmt.setString(2, name) + stmt.setString(3, phoneNumber) + stmt.setString(4, emailAddress) + stmt.setString(5, cashoutPayto?.canonical) + if (isTalerExchange == null) + stmt.setNull(6, Types.NULL) + else stmt.setBoolean(6, isTalerExchange) + if (debtLimit == null) { + stmt.setNull(7, Types.NULL) + stmt.setNull(8, Types.NULL) + } else { + stmt.setLong(7, debtLimit.value) + stmt.setInt(8, debtLimit.frac) + } + stmt.setBoolean(9, isAdmin) + stmt.executeQuery().use { + when { + !it.next() -> throw internalServerError("accountReconfig() returned nothing") + it.getBoolean("out_not_found") -> AccountPatchResult.UnknownAccount + it.getBoolean("out_legal_name_change") -> AccountPatchResult.NonAdminLegalName + it.getBoolean("out_debt_limit_change") -> AccountPatchResult.NonAdminDebtLimit + else -> AccountPatchResult.Success + } + } + } + + + /** Result status of customer account auth patch */ + enum class AccountPatchAuthResult { + UnknownAccount, + OldPasswordMismatch, + Success + } + + /** Change account [login] password to [newPw] if current match [oldPw] */ + suspend fun reconfigPassword(login: String, newPw: String, oldPw: String?): AccountPatchAuthResult = db.serializable { + it.transaction { conn -> + val currentPwh = conn.prepareStatement(""" + SELECT password_hash FROM customers WHERE login=? + """).run { + setString(1, login) + oneOrNull { it.getString(1) } + } + if (currentPwh == null) { + AccountPatchAuthResult.UnknownAccount + } else if (oldPw != null && !CryptoUtil.checkpw(oldPw, currentPwh)) { + AccountPatchAuthResult.OldPasswordMismatch + } else { + val stmt = conn.prepareStatement(""" + UPDATE customers SET password_hash=? where login=? + """) + stmt.setString(1, CryptoUtil.hashpw(newPw)) + stmt.setString(2, login) + stmt.executeUpdateCheck() + AccountPatchAuthResult.Success + } + } + } + + /** Get password hash of account [login] */ + suspend fun passwordHash(login: String): String? = db.conn { conn -> + val stmt = conn.prepareStatement(""" + SELECT password_hash FROM customers WHERE login=? + """) + stmt.setString(1, login) + stmt.oneOrNull { + it.getString(1) + } + } + + /** Get login of account [id] */ + suspend fun login(id: Long): String? = db.conn { conn -> + val stmt = conn.prepareStatement(""" + SELECT login FROM customers WHERE customer_id=? + """) + stmt.setLong(1, id) + stmt.oneOrNull { + it.getString(1) + } + } + + /** Get bank info of account [login] */ + suspend fun bankInfo(login: String): BankInfo? = db.conn { conn -> + val stmt = conn.prepareStatement(""" + SELECT + bank_account_id + ,internal_payto_uri + ,is_taler_exchange + FROM bank_accounts + JOIN customers + ON customer_id=owning_customer_id + WHERE login=? + """) + stmt.setString(1, login) + stmt.oneOrNull { + BankInfo( + internalPaytoUri = it.getString("internal_payto_uri"), + isTalerExchange = it.getBoolean("is_taler_exchange"), + bankAccountId = it.getLong("bank_account_id") + ) + } + } + + /** Get data of account [login] */ + suspend fun get(login: String): AccountData? = db.conn { conn -> + val stmt = conn.prepareStatement(""" + SELECT + name + ,email + ,phone + ,cashout_payto + ,internal_payto_uri + ,(balance).val AS balance_val + ,(balance).frac AS balance_frac + ,has_debt + ,(max_debt).val AS max_debt_val + ,(max_debt).frac AS max_debt_frac + FROM customers + JOIN bank_accounts + ON customer_id=owning_customer_id + WHERE login=? + """) + stmt.setString(1, login) + stmt.oneOrNull { + AccountData( + name = it.getString("name"), + contact_data = ChallengeContactData( + email = it.getString("email"), + phone = it.getString("phone") + ), + cashout_payto_uri = it.getString("cashout_payto"), + payto_uri = it.getString("internal_payto_uri"), + balance = Balance( + amount = it.getAmount("balance", db.bankCurrency), + credit_debit_indicator = + if (it.getBoolean("has_debt")) { + CreditDebitInfo.debit + } else { + CreditDebitInfo.credit + } + ), + debit_threshold = it.getAmount("max_debt", db.bankCurrency), + ) + } + } + + /** Get a page of all public accounts */ + suspend fun pagePublic(params: AccountParams): List<PublicAccount> + = db.page( + params.page, + "bank_account_id", + """ + SELECT + (balance).val AS balance_val, + (balance).frac AS balance_frac, + has_debt, + internal_payto_uri, + c.login + FROM bank_accounts JOIN customers AS c + ON owning_customer_id = c.customer_id + WHERE is_public=true AND c.login LIKE ? AND + """, + { + setString(1, params.loginFilter) + 1 + } + ) { + PublicAccount( + account_name = it.getString("login"), + payto_uri = it.getString("internal_payto_uri"), + balance = Balance( + amount = it.getAmount("balance", db.bankCurrency), + credit_debit_indicator = if (it.getBoolean("has_debt")) { + CreditDebitInfo.debit + } else { + CreditDebitInfo.credit + } + ) + ) + } + + /** Get a page of accounts */ + suspend fun pageAdmin(params: AccountParams): List<AccountMinimalData> + = db.page( + params.page, + "bank_account_id", + """ + SELECT + login, + name, + (b.balance).val AS balance_val, + (b.balance).frac AS balance_frac, + (b).has_debt AS balance_has_debt, + (max_debt).val as max_debt_val, + (max_debt).frac as max_debt_frac + FROM customers JOIN bank_accounts AS b + ON customer_id = b.owning_customer_id + WHERE name LIKE ? AND + """, + { + setString(1, params.loginFilter) + 1 + } + ) { + AccountMinimalData( + username = it.getString("login"), + name = it.getString("name"), + balance = Balance( + amount = it.getAmount("balance", db.bankCurrency), + credit_debit_indicator = if (it.getBoolean("balance_has_debt")) { + CreditDebitInfo.debit + } else { + CreditDebitInfo.credit + } + ), + debit_threshold = it.getAmount("max_debt", db.bankCurrency), + ) + } +} +\ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/CashoutDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/CashoutDAO.kt @@ -19,45 +19,28 @@ package tech.libeufin.bank -import java.time.Instant import java.time.Duration +import java.time.Instant import java.util.concurrent.TimeUnit import tech.libeufin.util.* -/** Result status of cashout operation creation */ -enum class CashoutCreationResult { - SUCCESS, - BAD_CONVERSION, - ACCOUNT_NOT_FOUND, - ACCOUNT_IS_EXCHANGE, - MISSING_TAN_INFO, - BALANCE_INSUFFICIENT, - REQUEST_UID_REUSE -} - -/** Result status of cashout operation confirmation */ -enum class CashoutConfirmationResult { - SUCCESS, - BAD_CONVERSION, - OP_NOT_FOUND, - BAD_TAN_CODE, - BALANCE_INSUFFICIENT, - NO_RETRY, - NO_CASHOUT_PAYTO, - ABORTED -} - +/** Data access logic for cashout operations */ class CashoutDAO(private val db: Database) { - - data class CashoutCreation( - val status: CashoutCreationResult, - val id: Long?, - val tanInfo: String?, - val tanCode: String? - ) + /** Result of cashout operation creation */ + sealed class CashoutCreationResult { + /** Cashout [id] has been created or refreshed. If [tanCode] is not null, use [tanInfo] to send it via [tanChannel] then call [markSent] */ + data class Success(val id: Long, val tanInfo: String, val tanCode: String?): CashoutCreationResult() + object BadConversion: CashoutCreationResult() + object AccountNotFound: CashoutCreationResult() + object AccountIsExchange: CashoutCreationResult() + object MissingTanInfo: CashoutCreationResult() + object BalanceInsufficient: CashoutCreationResult() + object RequestUidReuse: CashoutCreationResult() + } + /** Create a new cashout operation */ suspend fun create( - accountUsername: String, + login: String, requestUid: ShortHashCode, amountDebit: TalerAmount, amountCredit: TalerAmount, @@ -67,7 +50,7 @@ class CashoutDAO(private val db: Database) { now: Instant, retryCounter: Int, validityPeriod: Duration - ): CashoutCreation = db.serializable { conn -> + ): CashoutCreationResult = db.serializable { conn -> val stmt = conn.prepareStatement(""" SELECT out_bad_conversion, @@ -81,7 +64,7 @@ class CashoutDAO(private val db: Database) { out_tan_code FROM cashout_create(?, ?, (?,?)::taler_amount, (?,?)::taler_amount, ?, ?, ?::tan_enum, ?, ?, ?) """) - stmt.setString(1, accountUsername) + stmt.setString(1, login) stmt.setBytes(2, requestUid.raw) stmt.setLong(3, amountDebit.value) stmt.setInt(4, amountDebit.frac) @@ -94,29 +77,25 @@ class CashoutDAO(private val db: Database) { stmt.setInt(11, retryCounter) stmt.setLong(12, TimeUnit.MICROSECONDS.convert(validityPeriod)) stmt.executeQuery().use { - var id: Long? = null - var info: String? = null; - var code: String? = null; - val status = when { + when { !it.next() -> throw internalServerError("No result from DB procedure cashout_create") - it.getBoolean("out_bad_conversion") -> CashoutCreationResult.BAD_CONVERSION - it.getBoolean("out_account_not_found") -> CashoutCreationResult.ACCOUNT_NOT_FOUND - it.getBoolean("out_account_is_exchange") -> CashoutCreationResult.ACCOUNT_IS_EXCHANGE - it.getBoolean("out_missing_tan_info") -> CashoutCreationResult.MISSING_TAN_INFO - it.getBoolean("out_balance_insufficient") -> CashoutCreationResult.BALANCE_INSUFFICIENT - it.getBoolean("out_request_uid_reuse") -> CashoutCreationResult.REQUEST_UID_REUSE - else -> { - id = it.getLong("out_cashout_id") - info = it.getString("out_tan_info") - code = it.getString("out_tan_code") - CashoutCreationResult.SUCCESS - } + it.getBoolean("out_bad_conversion") -> CashoutCreationResult.BadConversion + it.getBoolean("out_account_not_found") -> CashoutCreationResult.AccountNotFound + it.getBoolean("out_account_is_exchange") -> CashoutCreationResult.AccountIsExchange + it.getBoolean("out_missing_tan_info") -> CashoutCreationResult.MissingTanInfo + it.getBoolean("out_balance_insufficient") -> CashoutCreationResult.BalanceInsufficient + it.getBoolean("out_request_uid_reuse") -> CashoutCreationResult.RequestUidReuse + else -> CashoutCreationResult.Success( + id = it.getLong("out_cashout_id"), + tanInfo = it.getString("out_tan_info"), + tanCode = it.getString("out_tan_code") + ) } - CashoutCreation(status, id, info, code) } } + /** Mark cashout operation [id] challenge as having being successfully sent [now] and not to be retransmit until after [retransmissionPeriod] */ suspend fun markSent( id: Long, now: Instant, @@ -133,6 +112,7 @@ class CashoutDAO(private val db: Database) { stmt.executeQueryCheck() } + /** Abort cashout operation [id] owned by [login] */ suspend fun abort(id: Long, login: String): AbortResult = db.serializable { conn -> val stmt = conn.prepareStatement(""" UPDATE cashout_operations @@ -144,12 +124,25 @@ class CashoutDAO(private val db: Database) { stmt.setLong(1, id) stmt.setString(2, login) when (stmt.oneOrNull { it.getBoolean(1) }) { - null -> AbortResult.NOT_FOUND - true -> AbortResult.CONFIRMED - false -> AbortResult.SUCCESS + null -> AbortResult.UnknownOperation + true -> AbortResult.AlreadyConfirmed + false -> AbortResult.Success } } + /** Result status of cashout operation confirmation */ + enum class CashoutConfirmationResult { + SUCCESS, + BAD_CONVERSION, + OP_NOT_FOUND, + BAD_TAN_CODE, + BALANCE_INSUFFICIENT, + NO_RETRY, + NO_CASHOUT_PAYTO, + ABORTED + } + + /** Confirm cashout operation [id] owned by [login] */ suspend fun confirm( id: Long, login: String, @@ -187,26 +180,7 @@ class CashoutDAO(private val db: Database) { } } - enum class CashoutDeleteResult { - SUCCESS, - CONFLICT_ALREADY_CONFIRMED - } - - suspend fun delete(id: Long): CashoutDeleteResult = db.serializable { conn -> - val stmt = conn.prepareStatement(""" - SELECT out_already_confirmed - FROM cashout_delete(?) - """) - stmt.setLong(1, id) - stmt.executeQuery().use { - when { - !it.next() -> throw internalServerError("Cashout deletion gave no result") - it.getBoolean("out_already_confirmed") -> CashoutDeleteResult.CONFLICT_ALREADY_CONFIRMED - else -> CashoutDeleteResult.SUCCESS - } - } - } - + /** Get status of cashout operation [id] owned by [login] */ suspend fun get(id: Long, login: String): CashoutStatusResponse? = db.conn { conn -> val stmt = conn.prepareStatement(""" SELECT @@ -233,20 +207,10 @@ class CashoutDAO(private val db: Database) { stmt.oneOrNull { CashoutStatusResponse( status = CashoutStatus.valueOf(it.getString("status")), - amount_debit = TalerAmount( - value = it.getLong("amount_debit_val"), - frac = it.getInt("amount_debit_frac"), - db.bankCurrency - ), - amount_credit = TalerAmount( - value = it.getLong("amount_credit_val"), - frac = it.getInt("amount_credit_frac"), - db.fiatCurrency!! - ), + amount_debit = it.getAmount("amount_debit", db.bankCurrency), + amount_credit = it.getAmount("amount_credit", db.fiatCurrency!!), subject = it.getString("subject"), - creation_time = TalerProtocolTimestamp( - it.getLong("creation_time").microsToJavaInstant() ?: throw faultyTimestampByBank() - ), + creation_time = it.getTalerTimestamp("creation_time"), confirmation_time = when (val timestamp = it.getLong("confirmation_date")) { 0L -> null else -> TalerProtocolTimestamp(timestamp.microsToJavaInstant() ?: throw faultyTimestampByBank()) @@ -255,6 +219,7 @@ class CashoutDAO(private val db: Database) { } } + /** Get a page of all cashout operations */ suspend fun pageAll(params: PageParams): List<GlobalCashoutInfo> = db.page(params, "cashout_id", """ SELECT @@ -277,6 +242,7 @@ class CashoutDAO(private val db: Database) { ) } + /** Get a page of all cashout operations owned by [login] */ suspend fun pageForUser(params: PageParams, login: String): List<CashoutInfo> = db.page(params, "cashout_id", """ SELECT diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/ConversionDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/ConversionDAO.kt @@ -19,13 +19,11 @@ package tech.libeufin.bank -import java.util.UUID -import java.time.Instant -import java.time.Duration -import java.util.concurrent.TimeUnit import tech.libeufin.util.* +/** Data access logic for conversion */ class ConversionDAO(private val db: Database) { + /** Update in-db conversion config */ suspend fun updateConfig(cfg: ConversionInfo) = db.serializable { it.transaction { conn -> var stmt = conn.prepareStatement("CALL config_set_amount(?, (?, ?)::taler_amount)") @@ -63,27 +61,28 @@ class ConversionDAO(private val db: Database) { } } - private suspend fun conversion(from: TalerAmount, direction: String, function: String, currency: String): TalerAmount? = db.conn { conn -> + /** Perform [direction] conversion of [amount] using in-db [function] */ + private suspend fun conversion(amount: TalerAmount, direction: String, function: String): TalerAmount? = db.conn { conn -> val stmt = conn.prepareStatement("SELECT too_small, (converted).val AS amount_val, (converted).frac AS amount_frac FROM $function((?, ?)::taler_amount, ?)") - stmt.setLong(1, from.value) - stmt.setInt(2, from.frac) + stmt.setLong(1, amount.value) + stmt.setInt(2, amount.frac) stmt.setString(3, direction) stmt.executeQuery().use { it.next() if (!it.getBoolean("too_small")) { - TalerAmount( - value = it.getLong("amount_val"), - frac = it.getInt("amount_frac"), - currency = currency - ) + it.getAmount("amount", if (amount.currency == db.bankCurrency) db.fiatCurrency!! else db.bankCurrency) } else { null } } } - suspend fun toCashout(amount: TalerAmount): TalerAmount? = conversion(amount, "cashout", "conversion_to", db.fiatCurrency!!) - suspend fun toCashin(amount: TalerAmount): TalerAmount? = conversion(amount, "cashin", "conversion_to", db.bankCurrency) - suspend fun fromCashout(amount: TalerAmount): TalerAmount? = conversion(amount, "cashout", "conversion_from", db.bankCurrency) - suspend fun fromCashin(amount: TalerAmount): TalerAmount? = conversion(amount, "cashin", "conversion_from", db.fiatCurrency!!) + /** Convert [regional] amount to fiat using cashout rate */ + suspend fun toCashout(regional: TalerAmount): TalerAmount? = conversion(regional, "cashout", "conversion_to") + /** Convert [fiat] amount to regional using cashin rate */ + suspend fun toCashin(fiat: TalerAmount): TalerAmount? = conversion(fiat, "cashin", "conversion_to") + /** Convert [fiat] amount to regional using inverse cashout rate */ + suspend fun fromCashout(fiat: TalerAmount): TalerAmount? = conversion(fiat, "cashout", "conversion_from") + /** Convert [regional] amount to fiat using inverse cashin rate */ + suspend fun fromCashin(regional: TalerAmount): TalerAmount? = conversion(regional, "cashin", "conversion_from") } \ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/Database.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/Database.kt @@ -76,6 +76,61 @@ class Database(dbConfig: String, internal val bankCurrency: String, internal val val withdrawal = WithdrawalDAO(this) val exchange = ExchangeDAO(this) val conversion = ConversionDAO(this) + val account = AccountDAO(this) + val transaction = TransactionDAO(this) + val token = TokenDAO(this) + + suspend fun monitor( + params: MonitorParams + ): MonitorResponse = conn { conn -> + val stmt = conn.prepareStatement(""" + SELECT + cashin_count + ,(cashin_regional_volume).val as cashin_regional_volume_val + ,(cashin_regional_volume).frac as cashin_regional_volume_frac + ,(cashin_fiat_volume).val as cashin_fiat_volume_val + ,(cashin_fiat_volume).frac as cashin_fiat_volume_frac + ,cashout_count + ,(cashout_regional_volume).val as cashout_regional_volume_val + ,(cashout_regional_volume).frac as cashout_regional_volume_frac + ,(cashout_fiat_volume).val as cashout_fiat_volume_val + ,(cashout_fiat_volume).frac as cashout_fiat_volume_frac + ,taler_in_count + ,(taler_in_volume).val as taler_in_volume_val + ,(taler_in_volume).frac as taler_in_volume_frac + ,taler_out_count + ,(taler_out_volume).val as taler_out_volume_val + ,(taler_out_volume).frac as taler_out_volume_frac + FROM stats_get_frame(now()::timestamp, ?::stat_timeframe_enum, ?) + """) + stmt.setString(1, params.timeframe.name) + if (params.which != null) { + stmt.setInt(2, params.which) + } else { + stmt.setNull(2, java.sql.Types.INTEGER) + } + stmt.oneOrNull { + fiatCurrency?.run { + MonitorWithConversion( + cashinCount = it.getLong("cashin_count"), + cashinRegionalVolume = it.getAmount("cashin_regional_volume", bankCurrency), + cashinFiatVolume = it.getAmount("cashin_fiat_volume", this), + cashoutCount = it.getLong("cashout_count"), + cashoutRegionalVolume = it.getAmount("cashout_regional_volume", bankCurrency), + cashoutFiatVolume = it.getAmount("cashout_fiat_volume", this), + talerInCount = it.getLong("taler_in_count"), + talerInVolume = it.getAmount("taler_in_volume", bankCurrency), + talerOutCount = it.getLong("taler_out_count"), + talerOutVolume = it.getAmount("taler_out_volume", bankCurrency), + ) + } ?: MonitorNoConversion( + talerInCount = it.getLong("taler_in_count"), + talerInVolume = it.getAmount("taler_in_volume", bankCurrency), + talerOutCount = it.getLong("taler_out_count"), + talerOutVolume = it.getAmount("taler_out_volume", bankCurrency), + ) + } ?: throw internalServerError("No result from DB procedure stats_get_frame") + } override fun close() { dbPool.close() @@ -107,645 +162,6 @@ class Database(dbConfig: String, internal val bankCurrency: String, internal val ) } - // CUSTOMERS - - /** - * Deletes a customer (including its bank account row) from - * the database. The bank account gets deleted by the cascade. - */ - suspend fun customerDeleteIfBalanceIsZero(login: String): CustomerDeletionResult = serializable { conn -> - val stmt = conn.prepareStatement(""" - SELECT - out_nx_customer, - out_balance_not_zero - FROM customer_delete(?); - """) - stmt.setString(1, login) - stmt.executeQuery().use { - when { - !it.next() -> throw internalServerError("Deletion returned nothing.") - it.getBoolean("out_nx_customer") -> CustomerDeletionResult.CUSTOMER_NOT_FOUND - it.getBoolean("out_balance_not_zero") -> CustomerDeletionResult.BALANCE_NOT_ZERO - else -> CustomerDeletionResult.SUCCESS - } - } - } - - suspend fun customerPasswordHashFromLogin(login: String): String? = conn { conn -> - val stmt = conn.prepareStatement(""" - SELECT password_hash FROM customers WHERE login=? - """) - stmt.setString(1, login) - stmt.oneOrNull { - it.getString(1) - } - } - - suspend fun customerLoginFromId(id: Long): String? = conn { conn -> - val stmt = conn.prepareStatement(""" - SELECT login FROM customers WHERE customer_id=? - """) - stmt.setLong(1, id) - stmt.oneOrNull { - it.getString(1) - } - } - - // BEARER TOKEN - suspend fun bearerTokenCreate( - login: String, - content: ByteArray, - creationTime: Instant, - expirationTime: Instant, - scope: TokenScope, - isRefreshable: Boolean - ): Boolean = serializable { conn -> - val bankCustomer = conn.prepareStatement(""" - SELECT customer_id FROM customers WHERE login=? - """).run { - setString(1, login) - oneOrNull { it.getLong(1) }!! - } - val stmt = conn.prepareStatement(""" - INSERT INTO bearer_tokens ( - content, - creation_time, - expiration_time, - scope, - bank_customer, - is_refreshable - ) VALUES (?, ?, ?, ?::token_scope_enum, ?, ?) - """) - stmt.setBytes(1, content) - stmt.setLong(2, creationTime.toDbMicros() ?: throw faultyTimestampByBank()) - stmt.setLong(3, expirationTime.toDbMicros() ?: throw faultyDurationByClient()) - stmt.setString(4, scope.name) - stmt.setLong(5, bankCustomer) - stmt.setBoolean(6, isRefreshable) - stmt.executeUpdateViolation() - } - - suspend fun bearerTokenGet(token: ByteArray): BearerToken? = conn { conn -> - val stmt = conn.prepareStatement(""" - SELECT - expiration_time, - creation_time, - bank_customer, - scope, - is_refreshable - FROM bearer_tokens - WHERE content=?; - """) - stmt.setBytes(1, token) - stmt.oneOrNull { - BearerToken( - content = token, - creationTime = it.getLong("creation_time").microsToJavaInstant() ?: throw faultyTimestampByBank(), - expirationTime = it.getLong("expiration_time").microsToJavaInstant() ?: throw faultyDurationByClient(), - bankCustomer = it.getLong("bank_customer"), - scope = TokenScope.valueOf(it.getString("scope")), - isRefreshable = it.getBoolean("is_refreshable") - ) - } - } - /** - * Deletes a bearer token from the database. Returns true, - * if deletion succeeds or false if the token could not be - * deleted (= not found). - */ - suspend fun bearerTokenDelete(token: ByteArray): Boolean = serializable { conn -> - val stmt = conn.prepareStatement(""" - DELETE FROM bearer_tokens - WHERE content = ? - RETURNING bearer_token_id; - """) - stmt.setBytes(1, token) - stmt.executeQueryCheck() - } - - // MIXED CUSTOMER AND BANK ACCOUNT DATA - - suspend fun accountCreate( - login: String, - password: String, - name: String, - email: String? = null, - phone: String? = null, - cashoutPayto: IbanPayTo? = null, - internalPaytoUri: IbanPayTo, - isPublic: Boolean, - isTalerExchange: Boolean, - maxDebt: TalerAmount, - bonus: TalerAmount? - ): CustomerCreationResult = serializable { it -> - val now = Instant.now().toDbMicros() ?: throw faultyTimestampByBank(); - it.transaction { conn -> - val idempotent = conn.prepareStatement(""" - SELECT password_hash, name=? - AND email IS NOT DISTINCT FROM ? - AND phone IS NOT DISTINCT FROM ? - AND cashout_payto IS NOT DISTINCT FROM ? - AND internal_payto_uri=? - AND is_public=? - AND is_taler_exchange=? - FROM customers - JOIN bank_accounts - ON customer_id=owning_customer_id - WHERE login=? - """).run { - setString(1, name) - setString(2, email) - setString(3, phone) - setString(4, cashoutPayto?.canonical) - setString(5, internalPaytoUri.canonical) - setBoolean(6, isPublic) - setBoolean(7, isTalerExchange) - setString(8, login) - oneOrNull { - CryptoUtil.checkpw(password, it.getString(1)) && it.getBoolean(2) - } - } - if (idempotent != null) { - if (idempotent) { - CustomerCreationResult.SUCCESS - } else { - CustomerCreationResult.CONFLICT_LOGIN - } - } else { - val customerId = conn.prepareStatement(""" - INSERT INTO customers ( - login - ,password_hash - ,name - ,email - ,phone - ,cashout_payto - ) VALUES (?, ?, ?, ?, ?, ?) - RETURNING customer_id - """ - ).run { - setString(1, login) - setString(2, CryptoUtil.hashpw(password)) - setString(3, name) - setString(4, email) - setString(5, phone) - setString(6, cashoutPayto?.canonical) - oneOrNull { it.getLong("customer_id") }!! - } - - conn.prepareStatement(""" - INSERT INTO iban_history( - iban - ,creation_time - ) VALUES (?, ?) - """).run { - setString(1, internalPaytoUri.iban) - setLong(2, now) - if (!executeUpdateViolation()) { - conn.rollback() - return@transaction CustomerCreationResult.CONFLICT_PAY_TO - } - } - - conn.prepareStatement(""" - INSERT INTO bank_accounts( - internal_payto_uri - ,owning_customer_id - ,is_public - ,is_taler_exchange - ,max_debt - ) VALUES (?, ?, ?, ?, (?, ?)::taler_amount) - """).run { - setString(1, internalPaytoUri.canonical) - setLong(2, customerId) - setBoolean(3, isPublic) - setBoolean(4, isTalerExchange) - setLong(5, maxDebt.value) - setInt(6, maxDebt.frac) - if (!executeUpdateViolation()) { - conn.rollback() - return@transaction CustomerCreationResult.CONFLICT_PAY_TO - } - } - - if (bonus != null) { - conn.prepareStatement(""" - SELECT out_balance_insufficient - FROM bank_transaction(?,'admin','bonus',(?,?)::taler_amount,?) - """).run { - setString(1, internalPaytoUri.canonical) - setLong(2, bonus.value) - setInt(3, bonus.frac) - setLong(4, now) - executeQuery().use { - when { - !it.next() -> throw internalServerError("Bank transaction didn't properly return") - it.getBoolean("out_balance_insufficient") -> { - conn.rollback() - CustomerCreationResult.BALANCE_INSUFFICIENT - } - else -> CustomerCreationResult.SUCCESS - } - } - } - } else { - CustomerCreationResult.SUCCESS - } - } - } - } - - suspend fun accountDataFromLogin( - login: String - ): AccountData? = conn { conn -> - val stmt = conn.prepareStatement(""" - SELECT - name - ,email - ,phone - ,cashout_payto - ,internal_payto_uri - ,(balance).val AS balance_val - ,(balance).frac AS balance_frac - ,has_debt - ,(max_debt).val AS max_debt_val - ,(max_debt).frac AS max_debt_frac - FROM customers - JOIN bank_accounts - ON customer_id=owning_customer_id - WHERE login=? - """) - stmt.setString(1, login) - stmt.oneOrNull { - AccountData( - name = it.getString("name"), - contact_data = ChallengeContactData( - email = it.getString("email"), - phone = it.getString("phone") - ), - cashout_payto_uri = it.getString("cashout_payto"), - payto_uri = it.getString("internal_payto_uri"), - balance = Balance( - amount = TalerAmount( - it.getLong("balance_val"), - it.getInt("balance_frac"), - bankCurrency - ), - credit_debit_indicator = - if (it.getBoolean("has_debt")) { - CreditDebitInfo.debit - } else { - CreditDebitInfo.credit - } - ), - debit_threshold = TalerAmount( - value = it.getLong("max_debt_val"), - frac = it.getInt("max_debt_frac"), - bankCurrency - ) - ) - } - } - - /** - * Updates accounts according to the PATCH /accounts/foo endpoint. - * The 'login' parameter decides which customer and bank account rows - * will get the update. - * - * Meaning of null in the parameters: when 'name' and 'isTalerExchange' - * are null, NOTHING gets changed. If any of the other values are null, - * WARNING: their value will be overridden with null. No parameter gets - * null as the default, as to always keep the caller aware of what gets in - * the database. - * - * The return type expresses either success, or that the target rows - * could not be found. - */ - suspend fun accountReconfig( - login: String, - name: String?, - cashoutPayto: IbanPayTo?, - phoneNumber: String?, - emailAddress: String?, - isTalerExchange: Boolean?, - debtLimit: TalerAmount?, - isAdmin: Boolean - ): CustomerPatchResult = serializable { conn -> - val stmt = conn.prepareStatement(""" - SELECT - out_not_found, - out_legal_name_change, - out_debt_limit_change - FROM account_reconfig(?, ?, ?, ?, ?, ?, (?, ?)::taler_amount, ?) - """) - stmt.setString(1, login) - stmt.setString(2, name) - stmt.setString(3, phoneNumber) - stmt.setString(4, emailAddress) - stmt.setString(5, cashoutPayto?.canonical) - if (isTalerExchange == null) - stmt.setNull(6, Types.NULL) - else stmt.setBoolean(6, isTalerExchange) - if (debtLimit == null) { - stmt.setNull(7, Types.NULL) - stmt.setNull(8, Types.NULL) - } else { - stmt.setLong(7, debtLimit.value) - stmt.setInt(8, debtLimit.frac) - } - stmt.setBoolean(9, isAdmin) - stmt.executeQuery().use { - when { - !it.next() -> throw internalServerError("accountReconfig() returned nothing") - it.getBoolean("out_not_found") -> CustomerPatchResult.ACCOUNT_NOT_FOUND - it.getBoolean("out_legal_name_change") -> CustomerPatchResult.CONFLICT_LEGAL_NAME - it.getBoolean("out_debt_limit_change") -> CustomerPatchResult.CONFLICT_DEBT_LIMIT - else -> CustomerPatchResult.SUCCESS - } - } - } - - suspend fun accountReconfigPassword(login: String, newPw: String, oldPw: String?): CustomerPatchAuthResult = serializable { - it.transaction { conn -> - val currentPwh = conn.prepareStatement(""" - SELECT password_hash FROM customers WHERE login=? - """).run { - setString(1, login) - oneOrNull { it.getString(1) } - } - if (currentPwh == null) { - CustomerPatchAuthResult.ACCOUNT_NOT_FOUND - } else if (oldPw != null && !CryptoUtil.checkpw(oldPw, currentPwh)) { - CustomerPatchAuthResult.CONFLICT_BAD_PASSWORD - } else { - val stmt = conn.prepareStatement(""" - UPDATE customers SET password_hash=? where login=? - """) - stmt.setString(1, CryptoUtil.hashpw(newPw)) - stmt.setString(2, login) - stmt.executeUpdateCheck() - CustomerPatchAuthResult.SUCCESS - } - } - } - - /** - * Gets the list of public accounts in the system. - * internalCurrency is the bank's currency and loginFilter is - * an optional filter on the account's login. - * - * Returns an empty list, if no public account was found. - */ - suspend fun accountsGetPublic(params: AccountParams): List<PublicAccount> - = page( - params.page, - "bank_account_id", - """ - SELECT - (balance).val AS balance_val, - (balance).frac AS balance_frac, - has_debt, - internal_payto_uri, - c.login - FROM bank_accounts JOIN customers AS c - ON owning_customer_id = c.customer_id - WHERE is_public=true AND c.login LIKE ? AND - """, - { - setString(1, params.loginFilter) - 1 - } - ) { - PublicAccount( - account_name = it.getString("login"), - payto_uri = it.getString("internal_payto_uri"), - balance = Balance( - amount = TalerAmount( - value = it.getLong("balance_val"), - frac = it.getInt("balance_frac"), - currency = bankCurrency - ), - credit_debit_indicator = if (it.getBoolean("has_debt")) { - CreditDebitInfo.debit - } else { - CreditDebitInfo.credit - } - ) - ) - } - - /** - * Gets a minimal set of account data, as outlined in the GET /accounts - * endpoint. The nameFilter parameter will be passed AS IS to the SQL - * LIKE operator. If it's null, it defaults to the "%" wildcard, meaning - * that it returns ALL the existing accounts. - */ - suspend fun accountsGetForAdmin(params: AccountParams): List<AccountMinimalData> - = page( - params.page, - "bank_account_id", - """ - SELECT - login, - name, - (b.balance).val AS balance_val, - (b.balance).frac AS balance_frac, - (b).has_debt AS balance_has_debt, - (max_debt).val as max_debt_val, - (max_debt).frac as max_debt_frac - FROM customers JOIN bank_accounts AS b - ON customer_id = b.owning_customer_id - WHERE name LIKE ? AND - """, - { - setString(1, params.loginFilter) - 1 - } - ) { - AccountMinimalData( - username = it.getString("login"), - name = it.getString("name"), - balance = Balance( - amount = TalerAmount( - value = it.getLong("balance_val"), - frac = it.getInt("balance_frac"), - currency = bankCurrency - ), - credit_debit_indicator = if (it.getBoolean("balance_has_debt")) { - CreditDebitInfo.debit - } else { - CreditDebitInfo.credit - } - ), - debit_threshold = TalerAmount( - value = it.getLong("max_debt_val"), - frac = it.getInt("max_debt_frac"), - currency = bankCurrency - ) - ) - } - - // BANK ACCOUNTS - - suspend fun bankAccountGetFromOwnerId(ownerId: Long): BankAccount? = conn { conn -> - val stmt = conn.prepareStatement(""" - SELECT - internal_payto_uri - ,is_taler_exchange - ,bank_account_id - FROM bank_accounts - WHERE owning_customer_id=? - """) - stmt.setLong(1, ownerId) - - stmt.oneOrNull { - BankAccount( - internalPaytoUri = it.getString("internal_payto_uri"), - isTalerExchange = it.getBoolean("is_taler_exchange"), - bankAccountId = it.getLong("bank_account_id") - ) - } - } - - suspend fun bankAccountGetFromCustomerLogin(login: String): BankAccount? = conn { conn -> - val stmt = conn.prepareStatement(""" - SELECT - bank_account_id - ,internal_payto_uri - ,is_taler_exchange - FROM bank_accounts - JOIN customers - ON customer_id=owning_customer_id - WHERE login=? - """) - stmt.setString(1, login) - - stmt.oneOrNull { - BankAccount( - internalPaytoUri = it.getString("internal_payto_uri"), - isTalerExchange = it.getBoolean("is_taler_exchange"), - bankAccountId = it.getLong("bank_account_id") - ) - } - } - - // BANK ACCOUNT TRANSACTIONS - - suspend fun bankTransaction( - creditAccountPayto: IbanPayTo, - debitAccountUsername: String, - subject: String, - amount: TalerAmount, - timestamp: Instant, - ): Pair<BankTransactionResult, Long?> = serializable { conn -> - conn.transaction { - val stmt = conn.prepareStatement(""" - SELECT - out_creditor_not_found - ,out_debtor_not_found - ,out_same_account - ,out_balance_insufficient - ,out_credit_bank_account_id - ,out_debit_bank_account_id - ,out_credit_row_id - ,out_debit_row_id - ,out_creditor_is_exchange - ,out_debtor_is_exchange - FROM bank_transaction(?,?,?,(?,?)::taler_amount,?) - """ - ) - stmt.setString(1, creditAccountPayto.canonical) - stmt.setString(2, debitAccountUsername) - stmt.setString(3, subject) - stmt.setLong(4, amount.value) - stmt.setInt(5, amount.frac) - stmt.setLong(6, timestamp.toDbMicros() ?: throw faultyTimestampByBank()) - stmt.executeQuery().use { - var rowId: Long? = null; - val result = when { - !it.next() -> throw internalServerError("Bank transaction didn't properly return") - it.getBoolean("out_creditor_not_found") -> BankTransactionResult.NO_CREDITOR - it.getBoolean("out_debtor_not_found") -> BankTransactionResult.NO_DEBTOR - it.getBoolean("out_same_account") -> BankTransactionResult.SAME_ACCOUNT - it.getBoolean("out_balance_insufficient") -> BankTransactionResult.BALANCE_INSUFFICIENT - else -> { - val creditAccountId = it.getLong("out_credit_bank_account_id") - val creditRowId = it.getLong("out_credit_row_id") - val debitAccountId = it.getLong("out_debit_bank_account_id") - val debitRowId = it.getLong("out_debit_row_id") - val metadata = TxMetadata.parse(subject) - if (it.getBoolean("out_creditor_is_exchange")) { - if (metadata is IncomingTxMetadata) { - conn.prepareStatement("CALL register_incoming(?, ?)").run { - setBytes(1, metadata.reservePub.raw) - setLong(2, creditRowId) - executeUpdate() - } - } else { - // TODO bounce - logger.warn("exchange account $creditAccountId received a transaction $creditRowId with malformed metadata, will bounce in future version") - } - } - if (it.getBoolean("out_debtor_is_exchange")) { - if (metadata is OutgoingTxMetadata) { - conn.prepareStatement("CALL register_outgoing(NULL, ?, ?, ?, ?, ?, ?)").run { - setBytes(1, metadata.wtid.raw) - setString(2, metadata.exchangeBaseUrl.url) - setLong(3, debitAccountId) - setLong(4, creditAccountId) - setLong(5, debitRowId) - setLong(6, creditRowId) - executeUpdate() - } - } else { - logger.warn("exchange account $debitAccountId sent a transaction $debitRowId with malformed metadata") - } - } - rowId = debitRowId; - BankTransactionResult.SUCCESS - } - } - Pair(result, rowId) - } - } - } - - // Get the bank transaction whose row ID is rowId - suspend fun bankTransactionGetFromInternalId(rowId: Long, login: String): BankAccountTransactionInfo? = conn { conn -> - val stmt = conn.prepareStatement(""" - SELECT - creditor_payto_uri - ,debtor_payto_uri - ,subject - ,(amount).val AS amount_val - ,(amount).frac AS amount_frac - ,transaction_date - ,direction - ,bank_transaction_id - FROM bank_account_transactions - JOIN bank_accounts ON bank_account_transactions.bank_account_id=bank_accounts.bank_account_id - JOIN customers ON customer_id=owning_customer_id - WHERE bank_transaction_id=? AND login=? - """) - stmt.setLong(1, rowId) - stmt.setString(2, login) - stmt.oneOrNull { - BankAccountTransactionInfo( - creditor_payto_uri = it.getString("creditor_payto_uri"), - debtor_payto_uri = it.getString("debtor_payto_uri"), - amount = TalerAmount( - it.getLong("amount_val"), - it.getInt("amount_frac"), - bankCurrency - ), - direction = TransactionDirection.valueOf(it.getString("direction")), - subject = it.getString("subject"), - date = TalerProtocolTimestamp(it.getLong("transaction_date").microsToJavaInstant() ?: throw faultyTimestampByBank()), - row_id = it.getLong("bank_transaction_id") - ) - } - } - /** Apply paging logic to a sql query */ internal suspend fun <T> page( params: PageParams, @@ -821,169 +237,25 @@ class Database(dbConfig: String, internal val bankCurrency: String, internal val load() } } - - suspend fun bankPoolHistory( - params: HistoryParams, - bankAccountId: Long - ): List<BankAccountTransactionInfo> { - return poolHistory(params, bankAccountId, NotificationWatcher::listenBank, """ - SELECT - bank_transaction_id - ,transaction_date - ,(amount).val AS amount_val - ,(amount).frac AS amount_frac - ,debtor_payto_uri - ,creditor_payto_uri - ,subject - ,direction - FROM bank_account_transactions - """) { - BankAccountTransactionInfo( - row_id = it.getLong("bank_transaction_id"), - date = TalerProtocolTimestamp( - it.getLong("transaction_date").microsToJavaInstant() ?: throw faultyTimestampByBank() - ), - debtor_payto_uri = it.getString("debtor_payto_uri"), - creditor_payto_uri = it.getString("creditor_payto_uri"), - amount = TalerAmount( - it.getLong("amount_val"), - it.getInt("amount_frac"), - bankCurrency - ), - subject = it.getString("subject"), - direction = TransactionDirection.valueOf(it.getString("direction")) - ) - } - } - - suspend fun monitor( - params: MonitorParams - ): MonitorResponse = conn { conn -> - val stmt = conn.prepareStatement(""" - SELECT - cashin_count - ,(cashin_regional_volume).val as cashin_regional_volume_val - ,(cashin_regional_volume).frac as cashin_regional_volume_frac - ,(cashin_fiat_volume).val as cashin_fiat_volume_val - ,(cashin_fiat_volume).frac as cashin_fiat_volume_frac - ,cashout_count - ,(cashout_regional_volume).val as cashout_regional_volume_val - ,(cashout_regional_volume).frac as cashout_regional_volume_frac - ,(cashout_fiat_volume).val as cashout_fiat_volume_val - ,(cashout_fiat_volume).frac as cashout_fiat_volume_frac - ,taler_in_count - ,(taler_in_volume).val as taler_in_volume_val - ,(taler_in_volume).frac as taler_in_volume_frac - ,taler_out_count - ,(taler_out_volume).val as taler_out_volume_val - ,(taler_out_volume).frac as taler_out_volume_frac - FROM stats_get_frame(now()::timestamp, ?::stat_timeframe_enum, ?) - """) - stmt.setString(1, params.timeframe.name) - if (params.which != null) { - stmt.setInt(2, params.which) - } else { - stmt.setNull(2, java.sql.Types.INTEGER) - } - stmt.oneOrNull { - fiatCurrency?.run { - MonitorWithConversion( - cashinCount = it.getLong("cashin_count"), - cashinRegionalVolume = TalerAmount( - value = it.getLong("cashin_regional_volume_val"), - frac = it.getInt("cashin_regional_volume_frac"), - currency = bankCurrency - ), - cashinFiatVolume = TalerAmount( - value = it.getLong("cashin_fiat_volume_val"), - frac = it.getInt("cashin_fiat_volume_frac"), - currency = this - ), - cashoutCount = it.getLong("cashout_count"), - cashoutRegionalVolume = TalerAmount( - value = it.getLong("cashout_regional_volume_val"), - frac = it.getInt("cashout_regional_volume_frac"), - currency = bankCurrency - ), - cashoutFiatVolume = TalerAmount( - value = it.getLong("cashout_fiat_volume_val"), - frac = it.getInt("cashout_fiat_volume_frac"), - currency = this - ), - talerInCount = it.getLong("taler_in_count"), - talerInVolume = TalerAmount( - value = it.getLong("taler_in_volume_val"), - frac = it.getInt("taler_in_volume_frac"), - currency = bankCurrency - ), - talerOutCount = it.getLong("taler_out_count"), - talerOutVolume = TalerAmount( - value = it.getLong("taler_out_volume_val"), - frac = it.getInt("taler_out_volume_frac"), - currency = bankCurrency - ) - ) - } ?: MonitorNoConversion( - talerInCount = it.getLong("taler_in_count"), - talerInVolume = TalerAmount( - value = it.getLong("taler_in_volume_val"), - frac = it.getInt("taler_in_volume_frac"), - currency = bankCurrency - ), - talerOutCount = it.getLong("taler_out_count"), - talerOutVolume = TalerAmount( - value = it.getLong("taler_out_volume_val"), - frac = it.getInt("taler_out_volume_frac"), - currency = bankCurrency - ) - ) - - } ?: throw internalServerError("No result from DB procedure stats_get_frame") - } -} - -/** Result status of customer account creation */ -enum class CustomerCreationResult { - SUCCESS, - CONFLICT_LOGIN, - CONFLICT_PAY_TO, - BALANCE_INSUFFICIENT, -} - -/** Result status of customer account patch */ -enum class CustomerPatchResult { - ACCOUNT_NOT_FOUND, - CONFLICT_LEGAL_NAME, - CONFLICT_DEBT_LIMIT, - SUCCESS -} - -/** Result status of customer account auth patch */ -enum class CustomerPatchAuthResult { - ACCOUNT_NOT_FOUND, - CONFLICT_BAD_PASSWORD, - SUCCESS } -/** Result status of customer account deletion */ -enum class CustomerDeletionResult { - SUCCESS, - CUSTOMER_NOT_FOUND, - BALANCE_NOT_ZERO +/** Result status of withdrawal or cashout operation abortion */ +enum class AbortResult { + Success, + UnknownOperation, + AlreadyConfirmed } -/** Result status of bank transaction creation .*/ -enum class BankTransactionResult { - NO_CREDITOR, - NO_DEBTOR, - SAME_ACCOUNT, - BALANCE_INSUFFICIENT, - SUCCESS, +fun ResultSet.getTalerTimestamp(name: String): TalerProtocolTimestamp{ + return TalerProtocolTimestamp( + getLong(name).microsToJavaInstant() ?: throw faultyTimestampByBank() + ) } -/** Result status of withdrawal or cashout operation abortion */ -enum class AbortResult { - SUCCESS, - NOT_FOUND, - CONFIRMED +fun ResultSet.getAmount(name: String, currency: String): TalerAmount{ + return TalerAmount( + getLong("${name}_val"), + getInt("${name}_frac"), + currency + ) } \ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/ExchangeDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/ExchangeDAO.kt @@ -25,34 +25,14 @@ import java.time.Duration import java.util.concurrent.TimeUnit import tech.libeufin.util.* -/** Result status of taler transfer transaction */ -enum class TalerTransferResult { - NO_DEBITOR, - NOT_EXCHANGE, - NO_CREDITOR, - BOTH_EXCHANGE, - REQUEST_UID_REUSE, - BALANCE_INSUFFICIENT, - SUCCESS -} - -/** Result status of taler add incoming transaction */ -enum class TalerAddIncomingResult { - NO_DEBITOR, - NOT_EXCHANGE, - NO_CREDITOR, - BOTH_EXCHANGE, - RESERVE_PUB_REUSE, - BALANCE_INSUFFICIENT, - SUCCESS -} - +/** Data access logic for exchange specific logic */ class ExchangeDAO(private val db: Database) { + /** Query [exchangeId] history of taler incoming transactions */ suspend fun incomingHistory( params: HistoryParams, - bankAccountId: Long + exchangeId: Long ): List<IncomingReserveTransaction> - = db.poolHistory(params, bankAccountId, NotificationWatcher::listenIncoming, """ + = db.poolHistory(params, exchangeId, NotificationWatcher::listenIncoming, """ SELECT bank_transaction_id ,transaction_date @@ -66,24 +46,19 @@ class ExchangeDAO(private val db: Database) { """) { IncomingReserveTransaction( row_id = it.getLong("bank_transaction_id"), - date = TalerProtocolTimestamp( - it.getLong("transaction_date").microsToJavaInstant() ?: throw faultyTimestampByBank() - ), - amount = TalerAmount( - it.getLong("amount_val"), - it.getInt("amount_frac"), - db.bankCurrency - ), + date = it.getTalerTimestamp("transaction_date"), + amount = it.getAmount("amount", db.bankCurrency), debit_account = it.getString("debtor_payto_uri"), reserve_pub = EddsaPublicKey(it.getBytes("reserve_pub")), ) } - + + /** Query [exchangeId] history of taler outgoing transactions */ suspend fun outgoingHistory( params: HistoryParams, - bankAccountId: Long + exchangeId: Long ): List<OutgoingTransaction> - = db.poolHistory(params, bankAccountId, NotificationWatcher::listenOutgoing, """ + = db.poolHistory(params, exchangeId, NotificationWatcher::listenOutgoing, """ SELECT bank_transaction_id ,transaction_date @@ -98,25 +73,20 @@ class ExchangeDAO(private val db: Database) { """) { OutgoingTransaction( row_id = it.getLong("bank_transaction_id"), - date = TalerProtocolTimestamp( - it.getLong("transaction_date").microsToJavaInstant() ?: throw faultyTimestampByBank() - ), - amount = TalerAmount( - it.getLong("amount_val"), - it.getInt("amount_frac"), - db.bankCurrency - ), + date = it.getTalerTimestamp("transaction_date"), + amount = it.getAmount("amount", db.bankCurrency), credit_account = it.getString("creditor_payto_uri"), wtid = ShortHashCode(it.getBytes("wtid")), exchange_base_url = it.getString("exchange_base_url") ) } + /** Query [merchantId] history of taler outgoing transactions to its account */ suspend fun revenueHistory( params: HistoryParams, - bankAccountId: Long + merchantId: Long ): List<MerchantIncomingBankTransaction> - = db.poolHistory(params, bankAccountId, NotificationWatcher::listenRevenue, """ + = db.poolHistory(params, merchantId, NotificationWatcher::listenRevenue, """ SELECT bank_transaction_id ,transaction_date @@ -131,35 +101,31 @@ class ExchangeDAO(private val db: Database) { """, "creditor_account_id") { MerchantIncomingBankTransaction( row_id = it.getLong("bank_transaction_id"), - date = TalerProtocolTimestamp( - it.getLong("transaction_date").microsToJavaInstant() ?: throw faultyTimestampByBank() - ), - amount = TalerAmount( - it.getLong("amount_val"), - it.getInt("amount_frac"), - db.bankCurrency - ), + date = it.getTalerTimestamp("transaction_date"), + amount = it.getAmount("amount", db.bankCurrency), debit_account = it.getString("debtor_payto_uri"), wtid = ShortHashCode(it.getBytes("wtid")), exchange_url = it.getString("exchange_base_url") ) } - data class TransferResult( - val txResult: TalerTransferResult, - /** - * bank transaction that backs this Taler transfer request. - * This is the debit transactions associated to the exchange - * bank account. - */ - val txRowId: Long? = null, - val timestamp: TalerProtocolTimestamp? = null - ) + /** Result of taler transfer transaction creation */ + sealed class TransferResult { + /** Transaction [id] and wire transfer [timestamp] */ + data class Success(val id: Long, val timestamp: TalerProtocolTimestamp): TransferResult() + object NotAnExchange: TransferResult() + object UnknownExchange: TransferResult() + object UnknownCreditor: TransferResult() + object BothPartyAreExchange: TransferResult() + object BalanceInsufficient: TransferResult() + object ReserveUidReuse: TransferResult() + } + /** Perform a Taler transfer */ suspend fun transfer( req: TransferRequest, - username: String, - timestamp: Instant + login: String, + now: Instant ): TransferResult = db.serializable { conn -> val subject = OutgoingTxMetadata(req.wtid, req.exchange_base_url).encode() val stmt = conn.prepareStatement(""" @@ -187,49 +153,46 @@ class ExchangeDAO(private val db: Database) { stmt.setInt(5, req.amount.frac) stmt.setString(6, req.exchange_base_url.url) stmt.setString(7, req.credit_account.canonical) - stmt.setString(8, username) - stmt.setLong(9, timestamp.toDbMicros() ?: throw faultyTimestampByBank()) + stmt.setString(8, login) + stmt.setLong(9, now.toDbMicros() ?: throw faultyTimestampByBank()) stmt.executeQuery().use { when { !it.next() -> throw internalServerError("SQL function taler_transfer did not return anything.") - it.getBoolean("out_debtor_not_found") -> - TransferResult(TalerTransferResult.NO_DEBITOR) - it.getBoolean("out_debtor_not_exchange") -> - TransferResult(TalerTransferResult.NOT_EXCHANGE) - it.getBoolean("out_creditor_not_found") -> - TransferResult(TalerTransferResult.NO_CREDITOR) - it.getBoolean("out_both_exchanges") -> - TransferResult(TalerTransferResult.BOTH_EXCHANGE) - it.getBoolean("out_exchange_balance_insufficient") -> - TransferResult(TalerTransferResult.BALANCE_INSUFFICIENT) - it.getBoolean("out_request_uid_reuse") -> - TransferResult(TalerTransferResult.REQUEST_UID_REUSE) - else -> { - TransferResult( - txResult = TalerTransferResult.SUCCESS, - txRowId = it.getLong("out_tx_row_id"), - timestamp = TalerProtocolTimestamp( - it.getLong("out_timestamp").microsToJavaInstant() ?: throw faultyTimestampByBank() - ) - ) - } + it.getBoolean("out_debtor_not_found") -> TransferResult.UnknownExchange + it.getBoolean("out_debtor_not_exchange") -> TransferResult.NotAnExchange + it.getBoolean("out_creditor_not_found") -> TransferResult.UnknownCreditor + it.getBoolean("out_both_exchanges") -> TransferResult.BothPartyAreExchange + it.getBoolean("out_exchange_balance_insufficient") -> TransferResult.BalanceInsufficient + it.getBoolean("out_request_uid_reuse") -> TransferResult.ReserveUidReuse + else -> TransferResult.Success( + id = it.getLong("out_tx_row_id"), + timestamp = it.getTalerTimestamp("out_timestamp") + ) } } } - data class AddIncomingResult( - val txResult: TalerAddIncomingResult, - val txRowId: Long? = null - ) + /** Result of taler add incoming transaction creation */ + sealed class AddIncomingResult { + /** Transaction [id] and wire transfer [timestamp] */ + data class Success(val id: Long, val timestamp: TalerProtocolTimestamp): AddIncomingResult() + object NotAnExchange: AddIncomingResult() + object UnknownExchange: AddIncomingResult() + object UnknownDebtor: AddIncomingResult() + object BothPartyAreExchange: AddIncomingResult() + object ReservePubReuse: AddIncomingResult() + object BalanceInsufficient: AddIncomingResult() + } + /** Add a new taler incoming transaction */ suspend fun addIncoming( req: AddIncomingRequest, - username: String, - timestamp: Instant - ): AddIncomingResult = db.serializable { conn -> - val subject = IncomingTxMetadata(req.reserve_pub).encode() + login: String, + now: Instant + ): AddIncomingResult = db.serializable { conn -> + val subject = IncomingTxMetadata(req.reserve_pub).encode() val stmt = conn.prepareStatement(""" SELECT out_creditor_not_found @@ -252,31 +215,23 @@ class ExchangeDAO(private val db: Database) { stmt.setLong(3, req.amount.value) stmt.setInt(4, req.amount.frac) stmt.setString(5, req.debit_account.canonical) - stmt.setString(6, username) - stmt.setLong(7, timestamp.toDbMicros() ?: throw faultyTimestampByBank()) + stmt.setString(6, login) + stmt.setLong(7, now.toDbMicros() ?: throw faultyTimestampByBank()) stmt.executeQuery().use { when { !it.next() -> throw internalServerError("SQL function taler_add_incoming did not return anything.") - it.getBoolean("out_creditor_not_found") -> - AddIncomingResult(TalerAddIncomingResult.NO_CREDITOR) - it.getBoolean("out_creditor_not_exchange") -> - AddIncomingResult(TalerAddIncomingResult.NOT_EXCHANGE) - it.getBoolean("out_debtor_not_found") -> - AddIncomingResult(TalerAddIncomingResult.NO_DEBITOR) - it.getBoolean("out_both_exchanges") -> - AddIncomingResult(TalerAddIncomingResult.BOTH_EXCHANGE) - it.getBoolean("out_debitor_balance_insufficient") -> - AddIncomingResult(TalerAddIncomingResult.BALANCE_INSUFFICIENT) - it.getBoolean("out_reserve_pub_reuse") -> - AddIncomingResult(TalerAddIncomingResult.RESERVE_PUB_REUSE) - else -> { - AddIncomingResult( - txResult = TalerAddIncomingResult.SUCCESS, - txRowId = it.getLong("out_tx_row_id") - ) - } + it.getBoolean("out_creditor_not_found") -> AddIncomingResult.UnknownExchange + it.getBoolean("out_creditor_not_exchange") -> AddIncomingResult.NotAnExchange + it.getBoolean("out_debtor_not_found") -> AddIncomingResult.UnknownDebtor + it.getBoolean("out_both_exchanges") -> AddIncomingResult.BothPartyAreExchange + it.getBoolean("out_debitor_balance_insufficient") -> AddIncomingResult.BalanceInsufficient + it.getBoolean("out_reserve_pub_reuse") -> AddIncomingResult.ReservePubReuse + else -> AddIncomingResult.Success( + id = it.getLong("out_tx_row_id"), + timestamp = TalerProtocolTimestamp(now) + ) } } } diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/NotificationWatcher.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/NotificationWatcher.kt @@ -19,28 +19,35 @@ package tech.libeufin.bank -import org.postgresql.ds.PGSimpleDataSource -import java.util.concurrent.ConcurrentHashMap import java.util.UUID -import kotlinx.coroutines.flow.* +import java.util.concurrent.ConcurrentHashMap import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* +import org.postgresql.ds.PGSimpleDataSource import tech.libeufin.util.* +/** Postgres notification collector and distributor */ internal class NotificationWatcher(private val pgSource: PGSimpleDataSource) { + // Transaction id ShareFlow that are manually counted for manual garbage collection private class CountedSharedFlow(val flow: MutableSharedFlow<Long>, var count: Int) + // Transaction flows, the keys are the bank account id private val bankTxFlows = ConcurrentHashMap<Long, CountedSharedFlow>() private val outgoingTxFlows = ConcurrentHashMap<Long, CountedSharedFlow>() private val incomingTxFlows = ConcurrentHashMap<Long, CountedSharedFlow>() private val revenueTxFlows = ConcurrentHashMap<Long, CountedSharedFlow>() + // Withdrawal confirmation flow, the key is the public withdrawal UUID private val withdrawalFlow = MutableSharedFlow<UUID>() init { + // Run notification logic in a separated thread kotlin.concurrent.thread(isDaemon = true) { runBlocking { while (true) { try { val conn = pgSource.pgConnection() + + // Listen to all notifications channels conn.execSQLUpdate("LISTEN bank_tx") conn.execSQLUpdate("LISTEN outgoing_tx") conn.execSQLUpdate("LISTEN incoming_tx") @@ -49,6 +56,7 @@ internal class NotificationWatcher(private val pgSource: PGSimpleDataSource) { while (true) { conn.getNotifications(0) // Block until we receive at least one notification .forEach { + // Extract informations and dispatch when (it.name) { "bank_tx" -> { val (debtor, creditor, debitRow, creditRow) = it.parameter.split(' ', limit = 4).map { it.toLong() } @@ -89,8 +97,9 @@ internal class NotificationWatcher(private val pgSource: PGSimpleDataSource) { } } + /** Listen to transaction ids flow from [map] for [account] using [lambda]*/ private suspend fun <R> listen(map: ConcurrentHashMap<Long, CountedSharedFlow>, account: Long, lambda: suspend (Flow<Long>) -> R): R { - // Register listener + // Register listener, create a new flow if missing val flow = map.compute(account) { _, v -> val tmp = v ?: CountedSharedFlow(MutableSharedFlow(), 0); tmp.count++; @@ -100,7 +109,7 @@ internal class NotificationWatcher(private val pgSource: PGSimpleDataSource) { try { return lambda(flow) } finally { - // Unregister listener + // Unregister listener, removing unused flow map.compute(account) { _, v -> v!!; v.count--; @@ -109,18 +118,19 @@ internal class NotificationWatcher(private val pgSource: PGSimpleDataSource) { } } + /** Listen for new bank transactions for [account] */ suspend fun <R> listenBank(account: Long, lambda: suspend (Flow<Long>) -> R): R = listen(bankTxFlows, account, lambda) - - suspend fun <R> listenOutgoing(account: Long, lambda: suspend (Flow<Long>) -> R): R - = listen(outgoingTxFlows, account, lambda) - - suspend fun <R> listenIncoming(account: Long, lambda: suspend (Flow<Long>) -> R): R - = listen(incomingTxFlows, account, lambda) - - suspend fun <R> listenRevenue(account: Long, lambda: suspend (Flow<Long>) -> R): R - = listen(revenueTxFlows, account, lambda) - + /** Listen for new taler outgoing transactions from [account] */ + suspend fun <R> listenOutgoing(exchange: Long, lambda: suspend (Flow<Long>) -> R): R + = listen(outgoingTxFlows, exchange, lambda) + /** Listen for new taler incoming transactions to [account] */ + suspend fun <R> listenIncoming(exchange: Long, lambda: suspend (Flow<Long>) -> R): R + = listen(incomingTxFlows, exchange, lambda) + /** Listen for new taler outgoing transactions to [account] */ + suspend fun <R> listenRevenue(merchant: Long, lambda: suspend (Flow<Long>) -> R): R + = listen(revenueTxFlows, merchant, lambda) + /** Listen for new withdrawal confirmations */ suspend fun <R> listenWithdrawals(lambda: suspend (Flow<UUID>) -> R): R = lambda(withdrawalFlow) } \ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/TokenDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/TokenDAO.kt @@ -0,0 +1,95 @@ +/* + * 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 tech.libeufin.util.* +import java.time.Instant + +/** Data access logic for auth tokens */ +class TokenDAO(private val db: Database) { + /** Create new token for [login] */ + suspend fun create( + login: String, + content: ByteArray, + creationTime: Instant, + expirationTime: Instant, + scope: TokenScope, + isRefreshable: Boolean + ): Boolean = db.serializable { conn -> + // TODO single query + val bankCustomer = conn.prepareStatement(""" + SELECT customer_id FROM customers WHERE login=? + """).run { + setString(1, login) + oneOrNull { it.getLong(1) }!! + } + val stmt = conn.prepareStatement(""" + INSERT INTO bearer_tokens ( + content, + creation_time, + expiration_time, + scope, + bank_customer, + is_refreshable + ) VALUES (?, ?, ?, ?::token_scope_enum, ?, ?) + """) + stmt.setBytes(1, content) + stmt.setLong(2, creationTime.toDbMicros() ?: throw faultyTimestampByBank()) + stmt.setLong(3, expirationTime.toDbMicros() ?: throw faultyDurationByClient()) + stmt.setString(4, scope.name) + stmt.setLong(5, bankCustomer) + stmt.setBoolean(6, isRefreshable) + stmt.executeUpdateViolation() + } + + /** Get info for [token] */ + suspend fun get(token: ByteArray): BearerToken? = db.conn { conn -> + val stmt = conn.prepareStatement(""" + SELECT + expiration_time, + creation_time, + bank_customer, + scope, + is_refreshable + FROM bearer_tokens + WHERE content=?; + """) + stmt.setBytes(1, token) + stmt.oneOrNull { + BearerToken( + content = token, + creationTime = it.getLong("creation_time").microsToJavaInstant() ?: throw faultyTimestampByBank(), + expirationTime = it.getLong("expiration_time").microsToJavaInstant() ?: throw faultyDurationByClient(), + bankCustomer = it.getLong("bank_customer"), + scope = TokenScope.valueOf(it.getString("scope")), + isRefreshable = it.getBoolean("is_refreshable") + ) + } + } + + /** Delete token [token] */ + suspend fun delete(token: ByteArray) = db.serializable { conn -> + val stmt = conn.prepareStatement(""" + DELETE FROM bearer_tokens WHERE content = ? + """) + stmt.setBytes(1, token) + stmt.execute() + } +} +\ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/TransactionDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/TransactionDAO.kt @@ -0,0 +1,175 @@ +/* + * 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 tech.libeufin.util.* +import java.time.* +import java.sql.Types + +/** Data access logic for transactions */ +class TransactionDAO(private val db: Database) { + /** Result status of bank transaction creation .*/ + sealed class BankTransactionResult { + data class Success(val id: Long): BankTransactionResult() + object UnknownCreditor: BankTransactionResult() + object UnknownDebtor: BankTransactionResult() + object BothPartySame: BankTransactionResult() + object BalanceInsufficient: BankTransactionResult() + + } + + /** Create a new transaction */ + suspend fun create( + creditAccountPayto: IbanPayTo, + debitAccountUsername: String, + subject: String, + amount: TalerAmount, + timestamp: Instant, + ): BankTransactionResult = db.serializable { conn -> + conn.transaction { + val stmt = conn.prepareStatement(""" + SELECT + out_creditor_not_found + ,out_debtor_not_found + ,out_same_account + ,out_balance_insufficient + ,out_credit_bank_account_id + ,out_debit_bank_account_id + ,out_credit_row_id + ,out_debit_row_id + ,out_creditor_is_exchange + ,out_debtor_is_exchange + FROM bank_transaction(?,?,?,(?,?)::taler_amount,?) + """ + ) + stmt.setString(1, creditAccountPayto.canonical) + stmt.setString(2, debitAccountUsername) + stmt.setString(3, subject) + stmt.setLong(4, amount.value) + stmt.setInt(5, amount.frac) + stmt.setLong(6, timestamp.toDbMicros() ?: throw faultyTimestampByBank()) + stmt.executeQuery().use { + when { + !it.next() -> throw internalServerError("Bank transaction didn't properly return") + it.getBoolean("out_creditor_not_found") -> BankTransactionResult.UnknownCreditor + it.getBoolean("out_debtor_not_found") -> BankTransactionResult.UnknownDebtor + it.getBoolean("out_same_account") -> BankTransactionResult.BothPartySame + it.getBoolean("out_balance_insufficient") -> BankTransactionResult.BalanceInsufficient + else -> { + val creditAccountId = it.getLong("out_credit_bank_account_id") + val creditRowId = it.getLong("out_credit_row_id") + val debitAccountId = it.getLong("out_debit_bank_account_id") + val debitRowId = it.getLong("out_debit_row_id") + val metadata = TxMetadata.parse(subject) + if (it.getBoolean("out_creditor_is_exchange")) { + if (metadata is IncomingTxMetadata) { + conn.prepareStatement("CALL register_incoming(?, ?)").run { + setBytes(1, metadata.reservePub.raw) + setLong(2, creditRowId) + executeUpdate() // TODO check reserve pub reuse + } + } else { + // TODO bounce + logger.warn("exchange account $creditAccountId received a transaction $creditRowId with malformed metadata, will bounce in future version") + } + } + if (it.getBoolean("out_debtor_is_exchange")) { + if (metadata is OutgoingTxMetadata) { + conn.prepareStatement("CALL register_outgoing(NULL, ?, ?, ?, ?, ?, ?)").run { + setBytes(1, metadata.wtid.raw) + setString(2, metadata.exchangeBaseUrl.url) + setLong(3, debitAccountId) + setLong(4, creditAccountId) + setLong(5, debitRowId) + setLong(6, creditRowId) + executeUpdate() // TODO check wtid reuse + } + } else { + logger.warn("exchange account $debitAccountId sent a transaction $debitRowId with malformed metadata") + } + } + BankTransactionResult.Success(debitRowId) + } + } + } + } + } + + /** Get transaction [rowId] owned by [login] */ + suspend fun get(rowId: Long, login: String): BankAccountTransactionInfo? = db.conn { conn -> + val stmt = conn.prepareStatement(""" + SELECT + creditor_payto_uri + ,debtor_payto_uri + ,subject + ,(amount).val AS amount_val + ,(amount).frac AS amount_frac + ,transaction_date + ,direction + ,bank_transaction_id + FROM bank_account_transactions + JOIN bank_accounts ON bank_account_transactions.bank_account_id=bank_accounts.bank_account_id + JOIN customers ON customer_id=owning_customer_id + WHERE bank_transaction_id=? AND login=? + """) + stmt.setLong(1, rowId) + stmt.setString(2, login) + stmt.oneOrNull { + BankAccountTransactionInfo( + creditor_payto_uri = it.getString("creditor_payto_uri"), + debtor_payto_uri = it.getString("debtor_payto_uri"), + amount = it.getAmount("amount", db.bankCurrency), + direction = TransactionDirection.valueOf(it.getString("direction")), + subject = it.getString("subject"), + date = it.getTalerTimestamp("transaction_date"), + row_id = it.getLong("bank_transaction_id") + ) + } + } + + /** Pool [accountId] transactions history */ + suspend fun pollHistory( + params: HistoryParams, + accountId: Long + ): List<BankAccountTransactionInfo> { + return db.poolHistory(params, accountId, NotificationWatcher::listenBank, """ + SELECT + bank_transaction_id + ,transaction_date + ,(amount).val AS amount_val + ,(amount).frac AS amount_frac + ,debtor_payto_uri + ,creditor_payto_uri + ,subject + ,direction + FROM bank_account_transactions + """) { + BankAccountTransactionInfo( + row_id = it.getLong("bank_transaction_id"), + date = it.getTalerTimestamp("transaction_date"), + debtor_payto_uri = it.getString("debtor_payto_uri"), + creditor_payto_uri = it.getString("creditor_payto_uri"), + amount = it.getAmount("amount", db.bankCurrency), + subject = it.getString("subject"), + direction = TransactionDirection.valueOf(it.getString("direction")) + ) + } + } +} +\ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/WithdrawalDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/WithdrawalDAO.kt @@ -27,37 +27,19 @@ import tech.libeufin.util.* import kotlinx.coroutines.flow.* import kotlinx.coroutines.* -/** Result status of withdrawal operation creation */ -enum class WithdrawalCreationResult { - SUCCESS, - ACCOUNT_NOT_FOUND, - ACCOUNT_IS_EXCHANGE, - BALANCE_INSUFFICIENT -} - -/** Result status of withdrawal operation selection */ -enum class WithdrawalSelectionResult { - SUCCESS, - OP_NOT_FOUND, - ALREADY_SELECTED, - RESERVE_PUB_REUSE, - ACCOUNT_NOT_FOUND, - ACCOUNT_IS_NOT_EXCHANGE -} - -/** Result status of withdrawal operation confirmation */ -enum class WithdrawalConfirmationResult { - SUCCESS, - OP_NOT_FOUND, - EXCHANGE_NOT_FOUND, - BALANCE_INSUFFICIENT, - NOT_SELECTED, - ABORTED -} - +/** Data access logic for withdrawal operations */ class WithdrawalDAO(private val db: Database) { + /** Result status of withdrawal operation creation */ + enum class WithdrawalCreationResult { + Success, + UnknownAccount, + AccountIsExchange, + BalanceInsufficient + } + + /** Create a new withdrawal operation */ suspend fun create( - walletAccountUsername: String, + login: String, uuid: UUID, amount: TalerAmount ): WithdrawalCreationResult = db.serializable { conn -> @@ -68,7 +50,7 @@ class WithdrawalDAO(private val db: Database) { out_balance_insufficient FROM create_taler_withdrawal(?, ?, (?,?)::taler_amount); """) - stmt.setString(1, walletAccountUsername) + stmt.setString(1, login) stmt.setObject(2, uuid) stmt.setLong(3, amount.value) stmt.setInt(4, amount.frac) @@ -76,14 +58,119 @@ class WithdrawalDAO(private val db: Database) { when { !it.next() -> throw internalServerError("No result from DB procedure create_taler_withdrawal") - it.getBoolean("out_account_not_found") -> WithdrawalCreationResult.ACCOUNT_NOT_FOUND - it.getBoolean("out_account_is_exchange") -> WithdrawalCreationResult.ACCOUNT_IS_EXCHANGE - it.getBoolean("out_balance_insufficient") -> WithdrawalCreationResult.BALANCE_INSUFFICIENT - else -> WithdrawalCreationResult.SUCCESS + it.getBoolean("out_account_not_found") -> WithdrawalCreationResult.UnknownAccount + it.getBoolean("out_account_is_exchange") -> WithdrawalCreationResult.AccountIsExchange + it.getBoolean("out_balance_insufficient") -> WithdrawalCreationResult.BalanceInsufficient + else -> WithdrawalCreationResult.Success + } + } + } + + /** Abort withdrawal operation [uuid] */ + suspend fun abort(uuid: UUID): AbortResult = db.serializable { conn -> + val stmt = conn.prepareStatement(""" + UPDATE taler_withdrawal_operations + SET aborted = NOT confirmation_done + WHERE withdrawal_uuid=? + RETURNING confirmation_done + """ + ) + stmt.setObject(1, uuid) + when (stmt.oneOrNull { it.getBoolean(1) }) { + null -> AbortResult.UnknownOperation + true -> AbortResult.AlreadyConfirmed + false -> AbortResult.Success + } + } + + /** Result withdrawal operation selection */ + sealed class WithdrawalSelectionResult { + data class Success(val confirmed: Boolean): WithdrawalSelectionResult() + object UnknownOperation: WithdrawalSelectionResult() + object AlreadySelected: WithdrawalSelectionResult() + object RequestPubReuse: WithdrawalSelectionResult() + object UnknownAccount: WithdrawalSelectionResult() + object AccountIsNotExchange: WithdrawalSelectionResult() + } + + /** Set details ([exchangePayto] & [reservePub]) for withdrawal operation [uuid] */ + suspend fun setDetails( + uuid: UUID, + exchangePayto: IbanPayTo, + reservePub: EddsaPublicKey + ): WithdrawalSelectionResult = db.serializable { conn -> + val subject = IncomingTxMetadata(reservePub).encode() + val stmt = conn.prepareStatement(""" + SELECT + out_no_op, + out_already_selected, + out_reserve_pub_reuse, + out_account_not_found, + out_account_is_not_exchange, + out_confirmation_done + FROM select_taler_withdrawal(?, ?, ?, ?); + """ + ) + stmt.setObject(1, uuid) + stmt.setBytes(2, reservePub.raw) + stmt.setString(3, subject) + stmt.setString(4, exchangePayto.canonical) + stmt.executeQuery().use { + when { + !it.next() -> + throw internalServerError("No result from DB procedure select_taler_withdrawal") + it.getBoolean("out_no_op") -> WithdrawalSelectionResult.UnknownOperation + it.getBoolean("out_already_selected") -> WithdrawalSelectionResult.AlreadySelected + it.getBoolean("out_reserve_pub_reuse") -> WithdrawalSelectionResult.RequestPubReuse + it.getBoolean("out_account_not_found") -> WithdrawalSelectionResult.UnknownAccount + it.getBoolean("out_account_is_not_exchange") -> WithdrawalSelectionResult.AccountIsNotExchange + else -> WithdrawalSelectionResult.Success(it.getBoolean("out_confirmation_done")) + } + } + } + + /** Result status of withdrawal operation confirmation */ + enum class WithdrawalConfirmationResult { + Success, + UnknownOperation, + UnknownExchange, + BalanceInsufficient, + NotSelected, + AlreadyAborted + } + + /** Confirm withdrawal operation [uuid] */ + suspend fun confirm( + uuid: UUID, + now: Instant + ): WithdrawalConfirmationResult = db.serializable { conn -> + val stmt = conn.prepareStatement(""" + SELECT + out_no_op, + out_exchange_not_found, + out_balance_insufficient, + out_not_selected, + out_aborted + FROM confirm_taler_withdrawal(?, ?); + """ + ) + stmt.setObject(1, uuid) + stmt.setLong(2, now.toDbMicros() ?: throw faultyTimestampByBank()) + stmt.executeQuery().use { + when { + !it.next() -> + throw internalServerError("No result from DB procedure confirm_taler_withdrawal") + it.getBoolean("out_no_op") -> WithdrawalConfirmationResult.UnknownOperation + it.getBoolean("out_exchange_not_found") -> WithdrawalConfirmationResult.UnknownExchange + it.getBoolean("out_balance_insufficient") -> WithdrawalConfirmationResult.BalanceInsufficient + it.getBoolean("out_not_selected") -> WithdrawalConfirmationResult.NotSelected + it.getBoolean("out_aborted") -> WithdrawalConfirmationResult.AlreadyAborted + else -> WithdrawalConfirmationResult.Success } } } + /** Get withdrawal operation [uuid] */ suspend fun get(uuid: UUID): BankAccountGetWithdrawalResponse? = db.conn { conn -> val stmt = conn.prepareStatement(""" SELECT @@ -100,11 +187,7 @@ class WithdrawalDAO(private val db: Database) { stmt.setObject(1, uuid) stmt.oneOrNull { BankAccountGetWithdrawalResponse( - amount = TalerAmount( - it.getLong("amount_val"), - it.getInt("amount_frac"), - db.bankCurrency - ), + amount = it.getAmount("amount", db.bankCurrency), selection_done = it.getBoolean("selection_done"), confirmation_done = it.getBoolean("confirmation_done"), aborted = it.getBoolean("aborted"), @@ -114,6 +197,7 @@ class WithdrawalDAO(private val db: Database) { } } + /** Pool public status of operation [uuid] */ suspend fun pollStatus(uuid: UUID, params: PollingParams): BankWithdrawalOperationStatus? { suspend fun load(): BankWithdrawalOperationStatus? = db.conn { conn -> val stmt = conn.prepareStatement(""" @@ -131,11 +215,7 @@ class WithdrawalDAO(private val db: Database) { stmt.setObject(1, uuid) stmt.oneOrNull { BankWithdrawalOperationStatus( - amount = TalerAmount( - it.getLong("amount_val"), - it.getInt("amount_frac"), - db.bankCurrency - ), + amount = it.getAmount("amount", db.bankCurrency), selection_done = it.getBoolean("selection_done"), transfer_done = it.getBoolean("confirmation_done"), aborted = it.getBoolean("aborted"), @@ -172,101 +252,4 @@ class WithdrawalDAO(private val db: Database) { load() } } - - /** - * Aborts one Taler withdrawal, only if it wasn't previously - * confirmed. It returns false if the UPDATE didn't succeed. - */ - suspend fun abort(uuid: UUID): AbortResult = db.serializable { conn -> - val stmt = conn.prepareStatement(""" - UPDATE taler_withdrawal_operations - SET aborted = NOT confirmation_done - WHERE withdrawal_uuid=? - RETURNING confirmation_done - """ - ) - stmt.setObject(1, uuid) - when (stmt.oneOrNull { it.getBoolean(1) }) { - null -> AbortResult.NOT_FOUND - true -> AbortResult.CONFIRMED - false -> AbortResult.SUCCESS - } - } - - /** - * Associates a reserve public key and an exchange to - * a Taler withdrawal. Returns true on success, false - * otherwise. - * - * Checking for idempotency is entirely on the Kotlin side. - */ - suspend fun setDetails( - uuid: UUID, - exchangePayto: IbanPayTo, - reservePub: EddsaPublicKey - ): Pair<WithdrawalSelectionResult, Boolean> = db.serializable { conn -> - val subject = IncomingTxMetadata(reservePub).encode() - val stmt = conn.prepareStatement(""" - SELECT - out_no_op, - out_already_selected, - out_reserve_pub_reuse, - out_account_not_found, - out_account_is_not_exchange, - out_confirmation_done - FROM select_taler_withdrawal(?, ?, ?, ?); - """ - ) - stmt.setObject(1, uuid) - stmt.setBytes(2, reservePub.raw) - stmt.setString(3, subject) - stmt.setString(4, exchangePayto.canonical) - stmt.executeQuery().use { - val status = when { - !it.next() -> - throw internalServerError("No result from DB procedure select_taler_withdrawal") - it.getBoolean("out_no_op") -> WithdrawalSelectionResult.OP_NOT_FOUND - it.getBoolean("out_already_selected") -> WithdrawalSelectionResult.ALREADY_SELECTED - it.getBoolean("out_reserve_pub_reuse") -> WithdrawalSelectionResult.RESERVE_PUB_REUSE - it.getBoolean("out_account_not_found") -> WithdrawalSelectionResult.ACCOUNT_NOT_FOUND - it.getBoolean("out_account_is_not_exchange") -> WithdrawalSelectionResult.ACCOUNT_IS_NOT_EXCHANGE - else -> WithdrawalSelectionResult.SUCCESS - } - Pair(status, it.getBoolean("out_confirmation_done")) - } - } - - /** - * Confirms a Taler withdrawal: flags the operation as - * confirmed and performs the related wire transfer. - */ - suspend fun confirm( - uuid: UUID, - now: Instant - ): WithdrawalConfirmationResult = db.serializable { conn -> - val stmt = conn.prepareStatement(""" - SELECT - out_no_op, - out_exchange_not_found, - out_balance_insufficient, - out_not_selected, - out_aborted - FROM confirm_taler_withdrawal(?, ?); - """ - ) - stmt.setObject(1, uuid) - stmt.setLong(2, now.toDbMicros() ?: throw faultyTimestampByBank()) - stmt.executeQuery().use { - when { - !it.next() -> - throw internalServerError("No result from DB procedure confirm_taler_withdrawal") - it.getBoolean("out_no_op") -> WithdrawalConfirmationResult.OP_NOT_FOUND - it.getBoolean("out_exchange_not_found") -> WithdrawalConfirmationResult.EXCHANGE_NOT_FOUND - it.getBoolean("out_balance_insufficient") -> WithdrawalConfirmationResult.BALANCE_INSUFFICIENT - it.getBoolean("out_not_selected") -> WithdrawalConfirmationResult.NOT_SELECTED - it.getBoolean("out_aborted") -> WithdrawalConfirmationResult.ABORTED - else -> WithdrawalConfirmationResult.SUCCESS - } - } - } } \ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt b/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2023 Stanisci and Dold. + * 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 @@ -23,22 +23,21 @@ import io.ktor.http.* import io.ktor.server.application.* import io.ktor.server.plugins.* import io.ktor.server.request.* -import io.ktor.server.util.* import io.ktor.server.routing.Route import io.ktor.server.routing.RouteSelector -import io.ktor.server.routing.RoutingResolveContext import io.ktor.server.routing.RouteSelectorEvaluation -import io.ktor.util.valuesOf +import io.ktor.server.routing.RoutingResolveContext +import io.ktor.server.util.* import io.ktor.util.pipeline.PipelineContext -import net.taler.common.errorcodes.TalerErrorCode -import net.taler.wallet.crypto.Base32Crockford -import org.slf4j.Logger -import org.slf4j.LoggerFactory -import tech.libeufin.util.* import java.net.URL import java.time.* import java.time.temporal.* import java.util.* +import net.taler.common.errorcodes.TalerErrorCode +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import tech.libeufin.bank.AccountDAO.* +import tech.libeufin.util.* private val logger: Logger = LoggerFactory.getLogger("tech.libeufin.bank.helpers") val reservedAccounts = setOf("admin", "bank") @@ -50,8 +49,8 @@ fun ApplicationCall.expectUriComponent(componentName: String) = ) /** Retrieve the bank account info for the selected username*/ -suspend fun ApplicationCall.bankAccount(db: Database): BankAccount - = db.bankAccountGetFromCustomerLogin(username) ?: throw notFound( +suspend fun ApplicationCall.bankInfo(db: Database): BankInfo + = db.account.bankInfo(username) ?: throw notFound( "Bank account for customer $username not found", TalerErrorCode.BANK_UNKNOWN_ACCOUNT ) @@ -129,7 +128,7 @@ suspend fun maybeCreateAdminAccount(db: Database, ctx: BankConfig, pw: String? = pwStr = String(pwBuf, Charsets.UTF_8) } - val res = db.accountCreate( + val res = db.account.create( login = "admin", password = pwStr, name = "Bank administrator", @@ -140,10 +139,10 @@ suspend fun maybeCreateAdminAccount(db: Database, ctx: BankConfig, pw: String? = bonus = null ) return when (res) { - CustomerCreationResult.BALANCE_INSUFFICIENT -> false - CustomerCreationResult.CONFLICT_LOGIN -> true - CustomerCreationResult.CONFLICT_PAY_TO -> false - CustomerCreationResult.SUCCESS -> true + AccountCreationResult.BonusBalanceInsufficient -> false + AccountCreationResult.LoginReuse -> true + AccountCreationResult.PayToReuse -> false + AccountCreationResult.Success -> true } } diff --git a/bank/src/test/kotlin/AmountTest.kt b/bank/src/test/kotlin/AmountTest.kt @@ -17,16 +17,17 @@ * <http://www.gnu.org/licenses/> */ +import java.time.Instant +import java.util.* +import kotlin.test.* import org.junit.Test import org.postgresql.jdbc.PgConnection import tech.libeufin.bank.* +import tech.libeufin.bank.TransactionDAO.* +import tech.libeufin.bank.WithdrawalDAO.* import tech.libeufin.util.* -import kotlin.test.* -import java.time.Instant -import java.util.* class AmountTest { - // Test amount computation in database @Test fun computationTest() = bankSetup { db -> @@ -48,7 +49,7 @@ class AmountTest { // Check bank transaction stmt.executeUpdate() - val (txRes, _) = db.bankTransaction( + val txRes = db.transaction.create( creditAccountPayto = exchangePayto, debitAccountUsername = "merchant", subject = "test", @@ -56,21 +57,21 @@ class AmountTest { timestamp = Instant.now(), ) val txBool = when (txRes) { - BankTransactionResult.BALANCE_INSUFFICIENT -> false - BankTransactionResult.SUCCESS -> true + BankTransactionResult.BalanceInsufficient -> false + is BankTransactionResult.Success -> true else -> throw Exception("Unexpected error $txRes") } // Check whithdraw stmt.executeUpdate() val wRes = db.withdrawal.create( - walletAccountUsername = "merchant", + login = "merchant", uuid = UUID.randomUUID(), amount = due, ) val wBool = when (wRes) { - WithdrawalCreationResult.BALANCE_INSUFFICIENT -> false - WithdrawalCreationResult.SUCCESS -> true + WithdrawalCreationResult.BalanceInsufficient -> false + WithdrawalCreationResult.Success -> true else -> throw Exception("Unexpected error $txRes") } diff --git a/bank/src/test/kotlin/BankIntegrationApiTest.kt b/bank/src/test/kotlin/BankIntegrationApiTest.kt @@ -1,20 +1,34 @@ +/* + * 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/> + */ + import io.ktor.client.plugins.* import io.ktor.client.request.* import io.ktor.client.statement.* -import io.ktor.client.HttpClient import io.ktor.http.* import io.ktor.server.testing.* -import kotlinx.serialization.json.* +import java.util.* +import kotlin.test.* import kotlinx.coroutines.* -import net.taler.wallet.crypto.Base32Crockford +import kotlinx.serialization.json.* import net.taler.common.errorcodes.TalerErrorCode import org.junit.Test import tech.libeufin.bank.* -import tech.libeufin.util.CryptoUtil -import java.util.* -import java.time.Instant -import kotlin.test.* -import randHashCode class BankIntegrationApiTest { // GET /taler-integration/config diff --git a/bank/src/test/kotlin/CoreBankApiTest.kt b/bank/src/test/kotlin/CoreBankApiTest.kt @@ -1,28 +1,39 @@ +/* + * 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/> + */ + import io.ktor.client.plugins.* import io.ktor.client.request.* import io.ktor.client.statement.* -import io.ktor.client.HttpClient import io.ktor.http.* import io.ktor.http.content.* import io.ktor.server.engine.* import io.ktor.server.testing.* -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonElement -import net.taler.wallet.crypto.Base32Crockford -import net.taler.common.errorcodes.TalerErrorCode -import org.junit.Test -import org.postgresql.jdbc.PgConnection -import tech.libeufin.bank.* -import tech.libeufin.util.CryptoUtil -import java.sql.DriverManager import java.time.Duration import java.time.Instant -import java.time.temporal.ChronoUnit import java.util.* -import java.io.File -import kotlin.random.Random import kotlin.test.* import kotlinx.coroutines.* +import kotlinx.serialization.json.JsonElement +import net.taler.common.errorcodes.TalerErrorCode +import net.taler.wallet.crypto.Base32Crockford +import org.junit.Test +import tech.libeufin.bank.* class CoreBankConfigTest { // GET /config @@ -60,7 +71,7 @@ class CoreBankTokenApiTest { json { "scope" to "readonly" } }.assertOkJson<TokenSuccessResponse> { // Checking that the token lifetime defaulted to 24 hours. - val token = db.bearerTokenGet(Base32Crockford.decode(it.access_token)) + val token = db.token.get(Base32Crockford.decode(it.access_token)) val lifeTime = Duration.between(token!!.creationTime, token.expirationTime) assertEquals(Duration.ofDays(1), lifeTime) } @@ -70,7 +81,7 @@ class CoreBankTokenApiTest { json { "scope" to "readonly" } }.assertOkJson<TokenSuccessResponse> { // Checking that the token lifetime defaulted to 24 hours. - val token = db.bearerTokenGet(Base32Crockford.decode(it.access_token)) + val token = db.token.get(Base32Crockford.decode(it.access_token)) val lifeTime = Duration.between(token!!.creationTime, token.expirationTime) assertEquals(Duration.ofDays(1), lifeTime) } diff --git a/bank/src/test/kotlin/DatabaseTest.kt b/bank/src/test/kotlin/DatabaseTest.kt @@ -17,25 +17,19 @@ * <http://www.gnu.org/licenses/> */ -import org.junit.Test -import org.postgresql.jdbc.PgConnection -import tech.libeufin.bank.* -import tech.libeufin.util.* -import java.sql.DriverManager -import java.time.Instant +import io.ktor.client.plugins.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* import java.time.Duration +import java.time.Instant import java.time.temporal.ChronoUnit -import java.util.Random -import java.util.UUID import java.util.concurrent.TimeUnit -import kotlin.experimental.inv import kotlin.test.* import kotlinx.coroutines.* -import io.ktor.http.* -import io.ktor.client.plugins.* -import io.ktor.client.request.* -import io.ktor.client.statement.* -import io.ktor.client.HttpClient +import org.junit.Test +import tech.libeufin.bank.* +import tech.libeufin.util.* class DatabaseTest { // Testing the helper that update conversion config diff --git a/bank/src/test/kotlin/JsonTest.kt b/bank/src/test/kotlin/JsonTest.kt @@ -1,11 +1,30 @@ +/* + * 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/> + */ + +import java.time.Duration +import java.time.Instant +import java.time.temporal.ChronoUnit import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import org.junit.Test import tech.libeufin.bank.* -import java.time.Duration -import java.time.Instant -import java.time.temporal.ChronoUnit @Serializable data class MyJsonType( diff --git a/bank/src/test/kotlin/RevenueApiTest.kt b/bank/src/test/kotlin/RevenueApiTest.kt @@ -1,20 +1,32 @@ +/* + * 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/> + */ + import io.ktor.client.plugins.* import io.ktor.client.request.* import io.ktor.client.statement.* -import io.ktor.client.HttpClient import io.ktor.http.* import io.ktor.server.testing.* -import kotlinx.serialization.json.* +import java.util.* import kotlinx.coroutines.* +import kotlinx.serialization.json.* import org.junit.Test import tech.libeufin.bank.* -import tech.libeufin.util.CryptoUtil -import net.taler.common.errorcodes.TalerErrorCode -import java.util.* -import java.time.Instant -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import randHashCode class RevenueApiTest { // GET /accounts/{USERNAME}/taler-revenue/history diff --git a/bank/src/test/kotlin/SecurityTest.kt b/bank/src/test/kotlin/SecurityTest.kt @@ -17,19 +17,17 @@ * <http://www.gnu.org/licenses/> */ -import org.junit.Test -import org.postgresql.jdbc.PgConnection -import tech.libeufin.bank.* -import tech.libeufin.util.* import io.ktor.client.plugins.* import io.ktor.client.request.* import io.ktor.client.statement.* -import io.ktor.client.HttpClient import io.ktor.http.content.* import io.ktor.server.engine.* import io.ktor.server.testing.* import kotlin.test.* import kotlinx.coroutines.* +import org.junit.Test +import tech.libeufin.bank.* +import tech.libeufin.util.* class SecurityTest { @Test diff --git a/bank/src/test/kotlin/StatsTest.kt b/bank/src/test/kotlin/StatsTest.kt @@ -26,9 +26,6 @@ import java.time.* import java.time.Instant import java.util.* import kotlin.test.* -import kotlin.reflect.full.declaredMemberProperties -import kotlin.suspend -import kotlinx.serialization.json.Json import org.junit.Test import tech.libeufin.bank.* import tech.libeufin.util.* diff --git a/bank/src/test/kotlin/WireGatewayApiTest.kt b/bank/src/test/kotlin/WireGatewayApiTest.kt @@ -1,20 +1,33 @@ +/* + * 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/> + */ + import io.ktor.client.plugins.* import io.ktor.client.request.* import io.ktor.client.statement.* -import io.ktor.client.HttpClient import io.ktor.http.* import io.ktor.server.testing.* -import kotlinx.serialization.json.* +import java.util.* import kotlinx.coroutines.* +import kotlinx.serialization.json.* +import net.taler.common.errorcodes.TalerErrorCode import org.junit.Test import tech.libeufin.bank.* -import tech.libeufin.util.CryptoUtil -import net.taler.common.errorcodes.TalerErrorCode -import java.util.* -import java.time.Instant -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import randHashCode class WireGatewayApiTest { // Test endpoint is correctly authenticated diff --git a/bank/src/test/kotlin/helpers.kt b/bank/src/test/kotlin/helpers.kt @@ -1,19 +1,37 @@ -import io.ktor.http.* -import io.ktor.client.statement.* -import io.ktor.client.request.* +/* + * 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/> + */ + import io.ktor.client.HttpClient +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* import io.ktor.server.testing.* +import java.io.ByteArrayOutputStream +import java.io.File +import java.util.zip.DeflaterOutputStream +import kotlin.test.* import kotlinx.coroutines.* import kotlinx.serialization.json.* -import net.taler.wallet.crypto.Base32Crockford import net.taler.common.errorcodes.TalerErrorCode -import kotlin.test.* +import net.taler.wallet.crypto.Base32Crockford import tech.libeufin.bank.* -import java.io.ByteArrayOutputStream -import java.io.File -import java.util.zip.DeflaterOutputStream -import java.util.UUID -import tech.libeufin.util.CryptoUtil +import tech.libeufin.bank.AccountDAO.* import tech.libeufin.util.* /* ----- Setup ----- */ @@ -51,7 +69,7 @@ fun bankSetup( ) { setup(conf) { db, ctx -> // Creating the exchange and merchant accounts first. - assertEquals(CustomerCreationResult.SUCCESS, db.accountCreate( + assertEquals(AccountCreationResult.Success, db.account.create( login = "merchant", password = "merchant-password", name = "Merchant", @@ -61,7 +79,7 @@ fun bankSetup( isPublic = false, bonus = null )) - assertEquals(CustomerCreationResult.SUCCESS, db.accountCreate( + assertEquals(AccountCreationResult.Success, db.account.create( login = "exchange", password = "exchange-password", name = "Exchange", @@ -71,7 +89,7 @@ fun bankSetup( isPublic = false, bonus = null )) - assertEquals(CustomerCreationResult.SUCCESS, db.accountCreate( + assertEquals(AccountCreationResult.Success, db.account.create( login = "customer", password = "customer-password", name = "Customer", @@ -232,10 +250,6 @@ suspend fun HttpResponse.assertErr(code: TalerErrorCode): HttpResponse { return this } -fun BankTransactionResult.assertSuccess() { - assertEquals(BankTransactionResult.SUCCESS, this) -} - suspend fun assertTime(min: Int, max: Int, lambda: suspend () -> Unit) { val start = System.currentTimeMillis() lambda() diff --git a/database-versioning/libeufin-bank-procedures.sql b/database-versioning/libeufin-bank-procedures.sql @@ -115,6 +115,7 @@ ELSE -- not a debt account END IF; END IF; END $$; +COMMENT ON FUNCTION account_balance_is_sufficient IS 'Check if an account have enough fund to transfer an amount.'; CREATE OR REPLACE FUNCTION account_reconfig( IN in_login TEXT, @@ -255,7 +256,7 @@ CALL stats_register_payment('taler_out', now()::TIMESTAMP, local_amount, null); PERFORM pg_notify('outgoing_tx', in_debtor_account_id || ' ' || in_creditor_account_id || ' ' || in_debit_row_id || ' ' || in_credit_row_id); END $$; COMMENT ON PROCEDURE register_outgoing - IS 'Register a bank transaction as a taler outgoing transaction'; + IS 'Register a bank transaction as a taler outgoing transaction and announce it'; CREATE OR REPLACE PROCEDURE register_incoming( IN in_reserve_pub BYTEA, @@ -284,7 +285,7 @@ CALL stats_register_payment('taler_in', now()::TIMESTAMP, local_amount, null); PERFORM pg_notify('incoming_tx', local_bank_account_id || ' ' || in_tx_row_id); END $$; COMMENT ON PROCEDURE register_incoming - IS 'Register a bank transaction as a taler incoming transaction'; + IS 'Register a bank transaction as a taler incoming transaction and announce it'; CREATE OR REPLACE FUNCTION taler_transfer( @@ -377,11 +378,7 @@ out_timestamp=in_timestamp; -- Register outgoing transaction CALL register_outgoing(in_request_uid, in_wtid, in_exchange_base_url, exchange_bank_account_id, receiver_bank_account_id, out_tx_row_id, credit_row_id); END $$; --- TODO new comment -COMMENT ON FUNCTION taler_transfer IS 'function that (1) inserts the TWG requests' - 'details into the database and (2) performs ' - 'the actual bank transaction to pay the merchant'; - +COMMENT ON FUNCTION taler_transfer IS 'Create an outgoing taler transaction and register it'; CREATE OR REPLACE FUNCTION taler_add_incoming( IN in_reserve_pub BYTEA, @@ -462,10 +459,7 @@ END IF; -- Register incoming transaction CALL register_incoming(in_reserve_pub, out_tx_row_id); END $$; --- TODO new comment -COMMENT ON FUNCTION taler_add_incoming IS 'function that (1) inserts the TWG requests' - 'details into the database and (2) performs ' - 'the actual bank transaction to pay the merchant'; +COMMENT ON FUNCTION taler_add_incoming IS 'Create an incoming taler transaction and register it'; CREATE OR REPLACE FUNCTION bank_transaction( IN in_credit_account_payto TEXT, @@ -537,6 +531,7 @@ IF out_balance_insufficient THEN RETURN; END IF; END $$; +COMMENT ON FUNCTION bank_transaction IS 'Create a bank transaction'; CREATE OR REPLACE FUNCTION create_taler_withdrawal( IN in_account_username TEXT, @@ -575,6 +570,7 @@ INSERT INTO taler_withdrawal_operations (withdrawal_uuid, wallet_bank_account, amount) VALUES (in_withdrawal_uuid, account_id, in_amount); END $$; +COMMENT ON FUNCTION create_taler_withdrawal IS 'Create a new withdrawal operation'; CREATE OR REPLACE FUNCTION select_taler_withdrawal( IN in_withdrawal_uuid uuid, @@ -636,7 +632,7 @@ IF NOT out_confirmation_done AND not_selected THEN WHERE withdrawal_uuid=in_withdrawal_uuid; END IF; END $$; - +COMMENT ON FUNCTION select_taler_withdrawal IS 'Set details of a withdrawal operation'; CREATE OR REPLACE FUNCTION confirm_taler_withdrawal( IN in_withdrawal_uuid uuid, @@ -1029,6 +1025,7 @@ END IF; -- update stats CALL stats_register_payment('cashin', now()::TIMESTAMP, converted_amount, in_amount); END $$; +COMMENT ON FUNCTION cashin IS 'Perform a cashin operation'; CREATE OR REPLACE FUNCTION cashout_create(