libeufin

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

commit ea3dbf1e782edb2fda4b13b9e53ef568d3ee2389
parent 52c5bfc2d9270138e689902766395c8f5564820f
Author: Antoine A <>
Date:   Fri, 24 Nov 2023 17:58:09 +0000

New secure withdrawal API with compatibility

Diffstat:
Mbank/src/main/kotlin/tech/libeufin/bank/BankIntegrationApi.kt | 8+++++---
Mbank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt | 40++++++++++++++++++++++++++++++++++++++++
Mbank/src/main/kotlin/tech/libeufin/bank/Params.kt | 20++++++++++++++++++++
Mbank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt | 62++++++++++++++++++++++++++++----------------------------------
Mbank/src/main/kotlin/tech/libeufin/bank/db/NotificationWatcher.kt | 38+++++++++++++++++++++-----------------
Mbank/src/main/kotlin/tech/libeufin/bank/db/WithdrawalDAO.kt | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
Mbank/src/test/kotlin/BankIntegrationApiTest.kt | 59++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mbank/src/test/kotlin/CoreBankApiTest.kt | 40+++++++++++++++++++++-------------------
Mdatabase-versioning/libeufin-bank-procedures.sql | 44++++++++++++++++++++++++++++++++++++++------
9 files changed, 278 insertions(+), 110 deletions(-)

