libeufin

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

withdrawal.rs (14962B)


      1 /*
      2 * This file is part of LibEuFin.
      3 * Copyright (C) 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 use compact_str::{CompactString, ToCompactString};
     21 use jiff::Timestamp;
     22 use sqlx::{PgPool, Row, postgres::PgRow};
     23 use taler_api::{
     24     db::{BindHelper, TypeHelper, pooling},
     25     notification::NotificationChannel,
     26     serialized,
     27 };
     28 use taler_common::{
     29     api::EddsaPublicKey,
     30     types::amount::{Amount, Currency},
     31 };
     32 use uuid::Uuid;
     33 
     34 use crate::{
     35     api::withdrawal::{
     36         BankWithdrawalOperationStatus, Status, WithdrawalPublicInfo, WithdrawalStatus,
     37     },
     38     config::WireMethod,
     39     payto::{BankPayto, PaytoCtx, sql_opt_bank_payto},
     40 };
     41 
     42 /** Result status of withdrawal operation creation */
     43 pub enum CreationResult {
     44     Success,
     45     UnknownAccount,
     46     AccountIsExchange,
     47     BalanceInsufficient,
     48     BadAmount,
     49 }
     50 
     51 /** Create a new withdrawal operation */
     52 pub async fn create(
     53     db: &PgPool,
     54     username: &str,
     55     uuid: Uuid,
     56     amount: Option<Amount>,
     57     suggested_amount: Option<Amount>,
     58     no_amount_to_wallet: bool,
     59     timestamp: &Timestamp,
     60     fees: Amount,
     61     min: Amount,
     62     max: Amount,
     63 ) -> sqlx::Result<CreationResult> {
     64     serialized!(
     65         sqlx::query(
     66             "
     67     SELECT
     68         out_account_not_found,
     69         out_account_is_exchange,
     70         out_balance_insufficient,
     71         out_bad_amount
     72     FROM create_taler_withdrawal(
     73         $1,$2,$3,$4,$5,$6,$7,$8,$9
     74     )
     75     ",
     76         )
     77         .bind(username)
     78         .bind(uuid)
     79         .bind(amount)
     80         .bind(suggested_amount)
     81         .bind(no_amount_to_wallet)
     82         .bind_timestamp(timestamp)
     83         .bind(fees)
     84         .bind(min)
     85         .bind(max)
     86         .try_map(|r: PgRow| {
     87             Ok(if r.try_get_flag("out_account_not_found")? {
     88                 CreationResult::UnknownAccount
     89             } else if r.try_get_flag("out_account_is_exchange")? {
     90                 CreationResult::AccountIsExchange
     91             } else if r.try_get_flag("out_balance_insufficient")? {
     92                 CreationResult::BalanceInsufficient
     93             } else if r.try_get_flag("out_bad_amount")? {
     94                 CreationResult::BadAmount
     95             } else {
     96                 CreationResult::Success
     97             })
     98         })
     99         .fetch_one(db)
    100     )
    101 }
    102 
    103 /** Result status of withdrawal or cashout operation abortion */
    104 pub enum AbortResult {
    105     Success,
    106     UnknownOperation,
    107     AlreadyConfirmed,
    108 }
    109 
    110 /** Abort withdrawal operation [uuid] */
    111 pub async fn abort(db: &PgPool, uuid: Uuid) -> sqlx::Result<AbortResult> {
    112     serialized!(
    113         sqlx::query(
    114             "
    115     SELECT
    116         out_no_op,
    117         out_already_confirmed
    118     FROM abort_taler_withdrawal($1)
    119     ",
    120         )
    121         .bind(uuid)
    122         .try_map(|r: PgRow| {
    123             Ok(if r.try_get_flag("out_no_op")? {
    124                 AbortResult::UnknownOperation
    125             } else if r.try_get_flag("out_already_confirmed")? {
    126                 AbortResult::AlreadyConfirmed
    127             } else {
    128                 AbortResult::Success
    129             })
    130         })
    131         .fetch_one(db)
    132     )
    133 }
    134 
    135 /** Result withdrawal operation selection */
    136 pub enum SelectionResult {
    137     Success(WithdrawalStatus),
    138     UnknownOperation,
    139     AlreadySelected,
    140     ReservePubReuse,
    141     UnknownAccount,
    142     AccountIsNotExchange,
    143     AmountDiffers,
    144     BalanceInsufficient,
    145     BadAmount,
    146     AlreadyAborted,
    147 }
    148 
    149 /** Set details ([exchangePayto] & [reservePub] & [amount]) for withdrawal operation [uuid] */
    150 pub async fn set_details(
    151     db: &PgPool,
    152     uuid: Uuid,
    153     exchange: &BankPayto,
    154     reserve_pub: &EddsaPublicKey,
    155     amount: Option<Amount>,
    156     fees: Amount,
    157     min: Amount,
    158     max: Amount,
    159 ) -> sqlx::Result<SelectionResult> {
    160     serialized!(
    161         sqlx::query(
    162             "
    163     SELECT
    164         out_no_op,
    165         out_already_selected,
    166         out_reserve_pub_reuse,
    167         out_account_not_found,
    168         out_account_is_not_exchange,
    169         out_status,
    170         out_amount_differs,
    171         out_balance_insufficient,
    172         out_bad_amount,
    173         out_aborted
    174     FROM select_taler_withdrawal(
    175         $1,$2,$3,$4,$5,$6,$7,$8
    176     )
    177     ",
    178         )
    179         .bind(uuid)
    180         .bind(reserve_pub)
    181         .bind(format!("Taler withdrawal {reserve_pub}"))
    182         .bind(exchange.canonical())
    183         .bind(amount)
    184         .bind(fees)
    185         .bind(min)
    186         .bind(max)
    187         .try_map(|r: PgRow| {
    188             Ok(if r.try_get_flag("out_aborted")? {
    189                 SelectionResult::AlreadyAborted
    190             } else if r.try_get_flag("out_balance_insufficient")? {
    191                 SelectionResult::BalanceInsufficient
    192             } else if r.try_get_flag("out_bad_amount")? {
    193                 SelectionResult::BadAmount
    194             } else if r.try_get_flag("out_no_op")? {
    195                 SelectionResult::UnknownOperation
    196             } else if r.try_get_flag("out_already_selected")? {
    197                 SelectionResult::AlreadySelected
    198             } else if r.try_get_flag("out_amount_differs")? {
    199                 SelectionResult::AmountDiffers
    200             } else if r.try_get_flag("out_reserve_pub_reuse")? {
    201                 SelectionResult::ReservePubReuse
    202             } else if r.try_get_flag("out_account_not_found")? {
    203                 SelectionResult::UnknownAccount
    204             } else if r.try_get_flag("out_account_is_not_exchange")? {
    205                 SelectionResult::AccountIsNotExchange
    206             } else {
    207                 SelectionResult::Success(r.try_get("out_status")?)
    208             })
    209         })
    210         .fetch_one(db)
    211     )
    212 }
    213 
    214 /** Result status of withdrawal operation confirmation */
    215 pub enum ConfirmationResult {
    216     Success,
    217     UnknownOperation,
    218     BalanceInsufficient,
    219     BadAmount,
    220     NotSelected,
    221     AlreadyAborted,
    222     TanRequired,
    223     MissingAmount,
    224     AmountDiffers,
    225     ReservePubReuse,
    226 }
    227 
    228 /** Confirm withdrawal operation [uuid] */
    229 pub async fn confirm(
    230     db: &PgPool,
    231     username: &str,
    232     uuid: Uuid,
    233     timestamp: &Timestamp,
    234     amount: Option<Amount>,
    235     is2fa: bool,
    236     fees: Amount,
    237     min: Amount,
    238     max: Amount,
    239 ) -> sqlx::Result<ConfirmationResult> {
    240     serialized!(
    241         sqlx::query(
    242             "
    243     SELECT
    244         out_no_op,
    245         out_balance_insufficient,
    246         out_bad_amount,
    247         out_not_selected,
    248         out_aborted,
    249         out_tan_required,
    250         out_missing_amount,
    251         out_amount_differs,
    252         out_reserve_pub_reuse
    253     FROM confirm_taler_withdrawal(
    254       $1,$2,$3,$4,$5,$6,$7,$8
    255     )
    256     ",
    257         )
    258         .bind(username)
    259         .bind(uuid)
    260         .bind_timestamp(timestamp)
    261         .bind(is2fa)
    262         .bind(fees)
    263         .bind(min)
    264         .bind(max)
    265         .bind(amount)
    266         .try_map(|r: PgRow| {
    267             Ok(if r.try_get_flag("out_no_op")? {
    268                 ConfirmationResult::UnknownOperation
    269             } else if r.try_get_flag("out_balance_insufficient")? {
    270                 ConfirmationResult::BalanceInsufficient
    271             } else if r.try_get_flag("out_bad_amount")? {
    272                 ConfirmationResult::BadAmount
    273             } else if r.try_get_flag("out_not_selected")? {
    274                 ConfirmationResult::NotSelected
    275             } else if r.try_get_flag("out_aborted")? {
    276                 ConfirmationResult::AlreadyAborted
    277             } else if r.try_get_flag("out_tan_required")? {
    278                 ConfirmationResult::TanRequired
    279             } else if r.try_get_flag("out_missing_amount")? {
    280                 ConfirmationResult::MissingAmount
    281             } else if r.try_get_flag("out_amount_differs")? {
    282                 ConfirmationResult::AmountDiffers
    283             } else if r.try_get_flag("out_reserve_pub_reuse")? {
    284                 ConfirmationResult::ReservePubReuse
    285             } else {
    286                 ConfirmationResult::Success
    287             })
    288         })
    289         .fetch_one(db)
    290     )
    291 }
    292 /** Get withdrawal operation [uuid] linked account username */
    293 pub async fn get_username(db: &PgPool, uuid: Uuid) -> sqlx::Result<Option<CompactString>> {
    294     serialized!(
    295         sqlx::query_scalar(
    296             "
    297     SELECT username
    298     FROM taler_withdrawal_operations
    299         JOIN bank_accounts ON wallet_bank_account=bank_account_id
    300         JOIN customers ON customer_id=owning_customer_id
    301     WHERE withdrawal_uuid=$1
    302     ",
    303         )
    304         .bind(uuid)
    305         .fetch_optional(db)
    306     )
    307 }
    308 
    309 /** Pool public info of operation [uuid] */
    310 pub async fn poll_info(
    311     db: &PgPool,
    312     ctx: &PaytoCtx,
    313     currency: &Currency,
    314     channel: &NotificationChannel<Uuid, Option<WithdrawalStatus>>,
    315     params: &Status,
    316     uuid: Uuid,
    317 ) -> sqlx::Result<Option<WithdrawalPublicInfo>> {
    318     pooling(
    319         &params.polling,
    320         || channel.subscribe(uuid),
    321         |it| it.is_some_and(|v| v  != params.status),
    322         async ||
    323             serialized!(
    324             sqlx::query(
    325                 "
    326                     SELECT
    327                     CASE
    328                         WHEN confirmation_done THEN 'confirmed'
    329                         WHEN aborted THEN 'aborted'
    330                         WHEN selection_done THEN 'selected'
    331                         ELSE 'pending'
    332                     END as status
    333                     ,amount
    334                     ,suggested_amount
    335                     ,selection_done
    336                     ,aborted
    337                     ,confirmation_done
    338                     ,reserve_pub
    339                     ,wallet_user.username
    340                     ,no_amount_to_wallet
    341                     ,exchange_account.internal_payto as exchange_payto
    342                     ,exchange_user.name as exchange_name
    343                     FROM taler_withdrawal_operations
    344                         LEFT JOIN bank_accounts AS wallet_account ON wallet_bank_account=wallet_account.bank_account_id
    345                         LEFT JOIN customers AS wallet_user ON wallet_user.customer_id=wallet_account.owning_customer_id
    346                         LEFT JOIN bank_accounts AS exchange_account ON exchange_bank_account=exchange_account.bank_account_id
    347                         LEFT JOIN customers AS exchange_user ON exchange_user.customer_id=exchange_account.owning_customer_id
    348                     WHERE withdrawal_uuid=$1
    349                     "
    350             ).bind(uuid)
    351             .try_map(|r: PgRow| Ok(WithdrawalPublicInfo {
    352                 status: r.try_get("status")?,
    353                 amount: r.try_get_opt_amount("amount", currency)?,
    354                 suggested_amount: r.try_get_opt_amount("suggested_amount", currency)?,
    355                 no_amount_to_wallet: r.try_get("no_amount_to_wallet")?,
    356                 username: r.try_get::<Option<CompactString>, _>("username")?.unwrap_or(CompactString::const_new("Uknonwn")),
    357                 selected_reserve_pub: r.try_get("reserve_pub")?,
    358                 selected_exchange_account: sql_opt_bank_payto(&r, ctx, "exchange_payto", "exchange_name")?
    359             }))
    360 
    361             .fetch_optional(db))
    362         ,
    363         |init| init.as_ref().map(|it| it.status != params.status).unwrap_or(true),
    364     ).await
    365 }
    366 
    367 /** Pool public status of operation [uuid] */
    368 pub async fn poll_status(
    369     db: &PgPool,
    370     ctx: &PaytoCtx,
    371     currency: &Currency,
    372     channel: &NotificationChannel<Uuid, Option<WithdrawalStatus>>,
    373     params: &Status,
    374     uuid: Uuid,
    375     wire: WireMethod,
    376     max: Amount,
    377 ) -> sqlx::Result<Option<BankWithdrawalOperationStatus>> {
    378     pooling(
    379         &params.polling,
    380         || channel.subscribe(uuid),
    381            |it| it.is_some_and(|v| v  != params.status),
    382         async ||
    383             serialized!(
    384             sqlx::query(
    385                 "
    386                     SELECT
    387                     CASE
    388                         WHEN confirmation_done THEN 'confirmed'
    389                         WHEN aborted THEN 'aborted'
    390                         WHEN selection_done THEN 'selected'
    391                         ELSE 'pending'
    392                     END as status
    393                     ,amount
    394                     ,suggested_amount
    395                     ,selection_done
    396                     ,aborted
    397                     ,confirmation_done
    398                     ,wallet_account.internal_payto
    399                     ,wallet_user.name
    400                     ,reserve_pub
    401                     ,exchange_account.internal_payto as exchange_payto
    402                     ,exchange_user.name as exchange_name
    403                     ,max_amount
    404                     ,no_amount_to_wallet
    405                     FROM taler_withdrawal_operations
    406                         LEFT JOIN bank_accounts AS wallet_account ON wallet_bank_account=wallet_account.bank_account_id
    407                         LEFT JOIN customers AS wallet_user ON wallet_user.customer_id=wallet_account.owning_customer_id
    408                         LEFT JOIN bank_accounts AS exchange_account ON exchange_bank_account=exchange_account.bank_account_id
    409                         LEFT JOIN customers AS exchange_user ON exchange_user.customer_id=exchange_account.owning_customer_id
    410                         ,account_max_amount(wallet_account.bank_account_id, $1) AS max_amount
    411                     WHERE withdrawal_uuid=$2
    412                     "
    413             )
    414             .bind(max)
    415             .bind(uuid)
    416             .try_map(|r: PgRow| {
    417                 Ok(BankWithdrawalOperationStatus {
    418                 status: r.try_get("status")?,
    419                 amount: r.try_get_opt_amount("amount", currency)?,
    420                 suggested_amount: r.try_get_opt_amount("suggested_amount", currency)?,
    421                 max_amount: r.try_get_opt_amount("max_amount", currency)?,
    422                 no_amount_to_wallet: r.try_get("no_amount_to_wallet")?,
    423                 selected_reserve_pub: r.try_get("reserve_pub")?,
    424                 selected_exchange_account: sql_opt_bank_payto(&r, ctx, "exchange_payto", "exchange_name")?,
    425                 selection_done: r.try_get("selection_done")?,
    426                 transfer_done: r.try_get("confirmation_done")?,
    427                 aborted : r.try_get("aborted")?,
    428                 card_fees: None,
    429                 currency: Some(*currency),
    430                 min_amount: None,
    431                 confirm_transfer_url: None,
    432                 required_exchange: None,
    433                 sender_wire: sql_opt_bank_payto(&r, ctx, "internal_payto", "name")?,
    434                 suggested_exchange: None,
    435                 wire_types: vec![wire.to_compact_string()]
    436             })})
    437             .fetch_optional(db))
    438         ,
    439         |init| init.as_ref().map(|it| it.status != params.status).unwrap_or(true),
    440     ).await
    441 }