libeufin

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

cashout.rs (15346B)


      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 std::sync::Arc;
     21 
     22 use axum::{
     23     Json, Router,
     24     extract::State,
     25     middleware::{self, Next},
     26     response::{IntoResponse, NoContent},
     27     routing::{get, post},
     28 };
     29 use compact_str::CompactString;
     30 use jiff::Timestamp;
     31 use serde::{Deserialize, Serialize};
     32 use taler_api::{
     33     error::{ApiResult, failure_code, not_implemented},
     34     extract::{Path, Query},
     35 };
     36 use taler_common::{
     37     api::{ShortHashCode, params::PageParams},
     38     error_code::ErrorCode,
     39     types::{amount::Amount, time::TalerTimestamp},
     40 };
     41 
     42 use crate::{
     43     api::BankState,
     44     auth::{AdminRAuth, UserRAuth},
     45     db::cashout::{CreationResult, create, get_for_user, page_all, page_for_user},
     46     mfa::{CashoutOp, MfaReq},
     47 };
     48 
     49 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
     50 #[allow(non_camel_case_types)]
     51 pub enum CashoutStatus {
     52     pending,
     53     aborted,
     54     confirmed,
     55 }
     56 
     57 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
     58 pub struct CashoutRequest {
     59     pub request_uid: ShortHashCode,
     60     pub subject: Option<String>,
     61     pub amount_debit: Amount,
     62     pub amount_credit: Amount,
     63 }
     64 
     65 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
     66 pub struct CashoutResponse {
     67     pub cashout_id: u64,
     68 }
     69 
     70 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
     71 pub struct Cashouts {
     72     pub cashouts: Vec<CashoutInfo>,
     73 }
     74 
     75 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
     76 pub struct CashoutInfo {
     77     pub cashout_id: u64,
     78     pub status: CashoutStatus,
     79 }
     80 
     81 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
     82 pub struct GlobalCashouts {
     83     pub cashouts: Vec<GlobalCashoutInfo>,
     84 }
     85 
     86 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
     87 pub struct GlobalCashoutInfo {
     88     pub cashout_id: u64,
     89     pub username: CompactString,
     90     pub status: CashoutStatus,
     91 }
     92 
     93 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
     94 pub struct CashoutStatusResponse {
     95     pub amount_debit: Amount,
     96     pub amount_credit: Amount,
     97     pub subject: String,
     98     pub creation_time: TalerTimestamp,
     99     pub confirmation_time: Option<TalerTimestamp>,
    100 }
    101 
    102 pub fn cashout_api(state: Arc<BankState>) -> Router<Arc<BankState>> {
    103     Router::new()
    104         .route(
    105             "/cashouts",
    106             get(
    107                 async |Query(params): Query<PageParams>,
    108                        _: AdminRAuth,
    109                        State(state): State<Arc<BankState>>| {
    110                     let params = params.check()?;
    111 
    112                     let cashouts = page_all(&state.db, &params).await?;
    113                     if cashouts.is_empty() {
    114                         ApiResult::Ok(NoContent.into_response())
    115                     } else {
    116                         Ok(Json(GlobalCashouts { cashouts }).into_response())
    117                     }
    118                 },
    119             ),
    120         )
    121         .route(
    122             "/accounts/{username}/cashouts",
    123             post(
    124                 async |State(state): State<Arc<BankState>>, req: MfaReq<CashoutOp>| {
    125                     let (mut auth, req, mfa) = req.solve(&state, &[]).await?;
    126                     state.cfg.check_regio(&req.amount_debit)?;
    127                     state.cfg.check_fiat(&req.amount_credit)?;
    128                     match create(
    129                         &state.db,
    130                         &auth.username,
    131                         &req.request_uid,
    132                         &req.amount_debit,
    133                         &req.amount_credit,
    134                         &req.subject.unwrap_or_default(),
    135                         &Timestamp::now(),
    136                         mfa.is_2fa(),
    137                     )
    138                     .await?
    139                     {
    140                         CreationResult::Success(cashout_id) => {
    141                             Ok(Json(CashoutResponse { cashout_id }).into_response())
    142                         }
    143                         CreationResult::UnderMin => {
    144                             Err(failure_code(ErrorCode::BANK_CONVERSION_AMOUNT_TO_SMALL))
    145                         }
    146                         CreationResult::BadConversion => {
    147                             Err(failure_code(ErrorCode::BANK_BAD_CONVERSION))
    148                         }
    149                         CreationResult::AccountNotFound => {
    150                             Err(failure_code(ErrorCode::BANK_UNKNOWN_ACCOUNT))
    151                         }
    152                         CreationResult::AccountIsExchange => {
    153                             Err(failure_code(ErrorCode::BANK_ACCOUNT_IS_EXCHANGE))
    154                         }
    155                         CreationResult::BalanceInsufficient => {
    156                             Err(failure_code(ErrorCode::BANK_UNALLOWED_DEBIT))
    157                         }
    158                         CreationResult::RequestUidReuse => {
    159                             Err(failure_code(ErrorCode::BANK_TRANSFER_REQUEST_UID_REUSED))
    160                         }
    161                         CreationResult::NoCashoutPayto => {
    162                             Err(failure_code(ErrorCode::BANK_CONFIRM_INCOMPLETE))
    163                         }
    164                         CreationResult::TanRequired => Ok(mfa
    165                             .response_mfa(&mut auth, &state.db, &state.cfg.ctx)
    166                             .await?
    167                             .into_response()),
    168                     }
    169                 },
    170             )
    171             .get(
    172                 async |Query(params): Query<PageParams>,
    173                        auth: UserRAuth,
    174                        State(state): State<Arc<BankState>>| {
    175                     let params = params.check()?;
    176 
    177                     let cashouts = page_for_user(&state.db, &auth.username, &params).await?;
    178                     if cashouts.is_empty() {
    179                         ApiResult::Ok(NoContent.into_response())
    180                     } else {
    181                         Ok(Json(Cashouts { cashouts }).into_response())
    182                     }
    183                 },
    184             ),
    185         )
    186         .route(
    187             "/accounts/{username}/cashouts/{id}",
    188             get(
    189                 async |Path((_, id)): Path<((), u64)>,
    190                        auth: UserRAuth,
    191                        State(state): State<Arc<BankState>>| {
    192                     match get_for_user(
    193                         &state.db,
    194                         &state.cfg.regional_currency,
    195                         state.cfg.fiat_currency().unwrap(),
    196                         &auth.username,
    197                         id,
    198                     )
    199                     .await?
    200                     {
    201                         Some(res) => Ok(Json(res)),
    202                         None => Err(failure_code(ErrorCode::BANK_TRANSACTION_NOT_FOUND)),
    203                     }
    204                 },
    205             ),
    206         )
    207         .route_layer(middleware::from_fn_with_state(
    208             state,
    209             async |State(state): State<Arc<BankState>>, req, next: Next| {
    210                 if state.cfg.fiat.is_none() {
    211                     not_implemented().into_response()
    212                 } else {
    213                     next.run(req).await
    214                 }
    215             },
    216         ))
    217 }
    218 
    219 #[cfg(test)]
    220 pub mod test {
    221     use axum::http::Method;
    222     use sqlx::postgres::PgConnectOptions;
    223     use taler_common::{api::ShortHashCode, error_code::ErrorCode, types::amount::amount};
    224     use taler_macros::db_test;
    225     use taler_test_utils::{
    226         json,
    227         routine::{Page, routine_pagination},
    228         server::TestServer,
    229         tasks,
    230     };
    231 
    232     use crate::api::{
    233         cashout::{CashoutResponse, CashoutStatusResponse, Cashouts, GlobalCashouts},
    234         conversion::ConversionRateClassResponse,
    235         test::{Auth, MfaRequest, bank_setup},
    236     };
    237 
    238     #[db_test(raw)]
    239     async fn cashout(db: PgConnectOptions) {
    240         let ctx = bank_setup(db).await;
    241 
    242         ctx.auth_routine(Method::POST, "/accounts/merchant/cashouts", Auth::UserOnly)
    243             .await;
    244         ctx.auth_routine(
    245             Method::GET,
    246             "/accounts/merchant/cashouts/42",
    247             Auth::UserOrAdmin,
    248         )
    249         .await;
    250         ctx.auth_routine(
    251             Method::GET,
    252             "/accounts/merchant/cashouts",
    253             Auth::UserOrAdmin,
    254         )
    255         .await;
    256         ctx.auth_routine(Method::GET, "/cashouts", Auth::Admin)
    257             .await;
    258 
    259         let str = "KUDOS:1.5";
    260         let debit = amount(str);
    261         let credit = ctx.convert(str).await;
    262 
    263         let req = json!({
    264             "request_uid": ShortHashCode::rand(),
    265             "amount_debit": debit,
    266             "amount_credit": credit,
    267             "subject": "test subject"
    268         });
    269 
    270         // Missing info
    271         ctx.posta("/accounts/customer/cashouts")
    272             .json(&req)
    273             .await
    274             .assert_error(ErrorCode::BANK_CONFIRM_INCOMPLETE);
    275 
    276         ctx.fill_cashout_info("customer").await;
    277 
    278         // Ok
    279         let res = ctx
    280             .posta("/accounts/customer/cashouts")
    281             .json(&req)
    282             .await
    283             .assert_ok_json::<CashoutResponse>();
    284         // Idempotent
    285         assert_eq!(
    286             res,
    287             ctx.posta("/accounts/customer/cashouts")
    288                 .json(&req)
    289                 .await
    290                 .assert_ok_json::<CashoutResponse>()
    291         );
    292 
    293         // Trigger conflict due to reused request_uid
    294         ctx.posta("/accounts/customer/cashouts")
    295             .json(json!(req + {
    296                    "amount_debit": "KUDOS:2",
    297                    "amount_credit": ctx.convert("KUDOS:2").await
    298             }))
    299             .await
    300             .assert_error(ErrorCode::BANK_TRANSFER_REQUEST_UID_REUSED);
    301 
    302         // Check exchange account
    303         ctx.posta("/accounts/exchange/cashouts")
    304             .json(&req)
    305             .await
    306             .assert_error(ErrorCode::BANK_ACCOUNT_IS_EXCHANGE);
    307 
    308         // Check insufficient fund
    309         ctx.posta("/accounts/customer/cashouts")
    310             .json(json!({
    311                 "request_uid": ShortHashCode::rand(),
    312                 "amount_debit": "KUDOS:75",
    313                 "amount_credit": ctx.convert("KUDOS:75").await,
    314             }))
    315             .await
    316             .assert_error(ErrorCode::BANK_UNALLOWED_DEBIT);
    317 
    318         // Check wrong conversion
    319         ctx.posta("/accounts/customer/cashouts")
    320             .json(json!(req + {
    321                    "amount_credit": ctx.convert("KUDOS:2").await
    322             }))
    323             .await
    324             .assert_error(ErrorCode::BANK_BAD_CONVERSION);
    325 
    326         // Check min amount
    327         ctx.posta("/accounts/customer/cashouts")
    328             .json(json!(req + { "amount_debit": "KUDOS:0.09" }))
    329             .await
    330             .assert_error(ErrorCode::BANK_CONVERSION_AMOUNT_TO_SMALL);
    331 
    332         // Check custom min account
    333         let id = ctx
    334             .post_admin("/conversion-rate-classes")
    335             .json(json!({
    336                 "name": "Custom class",
    337                 "cashout_min_amount": "KUDOS:10"
    338             }))
    339             .await
    340             .assert_ok_json::<ConversionRateClassResponse>()
    341             .conversion_rate_class_id;
    342         ctx.patch_admin("/accounts/customer")
    343             .json(json!({
    344                 "conversion_rate_class_id": id
    345             }))
    346             .await
    347             .assert_no_content();
    348         ctx.posta("/accounts/customer/cashouts")
    349             .json(json!(req + {
    350                    "amount_debit": "KUDOS:5",
    351                    "amount_credit": ctx.convert("KUDOS:5").await
    352             }))
    353             .await
    354             .assert_error(ErrorCode::BANK_CONVERSION_AMOUNT_TO_SMALL);
    355         ctx.patch_admin("/accounts/customer")
    356             .json(json!({
    357                 "conversion_rate_class_id": ()
    358             }))
    359             .await
    360             .assert_no_content();
    361 
    362         // Wrong currency
    363         ctx.posta("/accounts/customer/cashouts")
    364             .json(json!(req + { "amount_debit": "EUR:1" }))
    365             .await
    366             .assert_error(ErrorCode::GENERIC_CURRENCY_MISMATCH);
    367         ctx.posta("/accounts/customer/cashouts")
    368             .json(json!(req + { "amount_credit": "KUDOS:1" }))
    369             .await
    370             .assert_error(ErrorCode::GENERIC_CURRENCY_MISMATCH);
    371 
    372         // 2fa
    373         ctx.fill_tan_info("merchant").await;
    374         ctx.fill_cashout_info("merchant").await;
    375         ctx.assert_balance("merchant", "0").await;
    376         ctx.posta("/accounts/merchant/cashouts")
    377             .json(json!(req + { "request_uid": ShortHashCode::rand() }))
    378             .await
    379             .assert_challenge_check(&ctx, async |_| ctx.assert_balance("merchant", "0").await)
    380             .await
    381             .assert_ok_json::<CashoutResponse>();
    382         ctx.assert_balance("merchant", "-1.5").await;
    383 
    384         // Get
    385         let c = ctx
    386             .geta(format!("/accounts/customer/cashouts/{}", res.cashout_id))
    387             .await
    388             .assert_ok_json::<CashoutStatusResponse>();
    389         assert_eq!(
    390             c,
    391             CashoutStatusResponse {
    392                 amount_debit: debit,
    393                 amount_credit: credit,
    394                 subject: "test subject".into(),
    395                 creation_time: c.creation_time,
    396                 confirmation_time: Some(c.creation_time)
    397             }
    398         );
    399 
    400         // Bad ID
    401         ctx.geta("/accounts/customer/cashouts/chocolate")
    402             .await
    403             .assert_error(ErrorCode::GENERIC_PATH_SEGMENT_MALFORMED);
    404 
    405         // Unknown
    406         ctx.geta("/accounts/customer/cashouts/42")
    407             .await
    408             .assert_error(ErrorCode::BANK_TRANSACTION_NOT_FOUND);
    409 
    410         // Another user's operation
    411         ctx.geta(format!("/accounts/merchant/cashouts/{}", res.cashout_id))
    412             .await
    413             .assert_error(ErrorCode::BANK_TRANSACTION_NOT_FOUND);
    414 
    415         // History
    416         let router = ctx.admin_router().await;
    417         let c = &ctx;
    418         routine_pagination::<Cashouts>(
    419             &router.suffix("/accounts/customer/cashouts"),
    420             tasks!({
    421                 c.cashout("0.1").await;
    422             }),
    423         )
    424         .await;
    425         routine_pagination::<Cashouts>(
    426             &router.suffix("/cashouts"),
    427             tasks!({
    428                 c.cashout("0.1").await;
    429             }),
    430         )
    431         .await;
    432 
    433         // Not implemented
    434         let ctx = ctx.swap_cfg("test_no_conversion.conf").await;
    435         ctx.get("/accounts/customer/cashouts")
    436             .await
    437             .assert_not_implemented();
    438     }
    439 
    440     impl Page for Cashouts {
    441         fn ids(&self) -> Vec<i64> {
    442             self.cashouts
    443                 .iter()
    444                 .map(|it| it.cashout_id as i64)
    445                 .collect()
    446         }
    447     }
    448 
    449     impl Page for GlobalCashouts {
    450         fn ids(&self) -> Vec<i64> {
    451             self.cashouts
    452                 .iter()
    453                 .map(|it| it.cashout_id as i64)
    454                 .collect()
    455         }
    456     }
    457 }