commit 77012addddc5ade4765a3a68f25be12c44667bef
parent 1935dc625b28540cc8f20ac670fc86313f8b3daf
Author: Antoine A <>
Date: Thu, 3 Oct 2024 17:10:19 +0200
bank: support setting withdrawal amount during confirmation
Diffstat:
11 files changed, 125 insertions(+), 57 deletions(-)
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Constants.kt b/bank/src/main/kotlin/tech/libeufin/bank/Constants.kt
@@ -37,6 +37,6 @@ val RESERVED_ACCOUNTS = setOf("admin", "bank")
const val IBAN_ALLOCATION_RETRY_COUNTER: Int = 5
// API version
-const val COREBANK_API_VERSION: String = "5:0:2"
+const val COREBANK_API_VERSION: String = "5:1:2"
const val CONVERSION_API_VERSION: String = "0:1:0"
-const val INTEGRATION_API_VERSION: String = "3:0:4"
+const val INTEGRATION_API_VERSION: String = "4:0:4"
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt b/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt
@@ -610,4 +610,10 @@ data class PublicAccount(
data class AccountPasswordChange(
val new_password: String,
val old_password: String? = null
+)
+
+// Request POST /accounts/{USERNAME}/withdrawals/{WITHDRAWAL_ID}/confirm
+@Serializable
+data class BankAccountConfirmWithdrawalRequest(
+ val amount: TalerAmount? = null
)
\ No newline at end of file
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/api/BankIntegrationApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/api/BankIntegrationApi.kt
@@ -96,10 +96,6 @@ fun Routing.bankIntegrationApi(db: Database, ctx: BankConfig) {
"Account ${req.selected_exchange} is not an exchange",
TalerErrorCode.BANK_ACCOUNT_IS_NOT_EXCHANGE
)
- WithdrawalSelectionResult.MissingAmount -> throw conflict(
- "An amount is required",
- TalerErrorCode.BANK_AMOUNT_REQUIRED
- )
WithdrawalSelectionResult.AmountDiffers -> throw conflict(
"Given amount is different from the current",
TalerErrorCode.BANK_AMOUNT_DIFFERS
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt
@@ -555,11 +555,13 @@ private fun Routing.coreBankWithdrawalApi(db: Database, ctx: BankConfig) {
}
post("/accounts/{USERNAME}/withdrawals/{withdrawal_id}/confirm") {
val id = call.uuidPath("withdrawal_id")
- val challenge = call.checkChallenge(db, Operation.withdrawal)
+ val (req, challenge) = call.receiveChallenge<BankAccountConfirmWithdrawalRequest>(db, Operation.withdrawal, BankAccountConfirmWithdrawalRequest())
+ req.amount?.run(ctx::checkRegionalCurrency)
when (db.withdrawal.confirm(
username,
id,
Instant.now(),
+ req.amount,
challenge != null,
ctx.wireTransferFees,
ctx.minAmount,
@@ -581,6 +583,14 @@ private fun Routing.coreBankWithdrawalApi(db: Database, ctx: BankConfig) {
"Insufficient funds",
TalerErrorCode.BANK_UNALLOWED_DEBIT
)
+ WithdrawalConfirmationResult.MissingAmount -> throw conflict(
+ "An amount is required",
+ TalerErrorCode.BANK_AMOUNT_REQUIRED
+ )
+ WithdrawalConfirmationResult.AmountDiffers -> throw conflict(
+ "Given amount is different from the current",
+ TalerErrorCode.BANK_AMOUNT_DIFFERS
+ )
WithdrawalConfirmationResult.BadAmount -> throw conflict(
"Amount either to high or too low",
TalerErrorCode.BANK_UNALLOWED_DEBIT
@@ -590,7 +600,7 @@ private fun Routing.coreBankWithdrawalApi(db: Database, ctx: BankConfig) {
TalerErrorCode.BANK_UNKNOWN_CREDITOR
)
WithdrawalConfirmationResult.TanRequired -> {
- call.respondChallenge(db, Operation.withdrawal, StoredUUID(id))
+ call.respondChallenge(db, Operation.withdrawal, req)
}
WithdrawalConfirmationResult.Success -> call.respond(HttpStatusCode.NoContent)
}
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/auth/Tan.kt b/bank/src/main/kotlin/tech/libeufin/bank/auth/Tan.kt
@@ -22,6 +22,7 @@ import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.request.*
import io.ktor.server.response.*
+import io.ktor.server.plugins.*
import kotlinx.serialization.json.Json
import tech.libeufin.bank.*
import tech.libeufin.bank.db.Database
@@ -70,13 +71,20 @@ suspend inline fun <reified B> ApplicationCall.respondChallenge(
*/
suspend inline fun <reified B> ApplicationCall.receiveChallenge(
db: Database,
- op: Operation
+ op: Operation,
+ default: B? = null
): Pair<B, Challenge?> {
val id = request.headers[X_CHALLENGE_ID]?.toLongOrNull()
return if (id != null) {
val challenge = db.tan.challenge(id, username, op)!!
Pair(Json.decodeFromString(challenge.body), challenge)
} else {
+ if (default != null) {
+ val contentLenght = request.headers[HttpHeaders.ContentLength]?.toIntOrNull()
+ if (contentLenght == 0) {
+ return Pair(default, null)
+ }
+ }
Pair(this.receive(), null)
}
}
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/WithdrawalDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/WithdrawalDAO.kt
@@ -126,7 +126,6 @@ class WithdrawalDAO(private val db: Database) {
data object RequestPubReuse: WithdrawalSelectionResult
data object UnknownAccount: WithdrawalSelectionResult
data object AccountIsNotExchange: WithdrawalSelectionResult
- data object MissingAmount: WithdrawalSelectionResult
data object AmountDiffers: WithdrawalSelectionResult
data object BalanceInsufficient: WithdrawalSelectionResult
data object BadAmount: WithdrawalSelectionResult
@@ -150,7 +149,6 @@ class WithdrawalDAO(private val db: Database) {
out_account_not_found,
out_account_is_not_exchange,
out_status,
- out_missing_amount,
out_amount_differs,
out_balance_insufficient,
out_bad_amount
@@ -183,7 +181,6 @@ class WithdrawalDAO(private val db: Database) {
it.getBoolean("out_bad_amount") -> WithdrawalSelectionResult.BadAmount
it.getBoolean("out_no_op") -> WithdrawalSelectionResult.UnknownOperation
it.getBoolean("out_already_selected") -> WithdrawalSelectionResult.AlreadySelected
- it.getBoolean("out_missing_amount") -> WithdrawalSelectionResult.MissingAmount
it.getBoolean("out_amount_differs") -> WithdrawalSelectionResult.AmountDiffers
it.getBoolean("out_reserve_pub_reuse") -> WithdrawalSelectionResult.RequestPubReuse
it.getBoolean("out_account_not_found") -> WithdrawalSelectionResult.UnknownAccount
@@ -202,7 +199,9 @@ class WithdrawalDAO(private val db: Database) {
BadAmount,
NotSelected,
AlreadyAborted,
- TanRequired
+ TanRequired,
+ MissingAmount,
+ AmountDiffers
}
/** Confirm withdrawal operation [uuid] */
@@ -210,6 +209,7 @@ class WithdrawalDAO(private val db: Database) {
username: String,
uuid: UUID,
timestamp: Instant,
+ amount: TalerAmount?,
is2fa: Boolean,
wireTransferFees: TalerAmount,
minAmount: TalerAmount,
@@ -223,8 +223,13 @@ class WithdrawalDAO(private val db: Database) {
out_bad_amount,
out_not_selected,
out_aborted,
- out_tan_required
- FROM confirm_taler_withdrawal(?,?,?,?,(?,?)::taler_amount,(?,?)::taler_amount,(?,?)::taler_amount);
+ out_tan_required,
+ out_missing_amount,
+ out_amount_differs
+ FROM confirm_taler_withdrawal(
+ ?,?,?,?,(?,?)::taler_amount,(?,?)::taler_amount,(?,?)::taler_amount,
+ ${if (amount != null) "(?, ?)::taler_amount" else "NULL"}
+ );
"""
) {
setString(1, username)
@@ -237,6 +242,10 @@ class WithdrawalDAO(private val db: Database) {
setInt(8, minAmount.frac)
setLong(9, maxAmount.value)
setInt(10, maxAmount.frac)
+ if (amount != null) {
+ setLong(11, amount.value)
+ setInt(12, amount.frac)
+ }
one {
when {
it.getBoolean("out_no_op") -> WithdrawalConfirmationResult.UnknownOperation
@@ -246,6 +255,8 @@ class WithdrawalDAO(private val db: Database) {
it.getBoolean("out_not_selected") -> WithdrawalConfirmationResult.NotSelected
it.getBoolean("out_aborted") -> WithdrawalConfirmationResult.AlreadyAborted
it.getBoolean("out_tan_required") -> WithdrawalConfirmationResult.TanRequired
+ it.getBoolean("out_missing_amount") -> WithdrawalConfirmationResult.MissingAmount
+ it.getBoolean("out_amount_differs") -> WithdrawalConfirmationResult.AmountDiffers
else -> WithdrawalConfirmationResult.Success
}
}
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt b/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt
@@ -120,21 +120,4 @@ fun Route.conditional(implemented: Boolean, callback: Route.() -> Unit): Route =
if (!implemented) {
throw apiError(HttpStatusCode.NotImplemented, "API not implemented", TalerErrorCode.END)
}
- }
-
-@Serializable(with = StoredUUID.Serializer::class)
-data class StoredUUID(val value: UUID) {
- internal object Serializer : KSerializer<StoredUUID> {
- override val descriptor: SerialDescriptor =
- PrimitiveSerialDescriptor("StoredUUID", PrimitiveKind.STRING)
-
- override fun serialize(encoder: Encoder, value: StoredUUID) {
- encoder.encodeString(value.value.toString())
- }
-
- override fun deserialize(decoder: Decoder): StoredUUID {
- val string = decoder.decodeString()
- return StoredUUID(UUID.fromString(string))
- }
- }
-}
+ }
+\ No newline at end of file
diff --git a/bank/src/test/kotlin/BankIntegrationApiTest.kt b/bank/src/test/kotlin/BankIntegrationApiTest.kt
@@ -174,14 +174,6 @@ class BankIntegrationApiTest {
}.assertOkJson<BankAccountCreateWithdrawalResponse> {
val uuid = it.withdrawal_id
- // Check missing amount
- client.post("/taler-integration/withdrawal-operation/$uuid") {
- json {
- "reserve_pub" to EddsaPublicKey.rand()
- "selected_exchange" to exchangePayto.canonical
- }
- }.assertConflict(TalerErrorCode.BANK_AMOUNT_REQUIRED)
-
// Check insufficient fund
client.post("/taler-integration/withdrawal-operation/$uuid") {
json {
diff --git a/bank/src/test/kotlin/CoreBankApiTest.kt b/bank/src/test/kotlin/CoreBankApiTest.kt
@@ -1419,10 +1419,46 @@ class CoreBankWithdrawalApiTest {
val uuid = it.withdrawal_id
withdrawalSelect(uuid)
+ // Check amount differs
+ client.postA("/accounts/merchant/withdrawals/$uuid/confirm") {
+ json { "amount" to "KUDOS:2" }
+ }.assertConflict(TalerErrorCode.BANK_AMOUNT_DIFFERS)
+
// Check OK
client.postA("/accounts/merchant/withdrawals/$uuid/confirm").assertNoContent()
// Check idempotence
client.postA("/accounts/merchant/withdrawals/$uuid/confirm").assertNoContent()
+
+ // Check amount differs
+ client.postA("/accounts/merchant/withdrawals/$uuid/confirm") {
+ json { "amount" to "KUDOS:2" }
+ }.assertConflict(TalerErrorCode.BANK_AMOUNT_DIFFERS)
+ }
+
+ // Check confirm with amount
+ client.postA("/accounts/merchant/withdrawals") {
+ json {}
+ }.assertOkJson<BankAccountCreateWithdrawalResponse> {
+ val uuid = it.withdrawal_id
+ withdrawalSelect(uuid)
+
+ // Check missing amount
+ client.postA("/accounts/merchant/withdrawals/$uuid/confirm")
+ .assertConflict(TalerErrorCode.BANK_AMOUNT_REQUIRED)
+
+ // Check OK
+ client.postA("/accounts/merchant/withdrawals/$uuid/confirm") {
+ json { "amount" to "KUDOS:1" }
+ }.assertNoContent()
+ // Check idempotence
+ client.postA("/accounts/merchant/withdrawals/$uuid/confirm") {
+ json { "amount" to "KUDOS:1" }
+ }.assertNoContent()
+
+ // Check amount differs
+ client.postA("/accounts/merchant/withdrawals/$uuid/confirm") {
+ json { "amount" to "KUDOS:2" }
+ }.assertConflict(TalerErrorCode.BANK_AMOUNT_DIFFERS)
}
// Check confirm aborted
@@ -1474,9 +1510,9 @@ class CoreBankWithdrawalApiTest {
client.postA("/accounts/merchant/withdrawals/${UUID.randomUUID()}/confirm")
.assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND)
- // Check 2fa
+ // Check 2fa without body
fillTanInfo("merchant")
- assertBalance("merchant", "-KUDOS:6")
+ assertBalance("merchant", "-KUDOS:7")
client.postA("/accounts/merchant/withdrawals") {
json { "amount" to "KUDOS:1" }
}.assertOkJson<BankAccountCreateWithdrawalResponse> {
@@ -1485,9 +1521,27 @@ class CoreBankWithdrawalApiTest {
client.postA("/accounts/merchant/withdrawals/$uuid/confirm")
.assertChallenge { _,_->
- assertBalance("merchant", "-KUDOS:6")
+ assertBalance("merchant", "-KUDOS:7")
+ }.assertNoContent()
+ }
+
+ // Check 2fa with body
+ fillTanInfo("merchant")
+ assertBalance("merchant", "-KUDOS:8")
+ client.postA("/accounts/merchant/withdrawals") {
+ json {}
+ }.assertOkJson<BankAccountCreateWithdrawalResponse> {
+ val uuid = it.withdrawal_id
+ withdrawalSelect(uuid)
+
+ client.postA("/accounts/merchant/withdrawals/$uuid/confirm") {
+ json { "amount" to "KUDOS:1" }
+ }
+ .assertChallenge { _,_->
+ assertBalance("merchant", "-KUDOS:8")
}.assertNoContent()
}
+ assertBalance("merchant", "-KUDOS:9")
}
@Test
diff --git a/bank/src/test/kotlin/GcTest.kt b/bank/src/test/kotlin/GcTest.kt
@@ -110,7 +110,7 @@ class GcTest {
db.withdrawal.setDetails(uuid, exchangePayto, EddsaPublicKey.rand(), null, ZERO, ZERO, MAX)
)
assertEquals(
- db.withdrawal.confirm(account, uuid, time, false, ZERO, ZERO, MAX),
+ db.withdrawal.confirm(account, uuid, time, null, false, ZERO, ZERO, MAX),
WithdrawalConfirmationResult.Success
)
assertIs<CashoutCreationResult.Success>(
diff --git a/database-versioning/libeufin-bank-procedures.sql b/database-versioning/libeufin-bank-procedures.sql
@@ -950,7 +950,6 @@ CREATE FUNCTION select_taler_withdrawal(
OUT out_reserve_pub_reuse BOOLEAN,
OUT out_account_not_found BOOLEAN,
OUT out_account_is_not_exchange BOOLEAN,
- OUT out_missing_amount BOOLEAN,
OUT out_amount_differs BOOLEAN,
OUT out_balance_insufficient BOOLEAN,
OUT out_bad_amount BOOLEAN,
@@ -973,13 +972,12 @@ SELECT
END,
selection_done
AND (selected_exchange_payto != in_selected_exchange_payto OR reserve_pub != in_reserve_pub OR amount != in_amount),
- amount IS NULL AND in_amount IS NULL,
- amount IS NOT NULL AND amount != in_amount,
+ amount != in_amount,
wallet_bank_account
- INTO not_selected, out_status, out_already_selected, out_missing_amount, out_amount_differs, account_id
+ INTO not_selected, out_status, out_already_selected, out_amount_differs, account_id
FROM taler_withdrawal_operations
WHERE withdrawal_uuid=in_withdrawal_uuid;
-IF NOT FOUND OR out_already_selected OR out_missing_amount OR out_amount_differs THEN
+IF NOT FOUND OR out_already_selected OR out_amount_differs THEN
out_no_op=NOT FOUND;
RETURN;
END IF;
@@ -1061,12 +1059,15 @@ CREATE FUNCTION confirm_taler_withdrawal(
IN in_wire_transfer_fees taler_amount,
IN in_min_amount taler_amount,
IN in_max_amount taler_amount,
+ IN in_amount taler_amount,
OUT out_no_op BOOLEAN,
OUT out_balance_insufficient BOOLEAN,
OUT out_bad_amount BOOLEAN,
OUT out_creditor_not_found BOOLEAN,
OUT out_exchange_not_found BOOLEAN,
OUT out_not_selected BOOLEAN,
+ OUT out_missing_amount BOOLEAN,
+ OUT out_amount_differs BOOLEAN,
OUT out_aborted BOOLEAN,
OUT out_tan_required BOOLEAN
)
@@ -1081,7 +1082,7 @@ DECLARE
exchange_bank_account_id INT8;
tx_row_id INT8;
BEGIN
--- Check op exists
+-- Check op exists and conflict
SELECT
confirmation_done,
aborted, NOT selection_done,
@@ -1089,7 +1090,9 @@ SELECT
selected_exchange_payto,
wallet_bank_account,
(amount).val, (amount).frac,
- (NOT in_is_tan AND tan_channel IS NOT NULL)
+ (NOT in_is_tan AND tan_channel IS NOT NULL),
+ amount IS NULL AND in_amount IS NULL,
+ amount != in_amount
INTO
already_confirmed,
out_aborted, out_not_selected,
@@ -1097,14 +1100,18 @@ SELECT
selected_exchange_payto_local,
wallet_bank_account_local,
amount_local.val, amount_local.frac,
- out_tan_required
+ out_tan_required,
+ out_missing_amount,
+ out_amount_differs
FROM taler_withdrawal_operations
JOIN bank_accounts ON wallet_bank_account=bank_account_id
JOIN customers ON owning_customer_id=customer_id
WHERE withdrawal_uuid=in_withdrawal_uuid AND username=in_username AND deleted_at IS NULL;
-IF NOT FOUND OR already_confirmed OR out_aborted OR out_not_selected THEN
- out_no_op=NOT FOUND;
+out_no_op=NOT FOUND;
+IF out_no_op OR already_confirmed OR out_aborted OR out_not_selected OR out_missing_amount OR out_amount_differs THEN
RETURN;
+ELSIF in_amount IS NOT NULL THEN
+ amount_local = in_amount;
END IF;
-- Check exchange account then 2fa
@@ -1137,9 +1144,9 @@ IF out_balance_insufficient OR out_bad_amount THEN
RETURN;
END IF;
--- Confirm operation
+-- Confirm operation and update amount
UPDATE taler_withdrawal_operations
- SET confirmation_done = true
+ SET amount=amount_local, confirmation_done = true
WHERE withdrawal_uuid=in_withdrawal_uuid;
-- Register incoming transaction