libeufin

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

commit 1f67cce74dbb6cd297cde4f7ac8e7da86a42b541
parent 53392e3316ce21ea9ca8254caf9c5a0811331387
Author: Antoine A <>
Date:   Fri, 21 Jun 2024 18:19:04 +0200

bank: add support for min and max wire transfer amount

Diffstat:
MAPI_CHANGES.md | 1+
Mbank/conf/test_with_fees.conf | 2++
Mbank/src/main/kotlin/tech/libeufin/bank/Config.kt | 12+++++++++---
Mbank/src/main/kotlin/tech/libeufin/bank/Constants.kt | 4++--
Mbank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt | 5++++-
Mbank/src/main/kotlin/tech/libeufin/bank/api/BankIntegrationApi.kt | 18++++++++++++++++--
Mbank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt | 34++++++++++++++++++++++++++++++----
Mbank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt | 2+-
Mbank/src/main/kotlin/tech/libeufin/bank/db/TransactionDAO.kt | 15++++++++++++---
Mbank/src/main/kotlin/tech/libeufin/bank/db/WithdrawalDAO.kt | 45+++++++++++++++++++++++++++++++++++----------
Mbank/src/test/kotlin/AmountTest.kt | 8++++++--
Mbank/src/test/kotlin/BankIntegrationApiTest.kt | 28++++++++++++++++++++++++++++
Mbank/src/test/kotlin/CoreBankApiTest.kt | 7+++----
Mbank/src/test/kotlin/GcTest.kt | 13++++++++-----
Mcontrib/bank.conf | 10++++++++--
Mdatabase-versioning/libeufin-bank-procedures.sql | 1479+++++++++++++++++++++++++++++++++++++++++--------------------------------------
16 files changed, 940 insertions(+), 743 deletions(-)

