taler-rust

GNU Taler code in Rust. Largely core banking integrations.
Log | Files | Refs | Submodules | README | LICENSE

api.rs (19660B)


      1 /*
      2   This file is part of TALER
      3   Copyright (C) 2025, 2026 Taler Systems SA
      4 
      5   TALER is free software; you can redistribute it and/or modify it under the
      6   terms of the GNU Affero General Public License as published by the Free Software
      7   Foundation; either version 3, or (at your option) any later version.
      8 
      9   TALER is distributed in the hope that it will be useful, but WITHOUT ANY
     10   WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
     11   A PARTICULAR PURPOSE.  See the GNU Affero General Public License for more details.
     12 
     13   You should have received a copy of the GNU Affero General Public License along with
     14   TALER; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
     15 */
     16 
     17 use jiff::Timestamp;
     18 use prometheus_client::{metrics::gauge::Gauge, registry::Registry};
     19 use sqlx::PgPool;
     20 use taler_api::{
     21     api::{
     22         TalerApi, observability::Observability, prepared::PreparedTransfer, revenue::Revenue,
     23         wire::WireGateway,
     24     },
     25     error::{ApiResult, failure_code},
     26     subject::{IncomingKey, fmt_in_subject},
     27 };
     28 use taler_common::{
     29     api::{
     30         params::{History, Page},
     31         prepared::{
     32             RegistrationRequest, RegistrationResponse, SubjectFormat, TransferSubject,
     33             Unregistration,
     34         },
     35         revenue::RevenueIncomingHistory,
     36         wire::{
     37             AddIncomingRequest, AddIncomingResponse, AddKycauthRequest, AddMappedRequest,
     38             IncomingHistory, OutgoingHistory, TransferList, TransferRequest, TransferResponse,
     39             TransferState, TransferStatus,
     40         },
     41     },
     42     db::IncomingType,
     43     error_code::ErrorCode,
     44     types::{amount::Currency, time::TalerTimestamp, utils::date_to_utc_ts},
     45 };
     46 use tokio::sync::watch::Sender;
     47 
     48 use crate::{
     49     FullHuPayto,
     50     constants::CURR,
     51     db::{self, AddIncomingResult, Transfer, TxInAdmin},
     52 };
     53 
     54 pub struct MagnetApi {
     55     pub pool: sqlx::PgPool,
     56     pub payto: FullHuPayto,
     57     pub in_channel: Sender<i64>,
     58     pub taler_in_channel: Sender<i64>,
     59     pub out_channel: Sender<i64>,
     60     pub taler_out_channel: Sender<i64>,
     61     metrics: Metrics,
     62     registry: Registry,
     63 }
     64 
     65 #[derive(Default)]
     66 struct Metrics {
     67     db_access: Gauge,
     68 }
     69 
     70 impl Metrics {
     71     pub fn registry(&self) -> Registry {
     72         let mut registry = Registry::default();
     73 
     74         registry.register(
     75             "db_access",
     76             "Whether the last database metrics refresh succeeded",
     77             self.db_access.clone(),
     78         );
     79         registry
     80     }
     81 
     82     pub async fn sync(&self, db: &PgPool) {
     83         let test = sqlx::query("SELECT 1").fetch_one(db).await.is_ok();
     84         self.db_access.set(if test { 1 } else { 0 });
     85     }
     86 }
     87 
     88 impl MagnetApi {
     89     pub async fn start(pool: sqlx::PgPool, payto: FullHuPayto) -> Self {
     90         let in_channel = Sender::new(0);
     91         let taler_in_channel = Sender::new(0);
     92         let out_channel = Sender::new(0);
     93         let taler_out_channel = Sender::new(0);
     94 
     95         let metrics = Metrics::default();
     96 
     97         let tmp = Self {
     98             pool: pool.clone(),
     99             payto,
    100             in_channel: in_channel.clone(),
    101             taler_in_channel: taler_in_channel.clone(),
    102             out_channel: out_channel.clone(),
    103             taler_out_channel: taler_out_channel.clone(),
    104             registry: metrics.registry(),
    105             metrics,
    106         };
    107         tokio::spawn(db::notification_listener(
    108             pool,
    109             in_channel,
    110             taler_in_channel,
    111             out_channel,
    112             taler_out_channel,
    113         ));
    114         tmp
    115     }
    116 }
    117 
    118 impl TalerApi for MagnetApi {
    119     fn currency(&self) -> Currency {
    120         CURR
    121     }
    122 
    123     fn implementation(&self) -> &'static str {
    124         "urn:net:taler:specs:taler-magnet-bank:taler-rust"
    125     }
    126 }
    127 
    128 impl WireGateway for MagnetApi {
    129     async fn transfer(&self, req: TransferRequest) -> ApiResult<TransferResponse> {
    130         let creditor = FullHuPayto::try_from(&req.credit_account)?;
    131         let result = db::make_transfer(
    132             &self.pool,
    133             &Transfer {
    134                 request_uid: req.request_uid,
    135                 wtid: req.wtid,
    136                 amount: req.amount.decimal(),
    137                 metadata: req.metadata,
    138                 creditor,
    139                 exchange_base_url: req.exchange_base_url,
    140             },
    141             &Timestamp::now(),
    142         )
    143         .await?;
    144         match result {
    145             db::TransferResult::Success { id, initiated_at } => Ok(TransferResponse {
    146                 timestamp: initiated_at.into(),
    147                 row_id: id,
    148             }),
    149             db::TransferResult::RequestUidReuse => {
    150                 Err(failure_code(ErrorCode::BANK_TRANSFER_REQUEST_UID_REUSED))
    151             }
    152             db::TransferResult::WtidReuse => {
    153                 Err(failure_code(ErrorCode::BANK_TRANSFER_WTID_REUSED))
    154             }
    155         }
    156     }
    157 
    158     async fn transfer_page(
    159         &self,
    160         page: Page,
    161         status: Option<TransferState>,
    162     ) -> ApiResult<TransferList> {
    163         Ok(TransferList {
    164             transfers: db::transfer_page(&self.pool, &status, &page).await?,
    165             debit_account: self.payto.as_uri(),
    166         })
    167     }
    168 
    169     async fn transfer_by_id(&self, id: u64) -> ApiResult<Option<TransferStatus>> {
    170         Ok(db::transfer_by_id(&self.pool, id).await?)
    171     }
    172 
    173     async fn outgoing_history(&self, params: History) -> ApiResult<OutgoingHistory> {
    174         Ok(OutgoingHistory {
    175             outgoing_transactions: db::outgoing_history(&self.pool, &params, || {
    176                 self.taler_out_channel.subscribe()
    177             })
    178             .await?,
    179             debit_account: self.payto.as_uri(),
    180         })
    181     }
    182 
    183     async fn incoming_history(&self, params: History) -> ApiResult<IncomingHistory> {
    184         Ok(IncomingHistory {
    185             incoming_transactions: db::incoming_history(&self.pool, &params, || {
    186                 self.taler_in_channel.subscribe()
    187             })
    188             .await?,
    189             credit_account: self.payto.as_uri(),
    190         })
    191     }
    192 
    193     async fn add_incoming_reserve(
    194         &self,
    195         req: AddIncomingRequest,
    196     ) -> ApiResult<AddIncomingResponse> {
    197         let debtor = FullHuPayto::try_from(&req.debit_account)?;
    198         let res = db::register_tx_in_admin(
    199             &self.pool,
    200             &TxInAdmin {
    201                 amount: req.amount,
    202                 subject: format!("Admin incoming {}", req.reserve_pub),
    203                 debtor,
    204                 metadata: IncomingKey::reserve(req.reserve_pub),
    205             },
    206             &Timestamp::now(),
    207         )
    208         .await?;
    209         match res {
    210             AddIncomingResult::Success {
    211                 row_id, valued_at, ..
    212             } => Ok(AddIncomingResponse {
    213                 row_id,
    214                 timestamp: date_to_utc_ts(&valued_at).into(),
    215             }),
    216             AddIncomingResult::ReservePubReuse => {
    217                 Err(failure_code(ErrorCode::BANK_DUPLICATE_RESERVE_PUB_SUBJECT))
    218             }
    219             AddIncomingResult::UnknownMapping | AddIncomingResult::MappingReuse => {
    220                 unreachable!("mapping not used")
    221             }
    222         }
    223     }
    224 
    225     async fn add_incoming_kyc(&self, req: AddKycauthRequest) -> ApiResult<AddIncomingResponse> {
    226         let debtor = FullHuPayto::try_from(&req.debit_account)?;
    227         let res = db::register_tx_in_admin(
    228             &self.pool,
    229             &TxInAdmin {
    230                 amount: req.amount,
    231                 subject: format!("Admin incoming KYC:{}", req.account_pub),
    232                 debtor,
    233                 metadata: IncomingKey::kyc(req.account_pub),
    234             },
    235             &Timestamp::now(),
    236         )
    237         .await?;
    238         match res {
    239             AddIncomingResult::Success {
    240                 row_id, valued_at, ..
    241             } => Ok(AddIncomingResponse {
    242                 row_id,
    243                 timestamp: date_to_utc_ts(&valued_at).into(),
    244             }),
    245             AddIncomingResult::ReservePubReuse => unreachable!("kyc"),
    246             AddIncomingResult::UnknownMapping | AddIncomingResult::MappingReuse => {
    247                 unreachable!("mapping not used")
    248             }
    249         }
    250     }
    251 
    252     async fn add_incoming_mapped(&self, req: AddMappedRequest) -> ApiResult<AddIncomingResponse> {
    253         let debtor = FullHuPayto::try_from(&req.debit_account)?;
    254         let res = db::register_tx_in_admin(
    255             &self.pool,
    256             &TxInAdmin {
    257                 amount: req.amount,
    258                 subject: format!("Admin incoming MAP:{}", req.authorization_pub),
    259                 debtor,
    260                 metadata: IncomingKey::map(req.authorization_pub),
    261             },
    262             &Timestamp::now(),
    263         )
    264         .await?;
    265         match res {
    266             AddIncomingResult::Success {
    267                 row_id, valued_at, ..
    268             } => Ok(AddIncomingResponse {
    269                 row_id,
    270                 timestamp: date_to_utc_ts(&valued_at).into(),
    271             }),
    272             AddIncomingResult::ReservePubReuse => {
    273                 Err(failure_code(ErrorCode::BANK_DUPLICATE_RESERVE_PUB_SUBJECT))
    274             }
    275             AddIncomingResult::UnknownMapping => {
    276                 Err(failure_code(ErrorCode::BANK_TRANSFER_MAPPING_UNKNOWN))
    277             }
    278             AddIncomingResult::MappingReuse => {
    279                 Err(failure_code(ErrorCode::BANK_TRANSFER_MAPPING_REUSED))
    280             }
    281         }
    282     }
    283 
    284     fn support_account_check(&self) -> bool {
    285         false
    286     }
    287 }
    288 
    289 impl Revenue for MagnetApi {
    290     async fn history(&self, params: History) -> ApiResult<RevenueIncomingHistory> {
    291         Ok(RevenueIncomingHistory {
    292             incoming_transactions: db::revenue_history(&self.pool, &params, || {
    293                 self.in_channel.subscribe()
    294             })
    295             .await?,
    296             credit_account: self.payto.as_uri(),
    297         })
    298     }
    299 }
    300 
    301 impl PreparedTransfer for MagnetApi {
    302     fn supported_formats(&self) -> &[SubjectFormat] {
    303         &[SubjectFormat::SIMPLE]
    304     }
    305 
    306     async fn registration(&self, req: RegistrationRequest) -> ApiResult<RegistrationResponse> {
    307         let creditor = FullHuPayto::try_from(&req.credit_account)?;
    308         if *creditor != *self.payto {
    309             return Err(failure_code(ErrorCode::BANK_UNKNOWN_CREDITOR));
    310         }
    311         match db::transfer_register(&self.pool, &req).await? {
    312             db::RegistrationResult::Success => {
    313                 let simple = TransferSubject::Simple {
    314                     credit_amount: req.credit_amount,
    315                     subject: if req.authorization_pub == req.account_pub && !req.recurrent {
    316                         fmt_in_subject(req.r#type.into(), &req.account_pub).to_string()
    317                     } else {
    318                         fmt_in_subject(IncomingType::map, &req.authorization_pub).to_string()
    319                     },
    320                 };
    321                 ApiResult::Ok(RegistrationResponse {
    322                     subjects: vec![simple],
    323                     expiration: TalerTimestamp::Never,
    324                 })
    325             }
    326             db::RegistrationResult::ReservePubReuse => {
    327                 ApiResult::Err(failure_code(ErrorCode::BANK_DUPLICATE_RESERVE_PUB_SUBJECT))
    328             }
    329         }
    330     }
    331 
    332     async fn unregistration(&self, req: Unregistration) -> ApiResult<bool> {
    333         Ok(db::transfer_unregister(&self.pool, &req).await?)
    334     }
    335 }
    336 
    337 impl Observability for MagnetApi {
    338     async fn metrics(&self) -> ApiResult<&Registry> {
    339         self.metrics.sync(&self.pool).await;
    340         Ok(&self.registry)
    341     }
    342 }
    343 
    344 #[cfg(test)]
    345 mod test {
    346 
    347     use std::sync::{
    348         Arc, LazyLock,
    349         atomic::{AtomicU64, Ordering},
    350     };
    351 
    352     use jiff::{Timestamp, Zoned};
    353     use sqlx::{PgPool, Row as _, postgres::PgRow};
    354     use taler_api::{
    355         api::TalerRouter as _,
    356         auth::AuthMethod,
    357         db::TypeHelper as _,
    358         subject::{IncomingKey, OutgoingSubject},
    359     };
    360     use taler_common::{
    361         api::{
    362             EddsaPublicKey,
    363             observability::Config,
    364             prepared::PreparedTransferConfig,
    365             revenue::RevenueConfig,
    366             wire::{TransferState, WireConfig},
    367         },
    368         db::IncomingType,
    369         types::{
    370             amount::amount,
    371             payto::{PaytoURI, payto},
    372         },
    373     };
    374     use taler_test_utils::{
    375         Router,
    376         db::db_test_setup,
    377         routine::{
    378             Status, admin_add_incoming_routine, in_history_routine, out_history_routine,
    379             registration_routine, revenue_routine, transfer_routine,
    380         },
    381         server::TestServer,
    382         tasks,
    383     };
    384 
    385     use crate::{
    386         FullHuPayto,
    387         api::MagnetApi,
    388         constants::CONFIG_SOURCE,
    389         db::{self, TxIn, TxOutKind},
    390         magnet_api::types::TxStatus,
    391         magnet_payto,
    392     };
    393 
    394     static PAYTO: LazyLock<FullHuPayto> = LazyLock::new(|| {
    395         magnet_payto("payto://iban/HU02162000031000164800000000?receiver-name=Smith")
    396     });
    397     static EXCHANGE: LazyLock<PaytoURI> = LazyLock::new(|| PAYTO.as_uri());
    398     static UNKNOWN: LazyLock<PaytoURI> =
    399         LazyLock::new(|| payto("payto://iban/HU60162006491000639900000000?receiver-name=Unknown"));
    400 
    401     async fn setup() -> (Router, PgPool) {
    402         let (_, pool) = db_test_setup(CONFIG_SOURCE).await;
    403         let api = Arc::new(MagnetApi::start(pool.clone(), PAYTO.clone()).await);
    404         let server = Router::new()
    405             .wire_gateway(api.clone(), AuthMethod::None)
    406             .prepared_transfer(api.clone())
    407             .revenue(api.clone(), AuthMethod::None)
    408             .observability(api, AuthMethod::None)
    409             .finalize();
    410 
    411         (server, pool)
    412     }
    413 
    414     #[tokio::test]
    415     async fn config() {
    416         let (server, _) = setup().await;
    417         server
    418             .get("/taler-wire-gateway/config")
    419             .await
    420             .assert_ok_json::<WireConfig>();
    421         server
    422             .get("/taler-prepared-transfer/config")
    423             .await
    424             .assert_ok_json::<PreparedTransferConfig>();
    425         server
    426             .get("/taler-revenue/config")
    427             .await
    428             .assert_ok_json::<RevenueConfig>();
    429         server
    430             .get(format!("/taler-observability/config"))
    431             .await
    432             .assert_ok_json::<Config>();
    433         server
    434             .get(format!("/taler-observability/metrics"))
    435             .await
    436             .assert_ok();
    437     }
    438 
    439     #[tokio::test]
    440     async fn transfer() {
    441         let (server, _) = setup().await;
    442         transfer_routine(
    443             &server.prefix("/taler-wire-gateway"),
    444             TransferState::pending,
    445             &payto("payto://iban/HU02162000031000164800000000?receiver-name=name"),
    446         )
    447         .await;
    448     }
    449 
    450     static CODE: AtomicU64 = AtomicU64::new(0);
    451 
    452     async fn r#in(db: &PgPool, subject: Option<IncomingKey>) {
    453         db::register_tx_in(
    454             &mut db.acquire().await.unwrap(),
    455             &TxIn {
    456                 code: CODE.fetch_add(1, Ordering::Relaxed),
    457                 amount: amount("EUR:10"),
    458                 subject: "subject".into(),
    459                 debtor: magnet_payto(
    460                     "payto://iban/HU30162000031000163100000000?receiver-name=name",
    461                 ),
    462                 value_date: Zoned::now().date(),
    463                 status: TxStatus::Completed,
    464             },
    465             &subject,
    466             &Timestamp::now(),
    467         )
    468         .await
    469         .unwrap();
    470     }
    471 
    472     async fn in_malformed(db: &PgPool) {
    473         r#in(db, None).await
    474     }
    475 
    476     async fn in_talerable(db: &PgPool) {
    477         r#in(db, Some(IncomingKey::reserve(EddsaPublicKey::rand()))).await
    478     }
    479 
    480     async fn out(db: &PgPool, kind: &TxOutKind) {
    481         db::register_tx_out(
    482             &mut db.acquire().await.unwrap(),
    483             &db::TxOut {
    484                 code: CODE.fetch_add(1, Ordering::Relaxed),
    485                 amount: amount("EUR:10"),
    486                 subject: "subject".into(),
    487                 creditor: PAYTO.clone(),
    488                 value_date: Zoned::now().date(),
    489                 status: TxStatus::Completed,
    490             },
    491             kind,
    492             &Timestamp::now(),
    493         )
    494         .await
    495         .unwrap();
    496     }
    497 
    498     async fn out_talerable(db: &PgPool) {
    499         out(db, &TxOutKind::Talerable(OutgoingSubject::rand())).await
    500     }
    501 
    502     async fn out_bounce(db: &PgPool) {
    503         out(db, &TxOutKind::Bounce(CODE.load(Ordering::Relaxed) as u32)).await
    504     }
    505 
    506     async fn out_malformed(db: &PgPool) {
    507         out(db, &TxOutKind::Simple).await
    508     }
    509 
    510     #[tokio::test]
    511     async fn outgoing_history() {
    512         let (server, db) = &setup().await;
    513         out_history_routine(
    514             &server.prefix("/taler-wire-gateway"),
    515             tasks!({ out_talerable(db).await }),
    516             tasks!(
    517                 { out_bounce(db).await },
    518                 { out_malformed(db).await },
    519                 { in_malformed(db).await },
    520                 { in_talerable(db).await }
    521             ),
    522         )
    523         .await;
    524     }
    525 
    526     #[tokio::test]
    527     async fn admin_add_incoming() {
    528         let (server, _) = setup().await;
    529         admin_add_incoming_routine(
    530             &server.prefix("/taler-wire-gateway"),
    531             &server.prefix("/taler-prepared-transfer"),
    532             &EXCHANGE,
    533             &EXCHANGE,
    534         )
    535         .await;
    536     }
    537 
    538     #[tokio::test]
    539     async fn in_history() {
    540         let (server, db) = &setup().await;
    541         in_history_routine(
    542             &server.prefix("/taler-wire-gateway"),
    543             &server.prefix("/taler-prepared-transfer"),
    544             &EXCHANGE,
    545             &EXCHANGE,
    546             tasks!({ in_talerable(db).await }),
    547             tasks!(
    548                 { out_malformed(db).await },
    549                 { out_talerable(db).await },
    550                 { out_bounce(db).await },
    551                 { in_malformed(db).await }
    552             ),
    553         )
    554         .await;
    555     }
    556 
    557     #[tokio::test]
    558     async fn revenue() {
    559         let (server, db) = &setup().await;
    560         revenue_routine(
    561             &server.prefix("/taler-wire-gateway"),
    562             &server.prefix("/taler-revenue"),
    563             &EXCHANGE,
    564             tasks!({ in_malformed(db).await }, { in_talerable(db).await },),
    565             tasks!({ out_malformed(db).await }, { out_talerable(db).await }, {
    566                 out_bounce(db).await
    567             }),
    568         )
    569         .await;
    570     }
    571 
    572     async fn check_in(pool: &PgPool) -> Vec<Status> {
    573         sqlx::query(
    574             "
    575             SELECT pending_recurrent_in.authorization_pub IS NOT NULL, initiated_id IS NOT NULL, type, metadata
    576             FROM tx_in
    577                 LEFT JOIN taler_in USING (tx_in_id)
    578                 LEFT JOIN pending_recurrent_in USING (tx_in_id)
    579                 LEFT JOIN bounced USING (tx_in_id)
    580             ORDER BY tx_in.tx_in_id
    581         ",
    582         )
    583         .try_map(|r: PgRow| {
    584             Ok(
    585                 if r.try_get_flag(0)? {
    586                     Status::Pending
    587                 } else if r.try_get_flag(1)? {
    588                     Status::Bounced
    589                 } else {
    590                     match r.try_get(2)? {
    591                         None => Status::Simple,
    592                         Some(IncomingType::reserve) => Status::Reserve(r.try_get(3)?),
    593                         Some(IncomingType::kyc) => Status::Kyc(r.try_get(3)?),
    594                         Some(e) => unreachable!("{e:?}")
    595                     }
    596                 }
    597             )
    598         })
    599         .fetch_all(pool)
    600         .await
    601         .unwrap()
    602     }
    603 
    604     #[tokio::test]
    605     async fn registration() {
    606         let (server, pool) = setup().await;
    607         registration_routine(
    608             &server.prefix("/taler-wire-gateway"),
    609             &server.prefix("/taler-prepared-transfer"),
    610             &EXCHANGE,
    611             &EXCHANGE,
    612             &UNKNOWN,
    613             || check_in(&pool),
    614         )
    615         .await;
    616     }
    617 }