diff --git a/bank/src/main/kotlin/tech/libeufin/bank/BankIntegrationApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/BankIntegrationApi.kt @@ -40,7 +40,7 @@ fun Routing.bankIntegrationApi(db: Database, ctx: BankConfig) { // Note: wopid acts as an authentication token. get("/taler-integration/withdrawal-operation/{wopid}") { val uuid = call.uuidUriComponent("wopid") - val params = PollingParams.extract(call.request.queryParameters) + val params = StatusParams.extract(call.request.queryParameters) val op = db.withdrawal.pollStatus(uuid, params) ?: throw notFound( "Withdrawal operation '$uuid' not found", TalerErrorCode.BANK_TRANSACTION_NOT_FOUND @@ -84,14 +84,16 @@ fun Routing.bankIntegrationApi(db: Database, ctx: BankConfig) { TalerErrorCode.BANK_ACCOUNT_IS_NOT_EXCHANGE ) is WithdrawalSelectionResult.Success -> { - val confirmUrl: String? = if (ctx.spaCaptchaURL !== null && !res.confirmed) { + val confirmUrl: String? = if (ctx.spaCaptchaURL !== null && res.status == WithdrawalStatus.selected) { getWithdrawalConfirmUrl( baseUrl = ctx.spaCaptchaURL, wopId = opId ) } else null call.respond(BankWithdrawalOperationPostResponse( - transfer_done = res.confirmed, confirm_transfer_url = confirmUrl + transfer_done = res.status == WithdrawalStatus.confirmed, + status = res.status, + confirm_transfer_url = confirmUrl )) } } diff --git a/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt @@ -387,6 +387,46 @@ private fun Routing.coreBankWithdrawalApi(db: Database, ctx: BankConfig) { } } } + post("/accounts/{USERNAME}/withdrawals/{withdrawal_id}/abort") { + val opId = call.uuidUriComponent("withdrawal_id") + when (db.withdrawal.abort(opId)) { + AbortResult.UnknownOperation -> throw notFound( + "Withdrawal operation $opId not found", + TalerErrorCode.BANK_TRANSACTION_NOT_FOUND + ) + AbortResult.AlreadyConfirmed -> throw conflict( + "Cannot abort confirmed withdrawal", + TalerErrorCode.BANK_ABORT_CONFIRM_CONFLICT + ) + AbortResult.Success -> call.respond(HttpStatusCode.NoContent) + } + } + post("/accounts/{USERNAME}/withdrawals/{withdrawal_id}/confirm") { + val opId = call.uuidUriComponent("withdrawal_id") + when (db.withdrawal.confirm(opId, Instant.now())) { + WithdrawalConfirmationResult.UnknownOperation -> throw notFound( + "Withdrawal operation $opId not found", + TalerErrorCode.BANK_TRANSACTION_NOT_FOUND + ) + WithdrawalConfirmationResult.AlreadyAborted -> throw conflict( + "Cannot confirm an aborted withdrawal", + TalerErrorCode.BANK_CONFIRM_ABORT_CONFLICT + ) + WithdrawalConfirmationResult.NotSelected -> throw conflict( + "Cannot confirm an unselected withdrawal", + TalerErrorCode.BANK_CONFIRM_INCOMPLETE + ) + WithdrawalConfirmationResult.BalanceInsufficient -> throw conflict( + "Insufficient funds", + TalerErrorCode.BANK_UNALLOWED_DEBIT + ) + WithdrawalConfirmationResult.UnknownExchange -> throw conflict( + "Exchange to withdraw from not found", + TalerErrorCode.BANK_UNKNOWN_CREDITOR + ) + WithdrawalConfirmationResult.Success -> call.respond(HttpStatusCode.NoContent) + } + } } get("/withdrawals/{withdrawal_id}") { val uuid = call.uuidUriComponent("withdrawal_id") diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Params.kt b/bank/src/main/kotlin/tech/libeufin/bank/Params.kt @@ -138,4 +138,24 @@ data class RateParams( return RateParams(debit, credit) } } +} + +data class StatusParams( + val polling: PollingParams, + val old_state: WithdrawalStatus +) { + companion object { + val names = WithdrawalStatus.values().map { it.name } + val names_fmt = names.joinToString() + fun extract(params: Parameters): StatusParams { + val old_state = params.get("old_state") ?: "pending"; + if (!names.contains(old_state)) { + throw badRequest("Param 'old_state' must be one of $names_fmt", TalerErrorCode.GENERIC_PARAMETER_MALFORMED) + } + return StatusParams( + polling = PollingParams.extract(params), + old_state = WithdrawalStatus.valueOf(old_state) + ) + } + } } \ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt b/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt @@ -52,6 +52,13 @@ enum class CashoutStatus { confirmed } +enum class WithdrawalStatus { + pending, + aborted, + selected, + confirmed +} + enum class RoundingMode { zero, up, @@ -243,7 +250,7 @@ data class TalerIntegrationConfigResponse( val currency_specification: CurrencySpecification ) { val name: String = "taler-bank-integration"; - val version: String = "0:0:0"; + val version: String = "1:0:1"; } enum class CreditDebitInfo { @@ -331,7 +338,12 @@ data class BankAccountCreateWithdrawalResponse( val taler_withdraw_uri: String ) -// Taler withdrawal details response +@Serializable +data class WithdrawalPublicInfo ( + val username: String +) + +// Taler withdrawal details response // TODO remove @Serializable data class BankAccountGetWithdrawalResponse( val amount: TalerAmount, @@ -339,7 +351,8 @@ data class BankAccountGetWithdrawalResponse( val confirmation_done: Boolean, val selection_done: Boolean, val selected_reserve_pub: EddsaPublicKey? = null, - val selected_exchange_account: String? = null + val selected_exchange_account: String? = null, + val username: String ) @Serializable @@ -351,42 +364,21 @@ data class CurrencySpecification( val alt_unit_names: Map<String, String> ) -/** - * Withdrawal status as specified in the Taler Integration API. - */ + @Serializable data class BankWithdrawalOperationStatus( - // Indicates whether the withdrawal was aborted. - val aborted: Boolean, - - /* Has the wallet selected parameters for the withdrawal operation - (exchange and reserve public key) and successfully sent it - to the bank? */ - val selection_done: Boolean, - - /* The transfer has been confirmed and registered by the bank. - Does not guarantee that the funds have arrived at the exchange - already. */ - val transfer_done: Boolean, - - /* Amount that will be withdrawn with this operation - (raw amount without fee considerations). */ + val status: WithdrawalStatus, val amount: TalerAmount, - - /* Bank account of the customer that is withdrawing, as a - ``payto`` URI. */ val sender_wire: String? = null, - - // Suggestion for an exchange given by the bank. val suggested_exchange: String? = null, - - /* URL that the user needs to navigate to in order to - complete some final confirmation (e.g. 2FA). - It may contain withdrawal operation id */ val confirm_transfer_url: String? = null, - - // Wire transfer types supported by the bank. - val wire_types: MutableList<String> = mutableListOf("iban") + val selected_reserve_pub: EddsaPublicKey? = null, + val selected_exchange_account: String? = null, + val wire_types: MutableList<String> = mutableListOf("iban"), + // TODO remove + val aborted: Boolean, + val selection_done: Boolean, + val transfer_done: Boolean, ) /** @@ -404,8 +396,10 @@ data class BankWithdrawalOperationPostRequest( */ @Serializable data class BankWithdrawalOperationPostResponse( + val status: WithdrawalStatus, + val confirm_transfer_url: String? = null, + // TODO remove val transfer_done: Boolean, - val confirm_transfer_url: String? = null ) @Serializable diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/NotificationWatcher.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/NotificationWatcher.kt @@ -28,16 +28,16 @@ import tech.libeufin.util.* /** Postgres notification collector and distributor */ internal class NotificationWatcher(private val pgSource: PGSimpleDataSource) { - // Transaction id ShareFlow that are manually counted for manual garbage collection - private class CountedSharedFlow(val flow: MutableSharedFlow<Long>, var count: Int) + // ShareFlow that are manually counted for manual garbage collection + private class CountedSharedFlow<T>(val flow: MutableSharedFlow<T>, var count: Int) // Transaction flows, the keys are the bank account id - private val bankTxFlows = ConcurrentHashMap<Long, CountedSharedFlow>() - private val outgoingTxFlows = ConcurrentHashMap<Long, CountedSharedFlow>() - private val incomingTxFlows = ConcurrentHashMap<Long, CountedSharedFlow>() - private val revenueTxFlows = ConcurrentHashMap<Long, CountedSharedFlow>() + private val bankTxFlows = ConcurrentHashMap<Long, CountedSharedFlow<Long>>() + private val outgoingTxFlows = ConcurrentHashMap<Long, CountedSharedFlow<Long>>() + private val incomingTxFlows = ConcurrentHashMap<Long, CountedSharedFlow<Long>>() + private val revenueTxFlows = ConcurrentHashMap<Long, CountedSharedFlow<Long>>() // Withdrawal confirmation flow, the key is the public withdrawal UUID - private val withdrawalFlow = MutableSharedFlow<UUID>() + private val withdrawalFlow = ConcurrentHashMap<UUID, CountedSharedFlow<WithdrawalStatus>>() init { // Run notification logic in a separated thread @@ -51,7 +51,7 @@ internal class NotificationWatcher(private val pgSource: PGSimpleDataSource) { conn.execSQLUpdate("LISTEN bank_tx") conn.execSQLUpdate("LISTEN outgoing_tx") conn.execSQLUpdate("LISTEN incoming_tx") - conn.execSQLUpdate("LISTEN withdrawal_confirm") + conn.execSQLUpdate("LISTEN withdrawal_status") while (true) { conn.getNotifications(0) // Block until we receive at least one notification @@ -82,9 +82,13 @@ internal class NotificationWatcher(private val pgSource: PGSimpleDataSource) { flow.emit(row) } } - "withdrawal_confirm" -> { - val uuid = UUID.fromString(it.parameter) - withdrawalFlow.emit(uuid) + "withdrawal_status" -> { + val raw = it.parameter.split(' ', limit = 2) + val uuid = UUID.fromString(raw[0]) + val status = WithdrawalStatus.valueOf(raw[1]) + withdrawalFlow[uuid]?.run { + flow.emit(status) + } } } } @@ -97,10 +101,10 @@ internal class NotificationWatcher(private val pgSource: PGSimpleDataSource) { } } - /** Listen to transaction ids flow from [map] for [account] using [lambda]*/ - private suspend fun <R> listen(map: ConcurrentHashMap<Long, CountedSharedFlow>, account: Long, lambda: suspend (Flow<Long>) -> R): R { + /** Listen to flow from [map] for [key] using [lambda]*/ + private suspend fun <R, K, V> listen(map: ConcurrentHashMap<K, CountedSharedFlow<V>>, key: K, lambda: suspend (Flow<V>) -> R): R { // Register listener, create a new flow if missing - val flow = map.compute(account) { _, v -> + val flow = map.compute(key) { _, v -> val tmp = v ?: CountedSharedFlow(MutableSharedFlow(), 0); tmp.count++; tmp @@ -110,7 +114,7 @@ internal class NotificationWatcher(private val pgSource: PGSimpleDataSource) { return lambda(flow) } finally { // Unregister listener, removing unused flow - map.compute(account) { _, v -> + map.compute(key) { _, v -> v!!; v.count--; if (v.count > 0) v else null @@ -131,6 +135,6 @@ internal class NotificationWatcher(private val pgSource: PGSimpleDataSource) { suspend fun <R> listenRevenue(merchant: Long, lambda: suspend (Flow<Long>) -> R): R = listen(revenueTxFlows, merchant, lambda) /** Listen for new withdrawal confirmations */ - suspend fun <R> listenWithdrawals(lambda: suspend (Flow<UUID>) -> R): R - = lambda(withdrawalFlow) + suspend fun <R> listenWithdrawals(withdrawal: UUID, lambda: suspend (Flow<WithdrawalStatus>) -> R): R + = listen(withdrawalFlow, withdrawal, lambda) } \ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/WithdrawalDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/WithdrawalDAO.kt @@ -68,24 +68,28 @@ class WithdrawalDAO(private val db: Database) { /** Abort withdrawal operation [uuid] */ suspend fun abort(uuid: UUID): AbortResult = db.serializable { conn -> + // TODO login check val stmt = conn.prepareStatement(""" - UPDATE taler_withdrawal_operations - SET aborted = NOT confirmation_done - WHERE withdrawal_uuid=? - RETURNING confirmation_done - """ - ) + SELECT + out_no_op, + out_already_confirmed + FROM abort_taler_withdrawal(?) + """) stmt.setObject(1, uuid) - when (stmt.oneOrNull { it.getBoolean(1) }) { - null -> AbortResult.UnknownOperation - true -> AbortResult.AlreadyConfirmed - false -> AbortResult.Success + stmt.executeQuery().use { + when { + !it.next() -> + throw internalServerError("No result from DB procedure abort_taler_withdrawal") + it.getBoolean("out_no_op") -> AbortResult.UnknownOperation + it.getBoolean("out_already_confirmed") -> AbortResult.AlreadyConfirmed + else -> AbortResult.Success + } } } /** Result withdrawal operation selection */ sealed class WithdrawalSelectionResult { - data class Success(val confirmed: Boolean): WithdrawalSelectionResult() + data class Success(val status: WithdrawalStatus): WithdrawalSelectionResult() object UnknownOperation: WithdrawalSelectionResult() object AlreadySelected: WithdrawalSelectionResult() object RequestPubReuse: WithdrawalSelectionResult() @@ -107,7 +111,7 @@ class WithdrawalDAO(private val db: Database) { out_reserve_pub_reuse, out_account_not_found, out_account_is_not_exchange, - out_confirmation_done + out_status FROM select_taler_withdrawal(?, ?, ?, ?); """ ) @@ -124,7 +128,7 @@ class WithdrawalDAO(private val db: Database) { it.getBoolean("out_reserve_pub_reuse") -> WithdrawalSelectionResult.RequestPubReuse it.getBoolean("out_account_not_found") -> WithdrawalSelectionResult.UnknownAccount it.getBoolean("out_account_is_not_exchange") -> WithdrawalSelectionResult.AccountIsNotExchange - else -> WithdrawalSelectionResult.Success(it.getBoolean("out_confirmation_done")) + else -> WithdrawalSelectionResult.Success(WithdrawalStatus.valueOf(it.getString("out_status"))) } } } @@ -144,6 +148,7 @@ class WithdrawalDAO(private val db: Database) { uuid: UUID, now: Instant ): WithdrawalConfirmationResult = db.serializable { conn -> + // TODO login check val stmt = conn.prepareStatement(""" SELECT out_no_op, @@ -170,6 +175,19 @@ class WithdrawalDAO(private val db: Database) { } } + /** Get withdrawal operation [uuid] linked account username */ + suspend fun getUsername(uuid: UUID): String? = db.conn { conn -> + val stmt = conn.prepareStatement(""" + SELECT username + FROM taler_withdrawal_operations + JOIN bank_accounts ON wallet_bank_account=bank_account_id + JOIN customers ON customer_id=owning_customer_id + WHERE withdrawal_uuid=? + """) + stmt.setObject(1, uuid) + stmt.oneOrNull { it.getString(1) } + } + /** Get withdrawal operation [uuid] */ suspend fun get(uuid: UUID): BankAccountGetWithdrawalResponse? = db.conn { conn -> val stmt = conn.prepareStatement(""" @@ -180,8 +198,11 @@ class WithdrawalDAO(private val db: Database) { ,aborted ,confirmation_done ,reserve_pub - ,selected_exchange_payto + ,selected_exchange_payto + ,username FROM taler_withdrawal_operations + JOIN bank_accounts ON wallet_bank_account=bank_account_id + JOIN customers ON customer_id=owning_customer_id WHERE withdrawal_uuid=? """) stmt.setObject(1, uuid) @@ -193,21 +214,30 @@ class WithdrawalDAO(private val db: Database) { aborted = it.getBoolean("aborted"), selected_exchange_account = it.getString("selected_exchange_payto"), selected_reserve_pub = it.getBytes("reserve_pub")?.run(::EddsaPublicKey), + username = it.getString("username") ) } } /** Pool public status of operation [uuid] */ - suspend fun pollStatus(uuid: UUID, params: PollingParams): BankWithdrawalOperationStatus? { + suspend fun pollStatus(uuid: UUID, params: StatusParams): BankWithdrawalOperationStatus? { suspend fun load(): BankWithdrawalOperationStatus? = db.conn { conn -> val stmt = conn.prepareStatement(""" SELECT - (amount).val as amount_val + CASE + WHEN confirmation_done THEN 'confirmed' + WHEN aborted THEN 'aborted' + WHEN selection_done THEN 'selected' + ELSE 'pending' + END as status + ,(amount).val as amount_val ,(amount).frac as amount_frac ,selection_done ,aborted ,confirmation_done ,internal_payto_uri + ,reserve_pub + ,selected_exchange_payto FROM taler_withdrawal_operations JOIN bank_accounts ON (wallet_bank_account=bank_account_id) WHERE withdrawal_uuid=? @@ -215,31 +245,34 @@ class WithdrawalDAO(private val db: Database) { stmt.setObject(1, uuid) stmt.oneOrNull { BankWithdrawalOperationStatus( + status = WithdrawalStatus.valueOf(it.getString("status")), amount = it.getAmount("amount", db.bankCurrency), selection_done = it.getBoolean("selection_done"), transfer_done = it.getBoolean("confirmation_done"), aborted = it.getBoolean("aborted"), sender_wire = it.getString("internal_payto_uri"), confirm_transfer_url = null, - suggested_exchange = null + suggested_exchange = null, + selected_exchange_account = it.getString("selected_exchange_payto"), + selected_reserve_pub = it.getBytes("reserve_pub")?.run(::EddsaPublicKey), ) } } - return if (params.poll_ms > 0) { - db.notifWatcher.listenWithdrawals { flow -> + return if (params.polling.poll_ms > 0) { + db.notifWatcher.listenWithdrawals(uuid) { flow -> coroutineScope { // Start buffering notification before loading transactions to not miss any val polling = launch { - withTimeoutOrNull(params.poll_ms) { - flow.first { it == uuid } + withTimeoutOrNull(params.polling.poll_ms) { + flow.first { it != params.old_state } } } // Initial loading val init = load() // Long polling if there is no operation or its not confirmed - if (init?.run { transfer_done == false } ?: true) { + if (init?.run { status == params.old_state } ?: true) { polling.join() load() } else { diff --git a/bank/src/test/kotlin/BankIntegrationApiTest.kt b/bank/src/test/kotlin/BankIntegrationApiTest.kt @@ -61,32 +61,73 @@ class BankIntegrationApiTest { client.postA("/accounts/customer/withdrawals") { json { "amount" to amount } }.assertOkJson<BankAccountCreateWithdrawalResponse> { resp -> - val never_confirmed_uuid = resp.taler_withdraw_uri.split("/").last() + val aborted_uuid = resp.taler_withdraw_uri.split("/").last() val confirmed_uuid = client.postA("/accounts/customer/withdrawals") { json { "amount" to amount } }.assertOkJson<BankAccountCreateWithdrawalResponse>() .taler_withdraw_uri.split("/").last() - withdrawalSelect(confirmed_uuid) + // Check no useless polling + assertTime(0, 100) { + client.get("/taler-integration/withdrawal-operation/$confirmed_uuid?long_poll_ms=1000&old_state=selected") + .assertOkJson<BankWithdrawalOperationStatus> { assertEquals(WithdrawalStatus.pending, it.status) } + } + + // Polling selected coroutineScope { - launch { // Check polling succeed forward + launch { // Check polling succeed assertTime(100, 200) { client.get("/taler-integration/withdrawal-operation/$confirmed_uuid?long_poll_ms=1000") - .assertOkJson<BankWithdrawalOperationStatus> { assert(it.selection_done) } + .assertOkJson<BankWithdrawalOperationStatus> { assertEquals(WithdrawalStatus.selected, it.status) } + } + } + launch { // Check polling succeed + assertTime(100, 200) { + client.get("/taler-integration/withdrawal-operation/$aborted_uuid?long_poll_ms=1000") + .assertOkJson<BankWithdrawalOperationStatus> { assertEquals(WithdrawalStatus.selected, it.status) } + } + } + delay(100) + withdrawalSelect(confirmed_uuid) + withdrawalSelect(aborted_uuid) + } + + // Polling confirmed + coroutineScope { + launch { // Check polling succeed + assertTime(100, 200) { + client.get("/taler-integration/withdrawal-operation/$confirmed_uuid?long_poll_ms=1000&old_state=selected") + .assertOkJson<BankWithdrawalOperationStatus> { assertEquals(WithdrawalStatus.confirmed, it.status)} } } - launch { // Check polling timeout forward + launch { // Check polling timeout assertTime(200, 300) { - client.get("/taler-integration/withdrawal-operation/$never_confirmed_uuid?long_poll_ms=200") - .assertOkJson<BankWithdrawalOperationStatus> { assert(!it.selection_done) } + client.get("/taler-integration/withdrawal-operation/$aborted_uuid?long_poll_ms=200&old_state=selected") + .assertOkJson<BankWithdrawalOperationStatus> { assertEquals(WithdrawalStatus.selected, it.status) } } } delay(100) client.post("/withdrawals/$confirmed_uuid/confirm").assertNoContent() } - } - + // Polling abort + coroutineScope { + launch { + assertTime(200, 300) { + client.get("/taler-integration/withdrawal-operation/$confirmed_uuid?long_poll_ms=200&old_state=confirmed") + .assertOkJson<BankWithdrawalOperationStatus> { assertEquals(WithdrawalStatus.confirmed, it.status)} + } + } + launch { + assertTime(100, 200) { + client.get("/taler-integration/withdrawal-operation/$aborted_uuid?long_poll_ms=1000&old_state=selected") + .assertOkJson<BankWithdrawalOperationStatus> { assertEquals(WithdrawalStatus.aborted, it.status) } + } + } + delay(100) + client.post("/withdrawals/$aborted_uuid/abort").assertNoContent() + } + } // Check unknown client.get("/taler-integration/withdrawal-operation/${UUID.randomUUID()}") diff --git a/bank/src/test/kotlin/CoreBankApiTest.kt b/bank/src/test/kotlin/CoreBankApiTest.kt @@ -737,9 +737,10 @@ class CoreBankWithdrawalApiTest { .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) } - // POST /withdrawals/withdrawal_id/abort + // POST /accounts/USERNAME/withdrawals/withdrawal_id/abort @Test fun abort() = bankSetup { _ -> + // TODO auth routine // Check abort created client.postA("/accounts/merchant/withdrawals") { json { "amount" to "KUDOS:1" } @@ -747,9 +748,9 @@ class CoreBankWithdrawalApiTest { val uuid = it.taler_withdraw_uri.split("/").last() // Check OK - client.post("/withdrawals/$uuid/abort").assertNoContent() + client.postA("/accounts/merchant/withdrawals/$uuid/abort").assertNoContent() // Check idempotence - client.post("/withdrawals/$uuid/abort").assertNoContent() + client.postA("/accounts/merchant/withdrawals/$uuid/abort").assertNoContent() } // Check abort selected @@ -760,9 +761,9 @@ class CoreBankWithdrawalApiTest { withdrawalSelect(uuid) // Check OK - client.post("/withdrawals/$uuid/abort").assertNoContent() + client.postA("/accounts/merchant/withdrawals/$uuid/abort").assertNoContent() // Check idempotence - client.post("/withdrawals/$uuid/abort").assertNoContent() + client.postA("/accounts/merchant/withdrawals/$uuid/abort").assertNoContent() } // Check abort confirmed @@ -771,24 +772,25 @@ class CoreBankWithdrawalApiTest { }.assertOkJson<BankAccountCreateWithdrawalResponse> { val uuid = it.taler_withdraw_uri.split("/").last() withdrawalSelect(uuid) - client.post("/withdrawals/$uuid/confirm").assertNoContent() + client.postA("/accounts/merchant/withdrawals/$uuid/confirm").assertNoContent() // Check error - client.post("/withdrawals/$uuid/abort") + client.postA("/accounts/merchant/withdrawals/$uuid/abort") .assertConflict(TalerErrorCode.BANK_ABORT_CONFIRM_CONFLICT) } // Check bad UUID - client.post("/withdrawals/chocolate/abort").assertBadRequest() + client.postA("/accounts/merchant/withdrawals/chocolate/abort").assertBadRequest() // Check unknown - client.post("/withdrawals/${UUID.randomUUID()}/abort") + client.postA("/accounts/merchant/withdrawals/${UUID.randomUUID()}/abort") .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) } - // POST /withdrawals/withdrawal_id/confirm + // POST /accounts/USERNAME/withdrawals/withdrawal_id/confirm @Test fun confirm() = bankSetup { _ -> + // TODO auth routine // Check confirm created client.postA("/accounts/merchant/withdrawals") { json { "amount" to "KUDOS:1" } @@ -796,7 +798,7 @@ class CoreBankWithdrawalApiTest { val uuid = it.taler_withdraw_uri.split("/").last() // Check err - client.post("/withdrawals/$uuid/confirm") + client.postA("/accounts/merchant/withdrawals/$uuid/confirm") .assertConflict(TalerErrorCode.BANK_CONFIRM_INCOMPLETE) } @@ -808,9 +810,9 @@ class CoreBankWithdrawalApiTest { withdrawalSelect(uuid) // Check OK - client.post("/withdrawals/$uuid/confirm").assertNoContent() + client.postA("/accounts/merchant/withdrawals/$uuid/confirm").assertNoContent() // Check idempotence - client.post("/withdrawals/$uuid/confirm").assertNoContent() + client.postA("/accounts/merchant/withdrawals/$uuid/confirm").assertNoContent() } // Check confirm aborted @@ -819,10 +821,10 @@ class CoreBankWithdrawalApiTest { }.assertOkJson<BankAccountCreateWithdrawalResponse> { val uuid = it.taler_withdraw_uri.split("/").last() withdrawalSelect(uuid) - client.post("/withdrawals/$uuid/abort").assertNoContent() + client.postA("/accounts/merchant/withdrawals/$uuid/abort").assertNoContent() // Check error - client.post("/withdrawals/$uuid/confirm") + client.postA("/accounts/merchant/withdrawals/$uuid/confirm") .assertConflict(TalerErrorCode.BANK_CONFIRM_ABORT_CONFLICT) } @@ -835,18 +837,18 @@ class CoreBankWithdrawalApiTest { // Send too much money tx("merchant", "KUDOS:5", "customer") - client.post("/withdrawals/$uuid/confirm") + client.postA("/accounts/merchant/withdrawals/$uuid/confirm") .assertConflict(TalerErrorCode.BANK_UNALLOWED_DEBIT) // Check can abort because not confirmed - client.post("/withdrawals/$uuid/abort").assertNoContent() + client.postA("/accounts/merchant/withdrawals/$uuid/abort").assertNoContent() } // Check bad UUID - client.post("/withdrawals/chocolate/confirm").assertBadRequest() + client.postA("/accounts/merchant/withdrawals/chocolate/confirm").assertBadRequest() // Check unknown - client.post("/withdrawals/${UUID.randomUUID()}/confirm") + client.postA("/accounts/merchant/withdrawals/${UUID.randomUUID()}/confirm") .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) } } diff --git a/database-versioning/libeufin-bank-procedures.sql b/database-versioning/libeufin-bank-procedures.sql @@ -584,7 +584,7 @@ CREATE OR REPLACE FUNCTION select_taler_withdrawal( OUT out_account_not_found BOOLEAN, OUT out_account_is_not_exchange BOOLEAN, -- Success return - OUT out_confirmation_done BOOLEAN + OUT out_status TEXT ) LANGUAGE plpgsql AS $$ DECLARE @@ -592,10 +592,15 @@ not_selected BOOLEAN; BEGIN -- Check for conflict and idempotence SELECT - NOT selection_done, confirmation_done, + NOT selection_done, + CASE + WHEN confirmation_done THEN 'confirmed' + WHEN aborted THEN 'aborted' + ELSE 'selected' + END, selection_done AND (selected_exchange_payto != in_selected_exchange_payto OR reserve_pub != in_reserve_pub) - INTO not_selected, out_confirmation_done, out_already_selected + INTO not_selected, out_status, out_already_selected FROM taler_withdrawal_operations WHERE withdrawal_uuid=in_withdrawal_uuid; IF NOT FOUND THEN @@ -605,7 +610,7 @@ ELSIF out_already_selected THEN RETURN; END IF; -IF NOT out_confirmation_done AND not_selected THEN +IF not_selected THEN -- Check reserve_pub reuse SELECT true FROM taler_exchange_incoming WHERE reserve_pub = in_reserve_pub UNION ALL @@ -630,10 +635,37 @@ IF NOT out_confirmation_done AND not_selected THEN UPDATE taler_withdrawal_operations SET selected_exchange_payto=in_selected_exchange_payto, reserve_pub=in_reserve_pub, subject=in_subject, selection_done=true WHERE withdrawal_uuid=in_withdrawal_uuid; + + -- Notify status change + PERFORM pg_notify('withdrawal_status', in_withdrawal_uuid::text || ' selected'); END IF; END $$; COMMENT ON FUNCTION select_taler_withdrawal IS 'Set details of a withdrawal operation'; +CREATE OR REPLACE FUNCTION abort_taler_withdrawal( + IN in_withdrawal_uuid uuid, + OUT out_no_op BOOLEAN, + OUT out_already_confirmed BOOLEAN +) +LANGUAGE plpgsql AS $$ +BEGIN +UPDATE taler_withdrawal_operations + SET aborted = NOT confirmation_done + WHERE withdrawal_uuid=in_withdrawal_uuid + RETURNING confirmation_done + INTO out_already_confirmed; +IF NOT FOUND THEN + out_no_op=TRUE; + RETURN; +ELSIF out_already_confirmed THEN + RETURN; +END IF; + +-- Notify status change +PERFORM pg_notify('withdrawal_status', in_withdrawal_uuid::text || ' aborted'); +END $$; +COMMENT ON FUNCTION abort_taler_withdrawal IS 'Abort a withdrawal operation.'; + CREATE OR REPLACE FUNCTION confirm_taler_withdrawal( IN in_withdrawal_uuid uuid, IN in_confirmation_date BIGINT, @@ -715,8 +747,8 @@ UPDATE taler_withdrawal_operations -- Register incoming transaction CALL register_incoming(reserve_pub_local, tx_row_id); --- Notify new transaction -PERFORM pg_notify('withdrawal_confirm', in_withdrawal_uuid::text); +-- Notify status change +PERFORM pg_notify('withdrawal_status', in_withdrawal_uuid::text || ' confirmed'); END $$; COMMENT ON FUNCTION confirm_taler_withdrawal IS 'Set a withdrawal operation as confirmed and wire the funds to the exchange.';