libeufin

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

prepared.rs (11490B)


      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     response::NoContent,
     26     routing::{get, post},
     27 };
     28 use jiff::Timestamp;
     29 use taler_api::{
     30     api::{Validation, prepared::simple_subject},
     31     constants::PREPARED_TRANSFER_API_VERSION,
     32     error::failure_code,
     33     extract::Req,
     34 };
     35 use taler_common::{
     36     api::prepared::{
     37         PreparedTransferConfig, RegistrationRequest, RegistrationResponse, SubjectFormat,
     38         TransferSubject, Unregistration,
     39     },
     40     error_code::ErrorCode,
     41     types::time::TalerTimestamp,
     42 };
     43 
     44 use crate::{
     45     api::{BankState, IMPLEMENTATION},
     46     db::prepared::{RegistrationResult, register, unregister},
     47     payto::BankPayto,
     48 };
     49 
     50 pub fn prepared_api() -> Router<Arc<BankState>> {
     51     Router::new()
     52         .route(
     53             "/taler-prepared-transfer/config",
     54             get(async |State(state): State<Arc<BankState>>| {
     55                 Json(PreparedTransferConfig {
     56                     name: (),
     57                     version: PREPARED_TRANSFER_API_VERSION,
     58                     implementation: Some(IMPLEMENTATION),
     59                     currency: state.cfg.regional_currency,
     60                     supported_formats: vec![SubjectFormat::URI, SubjectFormat::SIMPLE],
     61                 })
     62             }),
     63         )
     64         .route(
     65             "/taler-prepared-transfer/registration",
     66             post(
     67                 async |State(state): State<Arc<BankState>>, Req(req): Req<RegistrationRequest>| {
     68                     req.check(&state.cfg.regional_currency)?;
     69                     let creditor = BankPayto::try_from(&req.credit_account)?;
     70                     match register(
     71                         &state.db,
     72                         &creditor,
     73                         req.r#type,
     74                         &req.account_pub,
     75                         &req.authorization_pub,
     76                         &req.authorization_sig,
     77                         req.recurrent,
     78                         &req.credit_amount,
     79                         &Timestamp::now(),
     80                     )
     81                     .await?
     82                     {
     83                         RegistrationResult::Success(uuid) => {
     84                             let mut subjects = Vec::new();
     85                             if let Some(uuid) = uuid {
     86                                 subjects.push(TransferSubject::Uri {
     87                                     credit_amount: req.credit_amount,
     88                                     uri: state.cfg.taler_withdraw_uri(uuid),
     89                                 });
     90                             }
     91                             subjects.push(simple_subject(req));
     92                             Ok(Json(RegistrationResponse {
     93                                 subjects,
     94                                 expiration: TalerTimestamp::Never,
     95                             }))
     96                         }
     97                         RegistrationResult::UnknownCreditor => {
     98                             Err(failure_code(ErrorCode::BANK_UNKNOWN_CREDITOR))
     99                         }
    100                         RegistrationResult::NotExchange => {
    101                             Err(failure_code(ErrorCode::BANK_ACCOUNT_IS_NOT_EXCHANGE))
    102                         }
    103                         RegistrationResult::ReservePubReuse => {
    104                             Err(failure_code(ErrorCode::BANK_DUPLICATE_RESERVE_PUB_SUBJECT))
    105                         }
    106                     }
    107                 },
    108             ),
    109         )
    110         .route(
    111             "/taler-prepared-transfer/unregistration",
    112             post(
    113                 async |State(state): State<Arc<BankState>>, Req(req): Req<Unregistration>| {
    114                     req.check(&state.cfg.regional_currency)?;
    115                     if unregister(&state.db, &req.authorization_pub, &Timestamp::now()).await? {
    116                         Ok(NoContent)
    117                     } else {
    118                         Err(failure_code(ErrorCode::BANK_TRANSACTION_NOT_FOUND))
    119                     }
    120                 },
    121             ),
    122         )
    123 }
    124 
    125 #[cfg(test)]
    126 mod test {
    127     use aws_lc_rs::signature::{Ed25519KeyPair, KeyPair as _};
    128     use sqlx::{
    129         Row as _,
    130         postgres::{PgConnectOptions, PgRow},
    131     };
    132     use taler_api::db::TypeHelper as _;
    133     use taler_common::{
    134         api::{
    135             EddsaPublicKey, EddsaSignature,
    136             prepared::{
    137                 PublicKeyAlg, RegistrationRequest, RegistrationResponse, TransferSubject,
    138                 TransferType,
    139             },
    140         },
    141         db::IncomingType,
    142         error_code::ErrorCode,
    143         types::amount::{Amount, Currency},
    144     };
    145     use taler_macros::db_test;
    146     use taler_test_utils::{
    147         json,
    148         routine::{Status, registration_routine},
    149         server::TestServer as _,
    150     };
    151 
    152     use crate::api::test::bank_setup;
    153 
    154     #[db_test(raw)]
    155     async fn registration(db: PgConnectOptions) {
    156         let ctx = bank_setup(db).await;
    157 
    158         let key_pair = Ed25519KeyPair::generate().unwrap();
    159         let auth_pub = EddsaPublicKey::try_from(key_pair.public_key().as_ref()).unwrap();
    160         let req = RegistrationRequest {
    161             credit_account: ctx.exchange_payto.as_uri(),
    162             r#type: TransferType::reserve,
    163             recurrent: false,
    164             credit_amount: Amount::new(&Currency::KUDOS, 1, 0),
    165             alg: PublicKeyAlg::EdDSA,
    166             account_pub: auth_pub,
    167             authorization_pub: auth_pub,
    168             authorization_sig: EddsaSignature::ZEROED,
    169         }
    170         .signed(&key_pair);
    171         ctx.post("/taler-prepared-transfer/registration")
    172             .json(
    173                 RegistrationRequest {
    174                     credit_account: ctx.customer_payto.as_uri(),
    175                     ..req.clone()
    176                 }
    177                 .signed(&key_pair),
    178             )
    179             .await
    180             .assert_error(ErrorCode::BANK_ACCOUNT_IS_NOT_EXCHANGE);
    181         ctx.post("/taler-prepared-transfer/registration")
    182             .json(
    183                 RegistrationRequest {
    184                     credit_account: ctx.unknown_payto.as_uri(),
    185                     ..req.clone()
    186                 }
    187                 .signed(&key_pair),
    188             )
    189             .await
    190             .assert_error(ErrorCode::BANK_UNKNOWN_CREDITOR);
    191 
    192         ctx.set_max_debt("customer", "1000").await;
    193         let admin = ctx.admin_router().await;
    194         registration_routine(
    195             &admin.prefix("/accounts/exchange/taler-wire-gateway"),
    196             &admin.prefix("/taler-prepared-transfer"),
    197             &ctx.customer_payto.as_uri(),
    198             &ctx.exchange_payto.as_uri(),
    199             &ctx.unknown_payto.as_uri(),
    200             async || {
    201                 sqlx::query(
    202                     "
    203                     SELECT
    204                         pending_recurrent_incoming_transactions.authorization_pub IS NOT NULL,
    205                         exchange_incoming_id IS NULL,
    206                         type::text,
    207                         metadata
    208                     FROM bank_account_transactions
    209                         JOIN bank_accounts ON bank_account_transactions.bank_account_id=bank_accounts.bank_account_id
    210                         JOIN customers ON customer_id=owning_customer_id
    211                         LEFT JOIN taler_exchange_incoming ON (bank_transaction=bank_transaction_id)
    212                         LEFT JOIN pending_recurrent_incoming_transactions USING (bank_transaction_id)
    213                     WHERE username='exchange' AND direction='credit'
    214                     ORDER BY bank_transaction_id
    215                 ",
    216                 )
    217                 .try_map(|r: PgRow| {
    218                     Ok(
    219                         if r.try_get_flag(0)? {
    220                             Status::Pending
    221                         } else if r.try_get_flag(1)? {
    222                             Status::Bounced
    223                         } else {
    224                             match r.try_get_opt_parse(2)? {
    225                                 None => Status::Simple,
    226                                 Some(IncomingType::reserve) => Status::Reserve(r.try_get(3)?),
    227                                 Some(IncomingType::kyc) => Status::Kyc(r.try_get(3)?),
    228                                 Some(e) => unreachable!("{e:?}")
    229                             }
    230                         }
    231                     )
    232                 })
    233                 .fetch_all(&ctx.state.db)
    234                 .await
    235                 .unwrap()
    236             }
    237         )
    238         .await;
    239 
    240         ctx.assert_balance("customer", "-630").await;
    241         ctx.assert_balance("exchange", "630").await;
    242         let subject = &format!("Taler MAP:{auth_pub}");
    243 
    244         // Non recurrent accept on then bounce
    245         let res = ctx
    246             .post("/taler-prepared-transfer/registration")
    247             .json(&req)
    248             .await
    249             .assert_ok_json::<RegistrationResponse>();
    250         let uuid = match &res.subjects[0] {
    251             TransferSubject::Uri { uri, .. } => uri.as_str().rsplit('/').next().unwrap(),
    252             _ => unreachable!(),
    253         };
    254 
    255         ctx.get(format!("/withdrawals/{uuid}")).await.assert_ok();
    256         ctx.get(format!("/taler-integration/withdrawal-operation/{uuid}"))
    257             .await
    258             .assert_ok();
    259         ctx.posta(format!("/accounts/customer/withdrawals/{uuid}/confirm"))
    260             .json(json!({}))
    261             .await
    262             .assert_no_content(); // reserve
    263         ctx.posta(format!("/accounts/merchant/withdrawals/{uuid}/confirm"))
    264             .json(json!({}))
    265             .await
    266             .assert_error(ErrorCode::BANK_TRANSACTION_NOT_FOUND);
    267         ctx.tx_s("customer", "1", "exchange", subject).await; // bounce
    268         ctx.tx_s("customer", "1", "exchange", subject).await; // bounce
    269         ctx.assert_balance("customer", "-631").await;
    270         ctx.assert_balance("exchange", "631").await;
    271 
    272         // Withdrawal is aborted on completion
    273         let res = ctx
    274             .post("/taler-prepared-transfer/registration")
    275             .json(
    276                 RegistrationRequest {
    277                     r#type: TransferType::kyc,
    278                     ..req.clone()
    279                 }
    280                 .signed(&key_pair),
    281             )
    282             .await
    283             .assert_ok_json::<RegistrationResponse>();
    284         let uuid = match &res.subjects[0] {
    285             TransferSubject::Uri { uri, .. } => uri.as_str().rsplit('/').next().unwrap(),
    286             _ => unreachable!(),
    287         };
    288         ctx.tx_s("customer", "1", "exchange", subject).await; // kyc
    289         ctx.tx_s("customer", "1", "exchange", subject).await; // bounce
    290         ctx.posta(format!("/accounts/customer/withdrawals/{uuid}/confirm"))
    291             .json(json!({}))
    292             .await
    293             .assert_error(ErrorCode::BANK_CONFIRM_ABORT_CONFLICT); // Aborted
    294         ctx.assert_balance("customer", "-632").await;
    295         ctx.assert_balance("exchange", "632").await;
    296     }
    297 }