libeufin

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

commit 0bcadb6e7637a92744029f909f14c5df8dfe078a
parent 55c355a71b389b23963c389ae8be77cbfae10b33
Author: Antoine A <>
Date:   Wed,  8 Nov 2023 16:04:54 +0000

Add missing cashout endpoints

Diffstat:
Mbank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt | 48++++++++++++++++++++++++++++++++++--------------
Abank/src/main/kotlin/tech/libeufin/bank/Params.kt | 123+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt | 40++++++++++++----------------------------
Mbank/src/main/kotlin/tech/libeufin/bank/db/CashoutDAO.kt | 166+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
Mbank/src/main/kotlin/tech/libeufin/bank/db/Database.kt | 66+++++++++++++++++++++++++++++++++++++++++++-----------------------
Mbank/src/main/kotlin/tech/libeufin/bank/helpers.kt | 89++++++++-----------------------------------------------------------------------
Mbank/src/test/kotlin/CoreBankApiTest.kt | 247++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Mbank/src/test/kotlin/StatsTest.kt | 24+++---------------------
Mbank/src/test/kotlin/WireGatewayApiTest.kt | 48++++--------------------------------------------
Mbank/src/test/kotlin/helpers.kt | 42++++++++++++++++++++++++++++++++++++++++++
Mdatabase-versioning/libeufin-bank-0001.sql | 2+-
Mdatabase-versioning/libeufin-bank-procedures.sql | 22+++++++++-------------
12 files changed, 574 insertions(+), 343 deletions(-)

