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(×tamp) 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 }