diff --git a/API_CHANGES.md b/API_CHANGES.md @@ -62,6 +62,7 @@ This files contains all the API changes for the current release: - POST /accounts/USERNAME/token: add optional description field - Add GET /accounts/USERNAME/tokens - GET /accounts/USERNAME/tokens: add missing row_id field +- GET /withdrawals/WITHDRAWAL_ID: add min_amount ## bank cli diff --git a/bank/conf/test_with_fees.conf b/bank/conf/test_with_fees.conf @@ -13,6 +13,8 @@ FIAT_CURRENCY = EUR tan_sms = libeufin-tan-file.sh tan_email = libeufin-tan-file.sh wire_transfer_fees = KUDOS:0.1 +min_wire_transfer_amount = KUDOS:0.01 +max_wire_transfer_amount = KUDOS:100 [libeufin-bankdb-postgres] CONFIG = postgresql:///libeufincheck diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Config.kt b/bank/src/main/kotlin/tech/libeufin/bank/Config.kt @@ -36,6 +36,8 @@ data class BankConfig( val regionalCurrency: String, val regionalCurrencySpec: CurrencySpecification, val wireTransferFees: TalerAmount, + val minAmount: TalerAmount, + val maxAmount: TalerAmount, val allowRegistration: Boolean, val allowAccountDeletion: Boolean, val allowEditName: Boolean, @@ -111,6 +113,8 @@ fun TalerConfig.loadBankConfig(): BankConfig = section("libeufin-bank").run { ) } + val ZERO = TalerAmount.zero(regionalCurrency) + val MAX = TalerAmount.max(regionalCurrency) BankConfig( name = string("name").default("Taler Bank"), regionalCurrency = regionalCurrency, @@ -120,9 +124,11 @@ fun TalerConfig.loadBankConfig(): BankConfig = section("libeufin-bank").run { allowEditName = boolean("allow_edit_name").default(false), allowEditCashout = boolean("allow_edit_cashout_payto_uri").default(false), allowConversion = allowConversion, - defaultDebtLimit = amount("default_debt_limit", regionalCurrency).default(TalerAmount.zero(regionalCurrency)), - registrationBonus = amount("registration_bonus", regionalCurrency).default(TalerAmount.zero(regionalCurrency)), - wireTransferFees = amount("wire_transfer_fees", regionalCurrency).default(TalerAmount.zero(regionalCurrency)), + defaultDebtLimit = amount("default_debt_limit", regionalCurrency).default(ZERO), + registrationBonus = amount("registration_bonus", regionalCurrency).default(ZERO), + wireTransferFees = amount("wire_transfer_fees", regionalCurrency).default(ZERO), + minAmount = amount("min_wire_transfer_amount", regionalCurrency).default(ZERO), + maxAmount = amount("max_wire_transfer_amount", regionalCurrency).default(MAX), suggestedWithdrawalExchange = string("suggested_withdrawal_exchange").orNull(), spaPath = path("spa").orNull(), baseUrl = string("base_url").orNull(), 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 = "4:12:1" +const val COREBANK_API_VERSION: String = "4:13:1" const val CONVERSION_API_VERSION: String = "0:1:0" -const val INTEGRATION_API_VERSION: String = "2:0:3" +const val INTEGRATION_API_VERSION: String = "2:1:3" diff --git a/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt b/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt @@ -322,7 +322,9 @@ data class Config( val default_debit_threshold: TalerAmount, val supported_tan_channels: Set<TanChannel>, val wire_type: WireMethod, - val wire_transfer_fees: TalerAmount + val wire_transfer_fees: TalerAmount, + val min_wire_transfer_amount: TalerAmount, + val max_wire_transfer_amount: TalerAmount ) { val name: String = "libeufin-bank" val version: String = COREBANK_API_VERSION @@ -472,6 +474,7 @@ data class BankWithdrawalOperationStatus( val status: WithdrawalStatus, val amount: TalerAmount? = null, val suggested_amount: TalerAmount? = null, + val min_amount: TalerAmount? = null, val max_amount: TalerAmount? = null, val card_fees: TalerAmount? = null, val sender_wire: String? = null, diff --git a/bank/src/main/kotlin/tech/libeufin/bank/api/BankIntegrationApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/api/BankIntegrationApi.kt @@ -44,7 +44,11 @@ fun Routing.bankIntegrationApi(db: Database, ctx: BankConfig) { get("/taler-integration/withdrawal-operation/{wopid}") { val uuid = call.uuidPath("wopid") val params = StatusParams.extract(call.request.queryParameters) - val op = db.withdrawal.pollStatus(uuid, params, ctx.wireMethod)?.copy(card_fees = ctx.wireTransferFees) ?: throw notFound( + val op = db.withdrawal.pollStatus(uuid, params, ctx.wireMethod)?.copy( + card_fees = ctx.wireTransferFees, + min_amount = ctx.minAmount, + max_amount = ctx.maxAmount + ) ?: throw notFound( "Withdrawal operation '$uuid' not found", TalerErrorCode.BANK_TRANSACTION_NOT_FOUND ) @@ -58,7 +62,13 @@ fun Routing.bankIntegrationApi(db: Database, ctx: BankConfig) { val req = call.receive<BankWithdrawalOperationPostRequest>() req.amount?.run(ctx::checkRegionalCurrency) val res = db.withdrawal.setDetails( - uuid, req.selected_exchange, req.reserve_pub, req.amount, ctx.wireTransferFees + uuid, + req.selected_exchange, + req.reserve_pub, + req.amount, + ctx.wireTransferFees, + ctx.minAmount, + ctx.maxAmount ) when (res) { @@ -94,6 +104,10 @@ fun Routing.bankIntegrationApi(db: Database, ctx: BankConfig) { "Insufficient funds", TalerErrorCode.BANK_UNALLOWED_DEBIT ) + WithdrawalSelectionResult.BadAmount -> throw conflict( + "Amount either to high or too low", + TalerErrorCode.BANK_UNALLOWED_DEBIT + ) is WithdrawalSelectionResult.Success -> { call.respond(BankWithdrawalOperationPostResponse( transfer_done = res.status == WithdrawalStatus.confirmed, diff --git a/bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt @@ -63,7 +63,9 @@ fun Routing.coreBankApi(db: Database, ctx: BankConfig) { allow_edit_name = ctx.allowEditName, allow_edit_cashout_payto_uri = ctx.allowEditCashout, wire_type = ctx.wireMethod, - wire_transfer_fees = ctx.wireTransferFees + wire_transfer_fees = ctx.wireTransferFees, + min_wire_transfer_amount = ctx.minAmount, + max_wire_transfer_amount = ctx.maxAmount ) ) } @@ -467,7 +469,9 @@ private fun Routing.coreBankTransactionsApi(db: Database, ctx: BankConfig) { timestamp = Instant.now(), requestUid = req.request_uid, is2fa = challenge != null, - wireTransferFees = ctx.wireTransferFees + wireTransferFees = ctx.wireTransferFees, + minAmount = ctx.minAmount, + maxAmount = ctx.maxAmount ) when (res) { BankTransactionResult.UnknownDebtor -> throw unknownAccount(username) @@ -487,6 +491,10 @@ private fun Routing.coreBankTransactionsApi(db: Database, ctx: BankConfig) { "Insufficient funds", TalerErrorCode.BANK_UNALLOWED_DEBIT ) + BankTransactionResult.BadAmount -> throw conflict( + "Amount either to high or too low", + TalerErrorCode.BANK_UNALLOWED_DEBIT + ) BankTransactionResult.RequestUidReuse -> throw conflict( "request_uid used already", TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED @@ -510,7 +518,9 @@ private fun Routing.coreBankWithdrawalApi(db: Database, ctx: BankConfig) { req.amount, req.suggested_amount, Instant.now(), - ctx.wireTransferFees + ctx.wireTransferFees, + ctx.minAmount, + ctx.maxAmount )) { WithdrawalCreationResult.UnknownAccount -> throw unknownAccount(username) WithdrawalCreationResult.AccountIsExchange -> throw conflict( @@ -521,6 +531,10 @@ private fun Routing.coreBankWithdrawalApi(db: Database, ctx: BankConfig) { "Insufficient funds to withdraw with Taler", TalerErrorCode.BANK_UNALLOWED_DEBIT ) + WithdrawalCreationResult.BadAmount -> throw conflict( + "Amount either to high or too low", + TalerErrorCode.BANK_UNALLOWED_DEBIT + ) WithdrawalCreationResult.Success -> { call.respond( BankAccountCreateWithdrawalResponse( @@ -534,7 +548,15 @@ 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) - when (db.withdrawal.confirm(username, id, ctx.wireTransferFees, Instant.now(), challenge != null)) { + when (db.withdrawal.confirm( + username, + id, + Instant.now(), + challenge != null, + ctx.wireTransferFees, + ctx.minAmount, + ctx.maxAmount + )) { WithdrawalConfirmationResult.UnknownOperation -> throw notFound( "Withdrawal operation $id not found", TalerErrorCode.BANK_TRANSACTION_NOT_FOUND @@ -551,6 +573,10 @@ private fun Routing.coreBankWithdrawalApi(db: Database, ctx: BankConfig) { "Insufficient funds", TalerErrorCode.BANK_UNALLOWED_DEBIT ) + WithdrawalConfirmationResult.BadAmount -> throw conflict( + "Amount either to high or too low", + TalerErrorCode.BANK_UNALLOWED_DEBIT + ) WithdrawalConfirmationResult.UnknownExchange -> throw conflict( "Exchange to withdraw from not found", TalerErrorCode.BANK_UNKNOWN_CREDITOR diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt @@ -163,7 +163,7 @@ class AccountDAO(private val db: Database) { if (bonus.value != 0L || bonus.frac != 0) { conn.withStatement(""" SELECT out_balance_insufficient - FROM bank_transaction(?,'admin','bonus',(?,?)::taler_amount,?,true,NULL,(0, 0)::taler_amount) + FROM bank_transaction(?,'admin','bonus',(?,?)::taler_amount,?,true,NULL,NULL,NULL,NULL) """) { setString(1, internalPayto.canonical) setLong(2, bonus.value) diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/TransactionDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/TransactionDAO.kt @@ -38,6 +38,7 @@ class TransactionDAO(private val db: Database) { data object UnknownDebtor: BankTransactionResult data object BothPartySame: BankTransactionResult data object BalanceInsufficient: BankTransactionResult + data object BadAmount: BankTransactionResult data object TanRequired: BankTransactionResult data object RequestUidReuse: BankTransactionResult } @@ -51,7 +52,9 @@ class TransactionDAO(private val db: Database) { timestamp: Instant, is2fa: Boolean, requestUid: ShortHashCode?, - wireTransferFees: TalerAmount + wireTransferFees: TalerAmount, + minAmount: TalerAmount, + maxAmount: TalerAmount ): BankTransactionResult = db.serializableTransaction { conn -> val timestamp = timestamp.micros() conn.withStatement(""" @@ -60,6 +63,7 @@ class TransactionDAO(private val db: Database) { ,out_debtor_not_found ,out_same_account ,out_balance_insufficient + ,out_bad_amount ,out_request_uid_reuse ,out_tan_required ,out_credit_bank_account_id @@ -70,7 +74,7 @@ class TransactionDAO(private val db: Database) { ,out_debtor_is_exchange ,out_creditor_admin ,out_idempotent - FROM bank_transaction(?,?,?,(?,?)::taler_amount,?,?,?,(?,?)::taler_amount) + FROM bank_transaction(?,?,?,(?,?)::taler_amount,?,?,?,(?,?)::taler_amount,(?,?)::taler_amount,(?,?)::taler_amount) """ ) { setString(1, creditAccountPayto.canonical) @@ -83,12 +87,17 @@ class TransactionDAO(private val db: Database) { setBytes(8, requestUid?.raw) setLong(9, wireTransferFees.value) setInt(10, wireTransferFees.frac) + setLong(11, minAmount.value) + setInt(12, minAmount.frac) + setLong(13, maxAmount.value) + setInt(14, maxAmount.frac) one { when { it.getBoolean("out_creditor_not_found") -> BankTransactionResult.UnknownCreditor it.getBoolean("out_debtor_not_found") -> BankTransactionResult.UnknownDebtor it.getBoolean("out_same_account") -> BankTransactionResult.BothPartySame it.getBoolean("out_balance_insufficient") -> BankTransactionResult.BalanceInsufficient + it.getBoolean("out_bad_amount") -> BankTransactionResult.BadAmount it.getBoolean("out_creditor_admin") -> BankTransactionResult.AdminCreditor it.getBoolean("out_request_uid_reuse") -> BankTransactionResult.RequestUidReuse it.getBoolean("out_idempotent") -> BankTransactionResult.Success(it.getLong("out_debit_row_id")) @@ -126,7 +135,7 @@ class TransactionDAO(private val db: Database) { // No error can happens because an opposite transaction already took place in the same transaction conn.withStatement(""" SELECT bank_wire_transfer( - ?, ?, ?, (?, ?)::taler_amount, ?, (0, 0)::taler_amount + ?, ?, ?, (?, ?)::taler_amount, ?, NULL,NULL,NULL ); """) { setLong(1, debitAccountId) diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/WithdrawalDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/WithdrawalDAO.kt @@ -36,7 +36,8 @@ class WithdrawalDAO(private val db: Database) { Success, UnknownAccount, AccountIsExchange, - BalanceInsufficient + BalanceInsufficient, + BadAmount } /** Create a new withdrawal operation */ @@ -47,17 +48,20 @@ class WithdrawalDAO(private val db: Database) { suggested_amount: TalerAmount?, timestamp: Instant, wireTransferFees: TalerAmount, + minAmount: TalerAmount, + maxAmount: TalerAmount ): WithdrawalCreationResult = db.serializableWrite( """ SELECT out_account_not_found, out_account_is_exchange, - out_balance_insufficient + out_balance_insufficient, + out_bad_amount FROM create_taler_withdrawal( ?,?, ${if (amount != null) "(?,?)::taler_amount" else "NULL"}, ${if (suggested_amount != null) "(?,?)::taler_amount" else "NULL"}, - ?, (?, ?)::taler_amount + ?, (?, ?)::taler_amount, (?, ?)::taler_amount, (?, ?)::taler_amount ); """ ) { @@ -77,11 +81,16 @@ class WithdrawalDAO(private val db: Database) { setLong(id, timestamp.micros()) setLong(id+1, wireTransferFees.value) setInt(id+2, wireTransferFees.frac) + setLong(id+3, minAmount.value) + setInt(id+4, minAmount.frac) + setLong(id+5, maxAmount.value) + setInt(id+6, maxAmount.frac) one { when { it.getBoolean("out_account_not_found") -> WithdrawalCreationResult.UnknownAccount it.getBoolean("out_account_is_exchange") -> WithdrawalCreationResult.AccountIsExchange it.getBoolean("out_balance_insufficient") -> WithdrawalCreationResult.BalanceInsufficient + it.getBoolean("out_bad_amount") -> WithdrawalCreationResult.BadAmount else -> WithdrawalCreationResult.Success } } @@ -117,6 +126,7 @@ class WithdrawalDAO(private val db: Database) { data object MissingAmount: WithdrawalSelectionResult data object AmountDiffers: WithdrawalSelectionResult data object BalanceInsufficient: WithdrawalSelectionResult + data object BadAmount: WithdrawalSelectionResult } /** Set details ([exchangePayto] & [reservePub] & [amount]) for withdrawal operation [uuid] */ @@ -126,6 +136,8 @@ class WithdrawalDAO(private val db: Database) { reservePub: EddsaPublicKey, amount: TalerAmount?, wireTransferFees: TalerAmount, + minAmount: TalerAmount, + maxAmount: TalerAmount ): WithdrawalSelectionResult = db.serializableWrite( """ SELECT @@ -137,11 +149,12 @@ class WithdrawalDAO(private val db: Database) { out_status, out_missing_amount, out_amount_differs, - out_balance_insufficient + out_balance_insufficient, + out_bad_amount FROM select_taler_withdrawal( ?, ?, ?, ?, ${if (amount != null) "(?, ?)::taler_amount" else "NULL"}, - (?,?)::taler_amount + (?,?)::taler_amount, (?,?)::taler_amount, (?,?)::taler_amount ); """ ) { @@ -157,9 +170,14 @@ class WithdrawalDAO(private val db: Database) { } setLong(id, wireTransferFees.value) setInt(id+1, wireTransferFees.frac) + setLong(id+2, minAmount.value) + setInt(id+3, minAmount.frac) + setLong(id+4, maxAmount.value) + setInt(id+5, maxAmount.frac) one { when { it.getBoolean("out_balance_insufficient") -> WithdrawalSelectionResult.BalanceInsufficient + 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 @@ -178,6 +196,7 @@ class WithdrawalDAO(private val db: Database) { UnknownOperation, UnknownExchange, BalanceInsufficient, + BadAmount, NotSelected, AlreadyAborted, TanRequired @@ -187,19 +206,22 @@ class WithdrawalDAO(private val db: Database) { suspend fun confirm( login: String, uuid: UUID, - wireTransferFees: TalerAmount, timestamp: Instant, - is2fa: Boolean + is2fa: Boolean, + wireTransferFees: TalerAmount, + minAmount: TalerAmount, + maxAmount: TalerAmount ): WithdrawalConfirmationResult = db.serializableWrite( """ SELECT out_no_op, out_exchange_not_found, out_balance_insufficient, + out_bad_amount, out_not_selected, out_aborted, out_tan_required - FROM confirm_taler_withdrawal(?,?,?,?,(?,?)::taler_amount); + FROM confirm_taler_withdrawal(?,?,?,?,(?,?)::taler_amount,(?,?)::taler_amount,(?,?)::taler_amount); """ ) { setString(1, login) @@ -208,11 +230,16 @@ class WithdrawalDAO(private val db: Database) { setBoolean(4, is2fa) setLong(5, wireTransferFees.value) setInt(6, wireTransferFees.frac) + setLong(7, minAmount.value) + setInt(8, minAmount.frac) + setLong(9, maxAmount.value) + setInt(10, maxAmount.frac) one { when { it.getBoolean("out_no_op") -> WithdrawalConfirmationResult.UnknownOperation it.getBoolean("out_exchange_not_found") -> WithdrawalConfirmationResult.UnknownExchange it.getBoolean("out_balance_insufficient") -> WithdrawalConfirmationResult.BalanceInsufficient + it.getBoolean("out_bad_amount") -> WithdrawalConfirmationResult.BadAmount it.getBoolean("out_not_selected") -> WithdrawalConfirmationResult.NotSelected it.getBoolean("out_aborted") -> WithdrawalConfirmationResult.AlreadyAborted it.getBoolean("out_tan_required") -> WithdrawalConfirmationResult.TanRequired @@ -342,8 +369,6 @@ class WithdrawalDAO(private val db: Database) { status = WithdrawalStatus.valueOf(it.getString("status")), amount = it.getOptAmount("amount", db.bankCurrency), suggested_amount = it.getOptAmount("suggested_amount", db.bankCurrency), - max_amount = null, - card_fees = null, selection_done = it.getBoolean("selection_done"), transfer_done = it.getBoolean("confirmation_done"), aborted = it.getBoolean("aborted"), diff --git a/bank/src/test/kotlin/AmountTest.kt b/bank/src/test/kotlin/AmountTest.kt @@ -55,7 +55,9 @@ class AmountTest { timestamp = Instant.now(), is2fa = false, requestUid = null, - wireTransferFees = TalerAmount.zero("KUDOS") + wireTransferFees = TalerAmount.zero("KUDOS"), + minAmount = TalerAmount.zero("KUDOS"), + maxAmount = TalerAmount.max("KUDOS") ) val txBool = when (txRes) { BankTransactionResult.BalanceInsufficient -> false @@ -72,7 +74,9 @@ class AmountTest { amount = due, suggested_amount = null, timestamp = Instant.now(), - wireTransferFees = TalerAmount.zero("KUDOS") + wireTransferFees = TalerAmount.zero("KUDOS"), + minAmount = TalerAmount.zero("KUDOS"), + maxAmount = TalerAmount.max("KUDOS") ) val wBool = when (wRes) { WithdrawalCreationResult.BalanceInsufficient -> false diff --git a/bank/src/test/kotlin/BankIntegrationApiTest.kt b/bank/src/test/kotlin/BankIntegrationApiTest.kt @@ -59,6 +59,8 @@ class BankIntegrationApiTest { assert(!it.aborted) assert(!it.transfer_done) assertEquals(it.card_fees, TalerAmount.zero("KUDOS")) + assertEquals(it.min_amount, TalerAmount.zero("KUDOS")) + assertEquals(it.max_amount, TalerAmount.max("KUDOS")) assertEquals(amount, it.amount) assertEquals(suggested, it.suggested_amount) assertEquals(listOf("iban"), it.wire_types) @@ -203,6 +205,32 @@ class BankIntegrationApiTest { } } + @Test + fun selectWithFee() = bankSetup(conf = "test_with_fees.conf") { + val uuid = client.postA("/accounts/merchant/withdrawals") { + json {} + }.assertOkJson<BankAccountCreateWithdrawalResponse>().withdrawal_id + // Check insufficient fund + for (amount in listOf("KUDOS:11", "KUDOS:10", "KUDOS:0", "KUDOS:150")) { + client.post("/taler-integration/withdrawal-operation/$uuid") { + json { + "reserve_pub" to EddsaPublicKey.rand() + "selected_exchange" to exchangePayto.canonical + "amount" to amount + } + }.assertConflict(TalerErrorCode.BANK_UNALLOWED_DEBIT) + } + + // Check OK + client.post("/taler-integration/withdrawal-operation/$uuid") { + json { + "reserve_pub" to EddsaPublicKey.rand() + "selected_exchange" to exchangePayto.canonical + "amount" to "KUDOS:9" + } + }.assertOk() + } + // POST /taler-integration/withdrawal-operation/UUID/abort @Test fun abort() = bankSetup { diff --git a/bank/src/test/kotlin/CoreBankApiTest.kt b/bank/src/test/kotlin/CoreBankApiTest.kt @@ -1221,15 +1221,14 @@ class CoreBankTransactionsApiTest { assertBalance("customer", "+KUDOS:3") assertBalance("admin", "+KUDOS:0.1") - // Check amount with fee are checked - for (amount in listOf("KUDOS:7", "KUDOS:6.9")) { + // Check amount with fee and min & max are checked + for (amount in listOf("KUDOS:7", "KUDOS:6.9", "KUDOS:0", "KUDOS:150")) { client.postA("/accounts/merchant/transactions") { json { "payto_uri" to "$customerPayto?message=payout2&amount=$amount" } }.assertConflict(TalerErrorCode.BANK_UNALLOWED_DEBIT) } - // Check empty account tx("merchant", "KUDOS:6.8", "customer") assertBalance("merchant", "-KUDOS:10") @@ -1300,7 +1299,7 @@ class CoreBankWithdrawalApiTest { @Test fun createWithFee() = bankSetup(conf = "test_with_fees.conf") { // Check insufficient fund - for (amount in listOf("KUDOS:11", "KUDOS:10")) { + for (amount in listOf("KUDOS:11", "KUDOS:10", "KUDOS:0", "KUDOS:150")) { for (name in listOf("amount", "suggested_amount")) { client.postA("/accounts/merchant/withdrawals") { json { name to amount } diff --git a/bank/src/test/kotlin/GcTest.kt b/bank/src/test/kotlin/GcTest.kt @@ -48,6 +48,9 @@ class GcTest { fun assertNbIncoming(nb: Int) = assertNb(nb, "SELECT count(*) from taler_exchange_incoming") fun assertNbOutgoing(nb: Int) = assertNb(nb, "SELECT count(*) from taler_exchange_outgoing") + val ZERO = TalerAmount.zero("KUDOS") + val MAX = TalerAmount.max("KUDOS") + // Time calculation val abortAfter = Duration.ofMinutes(15) val cleanAfter = Duration.ofDays(14) @@ -98,26 +101,26 @@ class GcTest { for (time in times) { val uuid = UUID.randomUUID() assertEquals( - db.withdrawal.create(account, uuid, from, null, time, TalerAmount.zero("KUDOS")), + db.withdrawal.create(account, uuid, from, null, time, ZERO, ZERO, MAX), WithdrawalCreationResult.Success ) assertIs<WithdrawalSelectionResult.Success>( - db.withdrawal.setDetails(uuid, exchangePayto, EddsaPublicKey.rand(), null, TalerAmount.zero("KUDOS")) + db.withdrawal.setDetails(uuid, exchangePayto, EddsaPublicKey.rand(), null, ZERO, ZERO, MAX) ) assertEquals( - db.withdrawal.confirm(account, uuid, TalerAmount.zero("KUDOS"), time, false), + db.withdrawal.confirm(account, uuid, time, false, ZERO, ZERO, MAX), WithdrawalConfirmationResult.Success ) assertIs<CashoutCreationResult.Success>( db.cashout.create(account, ShortHashCode.rand(), from, to, "", time, false), ) assertIs<BankTransactionResult.Success>( - db.transaction.create(customerPayto, account, "", from, time, false, ShortHashCode.rand(), TalerAmount.zero("KUDOS")), + db.transaction.create(customerPayto, account, "", from, time, false, ShortHashCode.rand(), ZERO, ZERO, MAX), ) } for (time in listOf(now, abort, clean, delete)) { assertEquals( - db.withdrawal.create(account, UUID.randomUUID(), from, null, time, TalerAmount.zero("KUDOS")), + db.withdrawal.create(account, UUID.randomUUID(), from, null, time, ZERO, ZERO, MAX), WithdrawalCreationResult.Success ) } diff --git a/contrib/bank.conf b/contrib/bank.conf @@ -18,8 +18,14 @@ WIRE_TYPE = # Bank display name, used in webui and TAN messages. Default is "Taler Bank" # NAME = "Custom Bank" -# Wire transfer execution fees. -# WIRE_TRANSFER_FEES = KUDOS:10 +# Wire transfer execution fees. Only applies to bank transactions and withdrawals. +# WIRE_TRANSFER_FEES = KUDOS:0 + +# Minimum wire transfer amount allowed. Only applies to bank transactions and withdrawals. +# MIN_WIRE_TRANSFER_AMOUNT = KUDOS:0 + +# Maximum wire transfer amount allowed. Only applies to bank transactions and withdrawals. +# MAX_WIRE_TRANSFER_AMOUNT = KUDOS:0 # Default debt limit for newly created accounts. Default is CURRENCY:0 # DEFAULT_DEBT_LIMIT = KUDOS:200 diff --git a/database-versioning/libeufin-bank-procedures.sql b/database-versioning/libeufin-bank-procedures.sql @@ -88,7 +88,10 @@ CREATE FUNCTION account_balance_is_sufficient( IN in_account_id INT8, IN in_amount taler_amount, IN in_wire_transfer_fees taler_amount, - OUT out_balance_insufficient BOOLEAN + IN in_min_amount taler_amount, + IN in_max_amount taler_amount, + OUT out_balance_insufficient BOOLEAN, + OUT out_bad_amount BOOLEAN ) LANGUAGE plpgsql AS $$ DECLARE @@ -97,10 +100,23 @@ account_balance taler_amount; account_max_debt taler_amount; amount_with_fee taler_amount; BEGIN + +-- Check min and max +SELECT (SELECT in_min_amount IS NOT NULL AND NOT ok FROM amount_left_minus_right(in_amount, in_min_amount)) OR + (SELECT in_max_amount IS NOT NULL AND NOT ok FROM amount_left_minus_right(in_max_amount, in_amount)) + INTO out_bad_amount; +IF out_bad_amount THEN + RETURN; +END IF; + -- Add fees to the amount -SELECT sum.val, sum.frac - INTO amount_with_fee.val, amount_with_fee.frac - FROM amount_add(in_amount, in_wire_transfer_fees) as sum; +IF in_wire_transfer_fees IS NOT NULL AND in_wire_transfer_fees != (0, 0)::taler_amount THEN + SELECT sum.val, sum.frac + INTO amount_with_fee.val, amount_with_fee.frac + FROM amount_add(in_amount, in_wire_transfer_fees) as sum; +ELSE + amount_with_fee = in_amount; +END IF; -- Get account info, we expect the account to exist SELECT @@ -147,208 +163,521 @@ END IF; END $$; COMMENT ON FUNCTION account_balance_is_sufficient IS 'Check if an account have enough fund to transfer an amount.'; -CREATE FUNCTION account_delete( - IN in_login TEXT, - IN in_timestamp INT8, - IN in_is_tan BOOLEAN, - OUT out_not_found BOOLEAN, - OUT out_balance_not_zero BOOLEAN, - OUT out_tan_required BOOLEAN -) -LANGUAGE plpgsql AS $$ -DECLARE -my_customer_id INT8; -BEGIN --- check if account exists, has zero balance and if 2FA is required -SELECT - customer_id - ,(NOT in_is_tan AND tan_channel IS NOT NULL) - ,((balance).val != 0 OR (balance).frac != 0) - INTO - my_customer_id - ,out_tan_required - ,out_balance_not_zero - FROM customers - JOIN bank_accounts ON owning_customer_id = customer_id - WHERE login = in_login AND deleted_at IS NULL; -IF NOT FOUND OR out_balance_not_zero OR out_tan_required THEN - out_not_found=NOT FOUND; - RETURN; -END IF; - --- actual deletion -UPDATE customers SET deleted_at = in_timestamp WHERE customer_id = my_customer_id; -END $$; -COMMENT ON FUNCTION account_delete IS 'Deletes an account if the balance is zero'; -CREATE PROCEDURE register_outgoing( - IN in_request_uid BYTEA, - IN in_wtid BYTEA, - IN in_exchange_base_url TEXT, - IN in_debtor_account_id INT8, +CREATE FUNCTION bank_wire_transfer( IN in_creditor_account_id INT8, - IN in_debit_row_id INT8, - IN in_credit_row_id INT8 -) -LANGUAGE plpgsql AS $$ -DECLARE - local_amount taler_amount; - local_bank_account_id INT8; -BEGIN --- register outgoing transaction -INSERT - INTO taler_exchange_outgoing ( - request_uid, - wtid, - exchange_base_url, - bank_transaction, - creditor_account_id -) VALUES ( - in_request_uid, - in_wtid, - in_exchange_base_url, - in_debit_row_id, - in_creditor_account_id -); --- TODO check if not drain --- update stats -SELECT (amount).val, (amount).frac, bank_account_id -INTO local_amount.val, local_amount.frac, local_bank_account_id -FROM bank_account_transactions WHERE bank_transaction_id=in_debit_row_id; -CALL stats_register_payment('taler_out', NULL, local_amount, null); --- notify new transaction -PERFORM pg_notify('outgoing_tx', in_debtor_account_id || ' ' || in_creditor_account_id || ' ' || in_debit_row_id || ' ' || in_credit_row_id); -END $$; -COMMENT ON PROCEDURE register_outgoing - IS 'Register a bank transaction as a taler outgoing transaction and announce it'; - -CREATE PROCEDURE register_incoming( - IN in_reserve_pub BYTEA, - IN in_tx_row_id INT8 -) -LANGUAGE plpgsql AS $$ -DECLARE -local_amount taler_amount; -local_bank_account_id INT8; -BEGIN --- Register incoming transaction -INSERT - INTO taler_exchange_incoming ( - reserve_pub, - bank_transaction -) VALUES ( - in_reserve_pub, - in_tx_row_id -); --- update stats -SELECT (amount).val, (amount).frac, bank_account_id -INTO local_amount.val, local_amount.frac, local_bank_account_id -FROM bank_account_transactions WHERE bank_transaction_id=in_tx_row_id; -CALL stats_register_payment('taler_in', NULL, local_amount, null); --- notify new transaction -PERFORM pg_notify('incoming_tx', local_bank_account_id || ' ' || in_tx_row_id); -END $$; -COMMENT ON PROCEDURE register_incoming - IS 'Register a bank transaction as a taler incoming transaction and announce it'; - - -CREATE FUNCTION taler_transfer( - IN in_request_uid BYTEA, - IN in_wtid BYTEA, + IN in_debtor_account_id INT8, IN in_subject TEXT, IN in_amount taler_amount, - IN in_exchange_base_url TEXT, - IN in_credit_account_payto TEXT, - IN in_username TEXT, IN in_timestamp INT8, + IN in_wire_transfer_fees taler_amount, + IN in_min_amount taler_amount, + IN in_max_amount taler_amount, -- Error status - OUT out_debtor_not_found BOOLEAN, - OUT out_debtor_not_exchange BOOLEAN, - OUT out_creditor_not_found BOOLEAN, - OUT out_both_exchanges BOOLEAN, - OUT out_request_uid_reuse BOOLEAN, - OUT out_exchange_balance_insufficient BOOLEAN, + OUT out_balance_insufficient BOOLEAN, + OUT out_bad_amount BOOLEAN, -- Success return - OUT out_tx_row_id INT8, - OUT out_timestamp INT8 + OUT out_credit_row_id INT8, + OUT out_debit_row_id INT8 ) LANGUAGE plpgsql AS $$ DECLARE -exchange_bank_account_id INT8; -receiver_bank_account_id INT8; -credit_row_id INT8; +amount_with_fee taler_amount; +admin_account_id INT8; +admin_has_debt BOOLEAN; +admin_balance taler_amount; +admin_payto TEXT; +admin_name TEXT; +debtor_has_debt BOOLEAN; +debtor_balance taler_amount; +debtor_max_debt taler_amount; +debtor_payto TEXT; +debtor_name TEXT; +creditor_has_debt BOOLEAN; +creditor_balance taler_amount; +creditor_payto TEXT; +creditor_name TEXT; +tmp_balance taler_amount; BEGIN --- Check for idempotence and conflict -SELECT (amount != in_amount - OR creditor_payto_uri != in_credit_account_payto - OR exchange_base_url != in_exchange_base_url - OR wtid != in_wtid) - ,bank_transaction_id, transaction_date - INTO out_request_uid_reuse, out_tx_row_id, out_timestamp - FROM taler_exchange_outgoing - JOIN bank_account_transactions AS txs - ON bank_transaction=txs.bank_transaction_id - WHERE request_uid = in_request_uid; -IF found THEN + +-- Check min and max +SELECT (SELECT in_min_amount IS NOT NULL AND NOT ok FROM amount_left_minus_right(in_amount, in_min_amount)) OR + (SELECT in_max_amount IS NOT NULL AND NOT ok FROM amount_left_minus_right(in_max_amount, in_amount)) + INTO out_bad_amount; +IF out_bad_amount THEN RETURN; END IF; --- Find exchange bank account id + +-- Retrieve debtor info SELECT - bank_account_id, NOT is_taler_exchange - INTO exchange_bank_account_id, out_debtor_not_exchange - FROM bank_accounts - JOIN customers - ON customer_id=owning_customer_id - WHERE login = in_username AND deleted_at IS NULL; -IF NOT FOUND OR out_debtor_not_exchange THEN - out_debtor_not_found=NOT FOUND; - RETURN; + has_debt, + (balance).val, (balance).frac, + (max_debt).val, (max_debt).frac, + internal_payto_uri, customers.name + INTO + debtor_has_debt, + debtor_balance.val, debtor_balance.frac, + debtor_max_debt.val, debtor_max_debt.frac, + debtor_payto, debtor_name + FROM bank_accounts + JOIN customers ON customer_id=owning_customer_id + WHERE bank_account_id=in_debtor_account_id; +IF NOT FOUND THEN + RAISE EXCEPTION 'Unknown debtor %', in_debtor_account_id; END IF; --- Find receiver bank account id +-- Retrieve creditor info SELECT - bank_account_id, is_taler_exchange - INTO receiver_bank_account_id, out_both_exchanges + has_debt, + (balance).val, (balance).frac, + internal_payto_uri, customers.name + INTO + creditor_has_debt, + creditor_balance.val, creditor_balance.frac, + creditor_payto, creditor_name FROM bank_accounts - WHERE internal_payto_uri = in_credit_account_payto; -IF NOT FOUND OR out_both_exchanges THEN - out_creditor_not_found=NOT FOUND; - RETURN; + JOIN customers ON customer_id=owning_customer_id + WHERE bank_account_id=in_creditor_account_id; +IF NOT FOUND THEN + RAISE EXCEPTION 'Unknown creditor %', in_creditor_account_id; END IF; --- Perform bank transfer +-- Retrieve admin info SELECT - out_balance_insufficient, - out_debit_row_id, out_credit_row_id - INTO - out_exchange_balance_insufficient, - out_tx_row_id, credit_row_id - FROM bank_wire_transfer( - receiver_bank_account_id, - exchange_bank_account_id, - in_subject, - in_amount, - in_timestamp, - (0, 0)::taler_amount - ) as transfer; -IF out_exchange_balance_insufficient THEN - RETURN; + bank_account_id, has_debt, + (balance).val, (balance).frac, + internal_payto_uri, customers.name + INTO + admin_account_id, admin_has_debt, + admin_balance.val, admin_balance.frac, + admin_payto, admin_name + FROM bank_accounts + JOIN customers ON customer_id=owning_customer_id + WHERE login = 'admin'; +IF NOT FOUND THEN + RAISE EXCEPTION 'No admin'; END IF; -out_timestamp=in_timestamp; --- Register outgoing transaction -CALL register_outgoing(in_request_uid, in_wtid, in_exchange_base_url, exchange_bank_account_id, receiver_bank_account_id, out_tx_row_id, credit_row_id); -END $$; -COMMENT ON FUNCTION taler_transfer IS 'Create an outgoing taler transaction and register it'; -CREATE FUNCTION taler_add_incoming( - IN in_reserve_pub BYTEA, - IN in_subject TEXT, - IN in_amount taler_amount, - IN in_debit_account_payto TEXT, - IN in_username TEXT, - IN in_timestamp INT8, - -- Error status - OUT out_creditor_not_found BOOLEAN, - OUT out_creditor_not_exchange BOOLEAN, - OUT out_debtor_not_found BOOLEAN, +-- Add fees to the amount +IF in_wire_transfer_fees IS NOT NULL AND in_wire_transfer_fees != (0, 0)::taler_amount AND admin_account_id != in_debtor_account_id THEN + SELECT sum.val, sum.frac + INTO amount_with_fee.val, amount_with_fee.frac + FROM amount_add(in_amount, in_wire_transfer_fees) as sum; +ELSE + amount_with_fee = in_amount; +END IF; + +-- DEBTOR SIDE +-- check debtor has enough funds. +IF debtor_has_debt THEN + -- debt case: simply checking against the max debt allowed. + SELECT sum.val, sum.frac + INTO debtor_balance.val, debtor_balance.frac + FROM amount_add(debtor_balance, amount_with_fee) as sum; + SELECT NOT ok + INTO out_balance_insufficient + FROM amount_left_minus_right(debtor_max_debt, + debtor_balance); + IF out_balance_insufficient THEN + RETURN; + END IF; +ELSE -- not a debt account + SELECT + NOT ok, + (diff).val, (diff).frac + INTO + out_balance_insufficient, + tmp_balance.val, + tmp_balance.frac + FROM amount_left_minus_right(debtor_balance, + amount_with_fee); + IF NOT out_balance_insufficient THEN -- debtor has enough funds in the (positive) balance. + debtor_balance=tmp_balance; + ELSE -- debtor will switch to debt: determine their new negative balance. + SELECT + (diff).val, (diff).frac + INTO + debtor_balance.val, debtor_balance.frac + FROM amount_left_minus_right(amount_with_fee, + debtor_balance); + debtor_has_debt=TRUE; + SELECT NOT ok + INTO out_balance_insufficient + FROM amount_left_minus_right(debtor_max_debt, + debtor_balance); + IF out_balance_insufficient THEN + RETURN; + END IF; + END IF; +END IF; + +-- CREDITOR SIDE. +-- Here we figure out whether the creditor would switch +-- from debit to a credit situation, and adjust the balance +-- accordingly. +IF NOT creditor_has_debt THEN -- easy case. + SELECT sum.val, sum.frac + INTO creditor_balance.val, creditor_balance.frac + FROM amount_add(creditor_balance, in_amount) as sum; +ELSE -- creditor had debit but MIGHT switch to credit. + SELECT + (diff).val, (diff).frac, + NOT ok + INTO + tmp_balance.val, tmp_balance.frac, + creditor_has_debt + FROM amount_left_minus_right(in_amount, + creditor_balance); + IF NOT creditor_has_debt THEN + creditor_balance=tmp_balance; + ELSE + -- the amount is not enough to bring the receiver + -- to a credit state, switch operators to calculate the new balance. + SELECT + (diff).val, (diff).frac + INTO creditor_balance.val, creditor_balance.frac + FROM amount_left_minus_right(creditor_balance, + in_amount); + END IF; +END IF; + +-- ADMIN SIDE. +-- Here we figure out whether the administrator would switch +-- from debit to a credit situation, and adjust the balance +-- accordingly. +IF amount_with_fee != in_amount THEN + IF NOT admin_has_debt THEN -- easy case. + SELECT sum.val, sum.frac + INTO admin_balance.val, admin_balance.frac + FROM amount_add(admin_balance, in_wire_transfer_fees) as sum; + ELSE -- creditor had debit but MIGHT switch to credit. + SELECT (diff).val, (diff).frac, NOT ok + INTO + tmp_balance.val, tmp_balance.frac, + admin_has_debt + FROM amount_left_minus_right(in_wire_transfer_fees, admin_balance); + IF NOT admin_has_debt THEN + admin_balance=tmp_balance; + ELSE + -- the amount is not enough to bring the receiver + -- to a credit state, switch operators to calculate the new balance. + SELECT (diff).val, (diff).frac + INTO admin_balance.val, admin_balance.frac + FROM amount_left_minus_right(admin_balance, in_wire_transfer_fees); + END IF; + END IF; +END IF; + +-- now actually create the bank transaction. +-- debtor side: +INSERT INTO bank_account_transactions ( + creditor_payto_uri + ,creditor_name + ,debtor_payto_uri + ,debtor_name + ,subject + ,amount + ,transaction_date + ,direction + ,bank_account_id + ) +VALUES ( + creditor_payto, + creditor_name, + debtor_payto, + debtor_name, + in_subject, + in_amount, + in_timestamp, + 'debit', + in_debtor_account_id +) RETURNING bank_transaction_id INTO out_debit_row_id; + +-- debtor side: +INSERT INTO bank_account_transactions ( + creditor_payto_uri + ,creditor_name + ,debtor_payto_uri + ,debtor_name + ,subject + ,amount + ,transaction_date + ,direction + ,bank_account_id + ) +VALUES ( + creditor_payto, + creditor_name, + debtor_payto, + debtor_name, + in_subject, + in_amount, + in_timestamp, + 'credit', + in_creditor_account_id +) RETURNING bank_transaction_id INTO out_credit_row_id; + +-- checks and balances set up, now update bank accounts. +UPDATE bank_accounts +SET + balance=debtor_balance, + has_debt=debtor_has_debt +WHERE bank_account_id=in_debtor_account_id; + +UPDATE bank_accounts +SET + balance=creditor_balance, + has_debt=creditor_has_debt +WHERE bank_account_id=in_creditor_account_id; + +-- Fee part +IF amount_with_fee != in_amount THEN + INSERT INTO bank_account_transactions ( + creditor_payto_uri + ,creditor_name + ,debtor_payto_uri + ,debtor_name + ,subject + ,amount + ,transaction_date + ,direction + ,bank_account_id + ) + VALUES ( + admin_payto, + admin_name, + debtor_payto, + debtor_name, + 'wire transfer fees for tx ' || out_debit_row_id, + in_wire_transfer_fees, + in_timestamp, + 'debit', + in_debtor_account_id + ), ( + admin_payto, + admin_name, + debtor_payto, + debtor_name, + 'wire transfer fees for tx ' || out_debit_row_id, + in_amount, + in_timestamp, + 'credit', + admin_account_id + ); + + UPDATE bank_accounts + SET + balance=admin_balance, + has_debt=admin_has_debt + WHERE bank_account_id=admin_account_id; +END IF; + +-- notify new transaction +PERFORM pg_notify('bank_tx', in_debtor_account_id || ' ' || in_creditor_account_id || ' ' || out_debit_row_id || ' ' || out_credit_row_id); +END $$; + +CREATE FUNCTION account_delete( + IN in_login TEXT, + IN in_timestamp INT8, + IN in_is_tan BOOLEAN, + OUT out_not_found BOOLEAN, + OUT out_balance_not_zero BOOLEAN, + OUT out_tan_required BOOLEAN +) +LANGUAGE plpgsql AS $$ +DECLARE +my_customer_id INT8; +BEGIN +-- check if account exists, has zero balance and if 2FA is required +SELECT + customer_id + ,(NOT in_is_tan AND tan_channel IS NOT NULL) + ,((balance).val != 0 OR (balance).frac != 0) + INTO + my_customer_id + ,out_tan_required + ,out_balance_not_zero + FROM customers + JOIN bank_accounts ON owning_customer_id = customer_id + WHERE login = in_login AND deleted_at IS NULL; +IF NOT FOUND OR out_balance_not_zero OR out_tan_required THEN + out_not_found=NOT FOUND; + RETURN; +END IF; + +-- actual deletion +UPDATE customers SET deleted_at = in_timestamp WHERE customer_id = my_customer_id; +END $$; +COMMENT ON FUNCTION account_delete IS 'Deletes an account if the balance is zero'; + +CREATE PROCEDURE register_outgoing( + IN in_request_uid BYTEA, + IN in_wtid BYTEA, + IN in_exchange_base_url TEXT, + IN in_debtor_account_id INT8, + IN in_creditor_account_id INT8, + IN in_debit_row_id INT8, + IN in_credit_row_id INT8 +) +LANGUAGE plpgsql AS $$ +DECLARE + local_amount taler_amount; + local_bank_account_id INT8; +BEGIN +-- register outgoing transaction +INSERT + INTO taler_exchange_outgoing ( + request_uid, + wtid, + exchange_base_url, + bank_transaction, + creditor_account_id +) VALUES ( + in_request_uid, + in_wtid, + in_exchange_base_url, + in_debit_row_id, + in_creditor_account_id +); +-- TODO check if not drain +-- update stats +SELECT (amount).val, (amount).frac, bank_account_id +INTO local_amount.val, local_amount.frac, local_bank_account_id +FROM bank_account_transactions WHERE bank_transaction_id=in_debit_row_id; +CALL stats_register_payment('taler_out', NULL, local_amount, null); +-- notify new transaction +PERFORM pg_notify('outgoing_tx', in_debtor_account_id || ' ' || in_creditor_account_id || ' ' || in_debit_row_id || ' ' || in_credit_row_id); +END $$; +COMMENT ON PROCEDURE register_outgoing + IS 'Register a bank transaction as a taler outgoing transaction and announce it'; + +CREATE PROCEDURE register_incoming( + IN in_reserve_pub BYTEA, + IN in_tx_row_id INT8 +) +LANGUAGE plpgsql AS $$ +DECLARE +local_amount taler_amount; +local_bank_account_id INT8; +BEGIN +-- Register incoming transaction +INSERT + INTO taler_exchange_incoming ( + reserve_pub, + bank_transaction +) VALUES ( + in_reserve_pub, + in_tx_row_id +); +-- update stats +SELECT (amount).val, (amount).frac, bank_account_id +INTO local_amount.val, local_amount.frac, local_bank_account_id +FROM bank_account_transactions WHERE bank_transaction_id=in_tx_row_id; +CALL stats_register_payment('taler_in', NULL, local_amount, null); +-- notify new transaction +PERFORM pg_notify('incoming_tx', local_bank_account_id || ' ' || in_tx_row_id); +END $$; +COMMENT ON PROCEDURE register_incoming + IS 'Register a bank transaction as a taler incoming transaction and announce it'; + + +CREATE FUNCTION taler_transfer( + IN in_request_uid BYTEA, + IN in_wtid BYTEA, + IN in_subject TEXT, + IN in_amount taler_amount, + IN in_exchange_base_url TEXT, + IN in_credit_account_payto TEXT, + IN in_username TEXT, + IN in_timestamp INT8, + -- Error status + OUT out_debtor_not_found BOOLEAN, + OUT out_debtor_not_exchange BOOLEAN, + OUT out_creditor_not_found BOOLEAN, + OUT out_both_exchanges BOOLEAN, + OUT out_request_uid_reuse BOOLEAN, + OUT out_exchange_balance_insufficient BOOLEAN, + -- Success return + OUT out_tx_row_id INT8, + OUT out_timestamp INT8 +) +LANGUAGE plpgsql AS $$ +DECLARE +exchange_bank_account_id INT8; +receiver_bank_account_id INT8; +credit_row_id INT8; +BEGIN +-- Check for idempotence and conflict +SELECT (amount != in_amount + OR creditor_payto_uri != in_credit_account_payto + OR exchange_base_url != in_exchange_base_url + OR wtid != in_wtid) + ,bank_transaction_id, transaction_date + INTO out_request_uid_reuse, out_tx_row_id, out_timestamp + FROM taler_exchange_outgoing + JOIN bank_account_transactions AS txs + ON bank_transaction=txs.bank_transaction_id + WHERE request_uid = in_request_uid; +IF found THEN + RETURN; +END IF; +-- Find exchange bank account id +SELECT + bank_account_id, NOT is_taler_exchange + INTO exchange_bank_account_id, out_debtor_not_exchange + FROM bank_accounts + JOIN customers + ON customer_id=owning_customer_id + WHERE login = in_username AND deleted_at IS NULL; +IF NOT FOUND OR out_debtor_not_exchange THEN + out_debtor_not_found=NOT FOUND; + RETURN; +END IF; +-- Find receiver bank account id +SELECT + bank_account_id, is_taler_exchange + INTO receiver_bank_account_id, out_both_exchanges + FROM bank_accounts + WHERE internal_payto_uri = in_credit_account_payto; +IF NOT FOUND OR out_both_exchanges THEN + out_creditor_not_found=NOT FOUND; + RETURN; +END IF; +-- Perform bank transfer +SELECT + out_balance_insufficient, + out_debit_row_id, out_credit_row_id + INTO + out_exchange_balance_insufficient, + out_tx_row_id, credit_row_id + FROM bank_wire_transfer( + receiver_bank_account_id, + exchange_bank_account_id, + in_subject, + in_amount, + in_timestamp, + NULL, + NULL, + NULL + ) as transfer; +IF out_exchange_balance_insufficient THEN + RETURN; +END IF; +out_timestamp=in_timestamp; +-- Register outgoing transaction +CALL register_outgoing(in_request_uid, in_wtid, in_exchange_base_url, exchange_bank_account_id, receiver_bank_account_id, out_tx_row_id, credit_row_id); +END $$; +COMMENT ON FUNCTION taler_transfer IS 'Create an outgoing taler transaction and register it'; + +CREATE FUNCTION taler_add_incoming( + IN in_reserve_pub BYTEA, + IN in_subject TEXT, + IN in_amount taler_amount, + IN in_debit_account_payto TEXT, + IN in_username TEXT, + IN in_timestamp INT8, + -- Error status + OUT out_creditor_not_found BOOLEAN, + OUT out_creditor_not_exchange BOOLEAN, + OUT out_debtor_not_found BOOLEAN, OUT out_both_exchanges BOOLEAN, OUT out_reserve_pub_reuse BOOLEAN, OUT out_debitor_balance_insufficient BOOLEAN, @@ -403,7 +732,9 @@ SELECT in_subject, in_amount, in_timestamp, - (0, 0)::taler_amount + NULL, + NULL, + NULL ) as transfer; IF out_debitor_balance_insufficient THEN RETURN; @@ -422,6 +753,8 @@ CREATE FUNCTION bank_transaction( IN in_is_tan BOOLEAN, IN in_request_uid BYTEA, IN in_wire_transfer_fees taler_amount, + IN in_min_amount taler_amount, + IN in_max_amount taler_amount, -- Error status OUT out_creditor_not_found BOOLEAN, OUT out_debtor_not_found BOOLEAN, @@ -430,6 +763,7 @@ CREATE FUNCTION bank_transaction( OUT out_creditor_admin BOOLEAN, OUT out_tan_required BOOLEAN, OUT out_request_uid_reuse BOOLEAN, + OUT out_bad_amount BOOLEAN, -- Success return OUT out_credit_bank_account_id INT8, OUT out_debit_bank_account_id INT8, @@ -481,10 +815,12 @@ END IF; -- Perform bank transfer SELECT transfer.out_balance_insufficient, + transfer.out_bad_amount, transfer.out_credit_row_id, transfer.out_debit_row_id INTO out_balance_insufficient, + out_bad_amount, out_credit_row_id, out_debit_row_id FROM bank_wire_transfer( @@ -493,559 +829,290 @@ SELECT in_subject, in_amount, in_timestamp, - in_wire_transfer_fees - ) as transfer; -IF out_balance_insufficient THEN - RETURN; -END IF; - --- Store operation -IF in_request_uid IS NOT NULL THEN - INSERT INTO bank_transaction_operations (request_uid, bank_transaction) - VALUES (in_request_uid, out_debit_row_id); -END IF; -END $$; -COMMENT ON FUNCTION bank_transaction IS 'Create a bank transaction'; - -CREATE FUNCTION create_taler_withdrawal( - IN in_account_username TEXT, - IN in_withdrawal_uuid UUID, - IN in_amount taler_amount, - IN in_suggested_amount taler_amount, - IN in_timestamp INT8, - IN in_wire_transfer_fees taler_amount, - -- Error status - OUT out_account_not_found BOOLEAN, - OUT out_account_is_exchange BOOLEAN, - OUT out_balance_insufficient BOOLEAN -) -LANGUAGE plpgsql AS $$ -DECLARE -account_id INT8; -amount_with_fee taler_amount; -BEGIN --- Check account exists -SELECT bank_account_id, is_taler_exchange - INTO account_id, out_account_is_exchange - FROM bank_accounts - JOIN customers ON bank_accounts.owning_customer_id = customers.customer_id - WHERE login=in_account_username AND deleted_at IS NULL; -IF NOT FOUND OR out_account_is_exchange THEN - out_account_not_found=NOT FOUND; - RETURN; -END IF; - --- Check enough funds -SELECT account_balance_is_sufficient(account_id, COALESCE(in_amount, in_suggested_amount), in_wire_transfer_fees) INTO out_balance_insufficient; -IF out_balance_insufficient THEN - RETURN; -END IF; - --- Create withdrawal operation -INSERT INTO taler_withdrawal_operations - (withdrawal_uuid, wallet_bank_account, amount, suggested_amount, creation_date) - VALUES (in_withdrawal_uuid, account_id, in_amount, in_suggested_amount, in_timestamp); -END $$; -COMMENT ON FUNCTION create_taler_withdrawal IS 'Create a new withdrawal operation'; - -CREATE FUNCTION select_taler_withdrawal( - IN in_withdrawal_uuid uuid, - IN in_reserve_pub BYTEA, - IN in_subject TEXT, - IN in_selected_exchange_payto TEXT, - IN in_amount taler_amount, - IN in_wire_transfer_fees taler_amount, - -- Error status - OUT out_no_op BOOLEAN, - OUT out_already_selected BOOLEAN, - 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, - -- Success return - OUT out_status TEXT -) -LANGUAGE plpgsql AS $$ -DECLARE -not_selected BOOLEAN; -account_id int8; -amount_with_fee taler_amount; -BEGIN --- Check for conflict and idempotence -SELECT - 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 OR amount != in_amount), - amount IS NULL AND in_amount IS NULL, - amount IS NOT NULL AND amount != in_amount, - wallet_bank_account - INTO not_selected, out_status, out_already_selected, out_missing_amount, 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 - out_no_op=NOT FOUND; - RETURN; -END IF; - -IF not_selected THEN - -- Check reserve_pub reuse - SELECT true FROM taler_exchange_incoming WHERE reserve_pub = in_reserve_pub - UNION ALL - SELECT true FROM taler_withdrawal_operations WHERE reserve_pub = in_reserve_pub - INTO out_reserve_pub_reuse; - IF out_reserve_pub_reuse THEN - RETURN; - END IF; - - -- Check exchange account - SELECT NOT is_taler_exchange - INTO out_account_is_not_exchange - FROM bank_accounts - WHERE internal_payto_uri=in_selected_exchange_payto; - IF NOT FOUND OR out_account_is_not_exchange THEN - out_account_not_found=NOT FOUND; - RETURN; - END IF; - - IF in_amount IS NOT NULL THEN - SELECT account_balance_is_sufficient(account_id, in_amount, in_wire_transfer_fees) INTO out_balance_insufficient; - IF out_balance_insufficient THEN - RETURN; - END IF; - END IF; - - -- Update withdrawal operation - UPDATE taler_withdrawal_operations - SET selected_exchange_payto=in_selected_exchange_payto, - reserve_pub=in_reserve_pub, - subject=in_subject, - selection_done=true, - amount=COALESCE(amount, in_amount) - 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 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 OR out_already_confirmed THEN - out_no_op=NOT FOUND; - 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 FUNCTION confirm_taler_withdrawal( - IN in_login TEXT, - IN in_withdrawal_uuid uuid, - IN in_timestamp INT8, - IN in_is_tan BOOLEAN, - IN in_wire_transfer_fees taler_amount, - OUT out_no_op BOOLEAN, - OUT out_balance_insufficient BOOLEAN, - OUT out_creditor_not_found BOOLEAN, - OUT out_exchange_not_found BOOLEAN, - OUT out_not_selected BOOLEAN, - OUT out_aborted BOOLEAN, - OUT out_tan_required BOOLEAN -) -LANGUAGE plpgsql AS $$ -DECLARE - already_confirmed BOOLEAN; - subject_local TEXT; - reserve_pub_local BYTEA; - selected_exchange_payto_local TEXT; - wallet_bank_account_local INT8; - amount_local taler_amount; - exchange_bank_account_id INT8; - tx_row_id INT8; -BEGIN --- Check op exists -SELECT - confirmation_done, - aborted, NOT selection_done, - reserve_pub, subject, - selected_exchange_payto, - wallet_bank_account, - (amount).val, (amount).frac, - (NOT in_is_tan AND tan_channel IS NOT NULL) - INTO - already_confirmed, - out_aborted, out_not_selected, - reserve_pub_local, subject_local, - selected_exchange_payto_local, - wallet_bank_account_local, - amount_local.val, amount_local.frac, - out_tan_required - 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 login=in_login AND deleted_at IS NULL; -IF NOT FOUND OR already_confirmed OR out_aborted OR out_not_selected THEN - out_no_op=NOT FOUND; + in_wire_transfer_fees, + in_min_amount, + in_max_amount + ) as transfer; +IF out_balance_insufficient OR out_bad_amount THEN RETURN; END IF; --- Check exchange account then 2fa -SELECT - bank_account_id - INTO exchange_bank_account_id - FROM bank_accounts - WHERE internal_payto_uri = selected_exchange_payto_local; -IF NOT FOUND OR out_tan_required THEN - out_exchange_not_found=NOT FOUND; - RETURN; +-- Store operation +IF in_request_uid IS NOT NULL THEN + INSERT INTO bank_transaction_operations (request_uid, bank_transaction) + VALUES (in_request_uid, out_debit_row_id); END IF; +END $$; +COMMENT ON FUNCTION bank_transaction IS 'Create a bank transaction'; -SELECT -- not checking for accounts existence, as it was done above. - transfer.out_balance_insufficient, - out_credit_row_id - INTO out_balance_insufficient, tx_row_id -FROM bank_wire_transfer( - exchange_bank_account_id, - wallet_bank_account_local, - subject_local, - amount_local, - in_timestamp, - in_wire_transfer_fees -) as transfer; -IF out_balance_insufficient THEN +CREATE FUNCTION create_taler_withdrawal( + IN in_account_username TEXT, + IN in_withdrawal_uuid UUID, + IN in_amount taler_amount, + IN in_suggested_amount taler_amount, + IN in_timestamp INT8, + IN in_wire_transfer_fees taler_amount, + IN in_min_amount taler_amount, + IN in_max_amount taler_amount, + -- Error status + OUT out_account_not_found BOOLEAN, + OUT out_account_is_exchange BOOLEAN, + OUT out_balance_insufficient BOOLEAN, + OUT out_bad_amount BOOLEAN +) +LANGUAGE plpgsql AS $$ +DECLARE +account_id INT8; +amount_with_fee taler_amount; +BEGIN +-- Check account exists +SELECT bank_account_id, is_taler_exchange + INTO account_id, out_account_is_exchange + FROM bank_accounts + JOIN customers ON bank_accounts.owning_customer_id = customers.customer_id + WHERE login=in_account_username AND deleted_at IS NULL; +IF NOT FOUND OR out_account_is_exchange THEN + out_account_not_found=NOT FOUND; RETURN; END IF; --- Confirm operation -UPDATE taler_withdrawal_operations - SET confirmation_done = true - WHERE withdrawal_uuid=in_withdrawal_uuid; - --- Register incoming transaction -CALL register_incoming(reserve_pub_local, tx_row_id); +-- Check enough funds +IF in_amount IS NOT NULL OR in_suggested_amount IS NOT NULL THEN + SELECT test.out_balance_insufficient, test.out_bad_amount FROM account_balance_is_sufficient( + account_id, + COALESCE(in_amount, in_suggested_amount), + in_wire_transfer_fees, + in_min_amount, + in_max_amount + ) AS test INTO out_balance_insufficient, out_bad_amount; + IF out_balance_insufficient OR out_bad_amount THEN + RETURN; + END IF; +END IF; --- Notify status change -PERFORM pg_notify('withdrawal_status', in_withdrawal_uuid::text || ' confirmed'); +-- Create withdrawal operation +INSERT INTO taler_withdrawal_operations + (withdrawal_uuid, wallet_bank_account, amount, suggested_amount, creation_date) + VALUES (in_withdrawal_uuid, account_id, in_amount, in_suggested_amount, in_timestamp); END $$; -COMMENT ON FUNCTION confirm_taler_withdrawal - IS 'Set a withdrawal operation as confirmed and wire the funds to the exchange.'; +COMMENT ON FUNCTION create_taler_withdrawal IS 'Create a new withdrawal operation'; -CREATE FUNCTION bank_wire_transfer( - IN in_creditor_account_id INT8, - IN in_debtor_account_id INT8, +CREATE FUNCTION select_taler_withdrawal( + IN in_withdrawal_uuid uuid, + IN in_reserve_pub BYTEA, IN in_subject TEXT, + IN in_selected_exchange_payto TEXT, IN in_amount taler_amount, - IN in_timestamp INT8, IN in_wire_transfer_fees taler_amount, + IN in_min_amount taler_amount, + IN in_max_amount taler_amount, -- Error status + OUT out_no_op BOOLEAN, + OUT out_already_selected BOOLEAN, + 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, -- Success return - OUT out_credit_row_id INT8, - OUT out_debit_row_id INT8 + OUT out_status TEXT ) -LANGUAGE plpgsql AS $$ +LANGUAGE plpgsql AS $$ DECLARE +not_selected BOOLEAN; +account_id int8; amount_with_fee taler_amount; -admin_account_id INT8; -admin_has_debt BOOLEAN; -admin_balance taler_amount; -admin_payto TEXT; -admin_name TEXT; -debtor_has_debt BOOLEAN; -debtor_balance taler_amount; -debtor_max_debt taler_amount; -debtor_payto TEXT; -debtor_name TEXT; -creditor_has_debt BOOLEAN; -creditor_balance taler_amount; -creditor_payto TEXT; -creditor_name TEXT; -tmp_balance taler_amount; BEGIN --- Retrieve debtor info -SELECT - has_debt, - (balance).val, (balance).frac, - (max_debt).val, (max_debt).frac, - internal_payto_uri, customers.name - INTO - debtor_has_debt, - debtor_balance.val, debtor_balance.frac, - debtor_max_debt.val, debtor_max_debt.frac, - debtor_payto, debtor_name - FROM bank_accounts - JOIN customers ON customer_id=owning_customer_id - WHERE bank_account_id=in_debtor_account_id; -IF NOT FOUND THEN - RAISE EXCEPTION 'Unknown debtor %', in_debtor_account_id; -END IF; --- Retrieve creditor info -SELECT - has_debt, - (balance).val, (balance).frac, - internal_payto_uri, customers.name - INTO - creditor_has_debt, - creditor_balance.val, creditor_balance.frac, - creditor_payto, creditor_name - FROM bank_accounts - JOIN customers ON customer_id=owning_customer_id - WHERE bank_account_id=in_creditor_account_id; -IF NOT FOUND THEN - RAISE EXCEPTION 'Unknown creditor %', in_creditor_account_id; -END IF; --- Retrieve admin info +-- Check for conflict and idempotence SELECT - bank_account_id, has_debt, - (balance).val, (balance).frac, - internal_payto_uri, customers.name - INTO - admin_account_id, admin_has_debt, - admin_balance.val, admin_balance.frac, - admin_payto, admin_name - FROM bank_accounts - JOIN customers ON customer_id=owning_customer_id - WHERE login = 'admin'; -IF NOT FOUND THEN - RAISE EXCEPTION 'No admin'; -END IF; - -IF in_wire_transfer_fees != (0, 0)::taler_amount AND admin_account_id != in_debtor_account_id THEN - SELECT sum.val, sum.frac - INTO amount_with_fee.val, amount_with_fee.frac - FROM amount_add(in_amount, in_wire_transfer_fees) as sum; -ELSE - amount_with_fee = in_amount; -END IF; - --- DEBTOR SIDE --- check debtor has enough funds. -IF debtor_has_debt THEN - -- debt case: simply checking against the max debt allowed. - SELECT sum.val, sum.frac - INTO debtor_balance.val, debtor_balance.frac - FROM amount_add(debtor_balance, amount_with_fee) as sum; - SELECT NOT ok - INTO out_balance_insufficient - FROM amount_left_minus_right(debtor_max_debt, - debtor_balance); - IF out_balance_insufficient THEN - RETURN; - END IF; -ELSE -- not a debt account - SELECT - NOT ok, - (diff).val, (diff).frac - INTO - out_balance_insufficient, - tmp_balance.val, - tmp_balance.frac - FROM amount_left_minus_right(debtor_balance, - amount_with_fee); - IF NOT out_balance_insufficient THEN -- debtor has enough funds in the (positive) balance. - debtor_balance=tmp_balance; - ELSE -- debtor will switch to debt: determine their new negative balance. - SELECT - (diff).val, (diff).frac - INTO - debtor_balance.val, debtor_balance.frac - FROM amount_left_minus_right(amount_with_fee, - debtor_balance); - debtor_has_debt=TRUE; - SELECT NOT ok - INTO out_balance_insufficient - FROM amount_left_minus_right(debtor_max_debt, - debtor_balance); - IF out_balance_insufficient THEN - RETURN; - END IF; - END IF; + 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 OR amount != in_amount), + amount IS NULL AND in_amount IS NULL, + amount IS NOT NULL AND amount != in_amount, + wallet_bank_account + INTO not_selected, out_status, out_already_selected, out_missing_amount, 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 + out_no_op=NOT FOUND; + RETURN; END IF; --- CREDITOR SIDE. --- Here we figure out whether the creditor would switch --- from debit to a credit situation, and adjust the balance --- accordingly. -IF NOT creditor_has_debt THEN -- easy case. - SELECT sum.val, sum.frac - INTO creditor_balance.val, creditor_balance.frac - FROM amount_add(creditor_balance, in_amount) as sum; -ELSE -- creditor had debit but MIGHT switch to credit. - SELECT - (diff).val, (diff).frac, - NOT ok - INTO - tmp_balance.val, tmp_balance.frac, - creditor_has_debt - FROM amount_left_minus_right(in_amount, - creditor_balance); - IF NOT creditor_has_debt THEN - creditor_balance=tmp_balance; - ELSE - -- the amount is not enough to bring the receiver - -- to a credit state, switch operators to calculate the new balance. - SELECT - (diff).val, (diff).frac - INTO creditor_balance.val, creditor_balance.frac - FROM amount_left_minus_right(creditor_balance, - in_amount); +IF not_selected THEN + -- Check reserve_pub reuse + SELECT true FROM taler_exchange_incoming WHERE reserve_pub = in_reserve_pub + UNION ALL + SELECT true FROM taler_withdrawal_operations WHERE reserve_pub = in_reserve_pub + INTO out_reserve_pub_reuse; + IF out_reserve_pub_reuse THEN + RETURN; END IF; -END IF; --- ADMIN SIDE. --- Here we figure out whether the administrator would switch --- from debit to a credit situation, and adjust the balance --- accordingly. -IF amount_with_fee != in_amount THEN - IF NOT admin_has_debt THEN -- easy case. - SELECT sum.val, sum.frac - INTO admin_balance.val, admin_balance.frac - FROM amount_add(admin_balance, in_wire_transfer_fees) as sum; - ELSE -- creditor had debit but MIGHT switch to credit. - SELECT (diff).val, (diff).frac, NOT ok - INTO - tmp_balance.val, tmp_balance.frac, - admin_has_debt - FROM amount_left_minus_right(in_wire_transfer_fees, admin_balance); - IF NOT admin_has_debt THEN - admin_balance=tmp_balance; - ELSE - -- the amount is not enough to bring the receiver - -- to a credit state, switch operators to calculate the new balance. - SELECT (diff).val, (diff).frac - INTO admin_balance.val, admin_balance.frac - FROM amount_left_minus_right(admin_balance, in_wire_transfer_fees); + -- Check exchange account + SELECT NOT is_taler_exchange + INTO out_account_is_not_exchange + FROM bank_accounts + WHERE internal_payto_uri=in_selected_exchange_payto; + IF NOT FOUND OR out_account_is_not_exchange THEN + out_account_not_found=NOT FOUND; + RETURN; + END IF; + + IF in_amount IS NOT NULL THEN + SELECT test.out_balance_insufficient, test.out_bad_amount FROM account_balance_is_sufficient( + account_id, + in_amount, + in_wire_transfer_fees, + in_min_amount, + in_max_amount + ) AS test INTO out_balance_insufficient, out_bad_amount; + IF out_balance_insufficient OR out_bad_amount THEN + RETURN; END IF; END IF; -END IF; --- now actually create the bank transaction. --- debtor side: -INSERT INTO bank_account_transactions ( - creditor_payto_uri - ,creditor_name - ,debtor_payto_uri - ,debtor_name - ,subject - ,amount - ,transaction_date - ,direction - ,bank_account_id - ) -VALUES ( - creditor_payto, - creditor_name, - debtor_payto, - debtor_name, - in_subject, - in_amount, - in_timestamp, - 'debit', - in_debtor_account_id -) RETURNING bank_transaction_id INTO out_debit_row_id; + -- Update withdrawal operation + UPDATE taler_withdrawal_operations + SET selected_exchange_payto=in_selected_exchange_payto, + reserve_pub=in_reserve_pub, + subject=in_subject, + selection_done=true, + amount=COALESCE(amount, in_amount) + WHERE withdrawal_uuid=in_withdrawal_uuid; --- debtor side: -INSERT INTO bank_account_transactions ( - creditor_payto_uri - ,creditor_name - ,debtor_payto_uri - ,debtor_name - ,subject - ,amount - ,transaction_date - ,direction - ,bank_account_id - ) -VALUES ( - creditor_payto, - creditor_name, - debtor_payto, - debtor_name, - in_subject, - in_amount, - in_timestamp, - 'credit', - in_creditor_account_id -) RETURNING bank_transaction_id INTO out_credit_row_id; + -- 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'; --- checks and balances set up, now update bank accounts. -UPDATE bank_accounts -SET - balance=debtor_balance, - has_debt=debtor_has_debt -WHERE bank_account_id=in_debtor_account_id; +CREATE 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 OR out_already_confirmed THEN + out_no_op=NOT FOUND; + RETURN; +END IF; -UPDATE bank_accounts -SET - balance=creditor_balance, - has_debt=creditor_has_debt -WHERE bank_account_id=in_creditor_account_id; +-- 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.'; --- Fee part -IF amount_with_fee != in_amount THEN - INSERT INTO bank_account_transactions ( - creditor_payto_uri - ,creditor_name - ,debtor_payto_uri - ,debtor_name - ,subject - ,amount - ,transaction_date - ,direction - ,bank_account_id - ) - VALUES ( - admin_payto, - admin_name, - debtor_payto, - debtor_name, - 'wire transfer fees for tx ' || out_debit_row_id, - in_wire_transfer_fees, - in_timestamp, - 'debit', - in_debtor_account_id - ), ( - admin_payto, - admin_name, - debtor_payto, - debtor_name, - 'wire transfer fees for tx ' || out_debit_row_id, - in_amount, - in_timestamp, - 'credit', - admin_account_id - ); +CREATE FUNCTION confirm_taler_withdrawal( + IN in_login TEXT, + IN in_withdrawal_uuid uuid, + IN in_timestamp INT8, + IN in_is_tan BOOLEAN, + IN in_wire_transfer_fees taler_amount, + IN in_min_amount taler_amount, + IN in_max_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_aborted BOOLEAN, + OUT out_tan_required BOOLEAN +) +LANGUAGE plpgsql AS $$ +DECLARE + already_confirmed BOOLEAN; + subject_local TEXT; + reserve_pub_local BYTEA; + selected_exchange_payto_local TEXT; + wallet_bank_account_local INT8; + amount_local taler_amount; + exchange_bank_account_id INT8; + tx_row_id INT8; +BEGIN +-- Check op exists +SELECT + confirmation_done, + aborted, NOT selection_done, + reserve_pub, subject, + selected_exchange_payto, + wallet_bank_account, + (amount).val, (amount).frac, + (NOT in_is_tan AND tan_channel IS NOT NULL) + INTO + already_confirmed, + out_aborted, out_not_selected, + reserve_pub_local, subject_local, + selected_exchange_payto_local, + wallet_bank_account_local, + amount_local.val, amount_local.frac, + out_tan_required + 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 login=in_login AND deleted_at IS NULL; +IF NOT FOUND OR already_confirmed OR out_aborted OR out_not_selected THEN + out_no_op=NOT FOUND; + RETURN; +END IF; - UPDATE bank_accounts - SET - balance=admin_balance, - has_debt=admin_has_debt - WHERE bank_account_id=admin_account_id; +-- Check exchange account then 2fa +SELECT + bank_account_id + INTO exchange_bank_account_id + FROM bank_accounts + WHERE internal_payto_uri = selected_exchange_payto_local; +IF NOT FOUND OR out_tan_required THEN + out_exchange_not_found=NOT FOUND; + RETURN; END IF; --- notify new transaction -PERFORM pg_notify('bank_tx', in_debtor_account_id || ' ' || in_creditor_account_id || ' ' || out_debit_row_id || ' ' || out_credit_row_id); +SELECT -- not checking for accounts existence, as it was done above. + transfer.out_balance_insufficient, + transfer.out_bad_amount, + out_credit_row_id + INTO out_balance_insufficient, out_bad_amount, tx_row_id +FROM bank_wire_transfer( + exchange_bank_account_id, + wallet_bank_account_local, + subject_local, + amount_local, + in_timestamp, + in_wire_transfer_fees, + in_min_amount, + in_max_amount +) as transfer; +IF out_balance_insufficient OR out_bad_amount THEN + RETURN; +END IF; + +-- Confirm operation +UPDATE taler_withdrawal_operations + SET confirmation_done = true + WHERE withdrawal_uuid=in_withdrawal_uuid; + +-- Register incoming transaction +CALL register_incoming(reserve_pub_local, tx_row_id); + +-- 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.'; CREATE FUNCTION cashin( IN in_timestamp INT8, @@ -1108,7 +1175,9 @@ SELECT in_subject, converted_amount, in_timestamp, - (0, 0)::taler_amount + NULL, + NULL, + NULL ) as transfer; IF out_balance_insufficient THEN RETURN; @@ -1211,7 +1280,9 @@ FROM bank_wire_transfer( in_subject, in_amount_debit, in_timestamp, - (0, 0)::taler_amount + NULL, + NULL, + NULL ) as transfer; IF out_balance_insufficient THEN RETURN;