diff --git a/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt @@ -303,7 +303,11 @@ private fun Routing.coreBankTransactionsApi(db: Database, ctx: BankConfig) { val history: List<BankAccountTransactionInfo> = db.bankPoolHistory(params, bankAccount.bankAccountId) - call.respond(BankAccountTransactionsResponse(history)) + if (history.isEmpty()) { + call.respond(HttpStatusCode.NoContent) + } else { + call.respond(BankAccountTransactionsResponse(history)) + } } get("/accounts/{USERNAME}/transactions/{T_ID}") { val tId = call.expectUriComponent("T_ID") @@ -487,7 +491,6 @@ private fun Routing.coreBankCashoutApi(db: Database, ctx: BankConfig) { val res = db.cashout.create( accountUsername = username, requestUid = req.request_uid, - cashoutUuid = UUID.randomUUID(), amountDebit = req.amount_debit, amountCredit = req.amount_credit, subject = req.subject ?: "", // TODO default subject @@ -543,15 +546,15 @@ private fun Routing.coreBankCashoutApi(db: Database, ctx: BankConfig) { } db.cashout.markSent(res.id!!, Instant.now(), TAN_RETRANSMISSION_PERIOD) } - call.respond(CashoutPending(res.id.toString())) + call.respond(CashoutPending(res.id!!)) } } } post("/accounts/{USERNAME}/cashouts/{CASHOUT_ID}/abort") { - val opId = call.uuidUriComponent("CASHOUT_ID") - when (db.cashout.abort(opId)) { + val id = call.longUriComponent("CASHOUT_ID") + when (db.cashout.abort(id)) { AbortResult.NOT_FOUND -> throw notFound( - "Cashout operation $opId not found", + "Cashout operation $id not found", TalerErrorCode.BANK_TRANSACTION_NOT_FOUND ) AbortResult.CONFIRMED -> throw conflict( @@ -563,14 +566,14 @@ private fun Routing.coreBankCashoutApi(db: Database, ctx: BankConfig) { } post("/accounts/{USERNAME}/cashouts/{CASHOUT_ID}/confirm") { val req = call.receive<CashoutConfirm>() - val opId = call.uuidUriComponent("CASHOUT_ID") + val id = call.longUriComponent("CASHOUT_ID") when (db.cashout.confirm( - opUuid = opId, + id = id, tanCode = req.tan, timestamp = Instant.now() )) { CashoutConfirmationResult.OP_NOT_FOUND -> throw notFound( - "Cashout operation $opId not found", + "Cashout operation $id not found", TalerErrorCode.BANK_TRANSACTION_NOT_FOUND ) CashoutConfirmationResult.ABORTED -> throw conflict( @@ -603,16 +606,33 @@ private fun Routing.coreBankCashoutApi(db: Database, ctx: BankConfig) { } } auth(db, TokenScope.readonly) { - get("/accounts/{USERNAME}/cashouts") { - // TODO - } get("/accounts/{USERNAME}/cashouts/{CASHOUT_ID}") { - // TODO + val id = call.longUriComponent("CASHOUT_ID") + val cashout = db.cashout.get(id) ?: throw notFound( + "Cashout operation $id not found", + TalerErrorCode.BANK_TRANSACTION_NOT_FOUND + ) + call.respond(cashout) + } + get("/accounts/{USERNAME}/cashouts") { + val params = PageParams.extract(call.request.queryParameters) + val cashouts = db.cashout.pageForUser(params, username) + if (cashouts.isEmpty()) { + call.respond(HttpStatusCode.NoContent) + } else { + call.respond(Cashouts(cashouts)) + } } } authAdmin(db, TokenScope.readonly) { get("/cashouts") { - // TODO + val params = PageParams.extract(call.request.queryParameters) + val cashouts = db.cashout.pageAll(params) + if (cashouts.isEmpty()) { + call.respond(HttpStatusCode.NoContent) + } else { + call.respond(GlobalCashouts(cashouts)) + } } } get("/cashout-rate") { diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Params.kt b/bank/src/main/kotlin/tech/libeufin/bank/Params.kt @@ -0,0 +1,122 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2023 Taler Systems S.A. + + * LibEuFin is free software; you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation; either version 3, or + * (at your option) any later version. + + * LibEuFin is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General + * Public License for more details. + + * You should have received a copy of the GNU Affero General Public + * License along with LibEuFin; see the file COPYING. If not, see + * <http://www.gnu.org/licenses/> + */ + +package tech.libeufin.bank + +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.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.* + +fun Parameters.expect(name: String): String + = get(name) ?: throw badRequest("Missing '$name' parameter") +fun Parameters.int(name: String): Int? + = get(name)?.run { toIntOrNull() ?: throw badRequest("Param 'which' not a number") } +fun Parameters.expectInt(name: String): Int + = int(name) ?: throw badRequest("Missing '$name' number parameter") +fun Parameters.long(name: String): Long? + = get(name)?.run { toLongOrNull() ?: throw badRequest("Param 'which' not a number") } +fun Parameters.expectLong(name: String): Long + = long(name) ?: throw badRequest("Missing '$name' number parameter") +fun Parameters.amount(name: String): TalerAmount? + = get(name)?.run { + try { + TalerAmount(this) + } catch (e: Exception) { + throw badRequest("Param '$name' not a taler amount") + } + } + +data class MonitorParams( + val timeframe: Timeframe, + val which: Int? +) { + companion object { + fun extract(params: Parameters): MonitorParams { + val timeframe = Timeframe.valueOf(params["timeframe"] ?: "hour") + val which = params.int("which") + if (which != null) { + val lastDayOfMonth = OffsetDateTime.now(ZoneOffset.UTC).with(TemporalAdjusters.lastDayOfMonth()).dayOfMonth + when { + timeframe == Timeframe.hour && (0 > which || which > 23) -> + throw badRequest("For hour timestamp param 'which' must be between 00 to 23") + timeframe == Timeframe.day && (1 > which || which > 23) -> + throw badRequest("For day timestamp param 'which' must be between 1 to $lastDayOfMonth") + timeframe == Timeframe.month && (1 > which || which > lastDayOfMonth) -> + throw badRequest("For month timestamp param 'which' must be between 1 to 12") + timeframe == Timeframe.year && (1 > which|| which > 9999) -> + throw badRequest("For year timestamp param 'which' must be between 0001 to 9999") + else -> {} + } + } + return MonitorParams(timeframe, which) + } + } +} + +data class HistoryParams( + val page: PageParams, val poll_ms: Long +) { + companion object { + fun extract(params: Parameters): HistoryParams { + val poll_ms: Long = params.long("long_poll_ms") ?: 0 + // TODO check poll_ms range + return HistoryParams(PageParams.extract(params), poll_ms) + } + } +} + +data class PageParams( + val delta: Int, val start: Long +) { + companion object { + fun extract(params: Parameters): PageParams { + val delta: Int = params.int("delta") ?: -20 + val start: Long = params.long("start") ?: if (delta >= 0) 0L else Long.MAX_VALUE + // TODO enforce delta limit + return PageParams(delta, start) + } + } +} + +data class RateParams( + val debit: TalerAmount?, val credit: TalerAmount? +) { + companion object { + fun extract(params: Parameters): RateParams { + val debit = params.amount("amount_debit") + val credit = params.amount("amount_credit") + if (debit == null && credit == null) { + throw badRequest("Either param 'amount_debit' or 'amount_credit' is required") + } + return RateParams(debit, credit) + } + } +} +\ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt b/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt @@ -48,6 +48,7 @@ enum class TransactionDirection { enum class CashoutStatus { pending, + aborted, confirmed } @@ -57,6 +58,13 @@ enum class RoundingMode { nearest } +enum class Timeframe { + hour, + day, + month, + year +} + /** * HTTP response type of successful token refresh. * access_token is the Crockford encoding of the 32 byte @@ -239,29 +247,6 @@ data class TalerWithdrawalOperation( val walletBankAccount: Long ) -/** - * Represents a cashout operation, as it is stored - * in the respective database table. - */ -data class Cashout( - val cashoutUuid: UUID, - val localTransaction: Long? = null, - val amountDebit: TalerAmount, - val amountCredit: TalerAmount, - val buyAtRatio: Int, - val buyInFee: TalerAmount, - val sellAtRatio: Int, - val sellOutFee: TalerAmount, - val subject: String, - val creationTime: Instant, - val tanConfirmationTime: Instant? = null, - val tanChannel: TanChannel, - val tanCode: String, - val bankAccount: Long, - val credit_payto_uri: String, - val cashoutCurrency: String -) - // Type to return as GET /config response @Serializable data class Config( @@ -459,7 +444,7 @@ data class CashoutRequest( @Serializable data class CashoutPending( - val cashout_id: String, + val cashout_id: Long, ) @Serializable @@ -469,7 +454,7 @@ data class Cashouts( @Serializable data class CashoutInfo( - val cashout_id: String, + val cashout_id: Long, val status: CashoutStatus, ) @@ -481,7 +466,7 @@ data class GlobalCashouts( @Serializable data class GlobalCashoutInfo( - val cashout_id: String, + val cashout_id: Long, val username: String, val status: CashoutStatus, ) @@ -492,9 +477,8 @@ data class CashoutStatusResponse( val amount_debit: TalerAmount, val amount_credit: TalerAmount, val subject: String, - val credit_payto_uri: IbanPayTo, val creation_time: TalerProtocolTimestamp, - val confirmation_time: TalerProtocolTimestamp?, + val confirmation_time: TalerProtocolTimestamp? = null, ) @Serializable 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,7 +19,6 @@ package tech.libeufin.bank -import java.util.UUID import java.time.Instant import java.time.Duration import java.util.concurrent.TimeUnit @@ -52,7 +51,7 @@ class CashoutDAO(private val db: Database) { data class CashoutCreation( val status: CashoutCreationResult, - val id: UUID?, + val id: Long?, val tanInfo: String?, val tanCode: String? ) @@ -60,7 +59,6 @@ class CashoutDAO(private val db: Database) { suspend fun create( accountUsername: String, requestUid: ShortHashCode, - cashoutUuid: UUID, amountDebit: TalerAmount, amountCredit: TalerAmount, subject: String, @@ -78,26 +76,25 @@ class CashoutDAO(private val db: Database) { out_missing_tan_info, out_balance_insufficient, out_request_uid_reuse, - out_cashout_uuid, + out_cashout_id, out_tan_info, out_tan_code - FROM cashout_create(?, ?, ?, (?,?)::taler_amount, (?,?)::taler_amount, ?, ?, ?::tan_enum, ?, ?, ?) + FROM cashout_create(?, ?, (?,?)::taler_amount, (?,?)::taler_amount, ?, ?, ?::tan_enum, ?, ?, ?) """) stmt.setString(1, accountUsername) stmt.setBytes(2, requestUid.raw) - stmt.setObject(3, cashoutUuid) - stmt.setLong(4, amountDebit.value) - stmt.setInt(5, amountDebit.frac) - stmt.setLong(6, amountCredit.value) - stmt.setInt(7, amountCredit.frac) - stmt.setString(8, subject) - stmt.setLong(9, now.toDbMicros() ?: throw faultyTimestampByBank()) - stmt.setString(10, tanChannel.name) - stmt.setString(11, tanCode) - stmt.setInt(12, retryCounter) - stmt.setLong(13, TimeUnit.MICROSECONDS.convert(validityPeriod)) + stmt.setLong(3, amountDebit.value) + stmt.setInt(4, amountDebit.frac) + stmt.setLong(5, amountCredit.value) + stmt.setInt(6, amountCredit.frac) + stmt.setString(7, subject) + stmt.setLong(8, now.toDbMicros() ?: throw faultyTimestampByBank()) + stmt.setString(9, tanChannel.name) + stmt.setString(10, tanCode) + stmt.setInt(11, retryCounter) + stmt.setLong(12, TimeUnit.MICROSECONDS.convert(validityPeriod)) stmt.executeQuery().use { - var id: UUID? = null + var id: Long? = null var info: String? = null; var code: String? = null; val status = when { @@ -110,7 +107,7 @@ class CashoutDAO(private val db: Database) { it.getBoolean("out_balance_insufficient") -> CashoutCreationResult.BALANCE_INSUFFICIENT it.getBoolean("out_request_uid_reuse") -> CashoutCreationResult.REQUEST_UID_REUSE else -> { - id = it.getObject("out_cashout_uuid") as UUID + id = it.getLong("out_cashout_id") info = it.getString("out_tan_info") code = it.getString("out_tan_code") CashoutCreationResult.SUCCESS @@ -121,29 +118,29 @@ class CashoutDAO(private val db: Database) { } suspend fun markSent( - uuid: UUID, + id: Long, now: Instant, retransmissionPeriod: Duration ) = db.conn { conn -> val stmt = conn.prepareStatement(""" SELECT challenge_mark_sent(challenge, ?, ?) FROM cashout_operations - WHERE cashout_uuid=? + WHERE cashout_id=? """) stmt.setLong(1, now.toDbMicros() ?: throw faultyTimestampByBank()) stmt.setLong(2, TimeUnit.MICROSECONDS.convert(retransmissionPeriod)) - stmt.setObject(3, uuid) + stmt.setLong(3, id) stmt.executeQueryCheck() } - suspend fun abort(opUUID: UUID): AbortResult = db.conn { conn -> + suspend fun abort(id: Long): AbortResult = db.conn { conn -> val stmt = conn.prepareStatement(""" UPDATE cashout_operations SET aborted = local_transaction IS NULL - WHERE cashout_uuid=? + WHERE cashout_id=? RETURNING local_transaction IS NOT NULL """) - stmt.setObject(1, opUUID) + stmt.setLong(1, id) when (stmt.oneOrNull { it.getBoolean(1) }) { null -> AbortResult.NOT_FOUND true -> AbortResult.CONFIRMED @@ -152,7 +149,7 @@ class CashoutDAO(private val db: Database) { } suspend fun confirm( - opUuid: UUID, + id: Long, tanCode: String, timestamp: Instant ): CashoutConfirmationResult = db.conn { conn -> @@ -167,7 +164,7 @@ class CashoutDAO(private val db: Database) { out_no_cashout_payto FROM cashout_confirm(?, ?, ?); """) - stmt.setObject(1, opUuid) + stmt.setLong(1, id) stmt.setString(2, tanCode) stmt.setLong(3, timestamp.toDbMicros() ?: throw faultyTimestampByBank()) stmt.executeQuery().use { @@ -191,12 +188,12 @@ class CashoutDAO(private val db: Database) { CONFLICT_ALREADY_CONFIRMED } - suspend fun delete(opUuid: UUID): CashoutDeleteResult = db.conn { conn -> + suspend fun delete(id: Long): CashoutDeleteResult = db.conn { conn -> val stmt = conn.prepareStatement(""" SELECT out_already_confirmed FROM cashout_delete(?) """) - stmt.setObject(1, opUuid) + stmt.setLong(1, id) stmt.executeQuery().use { when { !it.next() -> throw internalServerError("Cashout deletion gave no result") @@ -206,70 +203,95 @@ class CashoutDAO(private val db: Database) { } } - suspend fun getFromUuid(opUuid: UUID): Cashout? = db.conn { conn -> + suspend fun get(id: Long): CashoutStatusResponse? = db.conn { conn -> val stmt = conn.prepareStatement(""" SELECT - (amount_debit).val as amount_debit_val + CASE + WHEN aborted THEN 'aborted' + WHEN local_transaction IS NOT NULL THEN 'confirmed' + ELSE 'pending' + END as status + ,(amount_debit).val as amount_debit_val ,(amount_debit).frac as amount_debit_frac ,(amount_credit).val as amount_credit_val ,(amount_credit).frac as amount_credit_frac - ,buy_at_ratio - ,(buy_in_fee).val as buy_in_fee_val - ,(buy_in_fee).frac as buy_in_fee_frac - ,sell_at_ratio - ,(sell_out_fee).val as sell_out_fee_val - ,(sell_out_fee).frac as sell_out_fee_frac - ,subject + ,cashout_operations.subject ,creation_time - ,tan_channel - ,tan_code - ,bank_account - ,credit_payto_uri - ,cashout_currency - ,tan_confirmation_time - ,local_transaction + ,transaction_date as confirmation_date FROM cashout_operations - WHERE cashout_uuid=?; + LEFT JOIN bank_account_transactions ON local_transaction=bank_transaction_id + WHERE cashout_id=? """) - stmt.setObject(1, opUuid) + stmt.setLong(1, id) stmt.oneOrNull { - Cashout( - amountDebit = TalerAmount( + CashoutStatusResponse( + status = CashoutStatus.valueOf(it.getString("status")), + amount_debit = TalerAmount( value = it.getLong("amount_debit_val"), frac = it.getInt("amount_debit_frac"), db.bankCurrency ), - amountCredit = TalerAmount( + amount_credit = TalerAmount( value = it.getLong("amount_credit_val"), frac = it.getInt("amount_credit_frac"), - db.bankCurrency - ), - bankAccount = it.getLong("bank_account"), - buyAtRatio = it.getInt("buy_at_ratio"), - buyInFee = TalerAmount( - value = it.getLong("buy_in_fee_val"), - frac = it.getInt("buy_in_fee_frac"), - db.bankCurrency - ), - credit_payto_uri = it.getString("credit_payto_uri"), - cashoutCurrency = it.getString("cashout_currency"), - cashoutUuid = opUuid, - creationTime = it.getLong("creation_time").microsToJavaInstant() ?: throw faultyTimestampByBank(), - sellAtRatio = it.getInt("sell_at_ratio"), - sellOutFee = TalerAmount( - value = it.getLong("sell_out_fee_val"), - frac = it.getInt("sell_out_fee_frac"), - db.bankCurrency + db.fiatCurrency!! ), subject = it.getString("subject"), - tanChannel = TanChannel.valueOf(it.getString("tan_channel")), - tanCode = it.getString("tan_code"), - localTransaction = it.getLong("local_transaction"), - tanConfirmationTime = when (val timestamp = it.getLong("tan_confirmation_time")) { + creation_time = TalerProtocolTimestamp( + it.getLong("creation_time").microsToJavaInstant() ?: throw faultyTimestampByBank() + ), + confirmation_time = when (val timestamp = it.getLong("confirmation_date")) { 0L -> null - else -> timestamp.microsToJavaInstant() ?: throw faultyTimestampByBank() + else -> TalerProtocolTimestamp(timestamp.microsToJavaInstant() ?: throw faultyTimestampByBank()) } ) } } + + suspend fun pageAll(params: PageParams): List<GlobalCashoutInfo> = + db.page(params, "cashout_id", """ + SELECT + cashout_id + ,login + ,CASE + WHEN aborted THEN 'aborted' + WHEN local_transaction IS NOT NULL THEN 'confirmed' + ELSE 'pending' + END as status + FROM cashout_operations + JOIN bank_accounts ON bank_account=bank_account_id + JOIN customers ON owning_customer_id=customer_id + WHERE + """) { + GlobalCashoutInfo( + cashout_id = it.getLong("cashout_id"), + username = it.getString("login"), + status = CashoutStatus.valueOf(it.getString("status")) + ) + } + + suspend fun pageForUser(params: PageParams, login: String): List<CashoutInfo> = + db.page(params, "cashout_id", """ + SELECT + cashout_id + ,CASE + WHEN aborted THEN 'aborted' + WHEN local_transaction IS NOT NULL THEN 'confirmed' + ELSE 'pending' + END as status + FROM cashout_operations + JOIN bank_accounts ON bank_account=bank_account_id + JOIN customers ON owning_customer_id=customer_id + WHERE login = ? AND + """, + bind = { + setString(1, login) + 1 + } + ) { + CashoutInfo( + cashout_id = it.getLong("cashout_id"), + status = CashoutStatus.valueOf(it.getString("status")) + ) + } } \ 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 @@ -730,52 +730,72 @@ class Database(dbConfig: String, internal val bankCurrency: String, internal val /** * The following function returns the list of transactions, according - * to the history parameters and perform long polling when necessary. + * to the page parameters */ - internal suspend fun <T> poolHistory( - params: HistoryParams, - bankAccountId: Long, - listen: suspend NotificationWatcher.(Long, suspend (Flow<Long>) -> Unit) -> Unit, + internal suspend fun <T> page( + params: PageParams, + idName: String, query: String, + bind: PreparedStatement.() -> Int = { 0 }, map: (ResultSet) -> T - ): List<T> { + ): List<T> = conn { conn -> val backward = params.delta < 0 - val nbTx = abs(params.delta) // Number of transaction to query val query = """ $query - WHERE bank_account_id=? AND - bank_transaction_id ${if (backward) '<' else '>'} ? - ORDER BY bank_transaction_id ${if (backward) "DESC" else "ASC"} + $idName ${if (backward) '<' else '>'} ? + ORDER BY $idName ${if (backward) "DESC" else "ASC"} LIMIT ? """ - - suspend fun load(amount: Int): List<T> = conn { conn -> - conn.prepareStatement(query).use { stmt -> - stmt.setLong(1, bankAccountId) - stmt.setLong(2, params.start) - stmt.setInt(3, amount) - stmt.all { map(it) } - } + conn.prepareStatement(query).run { + val pad = bind() + setLong(pad + 1, params.start) + setInt(pad + 2, abs(params.delta)) + all { map(it) } } + } + + /** + * The following function returns the list of transactions, according + * to the history parameters and perform long polling when necessary + */ + internal suspend fun <T> poolHistory( + params: HistoryParams, + bankAccountId: Long, + listen: suspend NotificationWatcher.(Long, suspend (Flow<Long>) -> Unit) -> Unit, + query: String, + map: (ResultSet) -> T + ): List<T> { + + suspend fun load(): List<T> = page( + params.page, + "bank_transaction_id", + "$query WHERE bank_account_id=? AND", + { + setLong(1, bankAccountId) + 1 + }, + map + ) + // TODO do we want to handle polling when going backward and there is no transactions yet ? // When going backward there is always at least one transaction or none - if (!backward && params.poll_ms > 0) { + if (params.page.delta >= 0 && params.poll_ms > 0) { var history = listOf<T>() notifWatcher.(listen)(bankAccountId) { flow -> coroutineScope { // Start buffering notification before loading transactions to not miss any val polling = launch { withTimeoutOrNull(params.poll_ms) { - flow.first { it > params.start } // Always forward so > + flow.first { it > params.page.start } // Always forward so > } } // Initial loading - history = load(nbTx) + history = load() // Long polling if we found no transactions if (history.isEmpty()) { polling.join() - history = load(nbTx) + history = load() } else { polling.cancel() } @@ -783,7 +803,7 @@ class Database(dbConfig: String, internal val bankCurrency: String, internal val } return history } else { - return load(nbTx) + return load() } } diff --git a/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt b/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt @@ -99,6 +99,15 @@ fun ApplicationCall.uuidUriComponent(name: String): UUID { } } +fun ApplicationCall.longUriComponent(name: String): Long { + try { + return expectUriComponent(name).toLong() + } catch (e: Exception) { + logger.error(e.message) + throw badRequest("UUID uri component malformed") + } +} + /** * This handler factors out the checking of the query param * and the retrieval of the related withdrawal database row. @@ -115,86 +124,6 @@ suspend fun ApplicationCall.getWithdrawal(db: Database, name: String): TalerWith return op } -enum class Timeframe { - hour, - day, - month, - year -} - -data class MonitorParams( - val timeframe: Timeframe, - val which: Int? -) { - companion object { - fun extract(params: Parameters): MonitorParams { - val timeframe = Timeframe.valueOf(params["timeframe"] ?: "hour") - val which = params["which"]?.run { toIntOrNull() ?: throw badRequest("Param 'which' not a number") } - if (which != null) { - val lastDayOfMonth = OffsetDateTime.now(ZoneOffset.UTC).with(TemporalAdjusters.lastDayOfMonth()).dayOfMonth - when { - timeframe == Timeframe.hour && (0 > which || which > 23) -> - throw badRequest("For hour timestamp param 'which' must be between 00 to 23") - timeframe == Timeframe.day && (1 > which || which > 23) -> - throw badRequest("For day timestamp param 'which' must be between 1 to $lastDayOfMonth") - timeframe == Timeframe.month && (1 > which || which > lastDayOfMonth) -> - throw badRequest("For month timestamp param 'which' must be between 1 to 12") - timeframe == Timeframe.year && (1 > which|| which > 9999) -> - throw badRequest("For year timestamp param 'which' must be between 0001 to 9999") - else -> {} - } - } - return MonitorParams(timeframe, which) - } - } -} - -data class HistoryParams( - val delta: Int, val start: Long, val poll_ms: Long -) { - companion object { - fun extract(params: Parameters): HistoryParams { - val deltaParam: String = - params["delta"] ?: throw MissingRequestParameterException(parameterName = "delta") - val delta: Int = deltaParam.toIntOrNull() ?: throw badRequest("Param 'delta' not a number") - // Note: minimum 'start' is zero, as database IDs start from 1. - val start: Long = when (val param = params["start"]) { - null -> if (delta >= 0) 0L else Long.MAX_VALUE - else -> param.toLongOrNull() ?: throw badRequest("Param 'start' not a number") - } - val poll_ms: Long = when (val param = params["long_poll_ms"]) { - null -> 0 - else -> param.toLongOrNull() ?: throw badRequest("Param 'long_poll_ms' not a number") - } - // TODO check params range - return HistoryParams(delta = delta, start = start, poll_ms = poll_ms) - } - } -} - -data class RateParams( - val debit: TalerAmount?, val credit: TalerAmount? -) { - companion object { - fun extract(params: Parameters): RateParams { - val debit = try { - params["amount_debit"]?.run(::TalerAmount) - } catch (e: Exception) { - throw badRequest("Param 'amount_debit' not a taler amount") - } - val credit = try { - params["amount_credit"]?.run(::TalerAmount) - } catch (e: Exception) { - throw badRequest("Param 'amount_credit' not a taler amount") - } - if (debit == null && credit == null) { - throw badRequest("Either param 'amount_debit' or 'amount_credit' is required") - } - return RateParams(debit, credit) - } - } -} - /** * This function creates the admin account ONLY IF it was * NOT found in the database. It sets it to a random password that diff --git a/bank/src/test/kotlin/CoreBankApiTest.kt b/bank/src/test/kotlin/CoreBankApiTest.kt @@ -535,39 +535,17 @@ class CoreBankTransactionsApiTest { @Test fun testHistory() = bankSetup { _ -> suspend fun HttpResponse.assertHistory(size: Int) { - assertOk() - val txt = bodyAsText() - val history = Json.decodeFromString<BankAccountTransactionsResponse>(txt) - val params = HistoryParams.extract(call.request.url.parameters) - - // testing the size is like expected. - assert(history.transactions.size == size) { - println("transactions has wrong size: ${history.transactions.size}") - println("Response was: ${txt}") - } - if (size > 0) { - if (params.delta < 0) { - // testing that the first row_id is at most the 'start' query param. - assert(history.transactions[0].row_id <= params.start) - // testing that the row_id decreases. - if (history.transactions.size > 1) - assert(history.transactions.windowed(2).all { (a, b) -> a.row_id > b.row_id }) - } else { - // testing that the first row_id is at least the 'start' query param. - assert(history.transactions[0].row_id >= params.start) - // testing that the row_id increases. - if (history.transactions.size > 1) - assert(history.transactions.windowed(2).all { (a, b) -> a.row_id < b.row_id }) - } + assertHistoryIds<BankAccountTransactionsResponse>(size) { + it.transactions.map { it.row_id } } } authRoutine("/accounts/merchant/transactions?delta=7", method = HttpMethod.Get) - // Check empty lisy when no transactions + // Check error when no transactions client.get("/accounts/merchant/transactions?delta=7") { basicAuth("merchant", "merchant-password") - }.assertHistory(0) + }.assertNoContent() // Gen three transactions from merchant to exchange repeat(3) { @@ -614,7 +592,7 @@ class CoreBankTransactionsApiTest { assertTime(200, 400) { client.get("/accounts/merchant/transactions?delta=1&start=11&long_poll_ms=300") { basicAuth("merchant", "merchant-password") - }.assertHistory(0) + }.assertNoContent() } } delay(200) @@ -1168,14 +1146,14 @@ class CoreBankCashoutApiTest { basicAuth("customer", "customer-password") jsonBody(req) }.assertOk().run { - val uuid = json<CashoutPending>().cashout_id + val id = json<CashoutPending>().cashout_id // Check OK - client.post("/accounts/customer/cashouts/$uuid/abort") { + client.post("/accounts/customer/cashouts/$id/abort") { basicAuth("customer", "customer-password") }.assertNoContent() // Check idempotence - client.post("/accounts/customer/cashouts/$uuid/abort") { + client.post("/accounts/customer/cashouts/$id/abort") { basicAuth("customer", "customer-password") }.assertNoContent() } @@ -1185,27 +1163,27 @@ class CoreBankCashoutApiTest { basicAuth("customer", "customer-password") jsonBody(json(req) { "request_uid" to randShortHashCode() }) }.assertOk().run { - val uuid = json<CashoutPending>().cashout_id + val id = json<CashoutPending>().cashout_id - client.post("/accounts/customer/cashouts/$uuid/confirm") { + client.post("/accounts/customer/cashouts/$id/confirm") { basicAuth("customer", "customer-password") jsonBody { "tan" to smsCode("+99") } }.assertNoContent() // Check error - client.post("/accounts/customer/cashouts/$uuid/abort") { + client.post("/accounts/customer/cashouts/$id/abort") { basicAuth("customer", "customer-password") }.assertConflict(TalerErrorCode.BANK_ABORT_CONFIRM_CONFLICT) } - // Check bad UUID + // Check bad id client.post("/accounts/customer/cashouts/chocolate/abort") { basicAuth("customer", "customer-password") jsonBody { "tan" to "code" } }.assertBadRequest() // Check unknown - client.post("/accounts/customer/cashouts/${UUID.randomUUID()}/abort") { + client.post("/accounts/customer/cashouts/42/abort") { basicAuth("customer", "customer-password") jsonBody { "tan" to "code" } }.assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) @@ -1235,10 +1213,10 @@ class CoreBankCashoutApiTest { basicAuth("customer", "customer-password") jsonBody(req) { "request_uid" to randShortHashCode() } }.assertOk().run { - val uuid = json<CashoutPending>().cashout_id + val id = json<CashoutPending>().cashout_id // Check missing cashout address - client.post("/accounts/customer/cashouts/$uuid/confirm") { + client.post("/accounts/customer/cashouts/$id/confirm") { basicAuth("customer", "customer-password") jsonBody { "tan" to "code" } }.assertConflict(TalerErrorCode.BANK_CONFIRM_INCOMPLETE) @@ -1253,7 +1231,7 @@ class CoreBankCashoutApiTest { }.assertNoContent() // Check bad TAN code - client.post("/accounts/customer/cashouts/$uuid/confirm") { + client.post("/accounts/customer/cashouts/$id/confirm") { basicAuth("customer", "customer-password") jsonBody { "tan" to "nice-try" } }.assertForbidden(TalerErrorCode.BANK_TAN_CHALLENGE_FAILED) @@ -1261,12 +1239,12 @@ class CoreBankCashoutApiTest { val code = smsCode("+99") // Check OK - client.post("/accounts/customer/cashouts/$uuid/confirm") { + client.post("/accounts/customer/cashouts/$id/confirm") { basicAuth("customer", "customer-password") jsonBody { "tan" to code } }.assertNoContent() // Check idempotence - client.post("/accounts/customer/cashouts/$uuid/confirm") { + client.post("/accounts/customer/cashouts/$id/confirm") { basicAuth("customer", "customer-password") jsonBody { "tan" to code } }.assertNoContent() @@ -1277,7 +1255,7 @@ class CoreBankCashoutApiTest { basicAuth("customer", "customer-password") jsonBody(json(req) { "request_uid" to randShortHashCode() }) }.assertOk().run { - val uuid = json<CashoutPending>().cashout_id + val id = json<CashoutPending>().cashout_id db.conversion.updateConfig(ConversionInfo( buy_ratio = DecimalNumber("1"), @@ -1292,13 +1270,13 @@ class CoreBankCashoutApiTest { sell_min_amount = TalerAmount("KUDOS:0.0001"), )) - client.post("/accounts/customer/cashouts/$uuid/confirm"){ + client.post("/accounts/customer/cashouts/$id/confirm"){ basicAuth("customer", "customer-password") jsonBody { "tan" to smsCode("+99") } }.assertConflict(TalerErrorCode.BANK_BAD_CONVERSION) // Check can abort because not confirmed - client.post("/accounts/customer/cashouts/$uuid/abort") { + client.post("/accounts/customer/cashouts/$id/abort") { basicAuth("customer", "customer-password") }.assertNoContent() } @@ -1311,7 +1289,7 @@ class CoreBankCashoutApiTest { "amount_credit" to convert("KUDOS:1") }) }.assertOk().run { - val uuid = json<CashoutPending>().cashout_id + val id = json<CashoutPending>().cashout_id // Send too much money client.post("/accounts/customer/transactions") { basicAuth("customer", "customer-password") @@ -1320,13 +1298,13 @@ class CoreBankCashoutApiTest { } }.assertNoContent() - client.post("/accounts/customer/cashouts/$uuid/confirm"){ + client.post("/accounts/customer/cashouts/$id/confirm"){ basicAuth("customer", "customer-password") jsonBody { "tan" to smsCode("+99") } }.assertConflict(TalerErrorCode.BANK_UNALLOWED_DEBIT) // Check can abort because not confirmed - client.post("/accounts/customer/cashouts/$uuid/abort") { + client.post("/accounts/customer/cashouts/$id/abort") { basicAuth("customer", "customer-password") }.assertNoContent() } @@ -1338,12 +1316,187 @@ class CoreBankCashoutApiTest { }.assertBadRequest() // Check unknown - client.post("/accounts/customer/cashouts/${UUID.randomUUID()}/confirm") { + client.post("/accounts/customer/cashouts/42/confirm") { basicAuth("customer", "customer-password") jsonBody { "tan" to "code" } }.assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) } + // GET /accounts/{USERNAME}/cashouts/{CASHOUT_ID} + @Test + fun get() = bankSetup { _ -> + // TODO auth routine + + client.patch("/accounts/customer") { + basicAuth("customer", "customer-password") + jsonBody(json { + "cashout_payto_uri" to IbanPayTo(genIbanPaytoUri()) + "challenge_contact_data" to json { + "phone" to "+99" + } + }) + }.assertNoContent() + + val amountDebit = TalerAmount("KUDOS:1.5") + val amountCredit = convert("KUDOS:1.5") + val req = json { + "amount_debit" to amountDebit + "amount_credit" to amountCredit + } + + // Check confirm + client.post("/accounts/customer/cashouts") { + basicAuth("customer", "customer-password") + jsonBody(req) { "request_uid" to randShortHashCode() } + }.assertOk().run { + val id = json<CashoutPending>().cashout_id + client.get("/accounts/customer/cashouts/$id") { + basicAuth("customer", "customer-password") + }.assertOk().run { + val res = json<CashoutStatusResponse>() + assertEquals(CashoutStatus.pending, res.status) + assertEquals(amountDebit, res.amount_debit) + assertEquals(amountCredit, res.amount_credit) + } + + client.post("/accounts/customer/cashouts/$id/confirm") { + basicAuth("customer", "customer-password") + jsonBody { "tan" to smsCode("+99") } + }.assertNoContent() + client.get("/accounts/customer/cashouts/$id") { + basicAuth("customer", "customer-password") + }.assertOk().run { + assertEquals(CashoutStatus.confirmed, json<CashoutStatusResponse>().status) + } + } + + // Check abort + client.post("/accounts/customer/cashouts") { + basicAuth("customer", "customer-password") + jsonBody(req) { "request_uid" to randShortHashCode() } + }.assertOk().run { + val id = json<CashoutPending>().cashout_id + client.get("/accounts/customer/cashouts/$id") { + basicAuth("customer", "customer-password") + }.assertOk().run { + assertEquals(CashoutStatus.pending, json<CashoutStatusResponse>().status) + } + + client.post("/accounts/customer/cashouts/$id/abort") { + basicAuth("customer", "customer-password") + }.assertNoContent() + client.get("/accounts/customer/cashouts/$id") { + basicAuth("customer", "customer-password") + }.assertOk().run { + assertEquals(CashoutStatus.aborted, json<CashoutStatusResponse>().status) + } + } + + // Check bad UUID + client.get("/accounts/customer/cashouts/chocolate") { + basicAuth("customer", "customer-password") + }.assertBadRequest() + + // Check unknown + client.get("/accounts/customer/cashouts/42") { + basicAuth("customer", "customer-password") + }.assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) + } + + // GET /accounts/{USERNAME}/cashouts + @Test + fun history() = bankSetup { _ -> + // TODO auth routine + + client.patch("/accounts/customer") { + basicAuth("customer", "customer-password") + jsonBody(json { + "cashout_payto_uri" to IbanPayTo(genIbanPaytoUri()) + "challenge_contact_data" to json { + "phone" to "+99" + } + }) + }.assertNoContent() + + suspend fun HttpResponse.assertHistory(size: Int) { + assertHistoryIds<Cashouts>(size) { + it.cashouts.map { it.cashout_id } + } + } + + // Empty + client.get("/accounts/customer/cashouts") { + basicAuth("customer", "customer-password") + }.assertNoContent() + + // Testing ranges. + repeat(30) { + cashout("KUDOS:0.${it+1}") + } + + // Default + client.get("/accounts/customer/cashouts") { + basicAuth("customer", "customer-password") + }.assertHistory(20) + + // Forward range: + client.get("/accounts/customer/cashouts?delta=10&start=20") { + basicAuth("customer", "customer-password") + }.assertHistory(10) + + // Fackward range: + client.get("/accounts/customer/cashouts?delta=-10&start=25") { + basicAuth("customer", "customer-password") + }.assertHistory(10) + } + + // GET /cashouts + @Test + fun globalHistory() = bankSetup { _ -> + // TODO admin auth routine + + client.patch("/accounts/customer") { + basicAuth("customer", "customer-password") + jsonBody(json { + "cashout_payto_uri" to IbanPayTo(genIbanPaytoUri()) + "challenge_contact_data" to json { + "phone" to "+99" + } + }) + }.assertNoContent() + + suspend fun HttpResponse.assertHistory(size: Int) { + assertHistoryIds<GlobalCashouts>(size) { + it.cashouts.map { it.cashout_id } + } + } + + // Empty + client.get("/cashouts") { + basicAuth("admin", "admin-password") + }.assertNoContent() + + // Testing ranges. + repeat(30) { + cashout("KUDOS:0.${it+1}") + } + + // Default + client.get("/cashouts") { + basicAuth("admin", "admin-password") + }.assertHistory(20) + + // Forward range: + client.get("/cashouts?delta=10&start=20") { + basicAuth("admin", "admin-password") + }.assertHistory(10) + + // Fackward range: + client.get("/cashouts?delta=-10&start=25") { + basicAuth("admin", "admin-password") + }.assertHistory(10) + } + // GET /cashout-rate @Test fun cashoutRate() = bankSetup { _ -> diff --git a/bank/src/test/kotlin/StatsTest.kt b/bank/src/test/kotlin/StatsTest.kt @@ -46,23 +46,6 @@ class StatsTest { }) }.assertNoContent() - suspend fun cashout(amount: String) { - client.post("/accounts/customer/cashouts") { - basicAuth("customer", "customer-password") - jsonBody(json { - "request_uid" to randShortHashCode() - "amount_debit" to amount - "amount_credit" to convert(amount) - }) - }.assertOk().run { - val uuid = json<CashoutPending>().cashout_id - client.post("/accounts/customer/cashouts/$uuid/confirm") { - basicAuth("customer", "customer-password") - jsonBody { "tan" to smsCode("+99") } - }.assertNoContent() - } - } - suspend fun monitor(countName: String, volumeName: String, count: Long, amount: String) { Timeframe.entries.forEach { timestamp -> client.get("/monitor?timestamp=${timestamp.name}") { basicAuth("admin", "admin-password") }.assertOk().run { @@ -114,10 +97,9 @@ class StatsTest { fun timeframe() = bankSetup { db -> db.conn { conn -> suspend fun register(now: OffsetDateTime, amount: TalerAmount) { - val stmt = - conn.prepareStatement( - "CALL stats_register_payment('taler_out', ?::timestamp, (?, ?)::taler_amount)" - ) + val stmt = conn.prepareStatement( + "CALL stats_register_payment('taler_out', ?::timestamp, (?, ?)::taler_amount)" + ) stmt.setObject(1, now) stmt.setLong(2, amount.value) stmt.setInt(3, amount.frac) diff --git a/bank/src/test/kotlin/WireGatewayApiTest.kt b/bank/src/test/kotlin/WireGatewayApiTest.kt @@ -181,28 +181,8 @@ class WireGatewayApiTest { setMaxDebt("merchant", TalerAmount("KUDOS:1000")) suspend fun HttpResponse.assertHistory(size: Int) { - assertOk() - val txt = bodyAsText() - val history = Json.decodeFromString<IncomingHistory>(txt) - val params = HistoryParams.extract(call.request.url.parameters) - - // testing the size is like expected. - assert(history.incoming_transactions.size == size) { - println("incoming_transactions has wrong size: ${history.incoming_transactions.size}") - println("Response was: ${txt}") - } - if (params.delta < 0) { - // testing that the first row_id is at most the 'start' query param. - assert(history.incoming_transactions[0].row_id <= params.start) - // testing that the row_id decreases. - if (history.incoming_transactions.size > 1) - assert(history.incoming_transactions.windowed(2).all { (a, b) -> a.row_id > b.row_id }) - } else { - // testing that the first row_id is at least the 'start' query param. - assert(history.incoming_transactions[0].row_id >= params.start) - // testing that the row_id increases. - if (history.incoming_transactions.size > 1) - assert(history.incoming_transactions.windowed(2).all { (a, b) -> a.row_id < b.row_id }) + assertHistoryIds<IncomingHistory>(size) { + it.incoming_transactions.map { it.row_id } } } @@ -350,28 +330,8 @@ class WireGatewayApiTest { setMaxDebt("exchange", TalerAmount("KUDOS:1000000")) suspend fun HttpResponse.assertHistory(size: Int) { - assertOk() - val txt = this.bodyAsText() - val history = Json.decodeFromString<OutgoingHistory>(txt) - val params = HistoryParams.extract(this.call.request.url.parameters) - - // testing the size is like expected. - assert(history.outgoing_transactions.size == size) { - println("outgoing_transactions has wrong size: ${history.outgoing_transactions.size}") - println("Response was: ${txt}") - } - if (params.delta < 0) { - // testing that the first row_id is at most the 'start' query param. - assert(history.outgoing_transactions[0].row_id <= params.start) - // testing that the row_id decreases. - if (history.outgoing_transactions.size > 1) - assert(history.outgoing_transactions.windowed(2).all { (a, b) -> a.row_id > b.row_id }) - } else { - // testing that the first row_id is at least the 'start' query param. - assert(history.outgoing_transactions[0].row_id >= params.start) - // testing that the row_id increases. - if (history.outgoing_transactions.size > 1) - assert(history.outgoing_transactions.windowed(2).all { (a, b) -> a.row_id < b.row_id }) + assertHistoryIds<OutgoingHistory>(size) { + it.outgoing_transactions.map { it.row_id } } } diff --git a/bank/src/test/kotlin/helpers.kt b/bank/src/test/kotlin/helpers.kt @@ -127,6 +127,23 @@ suspend fun ApplicationTestBuilder.addIncoming(amount: String) { }.assertOk() } +suspend fun ApplicationTestBuilder.cashout(amount: String) { + client.post("/accounts/customer/cashouts") { + basicAuth("customer", "customer-password") + jsonBody(json { + "request_uid" to randShortHashCode() + "amount_debit" to amount + "amount_credit" to convert(amount) + }) + }.assertOk().run { + val id = json<CashoutPending>().cashout_id + client.post("/accounts/customer/cashouts/$id/confirm") { + basicAuth("customer", "customer-password") + jsonBody { "tan" to smsCode("+99") } + }.assertNoContent() + } +} + suspend fun ApplicationTestBuilder.convert(amount: String): TalerAmount { client.get("/cashout-rate?amount_debit=$amount").assertOk().run { return json<ConversionResponse>().amount_credit @@ -198,6 +215,31 @@ fun assertException(msg: String, lambda: () -> Unit) { } } +inline suspend fun <reified B> HttpResponse.assertHistoryIds(size: Int, ids: (B) -> List<Long>): B { + assertOk() + val body = json<B>() + val history = ids(body) + val params = PageParams.extract(call.request.url.parameters) + + // testing the size is like expected. + assertEquals(size, history.size) + if (params.delta < 0) { + // testing that the first id is at most the 'start' query param. + assert(history[0] <= params.start) + // testing that the id decreases. + if (history.size > 1) + assert(history.windowed(2).all { (a, b) -> a > b }) + } else { + // testing that the first id is at least the 'start' query param. + assert(history[0] >= params.start) + // testing that the id increases. + if (history.size > 1) + assert(history.windowed(2).all { (a, b) -> a < b }) + } + + return body +} + /* ----- Body helper ----- */ inline fun <reified B> HttpRequestBuilder.jsonBody(b: B, deflate: Boolean = false) { diff --git a/database-versioning/libeufin-bank-0001.sql b/database-versioning/libeufin-bank-0001.sql @@ -184,7 +184,7 @@ COMMENT ON COLUMN challenges.confirmation_date -- start of: cashout management CREATE TABLE IF NOT EXISTS cashout_operations - (cashout_uuid uuid NOT NULL PRIMARY KEY + (cashout_id BIGINT GENERATED BY DEFAULT AS IDENTITY UNIQUE ,request_uid BYTEA NOT NULL UNIQUE CHECK (LENGTH(request_uid)=32) ,amount_debit taler_amount NOT NULL ,amount_credit taler_amount NOT NULL diff --git a/database-versioning/libeufin-bank-procedures.sql b/database-versioning/libeufin-bank-procedures.sql @@ -1003,7 +1003,6 @@ END $$; CREATE OR REPLACE FUNCTION cashout_create( IN in_account_username TEXT, IN in_request_uid BYTEA, - IN in_cashout_uuid uuid, IN in_amount_debit taler_amount, IN in_amount_credit taler_amount, IN in_subject TEXT, @@ -1020,7 +1019,7 @@ CREATE OR REPLACE FUNCTION cashout_create( OUT out_balance_insufficient BOOLEAN, OUT out_request_uid_reuse BOOLEAN, -- Success return - OUT out_cashout_uuid uuid, + OUT out_cashout_id BIGINT, OUT out_tan_info TEXT, OUT out_tan_code TEXT ) @@ -1066,14 +1065,13 @@ END IF; SELECT (amount_debit != in_amount_debit OR subject != in_subject OR bank_account != account_id) - ,challenge, cashout_uuid - INTO out_request_uid_reuse, challenge_id, out_cashout_uuid + , challenge, cashout_id + INTO out_request_uid_reuse, challenge_id, out_cashout_id FROM cashout_operations WHERE request_uid = in_request_uid; IF NOT found THEN -- New cashout - out_cashout_uuid = in_cashout_uuid; out_tan_code = in_tan_code; -- Create challenge @@ -1081,8 +1079,7 @@ IF NOT found THEN -- Create cashout operation INSERT INTO cashout_operations ( - cashout_uuid - ,request_uid + request_uid ,amount_debit ,amount_credit ,subject @@ -1090,22 +1087,21 @@ IF NOT found THEN ,bank_account ,challenge ) VALUES ( - in_cashout_uuid - ,in_request_uid + in_request_uid ,in_amount_debit ,in_amount_credit ,in_subject ,in_now_date ,account_id ,challenge_id - ); + ) RETURNING cashout_id INTO out_cashout_id; ELSE -- Already exist, check challenge retransmission SELECT challenge_resend(challenge_id, in_tan_code, in_now_date, in_validity_period, in_retry_counter) INTO out_tan_code; END IF; END $$; CREATE OR REPLACE FUNCTION cashout_confirm( - IN in_cashout_uuid uuid, + IN in_cashout_id BIGINT, IN in_tan_code TEXT, IN in_now_date BIGINT, OUT out_no_op BOOLEAN, @@ -1145,7 +1141,7 @@ SELECT FROM cashout_operations JOIN bank_accounts ON bank_account_id=bank_account JOIN customers ON customer_id=owning_customer_id - WHERE cashout_uuid=in_cashout_uuid; + WHERE cashout_id=in_cashout_id; IF NOT FOUND THEN out_no_op=TRUE; RETURN; @@ -1195,7 +1191,7 @@ END IF; -- Confirm operation UPDATE cashout_operations SET local_transaction = tx_id - WHERE cashout_uuid=in_cashout_uuid; + WHERE cashout_id=in_cashout_id; -- update stats CALL stats_register_payment('cashout', now()::TIMESTAMP, amount_credit_local);