commit 519d6229087f969ae987f9db60ee84236c571abf
parent 46065585cc43769e2e2eaf3bb09778278dfe605e
Author: Antoine A <>
Date: Wed, 13 May 2026 15:15:01 +0200
bank: add conversion API
Diffstat:
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,
+ ¶ms,
+ 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,
+ ¶ms,
+ 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,
+ ¶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,
+ )
+ .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,
+ ¶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,
+ )
+ .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,
+ ¶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
+ },
+ )
+ .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,
+ ¶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
+ },
+ )
+ .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(),
+ ¶ms,
+ )
+ .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", ¤cy).default(zero)?,
registration_bonus: s.amount("registration_bonus", ¤cy).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, ¶ms.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) = ¶ms.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)
}
}