commit 4944834d97639dabaca459edb092fe646775cf38
parent 39c74e88e8f9c0ddff1ce085880906bc0c4f181c
Author: Antoine A <>
Date: Tue, 26 May 2026 18:24:39 +0200
bank: more testing and clean code
Diffstat:
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,
- ¶ms,
- 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,
- ¶ms,
- 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,
- ¶ms,
- 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,
- ¶ms,
- 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,
- ¶ms,
- 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,
- ¶ms,
- 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),