libeufin

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

commit 519d6229087f969ae987f9db60ee84236c571abf
parent 46065585cc43769e2e2eaf3bb09778278dfe605e
Author: Antoine A <>
Date:   Wed, 13 May 2026 15:15:01 +0200

bank: add conversion API

Diffstat:
MCargo.lock | 8++++----
Mcrates/libeufin-bank/src/api.rs | 36+++++++++++++++++++++---------------
Mcrates/libeufin-bank/src/api/account.rs | 76+++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Mcrates/libeufin-bank/src/api/conversion.rs | 915+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/libeufin-bank/src/api/token.rs | 6+++---
Mcrates/libeufin-bank/src/api/tx.rs | 2+-
Mcrates/libeufin-bank/src/auth.rs | 98+++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------
Mcrates/libeufin-bank/src/config.rs | 4++--
Mcrates/libeufin-bank/src/db/account.rs | 50++++++++------------------------------------------
Mcrates/libeufin-bank/src/db/conversion.rs | 541++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mlibeufin-bank/src/main/kotlin/tech/libeufin/bank/db/ConversionDAO.kt | 2+-
11 files changed, 1569 insertions(+), 169 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -175,9 +175,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "aws-lc-rs" -version = "1.16.3" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -186,9 +186,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.40.0" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" dependencies = [ "cc", "cmake", diff --git a/crates/libeufin-bank/src/api.rs b/crates/libeufin-bank/src/api.rs @@ -50,7 +50,7 @@ pub mod test { error_code::ErrorCode, types::{ amount::{Amount, Decimal}, - payto::{IbanPayto, PaytoURI}, + payto::PaytoURI, }, }; use taler_test_utils::{ @@ -80,7 +80,7 @@ pub mod test { pub enum Auth { Admin, - Exchange, + Optional, User, UserOnly, Token, @@ -114,7 +114,7 @@ pub mod test { .merge(account_api()) .merge(tx_api()) .merge(tan_api()) - .merge(conversion_api()) + .merge(conversion_api(state.clone())) .with_state(state.clone()) .finalize(); @@ -152,7 +152,7 @@ pub mod test { None, rand_iban_payto().into_inner().into(), false, - false, + true, Decimal::new(10, 0).to_amount(&state.cfg.regional_currency), Amount::zero(&state.cfg.regional_currency), &[], @@ -208,6 +208,10 @@ pub mod test { ctx.cache_tokens(&["admin", "merchant", "exchange", "customer"]) .await; + if state.cfg.fiat.is_some() { + ctx.create_conversion_rate_class().await; + } + ctx } @@ -226,7 +230,7 @@ pub mod test { .merge(account_api()) .merge(tx_api()) .merge(tan_api()) - .merge(conversion_api()) + .merge(conversion_api(state.clone())) .with_state(state.clone()) .finalize(); self @@ -334,27 +338,29 @@ pub mod test { .assert_error(ErrorCode::GENERIC_UNAUTHORIZED); if !matches!(auth, Auth::Token) { - // No header - self.request(method.clone(), path) - .await - .assert_error_status( - ErrorCode::GENERIC_PARAMETER_MISSING, - StatusCode::UNAUTHORIZED, - ); + if !matches!(auth, Auth::Optional) { + // No header + self.request(method.clone(), path) + .await + .assert_error_status( + ErrorCode::GENERIC_PARAMETER_MISSING, + StatusCode::UNAUTHORIZED, + ); + } // Other account - self.requesta(method.clone(), path, Some("merchant")) + self.requesta(method.clone(), path, Some("customer")) .await .assert_error(ErrorCode::GENERIC_FORBIDDEN); } match auth { - Auth::Admin | Auth::Exchange => { + Auth::Admin => { self.requesta(method.clone(), path, Some("merchant")) .await .assert_error(ErrorCode::GENERIC_FORBIDDEN); } - Auth::User | Auth::Token => {} + Auth::User | Auth::Token | Auth::Optional => {} Auth::UserOnly => { self.requesta(method.clone(), path, Some("admin")) .await diff --git a/crates/libeufin-bank/src/api/account.rs b/crates/libeufin-bank/src/api/account.rs @@ -29,7 +29,7 @@ use axum::{ response::{IntoResponse, NoContent}, routing::{get, patch, post}, }; -use compact_str::CompactString; +use compact_str::{CompactString, format_compact}; use regex::Regex; use serde::{Deserialize, Serialize}; use sqlx::{Database, PgPool}; @@ -38,6 +38,7 @@ use taler_api::{ extract::{Query, Req}, }; use taler_common::{ + api_params::{Page, PageParams, ParamsErr}, error_code::ErrorCode::{self}, types::{ amount::Amount, @@ -56,8 +57,8 @@ use crate::{ db::{ self, account::{ - AccountParams, CreationResult, DeletionResult, PatchAuthResult, PatchResult, - by_username, page_admin, page_public, reconfig, reconfig_password, + CreationResult, DeletionResult, PatchAuthResult, PatchResult, by_username, page_admin, + page_public, reconfig, reconfig_password, }, }, mfa::{AccountDeletionOp, AccountPasswordOp, AccountReconfigOp, MfaReq, Tans}, @@ -414,6 +415,31 @@ pub struct AccountPasswordChange { pub old_password: Option<CompactString>, } +#[derive(Debug, Clone, Deserialize)] +pub struct AccountParams { + #[serde(flatten)] + pub page: PageParams, + pub filter_name: Option<CompactString>, + pub conversion_rate_class_id: Option<u64>, +} + +impl AccountParams { + pub fn check(self) -> Result<Account, ParamsErr> { + Ok(Account { + page: self.page.check()?, + filter_name: self.filter_name.map(|it| format_compact!("%{it}%")), + conversion_rate_class_id: self.conversion_rate_class_id, + }) + } +} + +#[derive(Debug)] +pub struct Account { + pub page: Page, + pub filter_name: Option<CompactString>, + pub conversion_rate_class_id: Option<u64>, +} + pub fn account_api() -> Router<Arc<BankState>> { Router::new() .route( @@ -543,7 +569,7 @@ pub fn account_api() -> Router<Arc<BankState>> { ErrorCode::BANK_RESERVED_USERNAME_CONFLICT, "Cannot delete reserved account", )); - } else if auth.username == "exchange" && state.cfg.allow_conversion { + } else if auth.username == "exchange" && state.cfg.fiat.is_some() { return Err(failure( ErrorCode::BANK_RESERVED_USERNAME_CONFLICT, "Cannot delete 'exchange' accounts when conversion is enabled", @@ -863,13 +889,9 @@ pub mod test { use serde::Serialize; use taler_common::{ error_code::ErrorCode, - types::{ - amount::amount, - payto::{FullPayto, PaytoImpl}, - }, + types::{amount::amount, payto::PaytoImpl}, }; use taler_test_utils::{json, server::TestServer as _}; - use tracing_subscriber::fmt::format; use crate::{ TanChannel, @@ -879,10 +901,11 @@ pub mod test { ListBankAccountsResponse, Maybe, PublicAccountsResponse, RegisterAccountResponse, rand_iban_payto, }, + conversion::ConversionRateClass, test::{Auth, BankTestCtx, MfaRequest, bank_setup, bank_setup_conf}, }, db::gc::collect, - payto::{FullBankPayto, LibeufinId}, + payto::FullBankPayto, }; #[tokio::test] @@ -894,7 +917,7 @@ pub mod test { ctx.post("/accounts").json(&body).await.assert_error(error); ctx.post_admin("/accounts").json(&body).await.assert_ok(); }; - ctx.auth_routine(Method::GET, "/accounts/customer", Auth::User) + ctx.auth_routine(Method::GET, "/accounts/merchant", Auth::User) .await; // Check generated payto @@ -1354,7 +1377,7 @@ pub mod test { #[tokio::test] async fn delete() { let mut ctx = bank_setup().await; - ctx.auth_routine(Method::DELETE, "/accounts/customer", Auth::Token) + ctx.auth_routine(Method::DELETE, "/accounts/merchant", Auth::Token) .await; // Reserved accounts @@ -1445,7 +1468,7 @@ pub mod test { async fn reconfig() { let mut ctx = bank_setup().await; - ctx.auth_routine(Method::PATCH, "/accounts/account", Auth::User) + ctx.auth_routine(Method::PATCH, "/accounts/merchant", Auth::User) .await; for channel in TanChannel::entries { @@ -1492,10 +1515,9 @@ pub mod test { ErrorCode::BANK_NON_ADMIN_PATCH_DEBT_LIMIT, ) .await; - let class_id = ctx.create_conversion_rate_class().await; check_admin_only( &ctx, - json!(req + { "conversion_rate_class_id": class_id }), + json!(req + { "conversion_rate_class_id": 1 }), ErrorCode::BANK_NON_ADMIN_SET_CONVERSION_RATE_CLASS, ) .await; @@ -1669,7 +1691,7 @@ pub mod test { #[tokio::test] async fn password() { let ctx = bank_setup().await; - ctx.auth_routine(Method::PATCH, "/accounts/customer/auth", Auth::User) + ctx.auth_routine(Method::PATCH, "/accounts/merchant/auth", Auth::User) .await; // Changing the password @@ -1816,6 +1838,8 @@ pub mod test { ctx.get("/public-accounts").await.assert_no_content(); ctx.get_admin("/accounts").await.assert_ok(); + let ctx = ctx.swap_cfg("test.conf"); + for _ in 0..3 { ctx.create_conversion_rate_class().await; } @@ -1847,7 +1871,14 @@ pub mod test { // Conversion rate let res: ListBankAccountsResponse = ctx.get_admin("/accounts").await.assert_ok_json(); for acc in res.accounts { - // TODO finish rate conversion + assert_eq!( + acc.conversion_rate, + Some( + ctx.get_admin(format!("/accounts/{}/conversion-info/rate", acc.username)) + .await + .assert_ok_json() + ) + ); } // Filtering let check_ids = async |query: &str, ids: &[&str]| { @@ -1874,7 +1905,14 @@ pub mod test { check_ids("conversion_rate_class_id=4", &[]).await; check_ids("conversion_rate_class_id=0", &["3", "0", "admin"]).await; check_ids("conversion_rate_class_id=0&filter_name=1", &["3", "0"]).await; - - // TODO finish rate conversion + for (id, num) in [(1, 2), (2, 1), (3, 0)] { + assert_eq!( + num, + ctx.get_admin(format!("/conversion-rate-classes/{id}")) + .await + .assert_ok_json::<ConversionRateClass>() + .num_users + ); + } } } diff --git a/crates/libeufin-bank/src/api/conversion.rs b/crates/libeufin-bank/src/api/conversion.rs @@ -19,25 +19,38 @@ use std::sync::Arc; -use axum::{Json, Router, extract::State, routing::post}; -use compact_str::CompactString; +use axum::{ + Json, Router, + extract::{FromRequestParts, State}, + middleware::{self, Next}, + response::{IntoResponse, NoContent, Response}, + routing::{get, patch, post}, +}; +use compact_str::{CompactString, format_compact}; use serde::{Deserialize, Serialize}; use sqlx::prelude::Type; use taler_api::{ - error::{ApiResult, bad_request, failure_code}, - extract::Req, + error::{ApiError, ApiResult, bad_request, failure, failure_code, forbidden, not_implemented}, + extract::{Path, Query, Req}, }; use taler_common::{ + api_params::{Page, PageParams, ParamsErr}, error_code::ErrorCode, - types::amount::{Amount, Decimal}, + types::amount::{Amount, Currency, Decimal}, }; use taler_macros::EnumMeta; use crate::{ api::BankState, - auth::AdminRWAuth, - config::BankCfg, - db::conversion::{CreateResult, create}, + 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, + }, }; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, EnumMeta, Type)] @@ -97,6 +110,12 @@ impl ConversionRate { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ConversionResponse { + pub amount_debit: Amount, + pub amount_credit: Amount, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ConversionRateClassInput { pub name: CompactString, pub description: Option<CompactString>, @@ -134,23 +153,873 @@ pub struct ConversionRateClassResponse { pub conversion_rate_class_id: u64, } -pub fn conversion_api() -> Router<Arc<BankState>> { - Router::new().route( - "/conversion-rate-classes", - post( - async |_: AdminRWAuth, - State(state): State<Arc<BankState>>, - Req(req): Req<ConversionRateClassInput>| { - req.check(&state.cfg)?; - match create(&state.db, &req).await? { - crate::db::conversion::CreateResult::Success(id) => { - Ok(Json(ConversionRateClassResponse { +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ConversionRateClass { + pub conversion_rate_class_id: u64, + pub name: CompactString, + pub description: Option<CompactString>, + pub num_users: u64, + pub cashin_ratio: Option<Decimal>, + pub cashin_fee: Option<Amount>, + pub cashin_rounding_mode: Option<RoundingMode>, + pub cashin_min_amount: Option<Amount>, + pub cashout_ratio: Option<Decimal>, + pub cashout_fee: Option<Amount>, + pub cashout_rounding_mode: Option<RoundingMode>, + pub cashout_min_amount: Option<Amount>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ConversionRateClasses { + pub classes: Vec<ConversionRateClass>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ConversionConfig<'a> { + pub name: &'a str, + pub version: &'a str, + pub regional_currency: &'a Currency, + pub regional_currency_specification: &'a CurrencySpecification, + pub fiat_currency: &'a Currency, + pub fiat_currency_specification: &'a CurrencySpecification, + pub conversion_rate: &'a ConversionRate, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ClassParams { + #[serde(flatten)] + pub page: PageParams, + pub filter_name: Option<CompactString>, +} + +impl ClassParams { + pub fn check(self) -> Result<Class, ParamsErr> { + Ok(Class { + page: self.page.check()?, + filter_name: self.filter_name.map(|it| format_compact!("%{it}%")), + }) + } +} + +#[derive(Debug)] +pub struct Class { + pub page: Page, + pub filter_name: Option<CompactString>, +} + +pub struct Conversion; + +impl FromRequestParts<Arc<BankState>> for Conversion { + type Rejection = ApiError; + + async fn from_request_parts( + _: &mut axum::http::request::Parts, + state: &Arc<BankState>, + ) -> Result<Self, Self::Rejection> { + if state.cfg.fiat.is_none() { + Err(not_implemented()) + } else { + Ok(Conversion) + } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +pub enum RateParams { + Debit { amount_debit: Amount }, + Credit { amount_credit: Amount }, +} + +impl RateParams { + pub fn check(&self, cfg: &BankCfg, cashin: bool) -> ApiResult<()> { + if cashin { + match self { + Self::Debit { amount_debit } => cfg.check_fiat(amount_debit), + Self::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), + } + } + } +} + +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?; + ApiResult::Ok( + Json(ConversionConfig { + name: "taler-conversion-info", + version: "2:0:1", + conversion_rate: &rate, + fiat_currency: fiat, + fiat_currency_specification: spec, + regional_currency: &state.cfg.regional_currency, + regional_currency_specification: &state.cfg.regional_currency_spec, + }) + .into_response(), + ) +} + +async fn update_cfg( + State(state): State<Arc<BankState>>, + _: AdminRWAuth, + Req(req): Req<ConversionRate>, +) -> ApiResult<NoContent> { + req.check(&state.cfg)?; + update_config(&state.db, &req).await?; + ApiResult::Ok(NoContent) +} + +async fn convert( + cfg: &BankCfg, + params: &RateParams, + cashin: bool, + to: impl AsyncFnOnce(&Amount) -> sqlx::Result<ConversionResult>, + from: impl AsyncFnOnce(&Amount) -> sqlx::Result<ConversionResult>, +) -> ApiResult<Response> { + params.check(cfg, cashin)?; + + fn err_handling(res: sqlx::Result<ConversionResult>) -> ApiResult<Amount> { + match res? { + ConversionResult::Success(amount) => Ok(amount), + ConversionResult::ToSmall => Err(failure( + ErrorCode::BANK_BAD_CONVERSION, + "amount is too small to be converted", + )), + ConversionResult::IsExchange => Err(failure( + ErrorCode::BANK_ACCOUNT_IS_EXCHANGE, + "exchange accounts cannot cashout", + )), + ConversionResult::NotExchange => Err(failure( + ErrorCode::BANK_ACCOUNT_IS_NOT_EXCHANGE, + "only exchange accounts can cashin", + )), + } + } + + 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) + } + }; + Ok(Json(ConversionResponse { + amount_debit, + amount_credit, + }) + .into_response()) +} + +pub fn conversion_api(state: Arc<BankState>) -> Router<Arc<BankState>> { + Router::new() + .route("/conversion-info/config", get(config)) + .route( + "/conversion-rate-classes/{id}/conversion-info/config", + get(config), + ) + .route("/accounts/{username}/conversion-info/config", get(config)) + .route("/conversion-info/conversion-rate", post(update_cfg)) + .route( + "/conversion-rate-classes/{id}/conversion-info/conversion-rate", + post(update_cfg), + ) + .route( + "/accounts/{username}/conversion-info/conversion-rate", + post(update_cfg), + ) + .route( + "/conversion-info/rate", + get(async |State(state): State<Arc<BankState>>| { + ApiResult::Ok(Json( + get_default_rate( + &state.db, + &state.cfg.regional_currency, + state.cfg.fiat_currency().unwrap(), + ) + .await?, + )) + }), + ) + .route( + "/conversion-info/cashout-rate", + get( + 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, + ) + .await + }, + ), + ) + .route( + "/conversion-info/cashin-rate", + get( + 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, + ) + .await + }, + ), + ) + .route( + "/conversion-rate-classes/{id}/conversion-info/rate", + get( + async |_: AdminRAuth, Path(id): Path<u64>, State(state): State<Arc<BankState>>| { + ApiResult::Ok(Json( + get_class_rate( + &state.db, + &state.cfg.regional_currency, + state.cfg.fiat_currency().unwrap(), + id, + ) + .await?, + )) + }, + ), + ) + .route( + "/conversion-rate-classes/{id}/conversion-info/cashout-rate", + get( + async |_: AdminRAuth, + Path(id): Path<u64>, + Query(params): Query<RateParams>, + 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, + ) + .await + }, + ), + ) + .route( + "/conversion-rate-classes/{id}/conversion-info/cashin-rate", + get( + async |_: AdminRAuth, + Path(id): Path<u64>, + Query(params): Query<RateParams>, + 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, + ) + .await + }, + ), + ) + .route( + "/accounts/{username}/conversion-info/rate", + get( + async |auth: UserOptRAuth, State(state): State<Arc<BankState>>| { + let (is_exchange, rate) = get_user_rate( + &state.db, + &state.cfg.regional_currency, + state.cfg.fiat_currency(), + &auth.username, + ) + .await?; + if !is_exchange && !auth.is_authenticated() { + return Err(forbidden("Non exchange account rates are private")); + } + Ok(Json(rate)) + }, + ), + ) + .route( + "/accounts/{username}/conversion-info/cashout-rate", + get( + async |auth: UserRAuth, + Query(params): Query<RateParams>, + 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 + }, + ) + .await + }, + ), + ) + .route( + "/accounts/{username}/conversion-info/cashin-rate", + get( + async |Path(username): Path<CompactString>, + Query(params): Query<RateParams>, + 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 + }, + ) + .await + }, + ), + ) + .route( + "/conversion-rate-classes", + post( + async |_: AdminRWAuth, + State(state): State<Arc<BankState>>, + Req(req): Req<ConversionRateClassInput>| { + req.check(&state.cfg)?; + match create(&state.db, &req).await? { + CreateResult::Success(id) => Ok(Json(ConversionRateClassResponse { conversion_rate_class_id: id, - })) + })), + CreateResult::NameReuse => Err(failure_code(ErrorCode::BANK_NAME_REUSE)), + } + }, + ) + .get( + async |_: AdminRAuth, + Query(params): Query<ClassParams>, + State(state): State<Arc<BankState>>| { + let params = params.check()?; + let classes = page_class( + &state.db, + &state.cfg.regional_currency, + state.cfg.fiat_currency().unwrap(), + &params, + ) + .await?; + if classes.is_empty() { + ApiResult::Ok(NoContent.into_response()) + } else { + ApiResult::Ok(Json(ConversionRateClasses { classes }).into_response()) + } + }, + ), + ) + .route( + "/conversion-rate-classes/{id}", + patch( + async |_: AdminRWAuth, + Path(id): Path<u64>, + State(state): State<Arc<BankState>>, + Req(req): Req<ConversionRateClassInput>| { + req.check(&state.cfg)?; + match patch_class(&state.db, id, &req).await? { + PatchResult::Success => Ok(NoContent), + PatchResult::Unknown => { + Err(failure_code(ErrorCode::BANK_TRANSACTION_NOT_FOUND)) + } + PatchResult::NameReuse => Err(failure_code(ErrorCode::BANK_NAME_REUSE)), } - CreateResult::NameReuse => Err(failure_code(ErrorCode::BANK_NAME_REUSE)), + }, + ) + .delete( + async |_: AdminRWAuth, Path(id): Path<u64>, State(state): State<Arc<BankState>>| { + if delete_class(&state.db, id).await? { + Ok(NoContent) + } else { + Err(failure_code(ErrorCode::BANK_TRANSACTION_NOT_FOUND)) + } + }, + ) + .get( + async |_: AdminRAuth, Path(id): Path<u64>, State(state): State<Arc<BankState>>| { + if let Some(class) = get_class( + &state.db, + &state.cfg.regional_currency, + state.cfg.fiat_currency().unwrap(), + id, + ) + .await? + { + Ok(Json(class)) + } else { + Err(failure_code(ErrorCode::BANK_TRANSACTION_NOT_FOUND)) + } + }, + ), + ) + .layer(middleware::from_fn_with_state( + state, + async |State(state): State<Arc<BankState>>, req, next: Next| { + if state.cfg.fiat.is_none() { + not_implemented().into_response() + } else { + next.run(req).await } }, - ), - ) + )) +} + +#[cfg(test)] +pub mod test { + + use axum::http::Method; + use taler_common::{error_code::ErrorCode, types::amount::amount}; + use taler_test_utils::{json, server::TestServer}; + + use crate::api::{ + conversion::{ + ConversionRate, ConversionRateClass, ConversionRateClassResponse, + ConversionRateClasses, ConversionResponse, + }, + test::{Auth, bank_setup}, + }; + + #[tokio::test] + async fn rates() { + let ctx = bank_setup().await; + + // Auth + for path in [ + "/conversion-rate-classes", + "/conversion-info/conversion-rate", + "/accounts/merchant/conversion-info/conversion-rate", + "/conversion-rate-classes/1/conversion-info/conversion-rate", + ] { + ctx.auth_routine(Method::POST, path, Auth::Admin).await; + } + ctx.auth_routine( + Method::GET, + "/accounts/merchant/conversion-info/rate", + Auth::Optional, + ) + .await; + ctx.auth_routine( + Method::GET, + "/conversion-rate-classes/1/conversion-info/rate", + Auth::Admin, + ) + .await; + for path in [ + "/conversion-rate-classes", + "/conversion-rate-classes/1", + "/conversion-rate-classes/1/conversion-info/cashout-rate?amount_debit=KUDOS:1", + ] { + ctx.auth_routine(Method::GET, path, Auth::Admin).await; + } + ctx.auth_routine( + Method::GET, + "/accounts/merchant/conversion-info/cashout-rate?amount_debit=KUDOS:1", + Auth::User, + ) + .await; + + // Convertion rate + { + let ok = json!({ + "cashin_ratio": "0.8", + "cashin_fee": "KUDOS:0.02", + "cashin_tiny_amount": "KUDOS:0.01", + "cashin_rounding_mode": "nearest", + "cashin_min_amount": "EUR:0", + "cashout_ratio": "1.25", + "cashout_fee": "EUR:0.003", + "cashout_tiny_amount": "EUR:0.01", + "cashout_rounding_mode": "zero", + "cashout_min_amount": "KUDOS:0.1" + }); + for prefix in ["", "/conversion-rate-classes/1", "/accounts/merchant"] { + // Good rates + ctx.post_admin(format!("{prefix}/conversion-info/conversion-rate")) + .json(&ok) + .await + .assert_no_content(); + // Bad currency + ctx.post_admin(format!("{prefix}/conversion-info/conversion-rate")) + .json(json!(ok + { "cashout_fee": "CHF:0.003" })) + .await + .assert_error(ErrorCode::GENERIC_CURRENCY_MISMATCH); + // Zero tiny amount + ctx.post_admin(format!("{prefix}/conversion-info/conversion-rate")) + .json(json!(ok + { "cashout_tiny_amount": "EUR:0" })) + .await + .assert_bad_request(); + ctx.post_admin(format!("{prefix}/conversion-info/conversion-rate")) + .json(json!(ok + { "cashin_tiny_amount": "KUDOS:0" })) + .await + .assert_bad_request(); + // Subcent cashout tiny amount + ctx.post_admin(format!("{prefix}/conversion-info/conversion-rate")) + .json(json!(ok + { "cashout_tiny_amount": "EUR:0.0001" })) + .await + .assert_bad_request() + } + } + + // User rate + { + ctx.get("/conversion-info/rate") + .await + .assert_ok_json::<ConversionRate>(); + ctx.geta("/accounts/merchant/conversion-info/rate") + .await + .assert_ok_json::<ConversionRate>(); + ctx.get("/accounts/exchange/conversion-info/rate") + .await + .assert_ok_json::<ConversionRate>(); + ctx.get_admin("/conversion-rate-classes/1/conversion-info/rate") + .await + .assert_ok_json::<ConversionRate>(); + } + + // Cashout rate + { + for prefix in ["", "/conversion-rate-classes/1", "/accounts/merchant"] { + // Check conversion to + let res: ConversionResponse = ctx + .get_admin(format!( + "{prefix}/conversion-info/cashout-rate?amount_debit=KUDOS:1" + )) + .await + .assert_ok_json(); + assert_eq!(res.amount_debit, amount("KUDOS:1")); + assert_eq!(res.amount_credit, amount("EUR:1.25")); + + // Check conversion from + let res: ConversionResponse = ctx + .get_admin(format!( + "{prefix}/conversion-info/cashout-rate?amount_credit=EUR:1.257" + )) + .await + .assert_ok_json(); + assert_eq!(res.amount_debit, amount("KUDOS:1.01")); + assert_eq!(res.amount_credit, amount("EUR:1.257")); + + // Too small + ctx.get_admin(format!( + "{prefix}/conversion-info/cashout-rate?amount_debit=KUDOS:0.0008" + )) + .await + .assert_error(ErrorCode::BANK_BAD_CONVERSION); + // No amount + ctx.get_admin(format!("{prefix}/conversion-info/cashout-rate")) + .await + .assert_error(ErrorCode::GENERIC_PARAMETER_MALFORMED); + // Both amount + /*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 + // Wrong format + ctx.get_admin(format!( + "{prefix}/conversion-info/cashout-rate?amount_debit=1" + )) + .await + .assert_error(ErrorCode::GENERIC_PARAMETER_MALFORMED); + ctx.get_admin(format!( + "{prefix}/conversion-info/cashout-rate?amount_credit=1" + )) + .await + .assert_error(ErrorCode::GENERIC_PARAMETER_MALFORMED); + // Wrong currency + ctx.get_admin(format!( + "{prefix}/conversion-info/cashout-rate?amount_debit=EUR:1" + )) + .await + .assert_error(ErrorCode::GENERIC_CURRENCY_MISMATCH); + ctx.get_admin(format!( + "{prefix}/conversion-info/cashout-rate?amount_credit=KUDOS:1" + )) + .await + .assert_error(ErrorCode::GENERIC_CURRENCY_MISMATCH); + } + ctx.geta("/accounts/exchange/conversion-info/cashout-rate?amount_debit=KUDOS:1") + .await + .assert_error(ErrorCode::BANK_ACCOUNT_IS_EXCHANGE); + } + + // Cashin rate + { + for prefix in ["", "/conversion-rate-classes/1", "/accounts/exchange"] { + for (from, to) in [(0.75, 0.58), (0.32, 0.24), (0.66, 0.51)] { + // Check conversion to + let res: ConversionResponse = ctx + .get_admin(format!( + "{prefix}/conversion-info/cashin-rate?amount_debit=EUR:{from}" + )) + .await + .assert_ok_json(); + assert_eq!(res.amount_credit, amount(format!("KUDOS:{to}"))); + assert_eq!(res.amount_debit, amount(format!("EUR:{from}"))); + + // Check conversion from + let res: ConversionResponse = ctx + .get_admin(format!( + "{prefix}/conversion-info/cashin-rate?amount_credit=KUDOS:{to}" + )) + .await + .assert_ok_json(); + assert_eq!(res.amount_credit, amount(format!("KUDOS:{to}"))); + assert_eq!(res.amount_debit, amount(format!("EUR:{from}"))); + } + + // No amount + ctx.get_admin(format!("{prefix}/conversion-info/cashin-rate")) + .await + .assert_error(ErrorCode::GENERIC_PARAMETER_MALFORMED); + // Both amount + /*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 + // Wrong format + ctx.get_admin(format!( + "{prefix}/conversion-info/cashin-rate?amount_debit=1" + )) + .await + .assert_error(ErrorCode::GENERIC_PARAMETER_MALFORMED); + ctx.get_admin(format!( + "{prefix}/conversion-info/cashin-rate?amount_credit=1" + )) + .await + .assert_error(ErrorCode::GENERIC_PARAMETER_MALFORMED); + // Wrong currency + ctx.get_admin(format!( + "{prefix}/conversion-info/cashin-rate?amount_debit=KUDOS:1" + )) + .await + .assert_error(ErrorCode::GENERIC_CURRENCY_MISMATCH); + ctx.get_admin(format!( + "{prefix}/conversion-info/cashin-rate?amount_credit=EUR:1" + )) + .await + .assert_error(ErrorCode::GENERIC_CURRENCY_MISMATCH); + } + ctx.geta("/accounts/merchant/conversion-info/cashin-rate?amount_debit=EUR:1") + .await + .assert_error(ErrorCode::BANK_ACCOUNT_IS_NOT_EXCHANGE); + } + + let prefixes = ["", "/conversion-rate-classes/1", "/accounts/merchant"]; + + // No rate + sqlx::query("DELETE FROM config WHERE key='conversion_rate'") + .execute(&ctx.db) + .await + .unwrap(); + for prefix in prefixes { + ctx.get(format!("{prefix}/conversion-info/config")) + .await + .assert_ok(); + ctx.get_admin(format!("{prefix}/conversion-info/cashin-rate")) + .await + .assert_error(ErrorCode::GENERIC_PARAMETER_MALFORMED); + ctx.get_admin(format!( + "{prefix}/conversion-info/cashout-rate?amount_credit=EUR:1" + )) + .await + .assert_error(ErrorCode::BANK_BAD_CONVERSION); + } + + // Not implemented + let ctx = ctx.swap_cfg("test_no_conversion.conf"); + ctx.get_admin("/conversion-rate-classes") + .await + .assert_not_implemented(); + ctx.get_admin("/conversion-rate-classes/1") + .await + .assert_not_implemented(); + + for prefix in prefixes { + ctx.get(format!("{prefix}/conversion-info/rate")) + .await + .assert_not_implemented(); + ctx.get(format!("{prefix}/conversion-info/cashin-rate")) + .await + .assert_not_implemented(); + ctx.get(format!("{prefix}/conversion-info/cashout-rate")) + .await + .assert_not_implemented(); + } + } + + #[tokio::test] + async fn classes() { + let ctx = bank_setup().await; + + // Config + for prefix in ["", "/conversion-rate-classes/1", "/accounts/merchant"] { + ctx.get(format!("{prefix}/conversion-info/config")) + .await + .assert_ok(); + } + + let full = json!({ + "description": "A nice little class", + "cashin_ratio": "0.1", + "cashin_fee": "KUDOS:0.2", + "cashin_tiny_amount": "KUDOS:0.3", + "cashin_rounding_mode": "nearest", + "cashin_min_amount": "EUR:0", + "cashout_ratio": "0.4", + "cashout_fee": "EUR:0.5", + "cashout_tiny_amount": "EUR:0.6", + "cashout_rounding_mode": "zero", + "cashout_min_amount": "KUDOS:0.7" + }); + + // Create full + let res: ConversionRateClassResponse = ctx + .post_admin("/conversion-rate-classes") + .json(json!(full + { "name": "Class n°1" })) + .await + .assert_ok_json(); + assert_eq!(res.conversion_rate_class_id, 2); + let rate: ConversionRateClass = ctx + .get_admin("/conversion-rate-classes/2") + .await + .assert_ok_json(); + ctx.patch_admin("/conversion-rate-classes/2") + .json(json!(full + { "name": "Class n°1" })) + .await + .assert_no_content(); + assert_eq!( + rate, + ctx.get_admin("/conversion-rate-classes/2") + .await + .assert_ok_json() + ); + + // Create empty + let res: ConversionRateClassResponse = ctx + .post_admin("/conversion-rate-classes") + .json(json!({ "name": "Class n°2" })) + .await + .assert_ok_json(); + assert_eq!(res.conversion_rate_class_id, 3); + let rate: ConversionRateClass = ctx + .get_admin("/conversion-rate-classes/3") + .await + .assert_ok_json(); + ctx.patch_admin("/conversion-rate-classes/3") + .json(json!({ "name": "Class n°2" })) + .await + .assert_no_content(); + assert_eq!( + rate, + ctx.get_admin("/conversion-rate-classes/3") + .await + .assert_ok_json() + ); + + // Bad currency + ctx.post_admin("/conversion-rate-classes") + .json(json!(full + { + "name": "Bad currency", + "cashout_fee": "CHF:0.003" + })) + .await + .assert_error(ErrorCode::GENERIC_CURRENCY_MISMATCH); + + // Name reuse currency + ctx.post_admin("/conversion-rate-classes") + .json(json!(full + { "name": "Class n°1" })) + .await + .assert_error(ErrorCode::BANK_NAME_REUSE); + ctx.patch_admin("/conversion-rate-classes/3") + .json(json!(full + { "name": "Class n°1" })) + .await + .assert_error(ErrorCode::BANK_NAME_REUSE); + + // Page + assert_eq!( + 3, + ctx.get_admin("/conversion-rate-classes") + .await + .assert_ok_json::<ConversionRateClasses>() + .classes + .len() + ); + for _ in 0..4 { + ctx.create_conversion_rate_class().await; + } + assert_eq!( + 7, + ctx.get_admin("/conversion-rate-classes") + .await + .assert_ok_json::<ConversionRateClasses>() + .classes + .len() + ); + assert_eq!( + 5, + ctx.get_admin("/conversion-rate-classes?filter_name=Gen") + .await + .assert_ok_json::<ConversionRateClasses>() + .classes + .len() + ); + + // Delete all + for id in (1..4).chain(5..9) { + ctx.delete_admin(format!("/conversion-rate-classes/{id}")) + .await + .assert_no_content(); + ctx.delete_admin(format!("/conversion-rate-classes/{id}")) + .await + .assert_error(ErrorCode::BANK_TRANSACTION_NOT_FOUND); + } + ctx.get_admin("/conversion-rate-classes?filter_name=Gen") + .await + .assert_no_content(); + + // Check no classes + ctx.get_admin("/conversion-rate-classes") + .await + .assert_no_content(); + ctx.get_admin("/conversion-rate-classes/1") + .await + .assert_error(ErrorCode::BANK_TRANSACTION_NOT_FOUND); + ctx.patch_admin("/conversion-rate-classes/1") + .json(json!(full + { "name": "Class" })) + .await + .assert_error(ErrorCode::BANK_TRANSACTION_NOT_FOUND); + ctx.delete_admin("/conversion-rate-classes/1") + .await + .assert_error(ErrorCode::BANK_TRANSACTION_NOT_FOUND); + } } diff --git a/crates/libeufin-bank/src/api/token.rs b/crates/libeufin-bank/src/api/token.rs @@ -220,9 +220,9 @@ pub mod test { async fn create_and_delete() { let ctx = bank_setup().await; - ctx.auth_routine(Method::POST, "/accounts/customer/token", Auth::Token) + ctx.auth_routine(Method::POST, "/accounts/merchant/token", Auth::Token) .await; - ctx.auth_routine(Method::DELETE, "/accounts/customer/tokens/1", Auth::User) + ctx.auth_routine(Method::DELETE, "/accounts/merchant/tokens/1", Auth::User) .await; // Unknown account @@ -504,7 +504,7 @@ pub mod test { #[tokio::test] async fn get() { let ctx = bank_setup().await; - ctx.auth_routine(Method::GET, "/accounts/customer/tokens", Auth::User) + ctx.auth_routine(Method::GET, "/accounts/merchant/tokens", Auth::User) .await; // Check OK diff --git a/crates/libeufin-bank/src/api/tx.rs b/crates/libeufin-bank/src/api/tx.rs @@ -127,7 +127,7 @@ pub mod test { async fn create() { let ctx = bank_setup().await; - ctx.auth_routine(Method::POST, "/accounts/customer/transactions", Auth::User) + ctx.auth_routine(Method::POST, "/accounts/merchant/transactions", Auth::User) .await; let valid_req = json!({ diff --git a/crates/libeufin-bank/src/auth.rs b/crates/libeufin-bank/src/auth.rs @@ -167,30 +167,10 @@ pub trait UserAuthScope: Send { const ALLOW_BASIC_AUTH: bool = false; } -pub type UserRWAuth = UserAuth<UserRWScope>; -pub type UserRAuth = UserAuth<UserRScope>; -pub type UserTokenAuth = UserAuth<UserTokenScope>; - -pub struct UserRWScope; - -impl UserAuthScope for UserRWScope { - const SCOPE: TokenLogicalScope = TokenLogicalScope::readwrite; - const KIND: AuthKind = AuthKind::UserOrAdmin; -} - -pub struct UserRScope; - -impl UserAuthScope for UserRScope { - const SCOPE: TokenLogicalScope = TokenLogicalScope::readonly; - const KIND: AuthKind = AuthKind::UserOrAdmin; -} - -pub struct UserTokenScope; - -impl UserAuthScope for UserTokenScope { - const SCOPE: TokenLogicalScope = TokenLogicalScope::refreshable; - const KIND: AuthKind = AuthKind::UserOrAdmin; - const ALLOW_BASIC_AUTH: bool = true; +fn extract_username(url: &Uri) -> &str { + let mut iter = url.path().strip_prefix('/').unwrap().split('/'); + assert_eq!(iter.next(), Some("accounts")); + iter.next().unwrap() } pub struct UserAuth<S> { @@ -224,12 +204,6 @@ impl<S> UserAuth<S> { } } -fn extract_username(url: &Uri) -> &str { - let mut iter = url.path().strip_prefix('/').unwrap().split('/'); - assert_eq!(iter.next(), Some("accounts")); - iter.next().unwrap() -} - impl<S: UserAuthScope> FromRequestParts<Arc<BankState>> for UserAuth<S> { type Rejection = ApiError; @@ -283,6 +257,70 @@ impl<S: UserAuthScope> FromRequestParts<Arc<BankState>> for UserAuth<S> { } } +pub struct UserOptAuth<S> { + pub username: CompactString, + auth_info: Option<BankInfo>, + scope: PhantomData<S>, +} + +impl<S> UserOptAuth<S> { + pub fn is_authenticated(&self) -> bool { + self.auth_info.is_some() + } +} + +impl<S: UserAuthScope> FromRequestParts<Arc<BankState>> for UserOptAuth<S> { + type Rejection = ApiError; + + async fn from_request_parts( + parts: &mut Parts, + state: &Arc<BankState>, + ) -> Result<Self, Self::Rejection> { + if parts.headers.get(AUTHORIZATION).is_some() { + let auth = UserAuth::<S>::from_request_parts(parts, state).await?; + Ok(Self { + username: auth.username, + auth_info: Some(auth.auth_info), + scope: PhantomData, + }) + } else { + let username = extract_username(&parts.uri); + Ok(Self { + username: username.into(), + auth_info: None, + scope: PhantomData, + }) + } + } +} + +pub struct UserRWScope; + +impl UserAuthScope for UserRWScope { + const SCOPE: TokenLogicalScope = TokenLogicalScope::readwrite; + const KIND: AuthKind = AuthKind::UserOrAdmin; +} + +pub struct UserRScope; + +impl UserAuthScope for UserRScope { + const SCOPE: TokenLogicalScope = TokenLogicalScope::readonly; + const KIND: AuthKind = AuthKind::UserOrAdmin; +} + +pub struct UserTokenScope; + +impl UserAuthScope for UserTokenScope { + const SCOPE: TokenLogicalScope = TokenLogicalScope::refreshable; + const KIND: AuthKind = AuthKind::UserOrAdmin; + const ALLOW_BASIC_AUTH: bool = true; +} + +pub type UserRWAuth = UserAuth<UserRWScope>; +pub type UserRAuth = UserAuth<UserRScope>; +pub type UserTokenAuth = UserAuth<UserTokenScope>; +pub type UserOptRAuth = UserOptAuth<UserRScope>; + pub trait RootAuthScope: Send { const SCOPE: TokenLogicalScope; } diff --git a/crates/libeufin-bank/src/config.rs b/crates/libeufin-bank/src/config.rs @@ -21,6 +21,7 @@ use std::collections::BTreeMap; use compact_str::CompactString; use jiff::Span; +use serde::{Deserialize, Serialize}; use taler_api::{ config::DbCfg, error::{ApiResult, failure}, @@ -41,6 +42,7 @@ pub fn parse_db_cfg(cfg: &Config) -> Result<DbCfg, ValueErr> { DbCfg::parse(cfg.section("libeufin-bankdb-postgres")) } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CurrencySpecification { pub name: CompactString, pub num_fractional_input_digits: u64, @@ -74,7 +76,6 @@ pub struct BankCfg { pub default_debt_limit: Amount, pub registration_bonus: Amount, pub suggested_withdrawal_exchange: Option<String>, - pub allow_conversion: bool, pub fiat: Option<(Currency, CurrencySpecification)>, pub spa_path: Option<String>, pub tan_channels: BTreeMap<TanChannel, (String, BTreeMap<CompactString, CompactString>)>, @@ -173,7 +174,6 @@ impl BankCfg { default_debt_limit: s.amount("default_debt_limit", &currency).default(zero)?, registration_bonus: s.amount("registration_bonus", &currency).default(zero)?, suggested_withdrawal_exchange: s.str("suggested_withdrawal_exchange").opt()?, - allow_conversion, fiat, spa_path: s.path("spa").opt()?, tan_channels, diff --git a/crates/libeufin-bank/src/db/account.rs b/crates/libeufin-bank/src/db/account.rs @@ -14,9 +14,8 @@ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -use compact_str::{CompactString, format_compact}; +use compact_str::CompactString; use jiff::Timestamp; -use serde::Deserialize; use sqlx::{ Arguments, PgPool, QueryBuilder, Row as _, postgres::{PgArguments, PgRow}, @@ -25,21 +24,18 @@ use taler_api::{ db::{BindHelper as _, PgError, TypeHelper as _, page}, error::ApiResult, }; -use taler_common::{ - api_params::{Page, PageParams, ParamsErr}, - types::{ - amount::{Amount, Currency}, - payto::{BankID, IbanPayto}, - }, +use taler_common::types::{ + amount::{Amount, Currency}, + payto::{BankID, IbanPayto}, }; use crate::{ PaytoCtx, TanChannel, api::account::{ - AccountData, AccountMinimalData, AccountReconfiguration, AccountStatus, Balance, + Account, AccountData, AccountMinimalData, AccountReconfiguration, AccountStatus, Balance, ChallengeContactData, CreditDebitInfo, PublicAccount, TanInfo, }, - db::conversion::{UserKind, user_rate}, + db::conversion::user_rate, mfa::Tans, payto::{FullBankPayto, LibeufinId, sql_bank_payto, sql_opt_iban_payto}, pw::PwCrypto, @@ -768,7 +764,7 @@ pub async fn by_username( is_locked: status == AccountStatus::locked, status, conversion_rate_class_id: r.try_get_opt_u64("conversion_rate_class_id")?, - conversion_rate: user_rate(&r, regional, fiat, UserKind::new(username, is_exchange))?, + conversion_rate: user_rate(&r, regional, fiat, username, is_exchange)?, name: r.try_get("name")?, }) }) @@ -776,31 +772,6 @@ pub async fn by_username( .await } -#[derive(Debug, Clone, Deserialize)] -pub struct AccountParams { - #[serde(flatten)] - pub page: PageParams, - pub filter_name: Option<CompactString>, - pub conversion_rate_class_id: Option<u64>, -} - -impl AccountParams { - pub fn check(self) -> Result<Account, ParamsErr> { - Ok(Account { - page: self.page.check()?, - filter_name: self.filter_name.map(|it| format_compact!("%{it}%")), - conversion_rate_class_id: self.conversion_rate_class_id, - }) - } -} - -#[derive(Debug)] -pub struct Account { - pub page: Page, - pub filter_name: Option<CompactString>, - pub conversion_rate_class_id: Option<u64>, -} - /** Get a page of all public accounts */ pub async fn page_public( db: &PgPool, @@ -938,12 +909,7 @@ pub async fn page_admin( is_locked: status == AccountStatus::locked, status, conversion_rate_class_id: r.try_get_opt_u64("conversion_rate_class_id")?, - conversion_rate: user_rate( - &r, - regional, - fiat, - UserKind::new(&username, is_exchange), - )?, + conversion_rate: user_rate(&r, regional, fiat, &username, is_exchange)?, name: r.try_get("name")?, username, }) diff --git a/crates/libeufin-bank/src/db/conversion.rs b/crates/libeufin-bank/src/db/conversion.rs @@ -19,42 +19,27 @@ //! Data access logic for conversion -use sqlx::{PgPool, Row, postgres::PgRow}; +use sqlx::{PgPool, QueryBuilder, Row, postgres::PgRow}; use taler_api::{ - db::{PgError, TypeHelper}, + db::{PgError, TypeHelper, page}, serialized, }; use taler_common::types::amount::{Amount, Currency, Decimal}; -use crate::api::conversion::{ConversionRate, ConversionRateClassInput, RoundingMode}; - -pub enum UserKind { - Admin, - Exchange, - User, -} - -impl UserKind { - pub fn new(username: &str, is_exchange: bool) -> Self { - if username == "admin" { - Self::Admin - } else if is_exchange { - Self::Exchange - } else { - Self::User - } - } -} +use crate::api::conversion::{ + Class, ConversionRate, ConversionRateClass, ConversionRateClassInput, RoundingMode, +}; pub fn user_rate( r: &PgRow, regional: &Currency, fiat: Option<&Currency>, - user: UserKind, + username: &str, + is_exchange: bool, ) -> sqlx::Result<Option<ConversionRate>> { let Some(fiat) = fiat else { return Ok(None) }; - let rate = match user { - UserKind::Admin => ConversionRate { + let rate = if username == "admin" { + ConversionRate { cashin_ratio: Decimal::ZERO, cashin_fee: Amount::zero(regional), cashin_tiny_amount: Amount::zero(regional), @@ -65,8 +50,9 @@ pub fn user_rate( cashout_tiny_amount: Amount::zero(fiat), cashout_rounding_mode: RoundingMode::zero, cashout_min_amount: Amount::zero(regional), - }, - UserKind::Exchange => ConversionRate { + } + } else if is_exchange { + ConversionRate { cashin_ratio: r.try_get("cashin_ratio")?, cashin_fee: r.try_get_amount("cashin_fee", regional)?, cashin_tiny_amount: r.try_get_amount("cashin_tiny_amount", regional)?, @@ -77,8 +63,9 @@ pub fn user_rate( cashout_tiny_amount: Amount::zero(fiat), cashout_rounding_mode: RoundingMode::zero, cashout_min_amount: Amount::zero(regional), - }, - UserKind::User => ConversionRate { + } + } else { + ConversionRate { cashin_ratio: Decimal::ZERO, cashin_fee: Amount::zero(regional), cashin_tiny_amount: Amount::zero(regional), @@ -89,11 +76,353 @@ pub fn user_rate( cashout_tiny_amount: r.try_get_amount("cashout_tiny_amount", fiat)?, cashout_rounding_mode: r.try_get("cashout_rounding_mode")?, cashout_min_amount: r.try_get_amount("cashout_min_amount", regional)?, - }, + } }; Ok(Some(rate)) } +/** Update in-db conversion config */ +pub async fn update_config(db: &PgPool, cfg: &ConversionRate) -> sqlx::Result<()> { + serialized!( + sqlx::query("CALL config_set_conversion_rate($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)") + .bind(cfg.cashin_ratio) + .bind(cfg.cashin_fee) + .bind(cfg.cashin_tiny_amount) + .bind(cfg.cashin_min_amount) + .bind(cfg.cashin_rounding_mode) + .bind(cfg.cashout_ratio) + .bind(cfg.cashout_fee) + .bind(cfg.cashout_tiny_amount) + .bind(cfg.cashout_min_amount) + .bind(cfg.cashin_rounding_mode) + .execute(db) + )?; + Ok(()) +} + +/** Get default conversion rate */ +pub async fn get_default_rate( + db: &PgPool, + regional: &Currency, + fiat: &Currency, +) -> sqlx::Result<ConversionRate> { + serialized!( + sqlx::query( + "SELECT + cashin_ratio, + cashin_fee, + cashin_tiny_amount, + cashin_min_amount, + cashin_rounding_mode, + cashout_ratio, + cashout_fee, + cashout_tiny_amount, + cashout_min_amount, + cashout_rounding_mode + FROM config_get_conversion_rate()", + ) + .try_map(|r: PgRow| { + Ok(ConversionRate { + cashin_ratio: r.try_get("cashin_ratio")?, + cashin_fee: r.try_get_amount("cashin_fee", regional)?, + cashin_tiny_amount: r.try_get_amount("cashin_tiny_amount", regional)?, + cashin_rounding_mode: r.try_get("cashin_rounding_mode")?, + cashin_min_amount: r.try_get_amount("cashin_min_amount", fiat)?, + cashout_ratio: r.try_get("cashout_ratio")?, + cashout_fee: r.try_get_amount("cashout_fee", fiat)?, + cashout_tiny_amount: r.try_get_amount("cashout_tiny_amount", fiat)?, + cashout_rounding_mode: r.try_get("cashout_rounding_mode")?, + cashout_min_amount: r.try_get_amount("cashout_min_amount", regional)?, + }) + }) + .fetch_one(db) + ) +} + +/** Get conversion class rate */ +pub async fn get_class_rate( + db: &PgPool, + regional: &Currency, + fiat: &Currency, + id: u64, +) -> sqlx::Result<ConversionRate> { + serialized!( + sqlx::query( + "SELECT + cashin_ratio, + cashin_fee, + cashin_tiny_amount, + cashin_min_amount, + cashin_rounding_mode, + cashout_ratio, + cashout_fee, + cashout_tiny_amount, + cashout_min_amount, + cashout_rounding_mode + FROM get_conversion_class_rate($1)", + ) + .bind(id as i64) + .try_map(|r: PgRow| { + Ok(ConversionRate { + cashin_ratio: r.try_get("cashin_ratio")?, + cashin_fee: r.try_get_amount("cashin_fee", regional)?, + cashin_tiny_amount: r.try_get_amount("cashin_tiny_amount", regional)?, + cashin_rounding_mode: r.try_get("cashin_rounding_mode")?, + cashin_min_amount: r.try_get_amount("cashin_min_amount", fiat)?, + cashout_ratio: r.try_get("cashout_ratio")?, + cashout_fee: r.try_get_amount("cashout_fee", fiat)?, + cashout_tiny_amount: r.try_get_amount("cashout_tiny_amount", fiat)?, + cashout_rounding_mode: r.try_get("cashout_rounding_mode")?, + cashout_min_amount: r.try_get_amount("cashout_min_amount", regional)?, + }) + }) + .fetch_one(db) + ) +} + +/** Get user rate */ +pub async fn get_user_rate( + db: &PgPool, + regional: &Currency, + fiat: Option<&Currency>, + username: &str, +) -> sqlx::Result<(bool, ConversionRate)> { + serialized!( + sqlx::query( + "SELECT + cashin_ratio, + cashin_fee, + cashin_tiny_amount, + cashin_min_amount, + cashin_rounding_mode, + cashout_ratio, + cashout_fee, + cashout_tiny_amount, + cashout_min_amount, + cashout_rounding_mode, + is_taler_exchange + FROM bank_accounts + JOIN customers ON customer_id=owning_customer_id + CROSS JOIN LATERAL get_conversion_class_rate(conversion_rate_class_id) + WHERE username=$1", + ) + .bind(username) + .try_map(|r: PgRow| { + let is_exchange = r.try_get("is_taler_exchange")?; + Ok(( + is_exchange, + user_rate(&r, regional, fiat, username, is_exchange)?.unwrap(), + )) + }) + .fetch_one(db) + ) +} + +/** Clear in-db conversion config */ +pub async fn clear_config(db: &PgPool) -> sqlx::Result<()> { + serialized!( + sqlx::query("DELETE FROM config WHERE key LIKE 'cashin%' OR key like 'cashout%'") + .execute(db) + )?; + Ok(()) +} + +/** Result of conversions operations */ +pub enum ConversionResult { + Success(Amount), + ToSmall, + IsExchange, + NotExchange, +} + +/** Perform [direction] conversion of [amount] using in-db [function] */ +async fn conversion( + db: &PgPool, + regional: &Currency, + fiat: &Currency, + amount: &Amount, + lambda: &str, + direction: &str, + rate_id: Option<u64>, +) -> sqlx::Result<ConversionResult> { + serialized!( + sqlx::query(&format!( + "SELECT too_small, converted FROM conversion_{lambda}($1,$2,$3)" + )) + .bind(amount) + .bind(direction) + .bind(rate_id.map(|it| it as i64)) + .try_map(|r: PgRow| { + Ok(if r.try_get_flag("too_small")? { + ConversionResult::ToSmall + } else { + ConversionResult::Success(r.try_get_amount( + "converted", + if amount.currency == *regional { + fiat + } else { + regional + }, + )?) + }) + }) + .fetch_one(db) + ) +} + +/** Perform [direction] conversion of [amount] using in-db [function] */ +async fn user_conversion( + db: &PgPool, + regional: &Currency, + fiat: &Currency, + amount: &Amount, + lambda: &str, + direction: &str, + username: &str, +) -> sqlx::Result<ConversionResult> { + serialized!( + sqlx::query(&format!( + "SELECT is_taler_exchange, too_small, converted + FROM bank_accounts + JOIN customers ON customer_id=owning_customer_id, + LATERAL conversion_{lambda}($1,$2,conversion_rate_class_id) + WHERE username=$3" + )) + .bind(amount) + .bind(direction) + .bind(username) + .try_map(|r: PgRow| { + let is_exchange: bool = r.try_get("is_taler_exchange")?; + Ok(if direction == "cashout" && is_exchange { + ConversionResult::IsExchange + } else if direction == "cashin" && !is_exchange { + ConversionResult::NotExchange + } else if r.try_get_flag("too_small")? { + ConversionResult::ToSmall + } else { + ConversionResult::Success(r.try_get_amount( + "converted", + if amount.currency == *regional { + fiat + } else { + regional + }, + )?) + }) + }) + .fetch_one(db) + ) +} + +/** 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), @@ -150,3 +479,157 @@ pub async fn create(db: &PgPool, req: &ConversionRateClassInput) -> sqlx::Result } } } + +/** Result status of conversion rate class patching */ +pub enum PatchResult { + Success, + Unknown, + NameReuse, +} + +/** Patch a conversion rate class */ +pub async fn patch_class( + db: &PgPool, + id: u64, + input: &ConversionRateClassInput, +) -> sqlx::Result<PatchResult> { + let res = serialized!( + sqlx::query( + "UPDATE conversion_rate_classes SET + name=$1 + ,description=$2 + ,cashin_ratio=$3 + ,cashin_fee=$4 + ,cashin_min_amount=$5 + ,cashin_rounding_mode=$6 + ,cashout_ratio=$7 + ,cashout_fee=$8 + ,cashout_min_amount=$9 + ,cashout_rounding_mode=$10 + WHERE conversion_rate_class_id=$11", + ) + .bind(&input.name) + .bind(&input.description) + .bind(input.cashin_ratio) + .bind(input.cashin_fee) + .bind(input.cashin_min_amount) + .bind(input.cashin_rounding_mode) + .bind(input.cashout_ratio) + .bind(input.cashout_fee) + .bind(input.cashout_min_amount) + .bind(input.cashout_rounding_mode) + .bind(id as i64) + .execute(db) + ); + match res { + Ok(r) => { + if r.rows_affected() > 0 { + Ok(PatchResult::Success) + } else { + Ok(PatchResult::Unknown) + } + } + Err(e) => { + if e.is_unique_err() { + Ok(PatchResult::NameReuse) + } else { + Err(e) + } + } + } +} + +/** Delete a conversion rate class */ +pub async fn delete_class(db: &PgPool, id: u64) -> sqlx::Result<bool> { + let res = serialized!( + sqlx::query("DELETE FROM conversion_rate_classes WHERE conversion_rate_class_id=$1") + .bind(id as i64) + .execute(db) + )?; + Ok(res.rows_affected() > 0) +} + +/** Get conversion rate class [id] */ +pub async fn get_class( + db: &PgPool, + regional: &Currency, + fiat: &Currency, + id: u64, +) -> sqlx::Result<Option<ConversionRateClass>> { + serialized!( + sqlx::query( + "SELECT + name, + description, + cashin_ratio, + cashin_fee, + cashin_min_amount, + cashin_rounding_mode, + cashout_ratio, + cashout_fee, + cashout_min_amount, + cashout_rounding_mode, + (SELECT count(*) FROM bank_accounts WHERE bank_accounts.conversion_rate_class_id=conversion_rate_classes.conversion_rate_class_id) as num_users + FROM conversion_rate_classes + WHERE conversion_rate_class_id=$1" + ).bind(id as i64) + .try_map(|r: PgRow| Ok(ConversionRateClass { + name: r.try_get("name")?, + description: r.try_get("description")?, + conversion_rate_class_id: id, + num_users: r.try_get_u64("num_users")?, + cashin_ratio: r.try_get("cashin_ratio")?, + cashin_fee: r.try_get_opt_amount("cashin_fee", regional)?, + cashin_rounding_mode: r.try_get("cashin_rounding_mode")?, + cashin_min_amount: r.try_get_opt_amount("cashin_min_amount", fiat)?, + cashout_ratio: r.try_get("cashout_ratio")?, + cashout_fee: r.try_get_opt_amount("cashout_fee", fiat)?, + cashout_rounding_mode: r.try_get("cashout_rounding_mode")?, + cashout_min_amount: r.try_get_opt_amount("cashout_min_amount", regional)?, + })).fetch_optional(db) + ) +} + +/** Get conversion rate class [id] */ +pub async fn page_class( + db: &PgPool, + regional: &Currency, + fiat: &Currency, + params: &Class, +) -> sqlx::Result<Vec<ConversionRateClass>> { + page(db, &params.page, "conversion_rate_class_id", ||{ + let mut query = QueryBuilder::new(" SELECT + name, + description, + cashin_ratio, + cashin_fee, + cashin_min_amount, + cashin_rounding_mode, + cashout_ratio, + cashout_fee, + cashout_min_amount, + cashout_rounding_mode, + (SELECT count(*) FROM bank_accounts WHERE bank_accounts.conversion_rate_class_id=conversion_rate_classes.conversion_rate_class_id) as num_users, + conversion_rate_class_id + FROM conversion_rate_classes + WHERE "); + if let Some(filter) = &params.filter_name { + query.push("name ILIKE ").push_bind(filter).push(" AND "); + } + query + }, |r: PgRow| Ok(ConversionRateClass { + name: r.try_get("name")?, + description: r.try_get("description")?, + conversion_rate_class_id: r.try_get_u64("conversion_rate_class_id")?, + num_users: r.try_get_u64("num_users")?, + cashin_ratio: r.try_get("cashin_ratio")?, + cashin_fee: r.try_get_opt_amount("cashin_fee", regional)?, + cashin_rounding_mode: r.try_get("cashin_rounding_mode")?, + cashin_min_amount: r.try_get_opt_amount("cashin_min_amount", fiat)?, + cashout_ratio: r.try_get("cashout_ratio")?, + cashout_fee: r.try_get_opt_amount("cashout_fee", fiat)?, + cashout_rounding_mode: r.try_get("cashout_rounding_mode")?, + cashout_min_amount: r.try_get_opt_amount("cashout_min_amount", regional)?, + }) + ).await +} diff --git a/libeufin-bank/src/main/kotlin/tech/libeufin/bank/db/ConversionDAO.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/db/ConversionDAO.kt @@ -179,7 +179,7 @@ class ConversionDAO(private val db: Database) { bind(username) one { val isTalerExchange = it.getBoolean("is_taler_exchange") - val rate = ConversionDAO.userRate(db, it, username,isTalerExchange)!! + val rate = ConversionDAO.userRate(db, it, username, isTalerExchange)!! Pair(isTalerExchange, rate) } }