libeufin

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

WithdrawalDAO.kt (16579B)


      1 /*
      2  * This file is part of LibEuFin.
      3  * Copyright (C) 2023, 2024, 2025, 2026 Taler Systems S.A.
      4 
      5  * LibEuFin is free software; you can redistribute it and/or modify
      6  * it under the terms of the GNU Affero General Public License as
      7  * published by the Free Software Foundation; either version 3, or
      8  * (at your option) any later version.
      9 
     10  * LibEuFin is distributed in the hope that it will be useful, but
     11  * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
     12  * or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Affero General
     13  * Public License for more details.
     14 
     15  * You should have received a copy of the GNU Affero General Public
     16  * License along with LibEuFin; see the file COPYING.  If not, see
     17  * <http://www.gnu.org/licenses/>
     18  */
     19 
     20 package tech.libeufin.bank.db
     21 
     22 import kotlinx.coroutines.coroutineScope
     23 import kotlinx.coroutines.flow.first
     24 import kotlinx.coroutines.launch
     25 import kotlinx.coroutines.withTimeoutOrNull
     26 import tech.libeufin.bank.*
     27 import tech.libeufin.common.EddsaPublicKey
     28 import tech.libeufin.common.Payto
     29 import tech.libeufin.common.TalerAmount
     30 import tech.libeufin.common.db.*
     31 import tech.libeufin.common.micros
     32 import java.time.Instant
     33 import java.util.*
     34 
     35 /** Data access logic for withdrawal operations */
     36 class WithdrawalDAO(private val db: Database) {
     37     /** Result status of withdrawal operation creation */
     38     enum class WithdrawalCreationResult {
     39         Success,
     40         UnknownAccount,
     41         AccountIsExchange,
     42         BalanceInsufficient,
     43         BadAmount
     44     }
     45 
     46     /** Create a new withdrawal operation */
     47     suspend fun create(
     48         username: String,
     49         uuid: UUID,
     50         amount: TalerAmount?,
     51         suggested_amount: TalerAmount?,
     52         no_amount_to_wallet: Boolean,
     53         timestamp: Instant,
     54         wireTransferFees: TalerAmount,
     55         minAmount: TalerAmount,
     56         maxAmount: TalerAmount
     57     ): WithdrawalCreationResult = db.serializable(
     58         """
     59         SELECT
     60             out_account_not_found,
     61             out_account_is_exchange,
     62             out_balance_insufficient,
     63             out_bad_amount
     64         FROM create_taler_withdrawal(
     65             ?,?,
     66             ${optAmount(amount)},
     67             ${optAmount(suggested_amount)},
     68             ?, ?, (?, ?)::taler_amount, (?, ?)::taler_amount, (?, ?)::taler_amount
     69         );
     70         """
     71     ) {
     72         
     73         bind(username)
     74         bind(uuid)
     75         bind(amount)
     76         bind(suggested_amount)
     77         bind(no_amount_to_wallet)
     78         bind(timestamp.micros())
     79         bind(wireTransferFees)
     80         bind(minAmount)
     81         bind(maxAmount)
     82         one {
     83             when {
     84                 it.getBoolean("out_account_not_found") -> WithdrawalCreationResult.UnknownAccount
     85                 it.getBoolean("out_account_is_exchange") -> WithdrawalCreationResult.AccountIsExchange
     86                 it.getBoolean("out_balance_insufficient") -> WithdrawalCreationResult.BalanceInsufficient
     87                 it.getBoolean("out_bad_amount") -> WithdrawalCreationResult.BadAmount
     88                 else -> WithdrawalCreationResult.Success
     89             }
     90         }
     91     }
     92 
     93     /** Abort withdrawal operation [uuid] */
     94     suspend fun abort(
     95         uuid: UUID
     96     ): AbortResult = db.serializable(
     97         """
     98         SELECT
     99             out_no_op,
    100             out_already_confirmed
    101         FROM abort_taler_withdrawal(?)
    102         """
    103     ) {
    104         bind(uuid)
    105         one {
    106             when {
    107                 it.getBoolean("out_no_op") -> AbortResult.UnknownOperation
    108                 it.getBoolean("out_already_confirmed") -> AbortResult.AlreadyConfirmed
    109                 else -> AbortResult.Success
    110             }
    111         }
    112     }
    113 
    114     /** Result withdrawal operation selection */
    115     sealed interface WithdrawalSelectionResult {
    116         data class Success(val status: WithdrawalStatus): WithdrawalSelectionResult
    117         data object UnknownOperation: WithdrawalSelectionResult
    118         data object AlreadySelected: WithdrawalSelectionResult
    119         data object ReservePubReuse: WithdrawalSelectionResult
    120         data object UnknownAccount: WithdrawalSelectionResult
    121         data object AccountIsNotExchange: WithdrawalSelectionResult
    122         data object AmountDiffers: WithdrawalSelectionResult
    123         data object BalanceInsufficient: WithdrawalSelectionResult
    124         data object BadAmount: WithdrawalSelectionResult
    125         data object AlreadyAborted: WithdrawalSelectionResult
    126     }
    127 
    128     /** Set details ([exchangePayto] & [reservePub] & [amount]) for withdrawal operation [uuid] */
    129     suspend fun setDetails(
    130         uuid: UUID,
    131         exchangePayto: Payto,
    132         reservePub: EddsaPublicKey,
    133         amount: TalerAmount?,
    134         wireTransferFees: TalerAmount,
    135         minAmount: TalerAmount,
    136         maxAmount: TalerAmount
    137     ): WithdrawalSelectionResult = db.serializable(
    138         """
    139         SELECT
    140             out_no_op,
    141             out_already_selected,
    142             out_reserve_pub_reuse,
    143             out_account_not_found,
    144             out_account_is_not_exchange,
    145             out_status,
    146             out_amount_differs,
    147             out_balance_insufficient,
    148             out_bad_amount,
    149             out_aborted
    150         FROM select_taler_withdrawal(
    151             ?, ?, ?, ?,
    152             ${optAmount(amount)},
    153             (?,?)::taler_amount, (?,?)::taler_amount, (?,?)::taler_amount
    154         );
    155         """
    156     ) {
    157         bind(uuid)
    158         bind(reservePub)
    159         bind("Taler withdrawal $reservePub")
    160         bind(exchangePayto.canonical)
    161         bind(amount)
    162         bind(wireTransferFees)
    163         bind(minAmount)
    164         bind(maxAmount)
    165         one {
    166             when {
    167                 it.getBoolean("out_aborted") -> WithdrawalSelectionResult.AlreadyAborted
    168                 it.getBoolean("out_balance_insufficient") -> WithdrawalSelectionResult.BalanceInsufficient
    169                 it.getBoolean("out_bad_amount") -> WithdrawalSelectionResult.BadAmount
    170                 it.getBoolean("out_no_op") -> WithdrawalSelectionResult.UnknownOperation
    171                 it.getBoolean("out_already_selected") -> WithdrawalSelectionResult.AlreadySelected
    172                 it.getBoolean("out_amount_differs") -> WithdrawalSelectionResult.AmountDiffers
    173                 it.getBoolean("out_reserve_pub_reuse") -> WithdrawalSelectionResult.ReservePubReuse
    174                 it.getBoolean("out_account_not_found") -> WithdrawalSelectionResult.UnknownAccount
    175                 it.getBoolean("out_account_is_not_exchange") -> WithdrawalSelectionResult.AccountIsNotExchange
    176                 else -> WithdrawalSelectionResult.Success(it.getEnum("out_status"))
    177             }
    178         }
    179     }
    180 
    181     /** Result status of withdrawal operation confirmation */
    182     enum class WithdrawalConfirmationResult {
    183         Success,
    184         UnknownOperation,
    185         BalanceInsufficient,
    186         BadAmount,
    187         NotSelected,
    188         AlreadyAborted,
    189         TanRequired,
    190         MissingAmount,
    191         AmountDiffers,
    192         ReservePubReuse
    193     }
    194 
    195     /** Confirm withdrawal operation [uuid] */
    196     suspend fun confirm(
    197         username: String,
    198         uuid: UUID,
    199         timestamp: Instant,
    200         amount: TalerAmount?,
    201         is2fa: Boolean,
    202         wireTransferFees: TalerAmount,
    203         minAmount: TalerAmount,
    204         maxAmount: TalerAmount
    205     ): WithdrawalConfirmationResult = db.serializable(
    206         """
    207         SELECT
    208             out_no_op,
    209             out_balance_insufficient,
    210             out_bad_amount,
    211             out_not_selected,
    212             out_aborted,
    213             out_tan_required,
    214             out_missing_amount,
    215             out_amount_differs,
    216             out_reserve_pub_reuse
    217         FROM confirm_taler_withdrawal(
    218             ?,?,?,?,(?,?)::taler_amount,(?,?)::taler_amount,(?,?)::taler_amount, ${optAmount(amount)}
    219         );
    220         """
    221     ) {
    222         bind(username)
    223         bind(uuid)
    224         bind(timestamp)
    225         bind(is2fa)
    226         bind(wireTransferFees)
    227         bind(minAmount)
    228         bind(maxAmount)
    229         bind(amount)
    230         one {
    231             when {
    232                 it.getBoolean("out_no_op") -> WithdrawalConfirmationResult.UnknownOperation
    233                 it.getBoolean("out_balance_insufficient") -> WithdrawalConfirmationResult.BalanceInsufficient
    234                 it.getBoolean("out_bad_amount") -> WithdrawalConfirmationResult.BadAmount
    235                 it.getBoolean("out_not_selected") -> WithdrawalConfirmationResult.NotSelected
    236                 it.getBoolean("out_aborted") -> WithdrawalConfirmationResult.AlreadyAborted
    237                 it.getBoolean("out_tan_required") -> WithdrawalConfirmationResult.TanRequired
    238                 it.getBoolean("out_missing_amount") -> WithdrawalConfirmationResult.MissingAmount
    239                 it.getBoolean("out_amount_differs") -> WithdrawalConfirmationResult.AmountDiffers
    240                 it.getBoolean("out_reserve_pub_reuse") -> WithdrawalConfirmationResult.ReservePubReuse
    241                 else -> WithdrawalConfirmationResult.Success
    242             }
    243         }
    244     }
    245 
    246     /** Get withdrawal operation [uuid] linked account username */
    247     suspend fun getUsername(uuid: UUID): String? = db.serializable(
    248         """
    249         SELECT username
    250         FROM taler_withdrawal_operations
    251             JOIN bank_accounts ON wallet_bank_account=bank_account_id
    252             JOIN customers ON customer_id=owning_customer_id
    253         WHERE withdrawal_uuid=?
    254         """
    255     ) {
    256         bind(uuid)
    257         oneOrNull { it.getString(1) }
    258     }
    259 
    260     private suspend fun <T> poll(
    261         uuid: UUID, 
    262         params: StatusParams, 
    263         status: (T) -> WithdrawalStatus,
    264         load: suspend () -> T?
    265     ): T? {
    266         return if (params.polling.timeout_ms > 0) {
    267             db.listenWithdrawals(uuid) { flow ->
    268                 coroutineScope {
    269                     // Start buffering notification before loading transactions to not miss any
    270                     val polling = launch {
    271                         withTimeoutOrNull(params.polling.timeout_ms) {
    272                             flow.first { it != params.old_state }
    273                         }
    274                     }    
    275                     // Initial loading
    276                     val init = load()
    277                     // Long polling if there is no operation or its not confirmed
    278                     if (init?.run { status(this) == params.old_state } != false) {
    279                         polling.join()
    280                         load()
    281                     } else {
    282                         polling.cancel()
    283                         init
    284                     }
    285                 }
    286             }
    287         } else {
    288             load()
    289         }
    290     }
    291 
    292     /** Pool public info of operation [uuid] */
    293     suspend fun pollInfo(uuid: UUID, params: StatusParams): WithdrawalPublicInfo? = 
    294         poll(uuid, params, status = { it.status }) {
    295             db.serializable(
    296                 """
    297                 SELECT
    298                 CASE 
    299                     WHEN confirmation_done THEN 'confirmed'
    300                     WHEN aborted THEN 'aborted'
    301                     WHEN selection_done THEN 'selected'
    302                     ELSE 'pending'
    303                 END as status
    304                 ,(amount).val as amount_val
    305                 ,(amount).frac as amount_frac
    306                 ,(suggested_amount).val as suggested_amount_val
    307                 ,(suggested_amount).frac as suggested_amount_frac
    308                 ,selection_done     
    309                 ,aborted     
    310                 ,confirmation_done     
    311                 ,reserve_pub
    312                 ,wallet_user.username
    313                 ,no_amount_to_wallet
    314                 ,exchange_account.internal_payto as exchange_payto
    315                 ,exchange_user.name as exchange_name
    316                 FROM taler_withdrawal_operations
    317                     JOIN bank_accounts AS wallet_account ON wallet_bank_account=wallet_account.bank_account_id
    318                     JOIN customers AS wallet_user ON wallet_user.customer_id=wallet_account.owning_customer_id
    319                     LEFT JOIN bank_accounts AS exchange_account ON exchange_bank_account=exchange_account.bank_account_id
    320                     LEFT JOIN customers AS exchange_user ON exchange_user.customer_id=exchange_account.owning_customer_id
    321                 WHERE withdrawal_uuid=?
    322                 """
    323             ) {
    324                 bind(uuid)
    325                 oneOrNull {
    326                     WithdrawalPublicInfo(
    327                         status = it.getEnum("status"),
    328                         amount = it.getOptAmount("amount", db.bankCurrency),
    329                         suggested_amount = it.getOptAmount("suggested_amount", db.bankCurrency),
    330                         username = it.getString("username"),
    331                         selected_exchange_account = it.getOptBankPayto("exchange_payto", "exchange_name", db.ctx),
    332                         selected_reserve_pub = it.getBytes("reserve_pub")?.run(::EddsaPublicKey),
    333                         no_amount_to_wallet = it.getBoolean("no_amount_to_wallet")
    334                     )
    335                 }
    336             }
    337         }
    338 
    339     /** Pool public status of operation [uuid] */
    340     suspend fun pollStatus(
    341         uuid: UUID,
    342         params: StatusParams,
    343         wire: WireMethod,
    344         maxAmount: TalerAmount
    345     ): BankWithdrawalOperationStatus? =
    346         poll(uuid, params, status = { it.status }) {
    347             db.serializable(
    348                 """
    349                 SELECT
    350                     CASE 
    351                     WHEN confirmation_done THEN 'confirmed'
    352                     WHEN aborted THEN 'aborted'
    353                     WHEN selection_done THEN 'selected'
    354                     ELSE 'pending'
    355                     END as status
    356                     ,(amount).val as amount_val
    357                     ,(amount).frac as amount_frac
    358                     ,(suggested_amount).val as suggested_amount_val
    359                     ,(suggested_amount).frac as suggested_amount_frac
    360                     ,selection_done     
    361                     ,aborted     
    362                     ,confirmation_done      
    363                     ,wallet_account.internal_payto
    364                     ,wallet_user.name
    365                     ,reserve_pub
    366                     ,exchange_account.internal_payto as exchange_payto
    367                     ,exchange_user.name as exchange_name
    368                     ,(max_amount).val as max_amount_val
    369                     ,(max_amount).frac as max_amount_frac
    370                     ,no_amount_to_wallet
    371                 FROM taler_withdrawal_operations
    372                     JOIN bank_accounts AS wallet_account ON wallet_bank_account=wallet_account.bank_account_id
    373                     JOIN customers AS wallet_user ON wallet_user.customer_id=wallet_account.owning_customer_id
    374                     LEFT JOIN bank_accounts AS exchange_account ON exchange_bank_account=exchange_account.bank_account_id
    375                     LEFT JOIN customers AS exchange_user ON exchange_user.customer_id=exchange_account.owning_customer_id
    376                     ,account_max_amount(wallet_account.bank_account_id, (?, ?)::taler_amount) AS max_amount
    377                 WHERE withdrawal_uuid=?
    378                 """
    379             ) {
    380                 bind(maxAmount)
    381                 bind(uuid)
    382                 oneOrNull {
    383                     BankWithdrawalOperationStatus(
    384                         status = it.getEnum("status"),
    385                         amount = it.getOptAmount("amount", db.bankCurrency),
    386                         suggested_amount = it.getOptAmount("suggested_amount", db.bankCurrency),
    387                         max_amount = it.getAmount("max_amount", db.bankCurrency),
    388                         selection_done = it.getBoolean("selection_done"),
    389                         transfer_done = it.getBoolean("confirmation_done"),
    390                         aborted = it.getBoolean("aborted"),
    391                         sender_wire = it.getBankPayto("internal_payto", "name", db.ctx),
    392                         confirm_transfer_url = null,
    393                         suggested_exchange = null,
    394                         selected_exchange_account = it.getOptBankPayto("exchange_payto", "exchange_name", db.ctx),
    395                         no_amount_to_wallet = it.getBoolean("no_amount_to_wallet"),
    396                         selected_reserve_pub = it.getBytes("reserve_pub")?.run(::EddsaPublicKey),
    397                         wire_types = listOf(
    398                             when (wire) {
    399                                 WireMethod.IBAN -> "iban"
    400                                 WireMethod.X_TALER_BANK -> "x-taler-bank"
    401                             } 
    402                         ),
    403                         currency = db.bankCurrency
    404                     )
    405                 }
    406             }
    407         }
    408 }