libeufin

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

commit 4944834d97639dabaca459edb092fe646775cf38
parent 39c74e88e8f9c0ddff1ce085880906bc0c4f181c
Author: Antoine A <>
Date:   Tue, 26 May 2026 18:24:39 +0200

bank: more testing and clean code

Diffstat:
MCargo.lock | 22+++++++++++-----------
Mcrates/libeufin-bank/src/api.rs | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Mcrates/libeufin-bank/src/api/cashout.rs | 4++--
Mcrates/libeufin-bank/src/api/conversion.rs | 162+++++++++++++++++++++++++++++++++++++------------------------------------------
Mcrates/libeufin-bank/src/api/token.rs | 12+++++++-----
Mcrates/libeufin-bank/src/api/tx.rs | 2+-
Mcrates/libeufin-bank/src/db.rs | 534+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/libeufin-bank/src/db/conversion.rs | 125+++++++------------------------------------------------------------------------
8 files changed, 702 insertions(+), 240 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -1268,9 +1268,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.0" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" dependencies = [ "bytes", "itoa", @@ -1577,23 +1577,23 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jiff" -version = "0.2.24" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f00b5dbd620d61dfdcb6007c9c1f6054ebd75319f163d886a9055cec1155073d" +checksum = "392c70591e8749fe235ddaf513e6f58b26bce3dcc16524cecc8936f75afa161e" dependencies = [ "jiff-static", "log", "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys 0.61.2", + "windows-link", ] [[package]] name = "jiff-static" -version = "0.2.24" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e000de030ff8022ea1da3f466fbb0f3a809f5e51ed31f6dd931c35181ad8e6d7" +checksum = "47b605b0c050d845fc355bb11eb3f9a8deddc218ea60c76e61aa1f2adfb2c96a" dependencies = [ "proc-macro2", "quote", @@ -1881,9 +1881,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" [[package]] name = "lru-slab" @@ -2487,9 +2487,9 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "reqwest" -version = "0.13.3" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ "base64", "bytes", diff --git a/crates/libeufin-bank/src/api.rs b/crates/libeufin-bank/src/api.rs @@ -34,7 +34,7 @@ use taler_common::{ api::LibtoolVersion, types::amount::{Amount, Currency}, }; -use taler_macros::api_config; +use taler_macros::{EnumMeta, api_config}; use tower_http::services::ServeDir; use uuid::Uuid; @@ -130,8 +130,9 @@ pub struct Config<'a> { pub max_wire_transfer_amount: Amount, } -#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize, sqlx::Type)] +#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize, sqlx::Type, EnumMeta)] #[sqlx(type_name = "stat_timeframe_enum")] +#[enum_meta(Str)] #[allow(non_camel_case_types)] pub enum Timeframe { #[default] @@ -227,13 +228,13 @@ pub fn bank_api(state: Arc<BankState>) -> Router { .merge(token_api()) .merge(account_api()) .merge(tx_api()) + .merge(withdrawal_api()) + .merge(cashout_api(state.clone())) .merge(tan_api()) .merge(conversion_api(state.clone())) - .merge(cashout_api(state.clone())) - .merge(withdrawal_api()) - .merge(revenue_api()) .merge(wire_api()) .merge(prepared_api()) + .merge(revenue_api()) .with_state(state) .finalize() } @@ -252,12 +253,13 @@ pub mod test { use jiff::Timestamp; use rand::{random_range, seq::IndexedRandom}; use sqlx::{PgPool, Pool, Postgres, pool::PoolConnection}; + use taler_api::db::BindHelper; use taler_common::{ api::{EddsaPublicKey, HashCode, ShortHashCode}, config::Config, error_code::ErrorCode, types::{ - amount::{Amount, Decimal}, + amount::{Amount, Decimal, decimal}, payto::PaytoURI, }, }; @@ -649,19 +651,6 @@ pub mod test { self.tx_s(from, amount, to, "payout").await } - pub async fn cashout(&self, amount: &str) { - self.posta("/accounts/customer/cashouts") - .json({ - json!({ - "request_uid": ShortHashCode::rand(), - "amount_debit": amount, - "amount_credit": self.convert(amount).await - }) - }) - .await - .assert_ok() - } - pub async fn transfer( &self, amount: &str, @@ -683,6 +672,58 @@ pub mod test { .assert_ok() } + /** Perform a taler incoming transaction of [amount] from merchant to exchange */ + pub async fn add_incoming(&self, amount: &str) { + self.post_admin("/accounts/exchange/taler-wire-gateway/admin/add-incoming") + .json({ + json!({ + "amount": format!("{}:{amount}", self.state.cfg.regional_currency), + "reserve_pub": EddsaPublicKey::rand(), + "debit_account": self.merchant_payto + }) + }) + .await + .assert_ok() + } + + pub async fn add_kyc(&self, amount: &str) { + self.post_admin("/accounts/exchange/taler-wire-gateway/admin/add-kycauth") + .json({ + json!({ + "amount": format!("{}:{amount}", self.state.cfg.regional_currency), + "account_pub": EddsaPublicKey::rand(), + "debit_account": self.merchant_payto + }) + }) + .await + .assert_ok() + } + + pub async fn cashout(&self, amount: &str) { + let amount = format!("{}:{amount}", self.state.cfg.regional_currency); + self.posta("/accounts/customer/cashouts") + .json({ + json!({ + "request_uid": ShortHashCode::rand(), + "amount_debit": amount, + "amount_credit": self.convert(&amount).await + }) + }) + .await + .assert_ok() + } + + pub async fn cashin(&self, amount: &str) { + sqlx::query("SELECT 0 FROM cashin($1, $2, $3, $4)") + .bind_timestamp(&Timestamp::now()) + .bind(ShortHashCode::rand()) + .bind(decimal(amount)) + .bind("") + .fetch_one(&self.state.db) + .await + .unwrap(); + } + pub async fn withdrawal(&self, amount: &str) { let uuid = self .posta("/accounts/merchant/withdrawals") @@ -879,7 +920,7 @@ pub mod test { .assert_error(ErrorCode::GENERIC_PARAMETER_MALFORMED); ctx.fill_cashout_info("customer").await; - ctx.cashout("KUDOS:1").await; + ctx.cashout("1").await; ctx.get_admin("/monitor") .await .assert_ok_json::<MonitorResponse>(); diff --git a/crates/libeufin-bank/src/api/cashout.rs b/crates/libeufin-bank/src/api/cashout.rs @@ -417,7 +417,7 @@ pub mod test { &router, "/accounts/customer/cashouts", tasks!({ - c.cashout("KUDOS:0.1").await; + c.cashout("0.1").await; }), ) .await; @@ -425,7 +425,7 @@ pub mod test { &router, "/cashouts", tasks!({ - c.cashout("KUDOS:0.1").await; + c.cashout("0.1").await; }), ) .await; diff --git a/crates/libeufin-bank/src/api/conversion.rs b/crates/libeufin-bank/src/api/conversion.rs @@ -45,11 +45,9 @@ use crate::{ auth::{AdminRAuth, AdminRWAuth, UserOptRAuth, UserRAuth}, config::{BankCfg, CurrencySpecification}, db::conversion::{ - ConversionResult, CreateResult, PatchResult, class_from_cashin, class_from_cashout, - class_to_cashin, class_to_cashout, create, default_from_cashin, default_from_cashout, - default_to_cashin, default_to_cashout, delete_class, get_class, get_class_rate, - get_default_rate, get_user_rate, page_class, patch_class, update_config, user_from_cashin, - user_from_cashout, user_to_cashin, user_to_cashout, + ConversionResult, CreateResult, PatchResult, conversion, create, delete_class, get_class, + get_class_rate, get_default_rate, get_user_rate, page_class, patch_class, update_config, + user_conversion, }, }; @@ -224,28 +222,52 @@ impl FromRequestParts<Arc<BankState>> for Conversion { } #[derive(Debug, Clone, Deserialize)] -#[serde(untagged)] -pub enum RateParams { - Debit { amount_debit: Amount }, - Credit { amount_credit: Amount }, +pub struct RateParams { + amount_debit: Option<Amount>, + amount_credit: Option<Amount>, } impl RateParams { - pub fn check(&self, cfg: &BankCfg, cashin: bool) -> ApiResult<()> { + pub fn check(self, cfg: &BankCfg, cashin: bool) -> ApiResult<Rate> { + let rate: Rate = { + match (self.amount_credit, self.amount_debit) { + (None, None) => { + return Err(failure( + ErrorCode::GENERIC_PARAMETER_MISSING, + "Either param 'amount_debit' or 'amount_credit' is require", + )); + } + (Some(_), Some(_)) => { + return Err(failure( + ErrorCode::GENERIC_PARAMETER_MALFORMED, + "Cannot have both 'amount_debit' and 'amount_credit' params", + )); + } + (None, Some(amount_debit)) => Rate::Debit { amount_debit }, + (Some(amount_credit), None) => Rate::Credit { amount_credit }, + } + }; if cashin { - match self { - Self::Debit { amount_debit } => cfg.check_fiat(amount_debit), - Self::Credit { amount_credit } => cfg.check_regio(amount_credit), + match &rate { + Rate::Debit { amount_debit } => cfg.check_fiat(amount_debit)?, + Rate::Credit { amount_credit } => cfg.check_regio(amount_credit)?, } } else { - match self { - Self::Debit { amount_debit } => cfg.check_regio(amount_debit), - Self::Credit { amount_credit } => cfg.check_fiat(amount_credit), + match &rate { + Rate::Debit { amount_debit } => cfg.check_regio(amount_debit)?, + Rate::Credit { amount_credit } => cfg.check_fiat(amount_credit)?, } - } + }; + Ok(rate) } } +#[derive(Debug, Clone)] +pub enum Rate { + Debit { amount_debit: Amount }, + Credit { amount_credit: Amount }, +} + async fn config(State(state): State<Arc<BankState>>) -> ApiResult<Response> { let (fiat, spec) = state.cfg.fiat.as_ref().unwrap(); let rate = get_default_rate(&state.db, &state.cfg.regional_currency, fiat).await?; @@ -276,12 +298,11 @@ async fn update_cfg( async fn convert( cfg: &BankCfg, - params: &RateParams, + params: RateParams, cashin: bool, - to: impl AsyncFnOnce(&Amount) -> sqlx::Result<ConversionResult>, - from: impl AsyncFnOnce(&Amount) -> sqlx::Result<ConversionResult>, + lambda: impl AsyncFnOnce(bool, &Amount) -> sqlx::Result<ConversionResult>, ) -> ApiResult<Response> { - params.check(cfg, cashin)?; + let params = params.check(cfg, cashin)?; fn err_handling(res: sqlx::Result<ConversionResult>) -> ApiResult<Amount> { match res? { @@ -302,12 +323,14 @@ async fn convert( } let (amount_debit, amount_credit) = match params { - RateParams::Debit { amount_debit } => { - (*amount_debit, err_handling(to(amount_debit).await)?) - } - RateParams::Credit { amount_credit } => { - (err_handling(from(amount_credit).await)?, *amount_credit) - } + Rate::Debit { amount_debit } => ( + amount_debit, + err_handling(lambda(true, &amount_debit).await)?, + ), + Rate::Credit { amount_credit } => ( + err_handling(lambda(false, &amount_credit).await)?, + amount_credit, + ), }; Ok(Json(ConversionResponse { amount_debit, @@ -352,13 +375,9 @@ pub fn conversion_api(state: Arc<BankState>) -> Router<Arc<BankState>> { async |State(state): State<Arc<BankState>>, Query(params): Query<RateParams>| { let regio = &state.cfg.regional_currency; let fiat = state.cfg.fiat_currency().unwrap(); - convert( - &state.cfg, - &params, - false, - async |amount| default_to_cashout(&state.db, regio, fiat, amount).await, - async |amount| default_from_cashout(&state.db, regio, fiat, amount).await, - ) + convert(&state.cfg, params, false, async |to, amount| { + conversion(&state.db, regio, fiat, amount, to, false, None).await + }) .await }, ), @@ -369,13 +388,9 @@ pub fn conversion_api(state: Arc<BankState>) -> Router<Arc<BankState>> { async |State(state): State<Arc<BankState>>, Query(params): Query<RateParams>| { let regio = &state.cfg.regional_currency; let fiat = state.cfg.fiat_currency().unwrap(); - convert( - &state.cfg, - &params, - true, - async |amount| default_to_cashin(&state.db, regio, fiat, amount).await, - async |amount| default_from_cashin(&state.db, regio, fiat, amount).await, - ) + convert(&state.cfg, params, true, async |to, amount| { + conversion(&state.db, regio, fiat, amount, to, true, None).await + }) .await }, ), @@ -405,13 +420,9 @@ pub fn conversion_api(state: Arc<BankState>) -> Router<Arc<BankState>> { State(state): State<Arc<BankState>>| { let regio = &state.cfg.regional_currency; let fiat = state.cfg.fiat_currency().unwrap(); - convert( - &state.cfg, - &params, - false, - async |amount| class_to_cashout(&state.db, regio, fiat, id, amount).await, - async |amount| class_from_cashout(&state.db, regio, fiat, id, amount).await, - ) + convert(&state.cfg, params, false, async |to, amount| { + conversion(&state.db, regio, fiat, amount, to, false, Some(id)).await + }) .await }, ), @@ -425,13 +436,9 @@ pub fn conversion_api(state: Arc<BankState>) -> Router<Arc<BankState>> { State(state): State<Arc<BankState>>| { let regio = &state.cfg.regional_currency; let fiat = state.cfg.fiat_currency().unwrap(); - convert( - &state.cfg, - &params, - true, - async |amount| class_to_cashin(&state.db, regio, fiat, id, amount).await, - async |amount| class_from_cashin(&state.db, regio, fiat, id, amount).await, - ) + convert(&state.cfg, params, true, async |to, amount| { + conversion(&state.db, regio, fiat, amount, to, true, Some(id)).await + }) .await }, ), @@ -462,17 +469,10 @@ pub fn conversion_api(state: Arc<BankState>) -> Router<Arc<BankState>> { State(state): State<Arc<BankState>>| { let regio = &state.cfg.regional_currency; let fiat = state.cfg.fiat_currency().unwrap(); - convert( - &state.cfg, - &params, - false, - async |amount| { - user_to_cashout(&state.db, regio, fiat, &auth.username, amount).await - }, - async |amount| { - user_from_cashout(&state.db, regio, fiat, &auth.username, amount).await - }, - ) + convert(&state.cfg, params, false, async |to, amount| { + user_conversion(&state.db, regio, fiat, amount, to, false, &auth.username) + .await + }) .await }, ), @@ -485,17 +485,9 @@ pub fn conversion_api(state: Arc<BankState>) -> Router<Arc<BankState>> { State(state): State<Arc<BankState>>| { let regio = &state.cfg.regional_currency; let fiat = state.cfg.fiat_currency().unwrap(); - convert( - &state.cfg, - &params, - true, - async |amount| { - user_to_cashin(&state.db, regio, fiat, &username, amount).await - }, - async |amount| { - user_from_cashin(&state.db, regio, fiat, &username, amount).await - }, - ) + convert(&state.cfg, params, true, async |to, amount| { + user_conversion(&state.db, regio, fiat, amount, to, true, &username).await + }) .await }, ), @@ -734,14 +726,13 @@ pub mod test { // No amount ctx.get_admin(format!("{prefix}/conversion-info/cashout-rate")) .await - .assert_error(ErrorCode::GENERIC_PARAMETER_MALFORMED); + .assert_error(ErrorCode::GENERIC_PARAMETER_MISSING); // Both amount - /*ctx.get_admin(format!( + ctx.get_admin(format!( "{prefix}/conversion-info/cashout-rate?amount_debit=EUR:1&amount_credit=KUDOS:1" )) .await - .assert_error(ErrorCode::GENERIC_PARAMETER_MALFORMED);*/ - // TODO + .assert_error(ErrorCode::GENERIC_PARAMETER_MALFORMED); // Wrong format ctx.get_admin(format!( "{prefix}/conversion-info/cashout-rate?amount_debit=1" @@ -798,14 +789,13 @@ pub mod test { // No amount ctx.get_admin(format!("{prefix}/conversion-info/cashin-rate")) .await - .assert_error(ErrorCode::GENERIC_PARAMETER_MALFORMED); + .assert_error(ErrorCode::GENERIC_PARAMETER_MISSING); // Both amount - /*ctx.get_admin(format!( + ctx.get_admin(format!( "{prefix}/conversion-info/cashin-rate?amount_debit=EUR:1&amount_credit=KUDOS:1" )) .await - .assert_error(ErrorCode::GENERIC_PARAMETER_MALFORMED);*/ - // TODO + .assert_error(ErrorCode::GENERIC_PARAMETER_MALFORMED); // Wrong format ctx.get_admin(format!( "{prefix}/conversion-info/cashin-rate?amount_debit=1" @@ -847,7 +837,7 @@ pub mod test { .assert_ok(); ctx.get_admin(format!("{prefix}/conversion-info/cashin-rate")) .await - .assert_error(ErrorCode::GENERIC_PARAMETER_MALFORMED); + .assert_error(ErrorCode::GENERIC_PARAMETER_MISSING); ctx.get_admin(format!( "{prefix}/conversion-info/cashout-rate?amount_credit=EUR:1" )) diff --git a/crates/libeufin-bank/src/api/token.rs b/crates/libeufin-bank/src/api/token.rs @@ -83,6 +83,8 @@ pub struct TokenSuccessResponse { pub token_id: u64, } +pub const TOKEN_DEFAULT_DURATION: Duration = Duration::from_hours(24); + pub fn token_api() -> Router<Arc<BankState>> { Router::new() .route( @@ -111,7 +113,7 @@ pub fn token_api() -> Router<Arc<BankState>> { let creation = Timestamp::now(); let expiration = match req .duration - .unwrap_or(RelativeTime::Duration(Duration::from_hours(24))) + .unwrap_or(RelativeTime::Duration(TOKEN_DEFAULT_DURATION)) { RelativeTime::Forever => TalerTimestamp::Never, RelativeTime::Duration(duration) => { @@ -196,7 +198,7 @@ pub mod test { use std::str::FromStr; use axum::http::{Method, header::AUTHORIZATION}; - use jiff::{SignedDuration, Timestamp}; + use jiff::Timestamp; use taler_common::{ api::ErrorDetail, error_code::ErrorCode, @@ -208,7 +210,7 @@ pub mod test { api::{ tan::ChallengeResponse, test::{Auth, MfaRequest, bank_setup, tan_code}, - token::{TokenInfos, TokenSuccessResponse}, + token::{TOKEN_DEFAULT_DURATION, TokenInfos, TokenSuccessResponse}, }, auth::TOKEN_PREFIX, db::{account::MAX_TOKEN_CREATION_ATTEMPTS, token::access}, @@ -266,8 +268,8 @@ pub mod test { match token.expiration { TalerTimestamp::Never => unreachable!(), TalerTimestamp::Timestamp(expiration) => { - let lifetime = token.creation.duration_until(expiration); - assert_eq!(lifetime, SignedDuration::from_hours(24)); + let lifetime = token.creation.duration_until(expiration).unsigned_abs(); + assert_eq!(lifetime, TOKEN_DEFAULT_DURATION); } } diff --git a/crates/libeufin-bank/src/api/tx.rs b/crates/libeufin-bank/src/api/tx.rs @@ -537,7 +537,7 @@ pub mod test { // Outgoing { ctx.tx("customer", "0.1", "merchant").await }, // Cashout from merchant - { ctx.cashout("KUDOS:0.1").await } + { ctx.cashout("0.1").await } ), tasks!( // Other account diff --git a/crates/libeufin-bank/src/db.rs b/crates/libeufin-bank/src/db.rs @@ -183,3 +183,537 @@ pub async fn monitor( } })) } + +#[cfg(test)] +mod test { + use std::ops::Range; + + use taler_common::{ + error_code::ErrorCode::BANK_UNALLOWED_DEBIT, + types::amount::{Currency, Decimal, MAX_VALUE, decimal}, + }; + use taler_test_utils::json; + + use crate::api::{ + Timeframe, + conversion::RoundingMode::{self, nearest, up, zero}, + test::bank_setup, + }; + + #[tokio::test] + async fn amount_computation() { + let ctx = bank_setup().await; + + sqlx::query( + "UPDATE libeufin_bank.bank_accounts SET balance.val = 100000 WHERE internal_payto=$1", + ) + .bind(ctx.customer_payto.canonical()) + .execute(&ctx.state.db) + .await + .unwrap(); + + let query = " + UPDATE libeufin_bank.bank_accounts + SET balance = $1, + has_debt = $2, + max_debt = $3 + WHERE internal_payto=$4 + "; + + // Test amount computation in db + { + for (balance, has_debt, max_debt, amount, success) in &[ + // Balance enough, assert for true + ( + Decimal::new(10, 0), + false, + Decimal::new(100, 0), + Decimal::new(8, 0), + true, + ), + // Balance still sufficient, thanks for big enough debt permission, assert true + ( + Decimal::new(10, 0), + false, + Decimal::new(100, 0), + Decimal::new(80, 0), + true, + ), + // Balance not enough, max debt cannot cover, asserting for false + ( + Decimal::new(10, 0), + true, + Decimal::new(50, 0), + Decimal::new(80, 0), + false, + ), + // Balance becomes enough, due to a larger max debt, asserting for true + ( + Decimal::new(10, 0), + false, + Decimal::new(70, 0), + Decimal::new(80, 0), + true, + ), + // Max debt not enough for the smallest fraction, asserting for false + ( + Decimal::new(0, 0), + false, + Decimal::new(0, 1), + Decimal::new(0, 2), + false, + ), + // Same as above, but already in debt + ( + Decimal::new(0, 1), + true, + Decimal::new(0, 1), + Decimal::new(0, 1), + false, + ), + ] { + let amount = amount.to_amount(&Currency::KUDOS); + // Check bank transaction + sqlx::query(query) + .bind(balance) + .bind(has_debt) + .bind(max_debt) + .bind(ctx.merchant_payto.canonical()) + .execute(&ctx.state.db) + .await + .unwrap(); + let res = ctx + .posta("/accounts/merchant/transactions") + .json(json!({ + "payto_uri": format!("{}?message=", ctx.customer_payto), + "amount": amount + })) + .await; + if *success { + res.assert_ok(); + } else { + res.assert_error(BANK_UNALLOWED_DEBIT); + } + + // Check whithdraw + sqlx::query(query) + .bind(balance) + .bind(has_debt) + .bind(max_debt) + .bind(ctx.merchant_payto.canonical()) + .execute(&ctx.state.db) + .await + .unwrap(); + for (amount, suggested) in [ + (Some(amount), None), + (None, Some(amount)), + (Some(amount), Some(amount)), + ] { + let res = ctx + .posta("/accounts/merchant/withdrawals") + .json(json!({ + "amount": amount, + "suggested_amount": suggested + })) + .await; + + if *success { + res.assert_ok(); + } else { + res.assert_error(BANK_UNALLOWED_DEBIT); + } + } + } + } + + // Max amount computation in db + for (balance, has_debt, max_debt, amount) in [ + // Without debt + ( + Decimal::new(10, 1), + false, + Decimal::new(100, 2), + Decimal::new(110, 3), + ), + // With debt + ( + Decimal::new(10, 1), + true, + Decimal::new(100, 2), + Decimal::new(90, 1), + ), + ] { + sqlx::query(query) + .bind(balance) + .bind(has_debt) + .bind(max_debt) + .bind(ctx.merchant_payto.canonical()) + .execute(&ctx.state.db) + .await + .unwrap(); + let max: Decimal = sqlx::query_scalar( + " + SELECT max_amount + FROM account_max_amount(1, $1) AS max_amount + ", + ) + .bind(Decimal::MAX) + .fetch_one(&ctx.state.db) + .await + .unwrap(); + assert_eq!(max, amount); + } + + let tiny = decimal("0.00000001"); + let apply = + async |nb: Decimal, + times: Decimal, + tiny: Decimal, + rounding: RoundingMode| + -> sqlx::Result<Decimal> { + sqlx::query_scalar( + "SELECT result FROM conversion_apply_ratio($1, $2, (0, 0)::taler_amount, $3, $4)", + ) + .bind(nb) + .bind(times) + .bind(tiny) + .bind(rounding).fetch_one(&ctx.state.db).await + }; + let assert_err = |err: sqlx::Result<Decimal>, msg: &str| { + assert_eq!( + err.unwrap_err().into_database_error().unwrap().message(), + msg + ) + }; + // Conversion apply + { + assert_eq!( + decimal("30.0629"), + apply(decimal("6.41"), decimal("4.69"), tiny, zero) + .await + .unwrap() + ); + assert_eq!( + decimal("6.41000641"), + apply(decimal("6.41"), decimal("1.000001"), tiny, zero) + .await + .unwrap() + ); + assert_eq!( + decimal("2.49999997"), + apply(decimal("0.99999999"), decimal("2.5"), tiny, zero) + .await + .unwrap() + ); + assert_eq!( + decimal(format!("{MAX_VALUE}.99999999")), + apply( + decimal(format!("{MAX_VALUE}.99999999")), + decimal("1"), + tiny, + zero + ) + .await + .unwrap() + ); + assert_eq!( + decimal(format!("{MAX_VALUE}")), + apply( + decimal(format!("{}", MAX_VALUE / 4)), + decimal("4"), + tiny, + zero + ) + .await + .unwrap() + ); + assert_err( + apply( + decimal(format!("{}", MAX_VALUE / 3)), + decimal("3.00000001"), + tiny, + zero, + ) + .await, + "amount value overflowed", + ); + assert_err( + apply( + decimal(format!("{}", (MAX_VALUE + 2) / 2)), + decimal("2"), + tiny, + zero, + ) + .await, + "amount value overflowed", + ); + + // Check rounding mode + let checks: &[(RoundingMode, &[(i32, Range<i32>)])] = &[ + (zero, &[(1, 10..20)]), + (up, &[(1, 10..11), (2, 11..20)]), + (nearest, &[(1, 10..15), (2, 15..20)]), + ]; + for (mode, rounding) in checks { + for (rounded, amounts) in *rounding { + for amount in amounts.clone().into_iter() { + // Check euro + assert_eq!( + decimal(format!("0.0{rounded}")), + apply( + decimal(format!("{amount}")), + decimal("0.001"), + decimal("0.01"), + *mode + ) + .await + .unwrap() + ); + // Check kudos + assert_eq!( + decimal(format!("0.0000000{rounded}")), + apply( + decimal(format!("0.{amount}")), + decimal("0.0000001"), + tiny, + *mode + ) + .await + .unwrap() + ); + } + } + } + // Check hungarian rounding + let checks: &[(RoundingMode, &[(i32, Range<i32>)])] = &[ + (zero, &[(10, 10..15), (15, 15..20)]), + (up, &[(10, 10..11), (15, 11..16), (20, 16..20)]), + (nearest, &[(10, 10..13), (15, 13..18), (20, 18..20)]), + ]; + for (mode, rounding) in checks { + for (rounded, amounts) in *rounding { + for amount in amounts.clone().into_iter() { + assert_eq!( + decimal(format!("{rounded}")), + apply( + decimal(format!("{amount}")), + decimal("1"), + decimal("5"), + *mode + ) + .await + .unwrap() + ); + } + } + } + for mode in RoundingMode::entries { + assert_eq!( + decimal("5"), + apply(decimal("5"), decimal("1"), decimal("1"), *mode) + .await + .unwrap() + ); + } + } + let revert = async |nb: Decimal, + times: Decimal, + tiny: Decimal, + rounding: RoundingMode, + reverse: Decimal| + -> sqlx::Result<Decimal> { + sqlx::query_scalar( + "SELECT result FROM conversion_revert_ratio($1, $2, (0, 0)::taler_amount, $3, $4, $5)", + ) + .bind(nb) + .bind(times) + .bind(tiny) + .bind(rounding).bind(reverse).fetch_one(&ctx.state.db).await + }; + // Conversion revert + { + assert_eq!( + decimal("6.41"), + revert(decimal("30.0629"), decimal("4.69"), tiny, zero, tiny) + .await + .unwrap() + ); + assert_eq!( + decimal("6.41"), + revert(decimal("6.41000641"), decimal("1.000001"), tiny, zero, tiny,) + .await + .unwrap() + ); + assert_eq!( + decimal("1"), + revert(decimal("2.49999998"), decimal("2.5"), tiny, zero, tiny,) + .await + .unwrap() + ); + assert_eq!( + decimal(format!("{MAX_VALUE}.99999999")), + revert( + decimal(format!("{MAX_VALUE}.99999999")), + decimal("1"), + tiny, + zero, + tiny, + ) + .await + .unwrap() + ); + assert_eq!( + decimal(format!("{MAX_VALUE}")), + revert( + decimal(format!("{}", MAX_VALUE / 4)), + decimal("0.25"), + tiny, + zero, + tiny, + ) + .await + .unwrap() + ); + assert_err( + revert( + decimal(format!("{}", MAX_VALUE / 4)), + decimal("0.24999999"), + tiny, + zero, + tiny, + ) + .await, + "amount value overflowed", + ); + assert_err( + revert( + decimal(format!("{}", (MAX_VALUE + 2) / 2)), + decimal("0.5"), + tiny, + zero, + tiny, + ) + .await, + "amount value overflowed", + ); + + for mode in RoundingMode::entries { + for tiny in ["0.01", "0.00000001", "1", "2", "3", "5"].map(decimal) { + for amount in (10..20).map(|i| decimal(format!("{i}"))) { + for ratio in + ["1", "1.25", "1.26", "0.01", "0.001", "0.00000001"].map(decimal) + { + for reverse in ["0.01", "0.00000001", "1"].map(decimal) { + // Apply ratio + let rounded = apply(amount, ratio, tiny, *mode).await.unwrap(); + // Revert ratio + let revert = + revert(rounded, ratio, tiny, *mode, reverse).await.unwrap(); + // Check applying ratio again give the same result + let check = apply(revert, ratio, tiny, *mode).await.unwrap(); + assert_eq!(rounded, check); + } + } + } + } + } + } + } + + #[tokio::test] + async fn statistics() { + let ctx = bank_setup().await; + for account in ["merchant", "exchange", "customer"] { + ctx.set_max_debt(account, "1000").await; + } + ctx.fill_cashout_info("customer").await; + + let check = async |values: &[(&str, serde_json::Value)]| { + for frame in Timeframe::entries { + let res = ctx + .get_admin(format!("/monitor?timestamp={frame}")) + .await + .assert_ok_json::<serde_json::Value>(); + for (k, v) in values { + assert_eq!(&res[k], v); + } + } + }; + + let monitor_taler_in = async |count: usize, amount: &str| { + check(&[ + ("talerInCount", json!(count)), + ("talerInVolume", json!(amount)), + ]) + .await + }; + let monitor_taler_out = async |count: usize, amount: &str| { + check(&[ + ("talerOutCount", json!(count)), + ("talerOutVolume", json!(amount)), + ]) + .await + }; + let monitor_cashin = async |count: usize, regional: &str, fiat: &str| { + check(&[ + ("cashinCount", json!(count)), + ("cashinRegionalVolume", json!(regional)), + ("cashinFiatVolume", json!(fiat)), + ]) + .await + }; + let monitor_cashout = async |count: usize, regional: &str, fiat: &str| { + check(&[ + ("cashoutCount", json!(count)), + ("cashoutRegionalVolume", json!(regional)), + ("cashoutFiatVolume", json!(fiat)), + ]) + .await + }; + + monitor_taler_in(0, "KUDOS:0").await; + monitor_taler_out(0, "KUDOS:0").await; + monitor_cashin(0, "KUDOS:0", "EUR:0").await; + monitor_cashout(0, "KUDOS:0", "EUR:0").await; + + ctx.add_incoming("3").await; + monitor_taler_in(1, "KUDOS:3").await; + ctx.add_incoming("7.6").await; + monitor_taler_in(2, "KUDOS:10.6").await; + ctx.add_incoming("12.3").await; + monitor_taler_in(3, "KUDOS:22.9").await; + + // KYC are ignored + ctx.add_kyc("3").await; + monitor_taler_in(3, "KUDOS:22.9").await; + + ctx.transfer("10", &ctx.customer_payto, None).await; + monitor_taler_out(1, "KUDOS:10").await; + ctx.transfer("30.5", &ctx.customer_payto, None).await; + monitor_taler_out(2, "KUDOS:40.5").await; + ctx.transfer("42", &ctx.customer_payto, None).await; + monitor_taler_out(3, "KUDOS:82.5").await; + + ctx.cashin("10").await; + monitor_cashin(1, "KUDOS:7.98", "EUR:10").await; + monitor_taler_in(4, "KUDOS:30.88").await; + ctx.cashin("20").await; + monitor_cashin(2, "KUDOS:23.96", "EUR:30").await; + monitor_taler_in(5, "KUDOS:46.86").await; + ctx.cashin("40").await; + monitor_cashin(3, "KUDOS:55.94", "EUR:70").await; + monitor_taler_in(6, "KUDOS:78.84").await; + + ctx.cashout("3").await; + monitor_cashout(1, "KUDOS:3", "EUR:3.78").await; + ctx.cashout("7.6").await; + monitor_cashout(2, "KUDOS:10.6", "EUR:13.35").await; + ctx.cashout("12.3").await; + monitor_cashout(3, "KUDOS:22.9", "EUR:28.85").await; + + monitor_taler_in(6, "KUDOS:78.84").await; + monitor_taler_out(3, "KUDOS:82.5").await; + monitor_cashin(3, "KUDOS:55.94", "EUR:70").await; + monitor_cashout(3, "KUDOS:22.9", "EUR:28.85").await; + } +} diff --git a/crates/libeufin-bank/src/db/conversion.rs b/crates/libeufin-bank/src/db/conversion.rs @@ -236,15 +236,17 @@ pub enum ConversionResult { } /** Perform [direction] conversion of [amount] using in-db [function] */ -async fn conversion( +pub async fn conversion( db: &PgPool, regional: &Currency, fiat: &Currency, amount: &Amount, - lambda: &str, - direction: &str, + to: bool, + cashin: bool, rate_id: Option<u64>, ) -> sqlx::Result<ConversionResult> { + let lambda = if to { "to" } else { "from" }; + let direction = if cashin { "cashin" } else { "cashout" }; serialized!( sqlx::query(&format!( "SELECT too_small, converted FROM conversion_{lambda}($1,$2,$3)" @@ -271,15 +273,17 @@ async fn conversion( } /** Perform [direction] conversion of [amount] using in-db [function] */ -async fn user_conversion( +pub async fn user_conversion( db: &PgPool, regional: &Currency, fiat: &Currency, amount: &Amount, - lambda: &str, - direction: &str, + to: bool, + cashin: bool, username: &str, ) -> sqlx::Result<ConversionResult> { + let lambda = if to { "to" } else { "from" }; + let direction = if cashin { "cashin" } else { "cashout" }; serialized!( sqlx::query(&format!( "SELECT is_taler_exchange, too_small, converted @@ -314,115 +318,6 @@ async fn user_conversion( ) } -/** Convert [regional] amount to fiat using cashout rate */ -pub async fn default_to_cashout( - db: &PgPool, - regional: &Currency, - fiat: &Currency, - amount: &Amount, -) -> sqlx::Result<ConversionResult> { - conversion(db, regional, fiat, amount, "to", "cashout", None).await -} -pub async fn class_to_cashout( - db: &PgPool, - regional: &Currency, - fiat: &Currency, - id: u64, - amount: &Amount, -) -> sqlx::Result<ConversionResult> { - conversion(db, regional, fiat, amount, "to", "cashout", Some(id)).await -} -pub async fn user_to_cashout( - db: &PgPool, - regional: &Currency, - fiat: &Currency, - username: &str, - amount: &Amount, -) -> sqlx::Result<ConversionResult> { - user_conversion(db, regional, fiat, amount, "to", "cashout", username).await -} -/** Convert [fiat] amount to regional using cashin rate */ -pub async fn default_to_cashin( - db: &PgPool, - regional: &Currency, - fiat: &Currency, - amount: &Amount, -) -> sqlx::Result<ConversionResult> { - conversion(db, regional, fiat, amount, "to", "cashin", None).await -} -pub async fn class_to_cashin( - db: &PgPool, - regional: &Currency, - fiat: &Currency, - id: u64, - amount: &Amount, -) -> sqlx::Result<ConversionResult> { - conversion(db, regional, fiat, amount, "to", "cashin", Some(id)).await -} -pub async fn user_to_cashin( - db: &PgPool, - regional: &Currency, - fiat: &Currency, - username: &str, - amount: &Amount, -) -> sqlx::Result<ConversionResult> { - user_conversion(db, regional, fiat, amount, "to", "cashin", username).await -} -/** Convert [fiat] amount to regional using inverse cashout rate */ -pub async fn default_from_cashout( - db: &PgPool, - regional: &Currency, - fiat: &Currency, - amount: &Amount, -) -> sqlx::Result<ConversionResult> { - conversion(db, regional, fiat, amount, "from", "cashout", None).await -} -pub async fn class_from_cashout( - db: &PgPool, - regional: &Currency, - fiat: &Currency, - id: u64, - amount: &Amount, -) -> sqlx::Result<ConversionResult> { - conversion(db, regional, fiat, amount, "from", "cashout", Some(id)).await -} -pub async fn user_from_cashout( - db: &PgPool, - regional: &Currency, - fiat: &Currency, - username: &str, - amount: &Amount, -) -> sqlx::Result<ConversionResult> { - user_conversion(db, regional, fiat, amount, "from", "cashout", username).await -} -/** Convert [regional] amount to fiat using inverse cashin rate */ -pub async fn default_from_cashin( - db: &PgPool, - regional: &Currency, - fiat: &Currency, - amount: &Amount, -) -> sqlx::Result<ConversionResult> { - conversion(db, regional, fiat, amount, "from", "cashin", None).await -} -pub async fn class_from_cashin( - db: &PgPool, - regional: &Currency, - fiat: &Currency, - id: u64, - amount: &Amount, -) -> sqlx::Result<ConversionResult> { - conversion(db, regional, fiat, amount, "from", "cashin", Some(id)).await -} -pub async fn user_from_cashin( - db: &PgPool, - regional: &Currency, - fiat: &Currency, - username: &str, - amount: &Amount, -) -> sqlx::Result<ConversionResult> { - user_conversion(db, regional, fiat, amount, "from", "cashin", username).await -} - /** Result status of conversion rate class creation */ pub enum CreateResult { Success(u64),