libeufin

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

tx.rs (20640B)


      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 //! Data access logic for transactions
     21 
     22 use std::sync::Arc;
     23 
     24 use axum::{
     25     Json, Router,
     26     extract::State,
     27     response::{IntoResponse, NoContent},
     28     routing::{get, post},
     29 };
     30 use jiff::Timestamp;
     31 use serde::{Deserialize, Serialize};
     32 use taler_api::{
     33     error::{ApiResult, bad_request, failure, failure_code},
     34     extract::{Path, Query},
     35 };
     36 use taler_common::{
     37     api::{ShortHashCode, params::HistoryParams},
     38     error_code::ErrorCode,
     39     types::{amount::Amount, payto::ParsedPayto, time::TalerTimestamp},
     40 };
     41 
     42 use crate::{
     43     api::BankState,
     44     auth::{UserOptRAuth, UserRAuth},
     45     db::tx::{TxResult, create, get_by_id, pool_history},
     46     mfa::{BankTxOp, MfaReq},
     47     payto::{BankPayto, FullBankPayto, LibeufinId},
     48 };
     49 
     50 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
     51 pub struct TransactionCreateRequest {
     52     pub payto_uri: ParsedPayto<LibeufinId>,
     53     pub amount: Option<Amount>,
     54     pub request_uid: Option<ShortHashCode>,
     55 }
     56 
     57 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
     58 pub struct TransactionCreateResponse {
     59     pub row_id: u64,
     60 }
     61 
     62 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
     63 #[sqlx(type_name = "direction_enum")]
     64 #[allow(non_camel_case_types)]
     65 pub enum TransactionDirection {
     66     credit,
     67     debit,
     68 }
     69 
     70 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
     71 pub struct BankAccountTransactionInfo {
     72     pub creditor_payto_uri: FullBankPayto,
     73     pub debtor_payto_uri: FullBankPayto,
     74     pub amount: Amount,
     75     pub direction: TransactionDirection,
     76     pub subject: String,
     77     pub row_id: u64,
     78     pub date: TalerTimestamp,
     79 }
     80 
     81 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
     82 pub struct BankAccountTransactionsResponse {
     83     pub transactions: Vec<BankAccountTransactionInfo>,
     84 }
     85 
     86 pub fn tx_api() -> Router<Arc<BankState>> {
     87     Router::new()
     88         .route(
     89             "/accounts/{username}/transactions",
     90             post(
     91                 async |State(state): State<Arc<BankState>>, req: MfaReq<BankTxOp>| {
     92                     let (mut auth, req, mfa) = req.solve(&state, &[]).await?;
     93                     let subject = req
     94                         .payto_uri
     95                         .subject
     96                         .clone()
     97                         .ok_or_else(|| bad_request("Wire transfer lacks subject"))?;
     98                     let amount = req
     99                         .payto_uri
    100                         .amount
    101                         .or(req.amount)
    102                         .ok_or_else(|| bad_request("Wire transfer lacks amount"))?;
    103 
    104                     state.cfg.check_regio(&amount)?;
    105 
    106                     match create(
    107                         &state.db,
    108                         BankPayto::new(req.payto_uri.into_inner()),
    109                         &auth.username,
    110                         &subject,
    111                         amount,
    112                         &Timestamp::now(),
    113                         mfa.is_2fa(),
    114                         &req.request_uid,
    115                         state.cfg.wire_transfer_fees,
    116                         state.cfg.min_amount,
    117                         state.cfg.max_amount,
    118                     )
    119                     .await?
    120                     {
    121                         TxResult::Success(row_id) => {
    122                             Ok(Json(TransactionCreateResponse { row_id }).into_response())
    123                         }
    124                         TxResult::UnknownCreditor => {
    125                             Err(failure_code(ErrorCode::BANK_UNKNOWN_CREDITOR))
    126                         }
    127                         TxResult::AdminCreditor => {
    128                             Err(failure_code(ErrorCode::BANK_ADMIN_CREDITOR))
    129                         }
    130                         TxResult::UnknownDebtor => {
    131                             Err(failure_code(ErrorCode::BANK_UNKNOWN_ACCOUNT))
    132                         }
    133                         TxResult::BothPartySame => Err(failure_code(ErrorCode::BANK_SAME_ACCOUNT)),
    134                         TxResult::BalanceInsufficient => Err(failure(
    135                             ErrorCode::BANK_UNALLOWED_DEBIT,
    136                             "Insufficient funds",
    137                         )),
    138                         TxResult::BadAmount => Err(failure(
    139                             ErrorCode::BANK_UNALLOWED_DEBIT,
    140                             "Amount either to high or too low",
    141                         )),
    142                         TxResult::TanRequired => {
    143                             mfa.response_mfa(&mut auth, &state.db, &state.cfg.ctx).await
    144                         }
    145                         TxResult::RequestUidReuse => {
    146                             Err(failure_code(ErrorCode::BANK_TRANSFER_REQUEST_UID_REUSED))
    147                         }
    148                     }
    149                 },
    150             )
    151             .get(
    152                 async |mut auth: UserOptRAuth,
    153                        Query(params): Query<HistoryParams>,
    154                        State(state): State<Arc<BankState>>| {
    155                     let params = params.check()?;
    156                     let info = auth
    157                         .bank_info_auth_or_public(&state.db, &state.cfg.ctx)
    158                         .await?;
    159                     let transactions = pool_history(
    160                         &state.db,
    161                         &state.cfg.ctx,
    162                         &state.cfg.regional_currency,
    163                         &state.tx_channel,
    164                         &params,
    165                         info.bank_account_id,
    166                     )
    167                     .await?;
    168                     if transactions.is_empty() {
    169                         ApiResult::Ok(NoContent.into_response())
    170                     } else {
    171                         Ok(Json(BankAccountTransactionsResponse { transactions }).into_response())
    172                     }
    173                 },
    174             ),
    175         )
    176         .route(
    177             "/accounts/{username}/transactions/{id}",
    178             get(
    179                 async |auth: UserRAuth,
    180                        Path((_, id)): Path<((), u64)>,
    181                        State(state): State<Arc<BankState>>| {
    182                     if let Some(tx) = get_by_id(
    183                         &state.db,
    184                         &state.cfg.regional_currency,
    185                         &state.cfg.ctx,
    186                         id,
    187                         &auth.username,
    188                     )
    189                     .await?
    190                     {
    191                         Ok(Json(tx))
    192                     } else {
    193                         Err(failure_code(ErrorCode::BANK_TRANSACTION_NOT_FOUND))
    194                     }
    195                 },
    196             ),
    197         )
    198 }
    199 
    200 #[cfg(test)]
    201 pub mod test {
    202 
    203     use axum::http::Method;
    204     use sqlx::postgres::PgConnectOptions;
    205     use taler_api::subject::{fmt_in_subject, fmt_out_subject};
    206     use taler_common::{
    207         api::{EddsaPublicKey, ShortHashCode},
    208         db::IncomingType,
    209         error_code::ErrorCode,
    210         types::amount::amount,
    211     };
    212     use taler_macros::db_test;
    213     use taler_test_utils::{
    214         json,
    215         routine::{Page, routine_history},
    216         server::TestServer,
    217         tasks,
    218     };
    219 
    220     use crate::api::{
    221         test::{Auth, MfaRequest, bank_setup, bank_setup_conf},
    222         tx::{
    223             BankAccountTransactionInfo, BankAccountTransactionsResponse, TransactionCreateResponse,
    224         },
    225     };
    226 
    227     #[db_test(raw)]
    228     async fn tx(db: PgConnectOptions) {
    229         let ctx = bank_setup(db).await;
    230 
    231         ctx.auth_routine(
    232             Method::POST,
    233             "/accounts/merchant/transactions",
    234             Auth::UserOnly,
    235         )
    236         .await;
    237         ctx.auth_routine(
    238             Method::GET,
    239             "/accounts/merchant/transactions/42",
    240             Auth::UserOrAdmin,
    241         )
    242         .await;
    243 
    244         let valid_req = json!({
    245             "payto_uri": format!("{}?message=payout", ctx.exchange_payto),
    246             "amount": "KUDOS:0.3"
    247         });
    248 
    249         // OK
    250         let id = ctx
    251             .posta("/accounts/merchant/transactions")
    252             .json(&valid_req)
    253             .await
    254             .assert_ok_json::<TransactionCreateResponse>()
    255             .row_id;
    256         let tx: BankAccountTransactionInfo = ctx
    257             .geta(format!("/accounts/merchant/transactions/{id}"))
    258             .await
    259             .assert_ok_json();
    260         assert_eq!(tx.subject, "payout");
    261         assert_eq!(tx.amount, amount("KUDOS:0.3"));
    262 
    263         // Idempotency
    264         let uid = ShortHashCode::rand();
    265         let id = ctx
    266             .posta("/accounts/merchant/transactions")
    267             .json(json!(valid_req + { "request_uid": uid }))
    268             .await
    269             .assert_ok_json::<TransactionCreateResponse>()
    270             .row_id;
    271         assert_eq!(
    272             id,
    273             ctx.posta("/accounts/merchant/transactions")
    274                 .json(json!(valid_req + { "request_uid": uid }))
    275                 .await
    276                 .assert_ok_json::<TransactionCreateResponse>()
    277                 .row_id
    278         );
    279         ctx.posta("/accounts/merchant/transactions")
    280             .json(json!(valid_req + {
    281                 "request_uid": uid,
    282                 "amount": "KUDOS:42"
    283             }))
    284             .await
    285             .assert_error(ErrorCode::BANK_TRANSFER_REQUEST_UID_REUSED);
    286 
    287         // Amount in payto_uri
    288         let id = ctx
    289             .posta("/accounts/merchant/transactions")
    290             .json(json!({
    291                 "payto_uri": format!("{}?message=payout3&amount=KUDOS:1.05", ctx.exchange_payto),
    292                 "amount": "KUDOS:10.003"
    293             }))
    294             .await
    295             .assert_ok_json::<TransactionCreateResponse>()
    296             .row_id;
    297         let tx: BankAccountTransactionInfo = ctx
    298             .geta(format!("/accounts/merchant/transactions/{id}"))
    299             .await
    300             .assert_ok_json();
    301         assert_eq!(tx.subject, "payout3");
    302         assert_eq!(tx.amount, amount("KUDOS:1.05"));
    303 
    304         // Unknown tx
    305         ctx.geta("/accounts/merchant/transactions/3")
    306             .await
    307             .assert_error(ErrorCode::BANK_TRANSACTION_NOT_FOUND);
    308         // Other user tx
    309         ctx.geta(format!("/accounts/customer/transactions/{id}"))
    310             .await
    311             .assert_error(ErrorCode::BANK_TRANSACTION_NOT_FOUND);
    312 
    313         // Wrong currency
    314         ctx.posta("/accounts/merchant/transactions")
    315             .json(json!(valid_req + { "amount": "EUR:3.3" }))
    316             .await
    317             .assert_error(ErrorCode::GENERIC_CURRENCY_MISMATCH);
    318         // Surpassing the debt limit
    319         ctx.posta("/accounts/merchant/transactions")
    320             .json(json!(valid_req + { "amount": "KUDOS:555" }))
    321             .await
    322             .assert_error(ErrorCode::BANK_UNALLOWED_DEBIT);
    323         // Missing message
    324         ctx.posta("/accounts/merchant/transactions")
    325             .json(json!(valid_req + { "payto_uri": ctx.exchange_payto }))
    326             .await
    327             .assert_bad_request();
    328         // Unknown creditor
    329         ctx.posta("/accounts/merchant/transactions")
    330             .json(json!(valid_req + {
    331                 "payto_uri": format!("{}?message=payout", ctx.unknown_payto)
    332             }))
    333             .await
    334             .assert_error(ErrorCode::BANK_UNKNOWN_CREDITOR);
    335         // Transaction to self
    336         ctx.posta("/accounts/merchant/transactions")
    337             .json(json!(valid_req + {
    338                 "payto_uri": format!("{}?message=payout", ctx.merchant_payto)
    339             }))
    340             .await
    341             .assert_error(ErrorCode::BANK_SAME_ACCOUNT);
    342         // Transaction to admin
    343         ctx.posta("/accounts/merchant/transactions")
    344             .json(json!(valid_req + {
    345                 "payto_uri": format!("{}?message=payout", ctx.admin_payto)
    346             }))
    347             .await
    348             .assert_error(ErrorCode::BANK_ADMIN_CREDITOR);
    349 
    350         // Init state
    351         ctx.assert_balance("merchant", "0").await;
    352         ctx.assert_balance("customer", "0").await;
    353         // Send 2 times 3
    354         for _ in 0..2 {
    355             ctx.tx("merchant", "3", "customer").await;
    356         }
    357         ctx.posta("/accounts/merchant/transactions")
    358             .json(json!(valid_req + {
    359                 "payto_uri": format!("{}?message=payout2&amount=KUDOS:5", ctx.customer_payto)
    360             }))
    361             .await
    362             .assert_error(ErrorCode::BANK_UNALLOWED_DEBIT);
    363         ctx.assert_balance("merchant", "-6").await;
    364         ctx.assert_balance("customer", "6").await;
    365         // Send through debt
    366         ctx.tx("customer", "10", "merchant").await;
    367         ctx.assert_balance("merchant", "4").await;
    368         ctx.assert_balance("customer", "-4").await;
    369         ctx.tx("merchant", "4", "customer").await;
    370 
    371         // Check bounce
    372         ctx.assert_balance("merchant", "0").await;
    373         ctx.assert_balance("exchange", "0").await;
    374         ctx.tx_s("merchant", "1", "exchange", "").await; // Bounce common to transaction
    375         ctx.tx_s("merchant", "1", "exchange", "Malformed").await; // Bounce malformed transaction
    376         ctx.tx_s("merchant", "1", "exchange", "ADMIN BALANCE ADJUST")
    377             .await; // Bounce admin balance adjust
    378         let key = EddsaPublicKey::rand();
    379         ctx.tx_s(
    380             "merchant",
    381             "1",
    382             "exchange",
    383             &fmt_in_subject(IncomingType::reserve, &key),
    384         )
    385         .await; // Accept incoming
    386         ctx.tx_s(
    387             "merchant",
    388             "1",
    389             "exchange",
    390             &fmt_in_subject(IncomingType::reserve, &key),
    391         )
    392         .await; // Bounce reserve_pub reuse
    393         ctx.assert_balance("merchant", "-1").await;
    394         ctx.assert_balance("exchange", "1").await;
    395 
    396         // Check warn
    397         ctx.tx_s("exchange", "1", "merchant", "").await; // Warn common to transaction
    398         ctx.tx_s("exchange", "1", "merchant", "Malformed").await; // Warn malformed transaction
    399         let wtid = ShortHashCode::rand();
    400         let url = "https://exchange.example.com";
    401         ctx.tx_s(
    402             "exchange",
    403             "1",
    404             "merchant",
    405             &fmt_out_subject(&wtid, url, None),
    406         )
    407         .await; // Accept outgoing
    408         ctx.tx_s(
    409             "exchange",
    410             "1",
    411             "merchant",
    412             &fmt_out_subject(&wtid, url, None),
    413         )
    414         .await; // Warn wtid reuse
    415         ctx.assert_balance("merchant", "3").await;
    416         ctx.assert_balance("exchange", "-3").await;
    417 
    418         // Public history is public
    419         ctx.patch_admin("/accounts/merchant")
    420             .json(json!({ "is_public": true}))
    421             .await
    422             .assert_no_content();
    423         ctx.get("/accounts/merchant/transactions").await.assert_ok();
    424         ctx.get("/accounts/customer/transactions")
    425             .await
    426             .assert_error(ErrorCode::BANK_UNKNOWN_ACCOUNT);
    427         ctx.patch_admin("/accounts/merchant")
    428             .json(json!({ "is_public": false}))
    429             .await
    430             .assert_no_content();
    431         ctx.get("/accounts/merchant/transactions")
    432             .await
    433             .assert_error(ErrorCode::BANK_UNKNOWN_ACCOUNT);
    434 
    435         // Check 2fa
    436         ctx.fill_tan_info("merchant").await;
    437         ctx.assert_balance("merchant", "3").await;
    438         ctx.assert_balance("customer", "0").await;
    439         ctx.posta("/accounts/merchant/transactions")
    440             .json(json!(valid_req + {
    441                 "payto_uri": format!("{}?message=tan+check&amount=KUDOS:1", ctx.customer_payto)
    442             }))
    443             .await
    444             .assert_challenge_check(&ctx, async |_| {
    445                 ctx.assert_balance("merchant", "3").await;
    446                 ctx.assert_balance("customer", "0").await;
    447             })
    448             .await
    449             .assert_ok();
    450         ctx.assert_balance("merchant", "2").await;
    451         ctx.assert_balance("customer", "1").await;
    452 
    453         // Check 2fa idempotency
    454         let req = json!({
    455             "payto_uri": format!("{}?message=tan+check&amount=KUDOS:1", ctx.customer_payto),
    456             "request_uid": ShortHashCode::rand(),
    457         });
    458         let res = ctx
    459             .posta("/accounts/merchant/transactions")
    460             .json(&req)
    461             .await
    462             .assert_challenge_check(&ctx, async |_| {
    463                 ctx.assert_balance("merchant", "2").await;
    464                 ctx.assert_balance("customer", "1").await;
    465             })
    466             .await
    467             .assert_ok_json::<TransactionCreateResponse>();
    468         ctx.assert_balance("merchant", "1").await;
    469         ctx.assert_balance("customer", "2").await;
    470         assert_eq!(
    471             res,
    472             ctx.posta("/accounts/merchant/transactions")
    473                 .json(&req)
    474                 .await
    475                 .assert_ok_json::<TransactionCreateResponse>()
    476         );
    477         ctx.posta("/accounts/merchant/transactions")
    478             .json(json!(req + {
    479                 "payto_uri": format!("{}?message=tan+chec2k&amount=KUDOS:1", ctx.customer_payto)
    480             }))
    481             .await
    482             .assert_error(ErrorCode::BANK_TRANSFER_REQUEST_UID_REUSED);
    483     }
    484 
    485     #[db_test(raw)]
    486     async fn tx_with_fee(db: PgConnectOptions) {
    487         let ctx = bank_setup_conf(db, "test_with_fees.conf").await;
    488 
    489         // Init state
    490         ctx.assert_balance("merchant", "0").await;
    491         ctx.assert_balance("customer", "0").await;
    492         ctx.assert_balance("admin", "0").await;
    493 
    494         // Check fee are sent to admin
    495         ctx.tx("merchant", "3", "customer").await;
    496         ctx.assert_balance("merchant", "-3.1").await;
    497         ctx.assert_balance("customer", "3").await;
    498         ctx.assert_balance("admin", "0.1").await;
    499 
    500         // Check amount with fee and min & max are checked
    501         for amount in ["KUDOS:7", "KUDOS:6.9", "KUDOS:0", "KUDOS:150"] {
    502             ctx.posta("/accounts/merchant/transactions")
    503                 .json(json!({
    504                     "payto_uri": format!("{}?message=payout2&amount={amount}", ctx.customer_payto)
    505                 }))
    506                 .await
    507                 .assert_error(ErrorCode::BANK_UNALLOWED_DEBIT);
    508         }
    509 
    510         // Check empty account
    511         ctx.tx("merchant", "6.8", "customer").await;
    512         ctx.assert_balance("merchant", "-10").await;
    513         ctx.assert_balance("customer", "9.8").await;
    514         ctx.assert_balance("admin", "0.2").await;
    515 
    516         // Admin check no fee
    517         ctx.tx("admin", "0.35", "merchant").await;
    518         ctx.assert_balance("merchant", "-9.65").await;
    519         ctx.assert_balance("admin", "-0.15").await;
    520 
    521         // Admin recover from debt
    522         ctx.tx("customer", "1", "merchant").await;
    523         ctx.assert_balance("admin", "-0.05").await;
    524         ctx.tx("customer", "1", "merchant").await;
    525         ctx.assert_balance("merchant", "-7.65").await;
    526         ctx.assert_balance("customer", "7.6").await;
    527         ctx.assert_balance("admin", "0.05").await;
    528     }
    529 
    530     impl Page for BankAccountTransactionsResponse {
    531         fn ids(&self) -> Vec<i64> {
    532             self.transactions
    533                 .iter()
    534                 .map(|it| it.row_id as i64)
    535                 .collect()
    536         }
    537     }
    538 
    539     #[db_test(raw)]
    540     async fn history(db: PgConnectOptions) {
    541         let ctx = &bank_setup(db).await;
    542 
    543         ctx.auth_routine(
    544             Method::POST,
    545             "/accounts/merchant/transactions",
    546             Auth::UserOrAdmin,
    547         )
    548         .await;
    549 
    550         ctx.fill_cashout_info("customer").await;
    551 
    552         routine_history::<BankAccountTransactionsResponse>(
    553             &ctx.admin_router()
    554                 .await
    555                 .suffix("/accounts/customer/transactions"),
    556             tasks!(
    557                 // Incoming
    558                 { ctx.tx("merchant", "0.1", "customer").await },
    559                 // Outgoing
    560                 { ctx.tx("customer", "0.1", "merchant").await },
    561                 // Cashout from merchant
    562                 { ctx.cashout("0.1").await }
    563             ),
    564             tasks!(
    565                 // Other account
    566                 { ctx.tx("merchant", "0.1", "exchange").await },
    567                 { ctx.tx("exchange", "0.1", "merchant").await },
    568             ),
    569         )
    570         .await;
    571     }
    572 }