commit 0bcadb6e7637a92744029f909f14c5df8dfe078a
parent 55c355a71b389b23963c389ae8be77cbfae10b33
Author: Antoine A <>
Date: Wed, 8 Nov 2023 16:04:54 +0000
Add missing cashout endpoints
Diffstat:
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);