commit ea3dbf1e782edb2fda4b13b9e53ef568d3ee2389
parent 52c5bfc2d9270138e689902766395c8f5564820f
Author: Antoine A <>
Date: Fri, 24 Nov 2023 17:58:09 +0000
New secure withdrawal API with compatibility
Diffstat:
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.';