libeufin

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

db.rs (30339B)


      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 #![allow(clippy::too_many_arguments)]
     21 
     22 use std::time::Duration;
     23 
     24 use anyhow::bail;
     25 use jiff::Timestamp;
     26 use sqlx::{PgConnection, PgPool, postgres::PgRow};
     27 use taler_api::{config::DbCfg, db::TypeHelper, notification::NotificationChannel, serialized};
     28 use taler_common::types::amount::{Amount, Currency};
     29 use tokio::join;
     30 use tracing::info;
     31 use uuid::Uuid;
     32 
     33 use crate::api::{MonitorParams, MonitorResponse, withdrawal::WithdrawalStatus};
     34 
     35 pub mod account;
     36 pub mod cashout;
     37 pub mod conversion;
     38 pub mod exchange;
     39 pub mod gc;
     40 pub mod prepared;
     41 pub mod tan;
     42 pub mod token;
     43 pub mod tx;
     44 pub mod withdrawal;
     45 
     46 const SCHEMA: &str = "libeufin_bank";
     47 
     48 pub async fn pool(cfg: &DbCfg) -> anyhow::Result<PgPool> {
     49     let pool = taler_common::db::pool(cfg.cfg.clone(), SCHEMA).await?;
     50     Ok(pool)
     51 }
     52 
     53 pub async fn dbinit(cfg: &DbCfg, reset: bool, conversion: bool) -> anyhow::Result<PgPool> {
     54     let pool = taler_common::db::pool(cfg.cfg.clone(), SCHEMA).await?;
     55     let mut db = pool.acquire().await?;
     56     // Keep conversion setup in sync in dbinit as procedure installation clears it
     57     taler_common::db::dbinit_setup(
     58         &mut db,
     59         cfg.sql_dir.as_ref(),
     60         "libeufin-bank",
     61         reset,
     62         async |tx| setup_conversion(cfg, tx.as_mut(), conversion).await,
     63     )
     64     .await?;
     65     Ok(pool)
     66 }
     67 
     68 pub async fn setup_conversion(
     69     cfg: &DbCfg,
     70     db: &mut PgConnection,
     71     enabled: bool,
     72 ) -> anyhow::Result<()> {
     73     if enabled {
     74         info!(target: "db", "Ensure conversion is enabled");
     75         match std::fs::read_to_string(format!("{}/libeufin-conversion-setup.sql", cfg.sql_dir)) {
     76             Ok(sql) => {
     77                 sqlx::raw_sql(&sql).execute(&mut *db).await?;
     78             }
     79             Err(e) => {
     80                 bail!(
     81                     "Could not read libeufin-conversion-setup.sql at '{}': {}",
     82                     cfg.sql_dir,
     83                     e.kind()
     84                 )
     85             }
     86         };
     87     } else {
     88         info!(target: "db", "Ensure conversion is disabled");
     89         match std::fs::read_to_string(format!("{}/libeufin-conversion-drop.sql", cfg.sql_dir)) {
     90             Ok(sql) => {
     91                 sqlx::raw_sql(&sql).execute(&mut *db).await?;
     92             }
     93             Err(e) => {
     94                 bail!(
     95                     "Could not read libeufin-conversion-setup.sql at '{}': {}",
     96                     cfg.sql_dir,
     97                     e.kind()
     98                 )
     99             }
    100         };
    101     }
    102     Ok(())
    103 }
    104 
    105 pub async fn notification_listener(
    106     pool: PgPool,
    107     tx_channel: NotificationChannel<u64, i64>,
    108     taler_out_channel: NotificationChannel<u64, i64>,
    109     taler_in_channel: NotificationChannel<u64, i64>,
    110     revenue_channel: NotificationChannel<u64, i64>,
    111     withdrawal_channel: NotificationChannel<Uuid, Option<WithdrawalStatus>>,
    112 ) -> sqlx::Result<()> {
    113     join!(
    114         async {
    115             // GC notifications channels every hours
    116             let mut interval = tokio::time::interval(Duration::from_hours(1));
    117             loop {
    118                 tx_channel.prune();
    119                 taler_out_channel.prune();
    120                 taler_in_channel.prune();
    121                 revenue_channel.prune();
    122                 withdrawal_channel.prune();
    123                 interval.tick().await;
    124             }
    125         },
    126         async {
    127             // And listen for notification
    128             taler_api::notification::notification_listener!(&pool,
    129                 "bank_tx" => (debtor: u64, creditor: u64, debit: i64, credit: i64) {
    130                     tx_channel.dispatch(&debtor, debit);
    131                     tx_channel.dispatch(&creditor, credit);
    132                     revenue_channel.dispatch(&creditor, credit);
    133                 },
    134                 "bank_outgoing_tx" => (account: u64, _a: u64, debit: i64, _d: i64) {
    135                     taler_out_channel.dispatch(&account, debit);
    136                 },
    137                 "bank_incoming_tx" => (account: u64, row: i64) {
    138                     taler_in_channel.dispatch(&account, row);
    139                 },
    140                 "bank_withdrawal_status" => (uuid: Uuid, status: WithdrawalStatus) {
    141                     withdrawal_channel.dispatch(&uuid, Some(status));
    142                 }
    143             )
    144         }
    145     );
    146     Ok(())
    147 }
    148 
    149 pub async fn monitor(
    150     db: &PgPool,
    151     regional: &Currency,
    152     fiat: Option<&Currency>,
    153     params: &MonitorParams,
    154 ) -> sqlx::Result<MonitorResponse> {
    155     let timestamp = if let Some(s) = params.date_s {
    156         Timestamp::from_second(s as i64).unwrap_or(Timestamp::MAX)
    157     } else {
    158         Timestamp::now()
    159     }
    160     .to_string();
    161     Ok(serialized!(
    162         sqlx::query(
    163             "
    164     SELECT
    165         cashin_count
    166         ,cashin_regional_volume
    167         ,cashin_fiat_volume
    168         ,cashout_count
    169         ,cashout_regional_volume
    170         ,cashout_fiat_volume
    171         ,taler_in_count
    172         ,taler_in_volume
    173         ,taler_out_count
    174         ,taler_out_volume
    175     FROM stats_get_frame($1::timestamp,$2::stat_timeframe_enum)
    176     ",
    177         )
    178         .bind(&timestamp)
    179         .bind(params.timeframe)
    180         .try_map(|r: PgRow| {
    181             Ok(if let Some(fiat) = fiat {
    182                 MonitorResponse::Conversion {
    183                     cashin_count: r.try_get_u64("cashin_count")?,
    184                     cashin_regional_volume: r.try_get_amount("cashin_regional_volume", regional)?,
    185                     cashin_fiat_volume: r.try_get_amount("cashin_fiat_volume", fiat)?,
    186                     cashout_count: r.try_get_u64("cashout_count")?,
    187                     cashout_regional_volume: r
    188                         .try_get_amount("cashout_regional_volume", regional)?,
    189                     cashout_fiat_volume: r.try_get_amount("cashout_fiat_volume", fiat)?,
    190                     taler_in_count: r.try_get_u64("taler_in_count")?,
    191                     taler_in_volume: r.try_get_amount("taler_in_volume", regional)?,
    192                     taler_out_count: r.try_get_u64("taler_out_count")?,
    193                     taler_out_volume: r.try_get_amount("taler_out_volume", regional)?,
    194                 }
    195             } else {
    196                 MonitorResponse::Simple {
    197                     taler_in_count: r.try_get_u64("taler_in_count")?,
    198                     taler_in_volume: r.try_get_amount("taler_in_volume", regional)?,
    199                     taler_out_count: r.try_get_u64("taler_out_count")?,
    200                     taler_out_volume: r.try_get_amount("taler_out_volume", regional)?,
    201                 }
    202             })
    203         })
    204         .fetch_optional(db)
    205     )?
    206     .unwrap_or_else(|| {
    207         if let Some(fiat) = fiat {
    208             MonitorResponse::Conversion {
    209                 cashin_count: 0,
    210                 cashin_regional_volume: Amount::zero(regional),
    211                 cashin_fiat_volume: Amount::zero(fiat),
    212                 cashout_count: 0,
    213                 cashout_regional_volume: Amount::zero(regional),
    214                 cashout_fiat_volume: Amount::zero(fiat),
    215                 taler_in_count: 0,
    216                 taler_in_volume: Amount::zero(regional),
    217                 taler_out_count: 0,
    218                 taler_out_volume: Amount::zero(regional),
    219             }
    220         } else {
    221             MonitorResponse::Simple {
    222                 taler_in_count: 0,
    223                 taler_in_volume: Amount::zero(regional),
    224                 taler_out_count: 0,
    225                 taler_out_volume: Amount::zero(regional),
    226             }
    227         }
    228     }))
    229 }
    230 
    231 #[cfg(test)]
    232 mod test {
    233     use std::time::Duration;
    234 
    235     use jiff::Timestamp;
    236     use sqlx::postgres::PgConnectOptions;
    237     use taler_common::{
    238         error_code::ErrorCode::BANK_UNALLOWED_DEBIT,
    239         types::{
    240             amount::{Currency, Decimal, MAX_VALUE, decimal},
    241             base32::Base32,
    242         },
    243     };
    244     use taler_macros::db_test;
    245     use taler_test_utils::json;
    246     use uuid::Uuid;
    247 
    248     use crate::{
    249         TanChannel,
    250         api::{
    251             Timeframe,
    252             conversion::RoundingMode::{self, nearest, up, zero},
    253             test::bank_setup,
    254         },
    255         db::tan::{self, SendResult, SolveResult, mark_sent, send, solve},
    256         mfa::Operation,
    257     };
    258 
    259     #[db_test(raw)]
    260     async fn amount_computation(db: PgConnectOptions) {
    261         let ctx = bank_setup(db).await;
    262 
    263         sqlx::query(
    264             "UPDATE libeufin_bank.bank_accounts SET balance.val = 100000 WHERE internal_payto=$1",
    265         )
    266         .bind(ctx.customer_payto.canonical())
    267         .execute(&ctx.state.db)
    268         .await
    269         .unwrap();
    270 
    271         let query = "
    272             UPDATE libeufin_bank.bank_accounts
    273             SET balance = $1,
    274                 has_debt = $2,
    275                 max_debt = $3
    276             WHERE internal_payto=$4
    277         ";
    278 
    279         // Test amount computation in db
    280         {
    281             for (balance, has_debt, max_debt, amount, success) in &[
    282                 // Balance enough, assert for true
    283                 (
    284                     Decimal::new(10, 0),
    285                     false,
    286                     Decimal::new(100, 0),
    287                     Decimal::new(8, 0),
    288                     true,
    289                 ),
    290                 // Balance still sufficient, thanks for big enough debt permission, assert true
    291                 (
    292                     Decimal::new(10, 0),
    293                     false,
    294                     Decimal::new(100, 0),
    295                     Decimal::new(80, 0),
    296                     true,
    297                 ),
    298                 // Balance not enough, max debt cannot cover, asserting for false
    299                 (
    300                     Decimal::new(10, 0),
    301                     true,
    302                     Decimal::new(50, 0),
    303                     Decimal::new(80, 0),
    304                     false,
    305                 ),
    306                 // Balance becomes enough, due to a larger max debt, asserting for true
    307                 (
    308                     Decimal::new(10, 0),
    309                     false,
    310                     Decimal::new(70, 0),
    311                     Decimal::new(80, 0),
    312                     true,
    313                 ),
    314                 // Max debt not enough for the smallest fraction, asserting for false
    315                 (
    316                     Decimal::new(0, 0),
    317                     false,
    318                     Decimal::new(0, 1),
    319                     Decimal::new(0, 2),
    320                     false,
    321                 ),
    322                 // Same as above, but already in debt
    323                 (
    324                     Decimal::new(0, 1),
    325                     true,
    326                     Decimal::new(0, 1),
    327                     Decimal::new(0, 1),
    328                     false,
    329                 ),
    330             ] {
    331                 let amount = amount.to_amount(&Currency::KUDOS);
    332                 // Check bank transaction
    333                 sqlx::query(query)
    334                     .bind(balance)
    335                     .bind(has_debt)
    336                     .bind(max_debt)
    337                     .bind(ctx.merchant_payto.canonical())
    338                     .execute(&ctx.state.db)
    339                     .await
    340                     .unwrap();
    341                 let res = ctx
    342                     .posta("/accounts/merchant/transactions")
    343                     .json(json!({
    344                         "payto_uri": format!("{}?message=", ctx.customer_payto),
    345                         "amount": amount
    346                     }))
    347                     .await;
    348                 if *success {
    349                     res.assert_ok();
    350                 } else {
    351                     res.assert_error(BANK_UNALLOWED_DEBIT);
    352                 }
    353 
    354                 // Check whithdraw
    355                 sqlx::query(query)
    356                     .bind(balance)
    357                     .bind(has_debt)
    358                     .bind(max_debt)
    359                     .bind(ctx.merchant_payto.canonical())
    360                     .execute(&ctx.state.db)
    361                     .await
    362                     .unwrap();
    363                 for (amount, suggested) in [
    364                     (Some(amount), None),
    365                     (None, Some(amount)),
    366                     (Some(amount), Some(amount)),
    367                 ] {
    368                     let res = ctx
    369                         .posta("/accounts/merchant/withdrawals")
    370                         .json(json!({
    371                             "amount": amount,
    372                             "suggested_amount": suggested
    373                         }))
    374                         .await;
    375 
    376                     if *success {
    377                         res.assert_ok();
    378                     } else {
    379                         res.assert_error(BANK_UNALLOWED_DEBIT);
    380                     }
    381                 }
    382             }
    383         }
    384 
    385         // Max amount computation in db
    386         for (balance, has_debt, max_debt, amount) in [
    387             // Without debt
    388             (
    389                 Decimal::new(10, 1),
    390                 false,
    391                 Decimal::new(100, 2),
    392                 Decimal::new(110, 3),
    393             ),
    394             // With debt
    395             (
    396                 Decimal::new(10, 1),
    397                 true,
    398                 Decimal::new(100, 2),
    399                 Decimal::new(90, 1),
    400             ),
    401         ] {
    402             sqlx::query(query)
    403                 .bind(balance)
    404                 .bind(has_debt)
    405                 .bind(max_debt)
    406                 .bind(ctx.merchant_payto.canonical())
    407                 .execute(&ctx.state.db)
    408                 .await
    409                 .unwrap();
    410             let max: Decimal = sqlx::query_scalar(
    411                 "
    412                 SELECT max_amount
    413                 FROM account_max_amount(1, $1) AS max_amount
    414             ",
    415             )
    416             .bind(Decimal::MAX)
    417             .fetch_one(&ctx.state.db)
    418             .await
    419             .unwrap();
    420             assert_eq!(max, amount);
    421         }
    422 
    423         let tiny = decimal("0.00000001");
    424         let apply =
    425             async |nb: Decimal,
    426                    times: Decimal,
    427                    tiny: Decimal,
    428                    rounding: RoundingMode|
    429                    -> sqlx::Result<Decimal> {
    430                 sqlx::query_scalar(
    431             "SELECT result FROM conversion_apply_ratio($1, $2, (0, 0)::taler_amount, $3, $4)",
    432         )
    433         .bind(nb)
    434         .bind(times)
    435         .bind(tiny)
    436         .bind(rounding).fetch_one(&ctx.state.db).await
    437             };
    438         let assert_err = |err: sqlx::Result<Decimal>, msg: &str| {
    439             assert_eq!(
    440                 err.unwrap_err().into_database_error().unwrap().message(),
    441                 msg
    442             )
    443         };
    444         // Conversion apply
    445         {
    446             assert_eq!(
    447                 decimal("30.0629"),
    448                 apply(decimal("6.41"), decimal("4.69"), tiny, zero)
    449                     .await
    450                     .unwrap()
    451             );
    452             assert_eq!(
    453                 decimal("6.41000641"),
    454                 apply(decimal("6.41"), decimal("1.000001"), tiny, zero)
    455                     .await
    456                     .unwrap()
    457             );
    458             assert_eq!(
    459                 decimal("2.49999997"),
    460                 apply(decimal("0.99999999"), decimal("2.5"), tiny, zero)
    461                     .await
    462                     .unwrap()
    463             );
    464             assert_eq!(
    465                 decimal(format!("{MAX_VALUE}.99999999")),
    466                 apply(
    467                     decimal(format!("{MAX_VALUE}.99999999")),
    468                     decimal("1"),
    469                     tiny,
    470                     zero
    471                 )
    472                 .await
    473                 .unwrap()
    474             );
    475             assert_eq!(
    476                 decimal(format!("{MAX_VALUE}")),
    477                 apply(
    478                     decimal(format!("{}", MAX_VALUE / 4)),
    479                     decimal("4"),
    480                     tiny,
    481                     zero
    482                 )
    483                 .await
    484                 .unwrap()
    485             );
    486             assert_err(
    487                 apply(
    488                     decimal(format!("{}", MAX_VALUE / 3)),
    489                     decimal("3.00000001"),
    490                     tiny,
    491                     zero,
    492                 )
    493                 .await,
    494                 "amount value overflowed",
    495             );
    496             assert_err(
    497                 apply(
    498                     decimal(format!("{}", (MAX_VALUE + 2) / 2)),
    499                     decimal("2"),
    500                     tiny,
    501                     zero,
    502                 )
    503                 .await,
    504                 "amount value overflowed",
    505             );
    506 
    507             // Check rounding mode
    508             let checks: &[(_, &[_])] = &[
    509                 (zero, &[(1, 10..20)]),
    510                 (up, &[(1, 10..11), (2, 11..20)]),
    511                 (nearest, &[(1, 10..15), (2, 15..20)]),
    512             ];
    513             for (mode, rounding) in checks {
    514                 for (rounded, amounts) in *rounding {
    515                     for amount in amounts.clone() {
    516                         // Check euro
    517                         assert_eq!(
    518                             decimal(format!("0.0{rounded}")),
    519                             apply(
    520                                 decimal(format!("{amount}")),
    521                                 decimal("0.001"),
    522                                 decimal("0.01"),
    523                                 *mode
    524                             )
    525                             .await
    526                             .unwrap()
    527                         );
    528                         // Check kudos
    529                         assert_eq!(
    530                             decimal(format!("0.0000000{rounded}")),
    531                             apply(
    532                                 decimal(format!("0.{amount}")),
    533                                 decimal("0.0000001"),
    534                                 tiny,
    535                                 *mode
    536                             )
    537                             .await
    538                             .unwrap()
    539                         );
    540                     }
    541                 }
    542             }
    543             // Check hungarian rounding
    544             let checks: &[(_, &[_])] = &[
    545                 (zero, &[(10, 10..15), (15, 15..20)]),
    546                 (up, &[(10, 10..11), (15, 11..16), (20, 16..20)]),
    547                 (nearest, &[(10, 10..13), (15, 13..18), (20, 18..20)]),
    548             ];
    549             for (mode, rounding) in checks {
    550                 for (rounded, amounts) in *rounding {
    551                     for amount in amounts.clone() {
    552                         assert_eq!(
    553                             decimal(format!("{rounded}")),
    554                             apply(
    555                                 decimal(format!("{amount}")),
    556                                 decimal("1"),
    557                                 decimal("5"),
    558                                 *mode
    559                             )
    560                             .await
    561                             .unwrap()
    562                         );
    563                     }
    564                 }
    565             }
    566             for mode in RoundingMode::entries {
    567                 assert_eq!(
    568                     decimal("5"),
    569                     apply(decimal("5"), decimal("1"), decimal("1"), *mode)
    570                         .await
    571                         .unwrap()
    572                 );
    573             }
    574         }
    575         let revert = async |nb: Decimal,
    576                             times: Decimal,
    577                             tiny: Decimal,
    578                             rounding: RoundingMode,
    579                             reverse: Decimal|
    580                -> sqlx::Result<Decimal> {
    581             sqlx::query_scalar(
    582             "SELECT result FROM conversion_revert_ratio($1, $2, (0, 0)::taler_amount, $3, $4, $5)",
    583         )
    584         .bind(nb)
    585         .bind(times)
    586         .bind(tiny)
    587         .bind(rounding).bind(reverse).fetch_one(&ctx.state.db).await
    588         };
    589         // Conversion revert
    590         {
    591             assert_eq!(
    592                 decimal("6.41"),
    593                 revert(decimal("30.0629"), decimal("4.69"), tiny, zero, tiny)
    594                     .await
    595                     .unwrap()
    596             );
    597             assert_eq!(
    598                 decimal("6.41"),
    599                 revert(decimal("6.41000641"), decimal("1.000001"), tiny, zero, tiny,)
    600                     .await
    601                     .unwrap()
    602             );
    603             assert_eq!(
    604                 decimal("1"),
    605                 revert(decimal("2.49999998"), decimal("2.5"), tiny, zero, tiny,)
    606                     .await
    607                     .unwrap()
    608             );
    609             assert_eq!(
    610                 decimal(format!("{MAX_VALUE}.99999999")),
    611                 revert(
    612                     decimal(format!("{MAX_VALUE}.99999999")),
    613                     decimal("1"),
    614                     tiny,
    615                     zero,
    616                     tiny,
    617                 )
    618                 .await
    619                 .unwrap()
    620             );
    621             assert_eq!(
    622                 decimal(format!("{MAX_VALUE}")),
    623                 revert(
    624                     decimal(format!("{}", MAX_VALUE / 4)),
    625                     decimal("0.25"),
    626                     tiny,
    627                     zero,
    628                     tiny,
    629                 )
    630                 .await
    631                 .unwrap()
    632             );
    633             assert_err(
    634                 revert(
    635                     decimal(format!("{}", MAX_VALUE / 4)),
    636                     decimal("0.24999999"),
    637                     tiny,
    638                     zero,
    639                     tiny,
    640                 )
    641                 .await,
    642                 "amount value overflowed",
    643             );
    644             assert_err(
    645                 revert(
    646                     decimal(format!("{}", (MAX_VALUE + 2) / 2)),
    647                     decimal("0.5"),
    648                     tiny,
    649                     zero,
    650                     tiny,
    651                 )
    652                 .await,
    653                 "amount value overflowed",
    654             );
    655 
    656             for mode in RoundingMode::entries {
    657                 for tiny in ["0.01", "0.00000001", "1", "2", "3", "5"].map(decimal) {
    658                     for amount in (10..20).map(|i| decimal(format!("{i}"))) {
    659                         for ratio in
    660                             ["1", "1.25", "1.26", "0.01", "0.001", "0.00000001"].map(decimal)
    661                         {
    662                             for reverse in ["0.01", "0.00000001", "1"].map(decimal) {
    663                                 // Apply ratio
    664                                 let rounded = apply(amount, ratio, tiny, *mode).await.unwrap();
    665                                 // Revert ratio
    666                                 let revert =
    667                                     revert(rounded, ratio, tiny, *mode, reverse).await.unwrap();
    668                                 // Check applying ratio again give the same result
    669                                 let check = apply(revert, ratio, tiny, *mode).await.unwrap();
    670                                 assert_eq!(rounded, check);
    671                             }
    672                         }
    673                     }
    674                 }
    675             }
    676         }
    677     }
    678 
    679     #[db_test(raw)]
    680     async fn statistics(db: PgConnectOptions) {
    681         let ctx = bank_setup(db).await;
    682         for account in ["merchant", "exchange", "customer"] {
    683             ctx.set_max_debt(account, "1000").await;
    684         }
    685         ctx.fill_cashout_info("customer").await;
    686 
    687         let check = async |values: &[(&str, serde_json::Value)]| {
    688             for frame in Timeframe::entries {
    689                 let res = ctx
    690                     .get_admin(format!("/monitor?timestamp={frame}"))
    691                     .await
    692                     .assert_ok_json::<serde_json::Value>();
    693                 for (k, v) in values {
    694                     assert_eq!(&res[k], v);
    695                 }
    696             }
    697         };
    698 
    699         let monitor_taler_in = async |count: usize, amount: &str| {
    700             check(&[
    701                 ("talerInCount", json!(count)),
    702                 ("talerInVolume", json!(amount)),
    703             ])
    704             .await
    705         };
    706         let monitor_taler_out = async |count: usize, amount: &str| {
    707             check(&[
    708                 ("talerOutCount", json!(count)),
    709                 ("talerOutVolume", json!(amount)),
    710             ])
    711             .await
    712         };
    713         let monitor_cashin = async |count: usize, regional: &str, fiat: &str| {
    714             check(&[
    715                 ("cashinCount", json!(count)),
    716                 ("cashinRegionalVolume", json!(regional)),
    717                 ("cashinFiatVolume", json!(fiat)),
    718             ])
    719             .await
    720         };
    721         let monitor_cashout = async |count: usize, regional: &str, fiat: &str| {
    722             check(&[
    723                 ("cashoutCount", json!(count)),
    724                 ("cashoutRegionalVolume", json!(regional)),
    725                 ("cashoutFiatVolume", json!(fiat)),
    726             ])
    727             .await
    728         };
    729 
    730         monitor_taler_in(0, "KUDOS:0").await;
    731         monitor_taler_out(0, "KUDOS:0").await;
    732         monitor_cashin(0, "KUDOS:0", "EUR:0").await;
    733         monitor_cashout(0, "KUDOS:0", "EUR:0").await;
    734 
    735         ctx.add_incoming("3").await;
    736         monitor_taler_in(1, "KUDOS:3").await;
    737         ctx.add_incoming("7.6").await;
    738         monitor_taler_in(2, "KUDOS:10.6").await;
    739         ctx.add_incoming("12.3").await;
    740         monitor_taler_in(3, "KUDOS:22.9").await;
    741 
    742         // KYC are ignored
    743         ctx.add_kyc("3").await;
    744         monitor_taler_in(3, "KUDOS:22.9").await;
    745 
    746         ctx.transfer("10", &ctx.customer_payto, None).await;
    747         monitor_taler_out(1, "KUDOS:10").await;
    748         ctx.transfer("30.5", &ctx.customer_payto, None).await;
    749         monitor_taler_out(2, "KUDOS:40.5").await;
    750         ctx.transfer("42", &ctx.customer_payto, None).await;
    751         monitor_taler_out(3, "KUDOS:82.5").await;
    752 
    753         ctx.cashin("10").await;
    754         monitor_cashin(1, "KUDOS:7.98", "EUR:10").await;
    755         monitor_taler_in(4, "KUDOS:30.88").await;
    756         ctx.cashin("20").await;
    757         monitor_cashin(2, "KUDOS:23.96", "EUR:30").await;
    758         monitor_taler_in(5, "KUDOS:46.86").await;
    759         ctx.cashin("40").await;
    760         monitor_cashin(3, "KUDOS:55.94", "EUR:70").await;
    761         monitor_taler_in(6, "KUDOS:78.84").await;
    762 
    763         ctx.cashout("3").await;
    764         monitor_cashout(1, "KUDOS:3", "EUR:3.77").await;
    765         ctx.cashout("7.6").await;
    766         monitor_cashout(2, "KUDOS:10.6", "EUR:13.34").await;
    767         ctx.cashout("12.3").await;
    768         monitor_cashout(3, "KUDOS:22.9", "EUR:28.83").await;
    769 
    770         monitor_taler_in(6, "KUDOS:78.84").await;
    771         monitor_taler_out(3, "KUDOS:82.5").await;
    772         monitor_cashin(3, "KUDOS:55.94", "EUR:70").await;
    773         monitor_cashout(3, "KUDOS:22.9", "EUR:28.83").await;
    774     }
    775 
    776     #[db_test(raw)]
    777     async fn tan_challenge(db: PgConnectOptions) {
    778         const VALIDITY_PERIOD: Duration = Duration::from_hours(1);
    779         const RETRANSMISSION_PERIOD: Duration = Duration::from_mins(1);
    780         const RETRY_COUNTER: u16 = 3;
    781 
    782         let ctx = bank_setup(db).await;
    783 
    784         let create = async |code: &str, now: &Timestamp| -> Uuid {
    785             tan::new(
    786                 &ctx.state.db,
    787                 "customer",
    788                 Operation::withdrawal,
    789                 &Base32::rand(),
    790                 &Base32::rand(),
    791                 code,
    792                 now,
    793                 RETRY_COUNTER,
    794                 VALIDITY_PERIOD,
    795                 TanChannel::sms,
    796                 "+88",
    797             )
    798             .await
    799             .unwrap()
    800         };
    801         let mark_sent = async |id: &Uuid, now: &Timestamp| {
    802             mark_sent(&ctx.state.db, id, &(*now + RETRANSMISSION_PERIOD))
    803                 .await
    804                 .unwrap()
    805         };
    806         let send = async |id: &Uuid, now: &Timestamp| -> SendResult {
    807             send(&ctx.state.db, id, now, 10).await.unwrap()
    808         };
    809         let solve = async |id: &Uuid, code: &str, now: &Timestamp| -> SolveResult {
    810             solve(&ctx.state.db, id, code, now).await.unwrap()
    811         };
    812 
    813         let now = Timestamp::now();
    814         let expired = now + VALIDITY_PERIOD;
    815         let retransmit = now + RETRANSMISSION_PERIOD;
    816 
    817         const GOOD: &str = "good-code";
    818         const BAD: &str = "bad-code";
    819 
    820         // Check basic
    821         let id = create(GOOD, &now).await;
    822         // Bad code
    823         assert_eq!(solve(&id, BAD, &now).await, SolveResult::BadCode);
    824         // Good code
    825         assert!(matches!(
    826             solve(&id, GOOD, &now).await,
    827             SolveResult::Success { .. }
    828         ));
    829         // Never resend a confirmed challenge
    830         assert_eq!(send(&id, &now).await, SendResult::Solved);
    831         // Confirmed challenge always ok
    832         assert!(matches!(
    833             solve(&id, GOOD, &now).await,
    834             SolveResult::Success { .. }
    835         ));
    836 
    837         // Check retry
    838         let id = create(GOOD, &now).await;
    839         mark_sent(&id, &now).await;
    840         // Bad code
    841         for _ in 0..RETRY_COUNTER - 1 {
    842             assert_eq!(solve(&id, BAD, &now).await, SolveResult::BadCode);
    843         }
    844         assert_eq!(solve(&id, BAD, &now).await, SolveResult::NoRetry);
    845         // Good code fail
    846         assert_eq!(solve(&id, GOOD, &now).await, SolveResult::NoRetry);
    847         // Exhausted challenge IDs are terminal and cannot be resent
    848         assert_eq!(send(&id, &now).await, SendResult::Expired);
    849 
    850         // Check retransmission
    851         let id = create(GOOD, &now).await;
    852         // Failed to send retransmit
    853         assert!(matches!(send(&id, &now).await, SendResult::Send { .. }));
    854         // Code successfully sent and still valid
    855         mark_sent(&id, &now).await;
    856         assert!(matches!(send(&id, &now).await, SendResult::Success { .. }));
    857         // Code is still valid but should be resent
    858         assert!(matches!(
    859             send(&id, &retransmit).await,
    860             SendResult::Send { .. }
    861         ));
    862         // Good code fail because expired
    863         assert_eq!(solve(&id, GOOD, &expired).await, SolveResult::Expired);
    864         // Expired challenge IDs are terminal and cannot be resent
    865         assert_eq!(send(&id, &expired).await, SendResult::Expired);
    866     }
    867 }