commit ec8de833da90f8d68b06beb157194e0b58f06408
parent 003364ba8128a3994dc1d2cb99d9d240eaf19ae7
Author: Antoine A <>
Date: Thu, 7 May 2026 13:24:14 +0200
bank: finish account creation
Diffstat:
26 files changed, 1548 insertions(+), 357 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -277,9 +277,9 @@ checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bcrypt"
-version = "0.19.0"
+version = "0.19.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "523ab528ce3a7ada6597f8ccf5bd8d85ebe26d5edf311cad4d1d3cfb2d357ac6"
+checksum = "24ae5479c93d3720e4c1dbd6b945b97457c50cb672781104768190371df1a905"
dependencies = [
"base64",
"blowfish",
@@ -308,9 +308,9 @@ dependencies = [
[[package]]
name = "blowfish"
-version = "0.9.1"
+version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7"
+checksum = "62ce3946557b35e71d1bbe07ec385073ce9eda05043f95de134eb578fcf1a298"
dependencies = [
"byteorder",
"cipher",
@@ -409,11 +409,11 @@ dependencies = [
[[package]]
name = "cipher"
-version = "0.4.4"
+version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
+checksum = "e34d8227fe1ba289043aeb13792056ff80fd6de1a9f49137a5f499de8e8c78ea"
dependencies = [
- "crypto-common",
+ "crypto-common 0.2.1",
"inout",
]
@@ -674,6 +674,15 @@ dependencies = [
]
[[package]]
+name = "crypto-common"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710"
+dependencies = [
+ "hybrid-array",
+]
+
+[[package]]
name = "darling"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -803,7 +812,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"const-oid",
- "crypto-common",
+ "crypto-common 0.1.7",
"subtle",
]
@@ -1264,6 +1273,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
+name = "hybrid-array"
+version = "0.4.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08d46837a0ed51fe95bd3b05de33cd64a1ee88fc797477ca48446872504507c5"
+dependencies = [
+ "typenum",
+]
+
+[[package]]
name = "hyper"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1478,11 +1496,11 @@ dependencies = [
[[package]]
name = "inout"
-version = "0.1.4"
+version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
+checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
dependencies = [
- "generic-array",
+ "hybrid-array",
]
[[package]]
@@ -1492,16 +1510,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
[[package]]
-name = "iri-string"
-version = "0.7.12"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20"
-dependencies = [
- "memchr",
- "serde",
-]
-
-[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1608,9 +1616,9 @@ dependencies = [
[[package]]
name = "js-sys"
-version = "0.3.97"
+version = "0.3.98"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf"
+checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08"
dependencies = [
"cfg-if",
"futures-util",
@@ -1669,6 +1677,7 @@ dependencies = [
"jiff",
"libeufin-ebics",
"owo-colors",
+ "pretty_assertions",
"rand 0.10.1",
"reedline",
"regex",
@@ -3503,20 +3512,20 @@ dependencies = [
[[package]]
name = "tower-http"
-version = "0.6.8"
+version = "0.6.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
+checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51"
dependencies = [
"bitflags",
"bytes",
"futures-util",
"http",
"http-body",
- "iri-string",
"pin-project-lite",
"tower",
"tower-layer",
"tower-service",
+ "url",
]
[[package]]
@@ -3825,9 +3834,9 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
[[package]]
name = "wasm-bindgen"
-version = "0.2.120"
+version = "0.2.121"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1"
+checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790"
dependencies = [
"cfg-if",
"once_cell",
@@ -3838,9 +3847,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-futures"
-version = "0.4.70"
+version = "0.4.71"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "af934872acec734c2d80e6617bbb5ff4f12b052dd8e6332b0817bce889516084"
+checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -3848,9 +3857,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro"
-version = "0.2.120"
+version = "0.2.121"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103"
+checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -3858,9 +3867,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
-version = "0.2.120"
+version = "0.2.121"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41"
+checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2"
dependencies = [
"bumpalo",
"proc-macro2",
@@ -3871,9 +3880,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-shared"
-version = "0.2.120"
+version = "0.2.121"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea"
+checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441"
dependencies = [
"unicode-ident",
]
@@ -3914,9 +3923,9 @@ dependencies = [
[[package]]
name = "web-sys"
-version = "0.3.97"
+version = "0.3.98"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2eadbac71025cd7b0834f20d1fe8472e8495821b4e9801eb0a60bd1f19827602"
+checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa"
dependencies = [
"js-sys",
"wasm-bindgen",
diff --git a/Cargo.toml b/Cargo.toml
@@ -38,6 +38,7 @@ jiff = { version = "0.2", default-features = false, features = ["tz-system"] }
clap = { version = "4.5", features = ["derive"] }
uuid = { version = "1.0", features = ["v4", "fast-rng", "serde"] }
rand = "0.10"
+pretty_assertions = "1"
#taler-common = { git = "git://git.taler.net/taler-rust.git/" }
#taler-api = { git = "git://git.taler.net/taler-rust.git/" }
#taler-build = { git = "git://git.taler.net/taler-rust.git/" }
diff --git a/crates/libeufin-bank/Cargo.toml b/crates/libeufin-bank/Cargo.toml
@@ -36,3 +36,6 @@ tracing-subscriber = "0.3"
owo-colors = "4.3"
shlex = "1.3"
bcrypt = "0.19.0"
+
+[dev-dependencies]
+pretty_assertions.workspace = true
+\ No newline at end of file
diff --git a/crates/libeufin-bank/src/api.rs b/crates/libeufin-bank/src/api.rs
@@ -22,6 +22,7 @@ use sqlx::PgPool;
use crate::config::BankCfg;
pub mod account;
+pub mod conversion;
pub mod tan;
pub mod token;
pub mod tx;
@@ -40,16 +41,14 @@ pub mod test {
http::{Method, StatusCode, header::AUTHORIZATION},
};
use compact_str::CompactString;
+ use jiff::Timestamp;
use rand::{random_range, seq::IndexedRandom};
use sqlx::{PgPool, Postgres, pool::PoolConnection};
use taler_api::api::TalerRouter;
use taler_common::{
config::Config,
error_code::ErrorCode,
- types::{
- amount::{Amount, Decimal},
- payto::{FullIbanPayto, FullPayto, IbanPayto, Payto},
- },
+ types::amount::{Amount, Decimal},
};
use taler_test_utils::{
db::db_test_setup,
@@ -61,7 +60,11 @@ pub mod test {
CONFIG_SOURCE,
api::{
BankState,
- account::{account_api, create_admin_account, rand_iban_payto},
+ account::{
+ AccountData, Balance, CreditDebitInfo, account_api, create_admin_account,
+ rand_iban_payto,
+ },
+ conversion::{ConversionRateClassResponse, conversion_api},
tan::{ChallengeResponse, tan_api},
token::token_api,
tx::tx_api,
@@ -72,6 +75,14 @@ pub mod test {
payto::{FullBankPayto, LibeufinId},
};
+ pub enum Auth {
+ Admin,
+ Exchange,
+ User,
+ UserOnly,
+ Token,
+ }
+
pub struct BankTestCtx {
pub merchant_payto: FullBankPayto,
pub exchange_payto: FullBankPayto,
@@ -108,7 +119,7 @@ pub mod test {
async {
let res = Self::pw_auth(
self.server.post(&format!("/accounts/{username}/token")),
- Some(username.as_str()),
+ Some("admin"),
)
.json(json!({
"scope": "readwrite",
@@ -172,6 +183,43 @@ pub mod test {
self.requesta(Method::DELETE, path, Some("admin"))
}
+ pub async fn auth_routine(&self, method: Method, path: &str, auth: Auth) {
+ // Bad header
+ self.request(method.clone(), path)
+ .header(AUTHORIZATION, "WTF")
+ .await
+ .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,
+ );
+
+ // Other account
+ self.requesta(method.clone(), path, Some("merchant"))
+ .await
+ .assert_error(ErrorCode::GENERIC_FORBIDDEN);
+ }
+
+ match auth {
+ Auth::Admin | Auth::Exchange => {
+ self.requesta(method.clone(), path, Some("merchant"))
+ .await
+ .assert_error(ErrorCode::GENERIC_FORBIDDEN);
+ }
+ Auth::User | Auth::Token => {}
+ Auth::UserOnly => {
+ self.requesta(method.clone(), path, Some("admin"))
+ .await
+ .assert_error(ErrorCode::GENERIC_FORBIDDEN);
+ }
+ }
+ }
+
pub async fn fill_tan_info(&self, username: &str) {
self.patch_admin(&format!("/accounts/{username}"))
.json(json!({
@@ -183,6 +231,43 @@ pub mod test {
.await
.assert_no_content();
}
+
+ pub async fn create_conversion_rate_class(&self) -> u64 {
+ self.post_admin("/conversion-rate-classes")
+ .json(json!({
+ "name": format!("Gen class {}", Timestamp::now())
+ }))
+ .await
+ .assert_ok_json::<ConversionRateClassResponse>()
+ .conversion_rate_class_id
+ }
+
+ /** Set [account] debit threshold to [maxDebt] amount */
+ pub async fn set_max_debt(&self, username: &str, amount: &str) {
+ self.patch_admin(&format!("/accounts/{username}"))
+ .json(json!({
+ "debit_threshold": amount
+ }))
+ .await
+ .assert_no_content();
+ }
+
+ /** Check [account] balance is [amount], [amount] is prefixed with + for credit and - for debit */
+ pub async fn assert_balance(&self, username: &str, expected: &str) {
+ let res: AccountData = self
+ .get_admin(&format!("/accounts/{username}"))
+ .await
+ .assert_ok_json();
+ let Balance {
+ amount,
+ credit_debit_indicator,
+ } = res.balance;
+ let prefix = match credit_debit_indicator {
+ CreditDebitInfo::credit => '+',
+ CreditDebitInfo::debit => '-',
+ };
+ pretty_assertions::assert_eq!(format!("{prefix}{amount}"), expected);
+ }
}
impl Deref for BankTestCtx {
@@ -197,6 +282,10 @@ pub mod test {
db_test_setup(CONFIG_SOURCE).await
}
+ pub async fn bank_setup() -> BankTestCtx {
+ bank_setup_conf("test.conf").await
+ }
+
pub async fn bank_setup_conf(conf: &str) -> BankTestCtx {
let (_, db) = db_setup().await;
let cfg = Config::from_file(
@@ -214,6 +303,7 @@ pub mod test {
.merge(account_api())
.merge(tx_api())
.merge(tan_api())
+ .merge(conversion_api())
.with_state(state.clone())
.finalize();
@@ -316,54 +406,6 @@ pub mod test {
ctx
}
- pub enum Auth {
- Admin,
- Exchange,
- User,
- UserOnly,
- }
-
- pub async fn auth_routine(
- ctx: &BankTestCtx,
- method: Method,
- path: &str,
- auth: Auth,
- optional: bool,
- ) {
- // Bad header
- ctx.request(method.clone(), path)
- .header(AUTHORIZATION, "WTF")
- .await
- .assert_error(ErrorCode::GENERIC_UNAUTHORIZED);
-
- if !optional {
- // No header
- ctx.request(method.clone(), path).await.assert_error_status(
- ErrorCode::GENERIC_PARAMETER_MISSING,
- StatusCode::UNAUTHORIZED,
- );
-
- // Other account
- ctx.requesta(method.clone(), path, Some("merchant"))
- .await
- .assert_error(ErrorCode::GENERIC_FORBIDDEN);
- }
-
- match auth {
- Auth::Admin | Auth::Exchange => {
- ctx.requesta(method.clone(), path, Some("merchant"))
- .await
- .assert_error(ErrorCode::GENERIC_FORBIDDEN);
- }
- Auth::User => {}
- Auth::UserOnly => {
- ctx.requesta(method.clone(), path, Some("admin"))
- .await
- .assert_error(ErrorCode::GENERIC_FORBIDDEN);
- }
- }
- }
-
pub fn tan_code(info: &str) -> Option<CompactString> {
let path = format!("/tmp/tan-{}.txt", info);
let code = match std::fs::read_to_string(&path) {
@@ -379,11 +421,11 @@ pub mod test {
fn assert_challenge_check(
&self,
ctx: &BankTestCtx,
- check: impl FnOnce(&ChallengeResponse),
+ check: impl AsyncFnOnce(&ChallengeResponse),
) -> impl std::future::Future<Output = Self>;
fn assert_challenge(&self, ctx: &BankTestCtx) -> impl std::future::Future<Output = Self> {
- self.assert_challenge_check(ctx, |_| {})
+ self.assert_challenge_check(ctx, async |_| {})
}
}
@@ -391,7 +433,7 @@ pub mod test {
async fn assert_challenge_check(
&self,
ctx: &BankTestCtx,
- check: impl FnOnce(&ChallengeResponse),
+ check: impl AsyncFnOnce(&ChallengeResponse),
) -> TestResponse {
let res: ChallengeResponse = self.assert_accepted_json();
let username = self.uri.path().split('/').nth(2).unwrap();
@@ -410,7 +452,7 @@ pub mod test {
.await
.assert_ok();
}
- check(&res);
+ check(&res).await;
for challenge in challenges {
let code = tan_code(&challenge.tan_info).unwrap();
@@ -431,7 +473,7 @@ pub mod test {
.join(", ");
ctx.requesta(self.method.clone(), self.uri.path(), Some(username))
.header(TALER_CHALLENGE_IDS, ids)
- .raw_json(self.request.clone())
+ .raw_json(self.req_body.clone())
.await
}
}
diff --git a/crates/libeufin-bank/src/api/account.rs b/crates/libeufin-bank/src/api/account.rs
@@ -32,33 +32,34 @@ use axum::{
use compact_str::CompactString;
use regex::Regex;
use serde::{Deserialize, Serialize};
-use sqlx::PgPool;
+use sqlx::{Database, PgPool};
use taler_api::{
- error::{ApiResult, failure, failure_code, failure_status},
+ error::{ApiResult, bad_request, failure, failure_code, failure_status},
extract::Req,
};
use taler_common::{
- error_code::ErrorCode::{self, GENERIC_JSON_INVALID},
+ error_code::ErrorCode::{self},
types::{
amount::Amount,
base32::Base32,
iban::{Country, IBAN},
- payto::{BankID, IbanPayto, Payto},
+ payto::{BankID, IbanPayto, Payto, PaytoURI},
},
};
+use taler_macros::EnumMeta;
use crate::{
TanChannel,
- api::BankState,
- auth::{RegistrationAuth, UserRW},
+ api::{BankState, conversion::ConversionRate},
+ auth::{RegistrationAuth, UserAuth, UserRAuth},
config::{BankCfg, WireMethod},
- db::account::{CreationResult, PatchResult, reconfig},
- mfa::{AccountReconfig, MfaReq, Tans},
+ db::account::{CreationResult, PatchResult, by_username, reconfig},
+ mfa::{AccountReconfigOp, MfaReq, Tans},
payto::{FullBankPayto, LibeufinId, XTalerBank},
pw::checkpw,
};
-#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChallengeContactData {
pub email: Maybe<CompactString>,
pub phone: Maybe<CompactString>,
@@ -75,24 +76,22 @@ impl ChallengeContactData {
if let Maybe::Some(email) = &self.email
&& !EMAIL_PATTERN.is_match(email)
{
- return Err(failure(
- GENERIC_JSON_INVALID,
- format_args!("email contact data '{email}' is malformed"),
- ));
+ return Err(bad_request(format_args!(
+ "email contact data '{email}' is malformed"
+ )));
}
if let Maybe::Some(phone) = &self.phone
&& !PHONE_PATTERN.is_match(phone)
{
- return Err(failure(
- GENERIC_JSON_INVALID,
- format_args!("phone contact data '{phone}' is malformed"),
- ));
+ return Err(bad_request(format_args!(
+ "phone contact data '{phone}' is malformed"
+ )));
}
Ok(())
}
}
-#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RegisterAccountRequest {
pub username: CompactString,
pub password: CompactString,
@@ -115,18 +114,14 @@ impl RegisterAccountRequest {
static USERNAME_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new("^[a-zA-Z0-9-._~]{1,126}$").unwrap());
if !USERNAME_REGEX.is_match(&self.username) {
- return Err(failure(
- GENERIC_JSON_INVALID,
- format_args!(
- "username '{}' is malformed, must match [a-zA-Z0-9-._~]{{1,126}}",
- self.username
- ),
- ));
+ return Err(bad_request(format_args!(
+ "username '{}' is malformed, must match [a-zA-Z0-9-._~]{{1,126}}",
+ self.username
+ )));
}
if self.tan_channel.is_some() && self.tan_channels.is_some() {
- return Err(failure(
- GENERIC_JSON_INVALID,
- format_args!("you must only use either tan_channel or tan_channels"),
+ return Err(bad_request(
+ "you must only use either tan_channel or tan_channels",
));
}
if let Some(contact_data) = &self.contact_data {
@@ -146,7 +141,7 @@ impl RegisterAccountRequest {
}
}
-#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RegisterAccountResponse {
pub internal_payto_uri: FullBankPayto,
}
@@ -179,6 +174,15 @@ impl<T> Maybe<T> {
}
}
+impl<T> From<Option<T>> for Maybe<T> {
+ fn from(value: Option<T>) -> Self {
+ match value {
+ None => Maybe::Null,
+ Some(v) => Maybe::Some(v),
+ }
+ }
+}
+
impl<'de, T> Deserialize<'de> for Maybe<T>
where
T: Deserialize<'de>,
@@ -190,12 +194,23 @@ where
let opt = Option::<Option<T>>::deserialize(deserializer)?;
Ok(match opt {
None => Maybe::Missing,
- Some(None) => Maybe::Null,
- Some(Some(v)) => Maybe::Some(v),
+ Some(v) => v.into(),
})
}
}
+impl<DB: Database, T: sqlx::Type<DB>> sqlx::Type<DB> for Maybe<T> {
+ fn type_info() -> DB::TypeInfo {
+ Option::<T>::type_info()
+ }
+}
+
+impl<'r, DB: Database, T: sqlx::Decode<'r, DB>> sqlx::Decode<'r, DB> for Maybe<T> {
+ fn decode(value: <DB as Database>::ValueRef<'r>) -> Result<Self, sqlx::error::BoxDynError> {
+ Ok(Option::<T>::decode(value)?.into())
+ }
+}
+
impl<T: Serialize> Serialize for Maybe<T> {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
self.opt().serialize(serializer)
@@ -220,7 +235,7 @@ pub trait TanInfo: Debug {
}
}
-#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AccountReconfiguration {
pub contact_data: Option<ChallengeContactData>,
pub cashout_payto_uri: Maybe<IbanPayto>,
@@ -262,8 +277,7 @@ impl TanInfo for AccountReconfiguration {
impl AccountReconfiguration {
pub fn check(&self) -> ApiResult<()> {
if self.tan_channel.is_some() && self.tan_channels.is_some() {
- return Err(failure(
- ErrorCode::GENERIC_JSON_INVALID,
+ return Err(bad_request(
"you must only use either tan_channel or tan_channels",
));
}
@@ -311,16 +325,58 @@ impl AccountReconfiguration {
}
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, EnumMeta)]
+#[enum_meta(Str)]
+#[allow(non_camel_case_types)]
+pub enum CreditDebitInfo {
+ credit,
+ debit,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Balance {
+ pub amount: Amount,
+ pub credit_debit_indicator: CreditDebitInfo,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, EnumMeta, sqlx::Type)]
+#[enum_meta(Str)]
+#[sqlx(type_name = "TEXT")]
+#[allow(non_camel_case_types)]
+pub enum AccountStatus {
+ active,
+ locked,
+ deleted,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct AccountData {
+ pub name: CompactString,
+ pub payto_uri: PaytoURI,
+ pub balance: Balance,
+ pub debit_threshold: Amount,
+ pub contact_data: ChallengeContactData,
+ pub cashout_payto_uri: Option<PaytoURI>,
+ pub tan_channel: Option<TanChannel>,
+ pub tan_channels: Vec<TanChannel>,
+ pub is_public: bool,
+ pub is_taler_exchange: bool,
+ pub is_locked: bool,
+ pub status: AccountStatus,
+ pub conversion_rate_class_id: Option<u64>,
+ pub conversion_rate: Option<ConversionRate>,
+}
+
pub fn account_api() -> Router<Arc<BankState>> {
Router::new()
.route(
"/accounts",
post(
- async |_: RegistrationAuth,
+ async |RegistrationAuth { is_admin }: RegistrationAuth,
State(state): State<Arc<BankState>>,
Req(req): Req<RegisterAccountRequest>|
-> ApiResult<Json<RegisterAccountResponse>> {
- match create_account(&state.db, &state.cfg, &req, false).await? {
+ match create_account(&state.db, &state.cfg, &req, is_admin).await? {
CreationResult::BonusBalanceInsufficient => {
Err(failure_code(ErrorCode::BANK_UNALLOWED_DEBIT))
}
@@ -331,7 +387,7 @@ pub fn account_api() -> Router<Arc<BankState>> {
Err(failure_code(ErrorCode::BANK_REGISTER_PAYTO_URI_REUSE))
}
CreationResult::UnknownConversionClass => {
- todo!()
+ Err(failure_code(ErrorCode::BANK_CONVERSION_RATE_CLASS_UNKNOWN))
}
CreationResult::Success(payto) => Ok(Json(RegisterAccountResponse {
internal_payto_uri: payto,
@@ -344,9 +400,9 @@ pub fn account_api() -> Router<Arc<BankState>> {
"/accounts/{username}",
patch(
async |State(state): State<Arc<BankState>>,
- MfaReq { mut auth, req, ctx }: MfaReq<UserRW, AccountReconfig>| {
- if let Some(tans) = ctx.pending_mfa() {
- return ctx.response_validation(&auth, &state.db, tans).await;
+ MfaReq { mut auth, req, mfa }: MfaReq<AccountReconfigOp>| {
+ if let Some(tans) = mfa.pending_mfa() {
+ return mfa.response_validation(&auth, &state.db, tans).await;
}
match patch_account(
&state.db,
@@ -354,16 +410,16 @@ pub fn account_api() -> Router<Arc<BankState>> {
&req,
&auth.username,
auth.is_admin(),
- ctx.is_2fa(),
+ mfa.is_2fa(),
)
.await?
{
PatchResult::Success => Ok(NoContent.into_response()),
PatchResult::Challenges(tans) => {
if tans.is_empty() {
- ctx.response_mfa(&mut auth, &state.db, &state.cfg.ctx).await
+ mfa.response_mfa(&mut auth, &state.db, &state.cfg.ctx).await
} else {
- ctx.response_validation(&auth, &state.db, &tans).await
+ mfa.response_validation(&auth, &state.db, &tans).await
}
}
PatchResult::UnknownAccount => todo!(),
@@ -379,7 +435,26 @@ pub fn account_api() -> Router<Arc<BankState>> {
PatchResult::NonAdminConversionRateClass => Err(failure_code(
ErrorCode::BANK_NON_ADMIN_SET_CONVERSION_RATE_CLASS,
)),
- PatchResult::UnknownConversionClass => todo!(),
+ PatchResult::UnknownConversionClass => {
+ Err(failure_code(ErrorCode::BANK_CONVERSION_RATE_CLASS_UNKNOWN))
+ }
+ }
+ },
+ )
+ .get(
+ async |State(state): State<Arc<BankState>>,
+ UserAuth { username, .. }: UserRAuth| {
+ match by_username(
+ &state.db,
+ &state.cfg.ctx,
+ &state.cfg.regional_currency,
+ state.cfg.fiat_currency(),
+ &username,
+ )
+ .await?
+ {
+ Some(acc) => Ok(Json(acc)),
+ None => Err(failure_code(ErrorCode::BANK_UNKNOWN_ACCOUNT)),
}
},
),
@@ -425,20 +500,26 @@ pub async fn create_account(
));
}
- let missing_info = match channel {
- TanChannel::sms => req.contact_data.as_ref().map(|it| &it.phone).is_some(),
- TanChannel::email => req.contact_data.as_ref().map(|it| &it.email).is_some(),
+ let info = match channel {
+ TanChannel::sms => req.contact_data.as_ref().map(|it| &it.phone),
+ TanChannel::email => req.contact_data.as_ref().map(|it| &it.email),
};
- if missing_info {
+ if info.is_none() {
return Err(failure(
ErrorCode::BANK_MISSING_TAN_INFO,
- format_args!("missing info for tan channel{channel}"),
+ format_args!("missing info for tan channel {channel}"),
));
}
}
- if req.username == "exchange" && !req.is_taler_exchange {}
+ if req.username == "exchange" && !req.is_taler_exchange {
+ return Err(failure_status(
+ ErrorCode::END,
+ "'exchange' account must be a taler exchange accont",
+ StatusCode::CONFLICT,
+ ));
+ }
checkpw(&req.password, cfg.pwd_check_quality)?;
@@ -497,13 +578,10 @@ pub async fn create_account(
if let Some(payto) = &req.payto_uri {
let libeufin_id = payto.expect_xtaler_bank()?;
if libeufin_id.username != req.username {
- return Err(failure(
- ErrorCode::GENERIC_JSON_INVALID,
- format_args!(
- "Expected a payto uri for '{}' got one for '{}'",
- req.username, libeufin_id.username
- ),
- ));
+ return Err(bad_request(format_args!(
+ "Expected a payto uri for '{}' got one for '{}'",
+ req.username, libeufin_id.username
+ )));
}
}
Ok(create(LibeufinId::XTalerBank(XTalerBank {
@@ -525,7 +603,6 @@ pub async fn create_admin_account(
cfg: &BankCfg,
pw: Option<&str>,
) -> anyhow::Result<CreationResult> {
- // TODO is this secure enough ?
let pw = pw
.map(|it| it.to_owned())
.unwrap_or_else(|| Base32::<32>::secure_rand().to_string());
@@ -613,27 +690,40 @@ pub async fn patch_account(
#[cfg(test)]
pub mod test {
- use axum::http::StatusCode;
+ use axum::http::{Method, StatusCode};
use serde::Serialize;
use taler_common::{
error_code::ErrorCode,
- types::payto::{BankID, FullPayto, IbanPayto},
+ types::{
+ amount::amount,
+ payto::{FullPayto, PaytoImpl},
+ },
};
use taler_test_utils::{json, server::TestServer as _};
use crate::{
TanChannel,
api::{
- account::{RegisterAccountResponse, rand_iban_payto},
- test::{BankTestCtx, bank_setup_conf},
+ account::{
+ AccountData, AccountStatus, Balance, ChallengeContactData, CreditDebitInfo, Maybe,
+ RegisterAccountResponse, rand_iban_payto,
+ },
+ test::{Auth, BankTestCtx, MfaRequest, bank_setup, bank_setup_conf},
},
- config::BankCfg,
- payto::{LibeufinId, PaytoCtx},
+ payto::LibeufinId,
};
#[tokio::test]
async fn create() {
- let ctx = bank_setup_conf("test.conf").await;
+ let mut ctx = bank_setup().await;
+
+ let admin_only = async |body: serde_json::Value, error: ErrorCode| {
+ // Check restricted
+ 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)
+ .await;
// Check generated payto
{
@@ -674,24 +764,24 @@ pub mod test {
.assert_error(ErrorCode::BANK_REGISTER_USERNAME_REUSE);
}
+ let name = "Jane";
+ let payto = rand_iban_payto();
+ let req = json!({
+ "username": "foo",
+ "password": "password",
+ "name": name,
+ "is_public": true,
+ "payto_uri": payto,
+ "is_taler_exchange": true
+ });
// Check given payto
{
- let name = "Jane";
- let payto = rand_iban_payto();
- let body = json!({
- "username": "foo",
- "password": "password",
- "name": name,
- "is_public": true,
- "payto_uri": payto,
- "is_taler_exchange": true
- });
- let full = FullPayto::new(LibeufinId::IBAN(payto.into_inner()), name);
+ let full = FullPayto::new(LibeufinId::IBAN(payto.clone().into_inner()), name);
// Check ok
assert_eq!(
full,
ctx.post("/accounts")
- .json(&body)
+ .json(&req)
.await
.assert_ok_json::<RegisterAccountResponse>()
.internal_payto_uri
@@ -700,7 +790,7 @@ pub mod test {
assert_eq!(
full,
ctx.post("/accounts")
- .json(&body)
+ .json(&req)
.await
.assert_ok_json::<RegisterAccountResponse>()
.internal_payto_uri
@@ -708,17 +798,380 @@ pub mod test {
}
// Check admin only debit_threshold
- let body = json!({
- "username": "bat",
- "password": "password",
- "name": "Bat",
- "debit_threshold": "KUDOS:42"
- });
+ admin_only(
+ json!({
+ "username": "bat",
+ "password": "password",
+ "name": "Bat",
+ "debit_threshold": "KUDOS:42"
+ }),
+ ErrorCode::BANK_NON_ADMIN_PATCH_DEBT_LIMIT,
+ )
+ .await;
+
+ // Check admin only conversion_rate_class_id
+ let conv_class_id = ctx.create_conversion_rate_class().await;
+ admin_only(
+ json!({
+ "username": "bat2",
+ "password": "password",
+ "name": "Bat",
+ "conversion_rate_class_id": conv_class_id
+ }),
+ ErrorCode::BANK_NON_ADMIN_SET_CONVERSION_RATE_CLASS,
+ )
+ .await;
+
+ // Check admin only tan_channel
+ admin_only(
+ json!({
+ "username": "bat3",
+ "password": "password",
+ "name": "Bat",
+ "contact_data" : {
+ "phone" : "+456"
+ },
+ "tan_channel": "sms"
+ }),
+ ErrorCode::BANK_NON_ADMIN_SET_TAN_CHANNEL,
+ )
+ .await;
+
+ // Check both tan channels
+ ctx.post("/accounts")
+ .json(json!({
+ "username": "bat3",
+ "password": "password",
+ "name": "Bat",
+ "tan_channel": "sms",
+ "tan_channels": []
+ }))
+ .await
+ .assert_bad_request();
+
+ // Check tan info
+ for channel in TanChannel::entries {
+ ctx.post_admin("/accounts")
+ .json(json!({
+ "username": "bat",
+ "password": "password",
+ "name": "Bat",
+ "tan_channel": channel
+ }))
+ .await
+ .assert_error(ErrorCode::BANK_MISSING_TAN_INFO);
+ ctx.post_admin("/accounts")
+ .json(json!({
+ "username": "bat",
+ "password": "password",
+ "name": "Bat",
+ "tan_channels": [channel]
+ }))
+ .await
+ .assert_error(ErrorCode::BANK_MISSING_TAN_INFO);
+ }
+ ctx.post_admin("/accounts")
+ .json(json!({
+ "username": "bat",
+ "password": "password",
+ "name": "Bat",
+ "tan_channels": TanChannel::entries
+ }))
+ .await
+ .assert_error(ErrorCode::BANK_MISSING_TAN_INFO);
+
+ // Check unknown conversion rate class
+ ctx.post_admin("/accounts")
+ .json(json!({
+ "username": "bat4",
+ "password": "password",
+ "name": "Bat",
+ "conversion_rate_class_id": 42
+ }))
+ .await
+ .assert_error(ErrorCode::BANK_CONVERSION_RATE_CLASS_UNKNOWN);
+
+ for username in ["admin", "bank"] {
+ ctx.post("/accounts")
+ .json(json!({
+ "username": username,
+ "password": "password",
+ "name": "John Smith"
+ }))
+ .await
+ .assert_error(ErrorCode::BANK_RESERVED_USERNAME_CONFLICT);
+ }
+
+ for username in [
+ "bad@username",
+ "bad/username",
+ " spaces ",
+ &"long".repeat(40),
+ ] {
+ ctx.post("/accounts")
+ .json(json!({
+ "username": username,
+ "password": "password",
+ "name": "John Smith"
+ }))
+ .await
+ .assert_bad_request();
+ }
+
+ // Non exchange account
+ ctx.post("/accounts")
+ .json(json!({
+ "username": "exchange",
+ "password": "password",
+ "name": "Exchange"
+ }))
+ .await
+ .assert_error_status(ErrorCode::END, StatusCode::CONFLICT);
+
+ // Username conflict
+ ctx.post("/accounts")
+ .json(json!(req + { "name" : "Foo" }))
+ .await
+ .assert_error(ErrorCode::BANK_REGISTER_USERNAME_REUSE);
+ // Payto conflict
ctx.post("/accounts")
- .json(&body)
+ .json(json!(req + { "username" : "bar" }))
+ .await
+ .assert_error(ErrorCode::BANK_REGISTER_PAYTO_URI_REUSE);
+ ctx.get_admin("/accounts/bar")
+ .await
+ .assert_error(ErrorCode::BANK_UNKNOWN_ACCOUNT);
+ // Bad payto kind
+ ctx.post("/accounts")
+ .json(json!(req +{
+ "payto_uri": "payto://x-taler-bank/bank.hostname.test/bar"
+ }))
.await
- .assert_error(ErrorCode::BANK_NON_ADMIN_PATCH_DEBT_LIMIT);
- // TODO check ok admin
+ .assert_bad_request();
+ // Short password
+ ctx.post("/accounts")
+ .json(json!(req + { "password" : "short" }))
+ .await
+ .assert_error(ErrorCode::BANK_PASSWORD_TOO_SHORT);
+ // Long password
+ ctx.post("/accounts")
+ .json(json!(req +{
+ "password": "loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong-password"
+ }))
+ .await
+ .assert_error(ErrorCode::BANK_PASSWORD_TOO_LONG);
+
+ // Check cashout payto receiver name logic
+ ctx.post("/accounts")
+ .json(json!({
+ "username": "cashout_guess",
+ "password": "cashout_guess-password",
+ "name": "Mr Guess My Name",
+ "cashout_payto_uri": payto
+ }))
+ .await
+ .assert_ok();
+ ctx.post("/accounts")
+ .json(json!({
+ "username": "cashout_keep",
+ "password": "cashout_keep-password",
+ "name": "Mr Keep My Name",
+ "cashout_payto_uri": payto.as_full_uri("Santa Claus")
+ }))
+ .await
+ .assert_ok();
+ ctx.cache_tokens(&["cashout_guess", "cashout_keep"]).await;
+ let acc: AccountData = ctx.geta("/accounts/cashout_guess").await.assert_ok_json();
+ assert_eq!(
+ acc.cashout_payto_uri.unwrap(),
+ payto.as_full_uri("Mr Guess My Name")
+ );
+ let acc: AccountData = ctx.geta("/accounts/cashout_keep").await.assert_ok_json();
+ assert_eq!(
+ acc.cashout_payto_uri.unwrap(),
+ payto.as_full_uri("Mr Keep My Name")
+ );
+
+ // Check input restriction
+ {
+ let req = json!({
+ "username": "username",
+ "password": "password",
+ "name": "Name"
+ });
+ ctx.post("/accounts")
+ .json(json!(req + {
+ "contact_data": { "phone" : " +456" }
+ }))
+ .await
+ .assert_bad_request();
+ ctx.post("/accounts")
+ .json(json!(req + {
+ "contact_data": { "phone" : "test@gmail.com" }
+ }))
+ .await
+ .assert_bad_request();
+ }
+
+ // Create -> get
+ ctx.post("/accounts")
+ .json(json!({
+ "username": "minimal",
+ "password": "minimal-password",
+ "name": "John Smith",
+ }))
+ .await
+ .assert_ok();
+ let new = rand_iban_payto();
+ ctx.post_admin("/accounts")
+ .json(json!({
+ "username": "maximal",
+ "password": "maximal-password",
+ "name": "John Smith",
+ "is_public": true,
+ "is_taler_exchange": true,
+ "contact_data": {
+ "phone": "+456",
+ "email": "test@gmail.com"
+ },
+ "cashout_payto_uri": payto,
+ "payto_uri": new,
+ "debit_threshold": "KUDOS:12",
+ "tan_channels": ["sms", "email"],
+ "conversion_rate_class_id": conv_class_id
+ }))
+ .await
+ .assert_ok();
+ ctx.cache_tokens(&["minimal", "maximal"]).await;
+ let acc: AccountData = ctx.geta("/accounts/minimal").await.assert_ok_json();
+ pretty_assertions::assert_eq!(
+ AccountData {
+ name: "John Smith".into(),
+ balance: Balance {
+ amount: amount("KUDOS:0"),
+ credit_debit_indicator: CreditDebitInfo::credit,
+ },
+ cashout_payto_uri: None,
+ contact_data: ChallengeContactData {
+ phone: Maybe::Missing,
+ email: Maybe::Missing,
+ },
+ conversion_rate: Some(acc.conversion_rate.clone().unwrap()),
+ conversion_rate_class_id: None,
+ debit_threshold: acc.debit_threshold,
+ is_locked: false,
+ is_public: false,
+ is_taler_exchange: false,
+ payto_uri: acc.payto_uri.clone(),
+ status: AccountStatus::active,
+ tan_channel: None,
+ tan_channels: Vec::new(),
+ },
+ acc
+ );
+ let acc: AccountData = ctx.geta("/accounts/maximal").await.assert_ok_json();
+ pretty_assertions::assert_eq!(
+ AccountData {
+ name: "John Smith".into(),
+ balance: Balance {
+ amount: amount("KUDOS:0"),
+ credit_debit_indicator: CreditDebitInfo::credit,
+ },
+ cashout_payto_uri: Some(payto.as_full_uri("John Smith")),
+ contact_data: ChallengeContactData {
+ phone: Maybe::Some("+456".into()),
+ email: Maybe::Some("test@gmail.com".into())
+ },
+ conversion_rate: Some(acc.conversion_rate.clone().unwrap()),
+ conversion_rate_class_id: Some(conv_class_id),
+ debit_threshold: acc.debit_threshold,
+ is_locked: false,
+ is_public: true,
+ is_taler_exchange: true,
+ payto_uri: new.as_full_uri("John Smith"),
+ status: AccountStatus::active,
+ tan_channel: Some(TanChannel::sms),
+ tan_channels: vec![TanChannel::sms, TanChannel::email]
+ },
+ acc
+ );
+
+ let ctx = bank_setup_conf("test_bonus.conf").await;
+ // Create bonus
+ {
+ let req = json!({
+ "password": "password",
+ "name": "Mallory"
+ });
+
+ ctx.set_max_debt("admin", "KUDOS:1000").await;
+
+ // Check OK
+ for i in 0..10 {
+ let username = format!("foo{i}");
+ ctx.post_admin("/accounts")
+ .json(json!(req + { "username": username }))
+ .await
+ .assert_ok();
+ ctx.assert_balance(&username, "+KUDOS:100").await;
+ }
+ ctx.assert_balance("admin", "-KUDOS:1000").await;
+
+ // Check insufficient fund
+ ctx.post_admin("/accounts")
+ .json(json!(req + { "username": "bar" }))
+ .await
+ .assert_error(ErrorCode::BANK_UNALLOWED_DEBIT);
+ ctx.get_admin("/accounts/bar")
+ .await
+ .assert_error(ErrorCode::BANK_UNKNOWN_ACCOUNT);
+ }
+
+ // Restricted account creation
+ let ctx = bank_setup_conf("test_restrict.conf").await;
+ ctx.auth_routine(Method::POST, "/accounts", Auth::User)
+ .await;
+ ctx.post_admin("/accounts")
+ .json(json!({
+ "username": "foo",
+ "password": "password-xyz",
+ "name": "Mallory"
+ }))
+ .await
+ .assert_ok();
+
+ // Unsupported tan
+ let ctx = bank_setup_conf("test_tan_err.conf").await;
+ ctx.post_admin("/accounts")
+ .json(json!({
+ "username": "foo",
+ "password": "password-xyz",
+ "name": "Mallory",
+ "tan_channel": "email"
+ }))
+ .await
+ .assert_error(ErrorCode::BANK_TAN_CHANNEL_NOT_SUPPORTED);
+
+ // No password check
+ let ctx = bank_setup_conf("test_no_password_check.conf").await;
+ // Short password
+ ctx.post("/accounts")
+ .json(json!({
+ "username": "short",
+ "password": "short",
+ "name": "John Smith",
+ }))
+ .await
+ .assert_ok();
+ // Long password
+ ctx.post("/accounts")
+ .json(json!({
+ "username": "long",
+ "password": "loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong-password",
+ "name": "John Smith"
+ }))
+ .await
+ .assert_ok();
}
async fn check_admin_only(ctx: &BankTestCtx, req: impl Serialize, error: ErrorCode) {
@@ -741,7 +1194,10 @@ pub mod test {
#[tokio::test]
async fn reconfig() {
- let ctx = bank_setup_conf("test.conf").await;
+ let mut ctx = bank_setup().await;
+
+ ctx.auth_routine(Method::PATCH, "/accounts/account", Auth::User)
+ .await;
for channel in TanChannel::entries {
ctx.patcha("/accounts/merchant")
@@ -797,5 +1253,96 @@ pub mod test {
}))
.await
.assert_error_status(ErrorCode::END, StatusCode::CONFLICT);
+
+ // Check cashout payto receiver name logic
+ ctx.post("/accounts")
+ .json(json!({
+ "username": "cashout",
+ "password": "cashout-password",
+ "name": "Mr Cashout Cashout"
+ }))
+ .await
+ .assert_ok();
+ ctx.cache_tokens(&["cashout"]).await;
+
+ for (cashout, name, expect) in [
+ (
+ cashout.as_uri(),
+ None,
+ cashout.as_full_uri("Mr Cashout Cashout"),
+ ),
+ (
+ cashout.as_uri(),
+ Some("New name"),
+ cashout.as_full_uri("New name"),
+ ),
+ (
+ cashout.as_full_uri("Full name"),
+ None,
+ cashout.as_full_uri("New name"),
+ ),
+ (
+ cashout.as_full_uri("Full second name"),
+ Some("Another name"),
+ cashout.as_full_uri("Another name"),
+ ),
+ ] {
+ let mut v = json!({
+ "cashout_payto_uri": cashout,
+ });
+ if let Some(name) = name {
+ v["name"] = name.into();
+ }
+ ctx.patch_admin("/accounts/cashout")
+ .json(v)
+ .await
+ .assert_no_content();
+ let acc: AccountData = ctx.geta("/accounts/cashout").await.assert_ok_json();
+ assert_eq!(expect, acc.cashout_payto_uri.unwrap());
+ }
+
+ // Check 2FA
+ ctx.fill_tan_info("merchant").await;
+ ctx.patcha("/accounts/merchant")
+ .json(json!({ "is_public": false }))
+ .await
+ .assert_challenge_check(&ctx, async |_| {
+ let acc: AccountData = ctx.geta("/accounts/merchant").await.assert_ok_json();
+ assert_eq!(acc.is_public, true);
+ })
+ .await
+ .assert_no_content();
+
+ let acc: AccountData = ctx.geta("/accounts/merchant").await.assert_ok_json();
+ assert_eq!(acc.is_public, false);
+ }
+
+ #[tokio::test]
+ async fn reconfig_restricted() {
+ let ctx = bank_setup_conf("test_restrict.conf").await;
+ // Check restricted
+ check_admin_only(
+ &ctx,
+ json!({ "name": "Another Foo" }),
+ ErrorCode::BANK_NON_ADMIN_PATCH_LEGAL_NAME,
+ )
+ .await;
+ check_admin_only(
+ &ctx,
+ json!({ "cashout_payto_uri": rand_iban_payto() }),
+ ErrorCode::BANK_NON_ADMIN_PATCH_CASHOUT,
+ )
+ .await;
+
+ // Check idempotent
+ let acc: AccountData = ctx.geta("/accounts/merchant").await.assert_ok_json();
+ ctx.patcha("/accounts/merchant")
+ .json(json!({
+ "name": acc.name,
+ "cashout_payto_uri": acc.cashout_payto_uri,
+ "debit_threshold": acc.debit_threshold
+ }))
+ .await
+ .assert_no_content();
}
}
diff --git a/crates/libeufin-bank/src/api/conversion.rs b/crates/libeufin-bank/src/api/conversion.rs
@@ -0,0 +1,156 @@
+/*
+* This file is part of LibEuFin.
+* Copyright (C) 2026 Taler Systems S.A.
+
+* LibEuFin is free software; you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation; either version 3, or
+* (at your option) any later version.
+
+* LibEuFin is distributed in the hope that it will be useful, but
+* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
+* Public License for more details.
+
+* You should have received a copy of the GNU Affero General Public
+* License along with LibEuFin; see the file COPYING. If not, see
+* <http://www.gnu.org/licenses/>
+*/
+
+use std::sync::Arc;
+
+use axum::{Json, Router, extract::State, routing::post};
+use compact_str::CompactString;
+use serde::{Deserialize, Serialize};
+use sqlx::prelude::Type;
+use taler_api::{
+ error::{ApiResult, bad_request, failure_code},
+ extract::Req,
+};
+use taler_common::{
+ error_code::ErrorCode,
+ types::amount::{Amount, Decimal},
+};
+use taler_macros::EnumMeta;
+
+use crate::{
+ api::BankState,
+ auth::AdminRWAuth,
+ config::BankCfg,
+ db::conversion::{CreateResult, create},
+};
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, EnumMeta, Type)]
+#[enum_meta(Str)]
+#[sqlx(type_name = "rounding_mode")]
+#[allow(non_camel_case_types)]
+pub enum RoundingMode {
+ zero,
+ up,
+ nearest,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct ConversionRate {
+ pub cashin_ratio: Decimal,
+ pub cashin_fee: Amount,
+ pub cashin_tiny_amount: Amount,
+ pub cashin_rounding_mode: RoundingMode,
+ pub cashin_min_amount: Amount,
+ pub cashout_ratio: Decimal,
+ pub cashout_fee: Amount,
+ pub cashout_tiny_amount: Amount,
+ pub cashout_rounding_mode: RoundingMode,
+ pub cashout_min_amount: Amount,
+}
+
+impl ConversionRate {
+ pub fn check(&self, cfg: &BankCfg) -> ApiResult<()> {
+ let Self {
+ cashin_fee,
+ cashin_tiny_amount,
+ cashin_min_amount,
+ cashout_fee,
+ cashout_tiny_amount,
+ cashout_min_amount,
+ ..
+ } = self;
+ for amount in &[cashin_fee, cashin_tiny_amount, cashout_min_amount] {
+ cfg.check_regio(amount)?;
+ }
+ for amount in &[cashout_fee, cashout_tiny_amount, cashin_min_amount] {
+ cfg.check_fiat(amount)?;
+ }
+
+ if cashout_tiny_amount.is_zero() {
+ Err(bad_request("cashout_tiny_amount must be > 0"))
+ } else if cashin_tiny_amount.is_zero() {
+ Err(bad_request("cashin_tiny_amount must be > 0"))
+ } else if cashout_tiny_amount.is_sub_cent() {
+ Err(bad_request(
+ "Sub-cent amounts no supported by cashout, cashout_tiny_amount must be >= 0.01",
+ ))
+ } else {
+ Ok(())
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct ConversionRateClassInput {
+ pub name: CompactString,
+ pub description: Option<CompactString>,
+ 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>,
+}
+
+impl ConversionRateClassInput {
+ pub fn check(&self, cfg: &BankCfg) -> ApiResult<()> {
+ let Self {
+ cashin_fee,
+ cashin_min_amount,
+ cashout_fee,
+ cashout_min_amount,
+ ..
+ } = self;
+ for amount in [cashin_fee, cashout_min_amount].into_iter().flatten() {
+ cfg.check_regio(amount)?;
+ }
+ for amount in [cashout_fee, cashin_min_amount].into_iter().flatten() {
+ cfg.check_fiat(amount)?;
+ }
+ Ok(())
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+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 {
+ conversion_rate_class_id: id,
+ }))
+ }
+ CreateResult::NameReuse => Err(failure_code(ErrorCode::BANK_NAME_REUSE)),
+ }
+ },
+ ),
+ )
+}
diff --git a/crates/libeufin-bank/src/api/tan.rs b/crates/libeufin-bank/src/api/tan.rs
@@ -250,17 +250,17 @@ pub mod test {
TanChannel,
api::{
tan::{Challenge, ChallengeRequestResponse, ChallengeResponse, MAX_ACTIVE_CHALLENGES},
- test::{MfaRequest, bank_setup_conf, tan_code},
+ test::{MfaRequest, bank_setup, bank_setup_conf, tan_code},
},
mfa::TALER_CHALLENGE_IDS,
};
#[tokio::test]
async fn send() {
- let ctx = bank_setup_conf("test.conf").await;
+ let ctx = bank_setup().await;
let expect_mfa = async |res: TestResponse, and: bool, tans: &[(TanChannel, &str)]| {
- res.assert_challenge_check(&ctx, |res| {
+ res.assert_challenge_check(&ctx, async |res| {
assert_eq!(
tans,
res.challenges
@@ -487,7 +487,7 @@ pub mod test {
#[tokio::test]
async fn rate_limited() {
- let ctx = bank_setup_conf("test.conf").await;
+ let ctx = bank_setup().await;
ctx.fill_tan_info("merchant").await;
// TODO need transaction API
@@ -573,7 +573,7 @@ pub mod test {
#[tokio::test]
async fn confirm() {
- let ctx = bank_setup_conf("test.conf").await;
+ let ctx = bank_setup().await;
ctx.fill_tan_info("merchant").await;
// Check simple case
diff --git a/crates/libeufin-bank/src/api/token.rs b/crates/libeufin-bank/src/api/token.rs
@@ -29,8 +29,8 @@ use compact_str::CompactString;
use jiff::Timestamp;
use serde::{Deserialize, Serialize};
use taler_api::{
- error::{ApiResult, failure},
- extract::{Path, Query, Req},
+ error::{ApiResult, bad_request, failure},
+ extract::{Path, Query},
};
use taler_common::{
api_params::PageParams,
@@ -43,11 +43,12 @@ use taler_common::{
use crate::{
api::BankState,
- auth::{Auth, TOKEN_PREFIX, Token, TokenScope, UserR, UserRW},
+ auth::{TOKEN_PREFIX, TokenScope, UserAuth, UserRAuth, UserRWAuth},
db::{
self,
token::{TokenCreationResult, access, create},
},
+ mfa::{MfaReq, TokenOp},
};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -88,12 +89,11 @@ pub fn token_api() -> Router<Arc<BankState>> {
.route(
"/accounts/{username}/token",
post(
- async |auth: Auth<Token>,
- State(state): State<Arc<BankState>>,
- Req(req): Req<TokenRequest>| {
- if let Some(token) = auth.token {
+ async |State(state): State<Arc<BankState>>,
+ MfaReq { mut auth, req, mfa }: MfaReq<TokenOp>| {
+ if let Some(token) = &auth.token {
// This block checks permissions ONLY IF the call was authenticated with a token
- let token = access(&state.db, &token, &Timestamp::now()).await?;
+ let token = access(&state.db, token, &Timestamp::now()).await?;
let Some(token) = token else {
return Err(failure(
ErrorCode::BANK_UNMANAGED_EXCEPTION,
@@ -115,14 +115,11 @@ pub fn token_api() -> Router<Arc<BankState>> {
.unwrap_or(RelativeTime::Duration(Duration::from_hours(24)))
{
RelativeTime::Forever => TalerTimestamp::Never,
- RelativeTime::Duration(duration) => TalerTimestamp::Timestamp(
- creation.checked_add(duration).map_err(|e| {
- failure(
- ErrorCode::GENERIC_JSON_INVALID,
- format_args!("Bad token duration: {}", e),
- )
- })?,
- ),
+ RelativeTime::Duration(duration) => {
+ TalerTimestamp::Timestamp(creation.checked_add(duration).map_err(
+ |e| bad_request(format_args!("Bad token duration: {}", e)),
+ )?)
+ }
};
match create(
&state.db,
@@ -133,7 +130,7 @@ pub fn token_api() -> Router<Arc<BankState>> {
&req.scope,
req.refreshable,
req.description.as_deref(),
- false,
+ auth.is_admin() || mfa.is_2fa(),
)
.await?
{
@@ -141,13 +138,16 @@ pub fn token_api() -> Router<Arc<BankState>> {
access_token: format!("{TOKEN_PREFIX}{new}"),
expiration,
token_id,
- })),
- TokenCreationResult::TanRequired => todo!(),
+ })
+ .into_response()),
+ TokenCreationResult::TanRequired => {
+ mfa.response_mfa(&mut auth, &state.db, &state.cfg.ctx).await
+ }
}
},
)
.delete(
- async |Auth { token, .. }: Auth<UserR>, State(state): State<Arc<BankState>>| {
+ async |UserAuth { token, .. }: UserRAuth, State(state): State<Arc<BankState>>| {
if let Some(token) = token {
db::token::delete(&state.db, &token).await?;
}
@@ -158,7 +158,7 @@ pub fn token_api() -> Router<Arc<BankState>> {
.route(
"/accounts/{username}/tokens/{id}",
delete(
- async |Auth { username, .. }: Auth<UserRW>,
+ async |UserAuth { username, .. }: UserRWAuth,
Path((_, id)): Path<(CompactString, u64)>,
State(state): State<Arc<BankState>>| {
if db::token::delete_by_id(&state.db, &username, id).await? {
@@ -175,7 +175,7 @@ pub fn token_api() -> Router<Arc<BankState>> {
.route(
"/accounts/{username}/tokens",
get(
- async |Auth { username, .. }: Auth<UserR>,
+ async |UserAuth { username, .. }: UserRAuth,
Query(params): Query<PageParams>,
State(state): State<Arc<BankState>>| {
let params = params.check()?;
@@ -199,6 +199,7 @@ pub mod test {
use axum::http::{Method, header::AUTHORIZATION};
use jiff::{SignedDuration, Timestamp};
use taler_common::{
+ api_common::ErrorDetail,
error_code::ErrorCode,
types::{base32::Base32, timestamp::TalerTimestamp},
};
@@ -206,33 +207,23 @@ pub mod test {
use crate::{
api::{
- test::{Auth, auth_routine, bank_setup_conf},
+ tan::ChallengeResponse,
+ test::{Auth, MfaRequest, bank_setup, tan_code},
token::{TokenInfos, TokenSuccessResponse},
},
auth::TOKEN_PREFIX,
- db::token::access,
+ db::{account::MAX_TOKEN_CREATION_ATTEMPTS, token::access},
+ mfa::TALER_CHALLENGE_IDS,
};
#[tokio::test]
async fn create_and_delete() {
- let ctx = bank_setup_conf("test.conf").await;
-
- auth_routine(
- &ctx,
- Method::POST,
- "/accounts/customer/token",
- Auth::User,
- true,
- )
- .await;
- auth_routine(
- &ctx,
- Method::DELETE,
- "/accounts/customer/tokens/1",
- Auth::User,
- false,
- )
- .await;
+ let ctx = bank_setup().await;
+
+ ctx.auth_routine(Method::POST, "/accounts/customer/token", Auth::Token)
+ .await;
+ ctx.auth_routine(Method::DELETE, "/accounts/customer/tokens/1", Auth::User)
+ .await;
// Unknown account
ctx.post("/accounts/merchant/token")
@@ -350,7 +341,7 @@ pub mod test {
}
}))
.await
- .assert_error(ErrorCode::GENERIC_JSON_INVALID);
+ .assert_bad_request();
ctx.postpw("/accounts/merchant/token")
.json(json!({
@@ -360,7 +351,7 @@ pub mod test {
}
}))
.await
- .assert_error(ErrorCode::GENERIC_JSON_INVALID);
+ .assert_bad_request();
ctx.postpw("/accounts/merchant/token")
.json(json!({
"scope": "readonly",
@@ -369,7 +360,7 @@ pub mod test {
}
}))
.await
- .assert_error(ErrorCode::GENERIC_JSON_INVALID);
+ .assert_bad_request();
// Delete current token
let res: TokenSuccessResponse = ctx
@@ -413,19 +404,108 @@ pub mod test {
.json(json!({ "scope": "readonly" }))
.await
.assert_error(ErrorCode::GENERIC_TOKEN_UNKNOWN);
+
+ // 2FA
+ {
+ // Setup a known phone 2FA
+ ctx.patcha("/accounts/merchant")
+ .json(json!({
+ "contact_data": { "phone": "+12345" },
+ "tan_channel": "sms"
+ }))
+ .await
+ .assert_challenge(&ctx)
+ .await
+ .assert_no_content();
+ // Check creating a token requires to solve an unauthenticated challenge
+ let challenge = ctx
+ .postpw("/accounts/merchant/token")
+ .json(json!({ "scope": "readonly" }))
+ .await
+ .assert_accepted_json::<ChallengeResponse>()
+ .challenges
+ .pop()
+ .unwrap();
+ ctx.post(&format!(
+ "/accounts/merchant/challenge/{}",
+ challenge.challenge_id
+ ))
+ .await
+ .assert_ok();
+ assert_eq!("REDACTED", challenge.tan_info); // Check phone number is hidden
+ let code = tan_code("+12345");
+ ctx.post(&format!(
+ "/accounts/merchant/challenge/{}/confirm",
+ challenge.challenge_id
+ ))
+ .json(json!({ "tan": code }))
+ .await
+ .assert_no_content();
+ ctx.postpw("/accounts/merchant/token")
+ .header(TALER_CHALLENGE_IDS, challenge.challenge_id)
+ .json(json!({ "scope": "readonly" }))
+ .await
+ .assert_ok_json::<TokenSuccessResponse>();
+ }
+
+ // Locked
+ {
+ let lock_account = async || {
+ let mut counter = MAX_TOKEN_CREATION_ATTEMPTS + 1;
+ while counter > 0 {
+ let challenge = ctx
+ .postpw("/accounts/merchant/token")
+ .json(json!({ "scope": "readonly" }))
+ .await
+ .assert_accepted_json::<ChallengeResponse>()
+ .challenges
+ .pop()
+ .unwrap();
+ ctx.post(&format!(
+ "/accounts/merchant/challenge/{}",
+ challenge.challenge_id
+ ))
+ .await
+ .assert_ok();
+ while counter > 0 {
+ let error: ErrorDetail = ctx
+ .post(&format!(
+ "/accounts/merchant/challenge/{}/confirm",
+ challenge.challenge_id
+ ))
+ .json(json!({ "tan": "bad code" }))
+ .await
+ .json_parse();
+ counter -= 1;
+ if error.code == ErrorCode::BANK_TAN_CHALLENGE_FAILED as u16 {
+ continue;
+ } else if error.code == ErrorCode::BANK_TAN_RATE_LIMITED as u16
+ || error.code == ErrorCode::BANK_TAN_CHALLENGE_EXPIRED as u16
+ {
+ break;
+ } else {
+ unreachable!("{error:?}")
+ }
+ }
+ }
+ ctx.postpw("/accounts/merchant/token")
+ .json(json!({ "scope": "readonly" }))
+ .await
+ .assert_error(ErrorCode::BANK_ACCOUNT_LOCKED);
+ };
+
+ lock_account().await;
+
+ // Check token still works
+ // TODO
+ }
}
#[tokio::test]
async fn get() {
- let ctx = bank_setup_conf("test.conf").await;
- auth_routine(
- &ctx,
- Method::GET,
- "/accounts/customer/tokens",
- Auth::User,
- false,
- )
- .await;
+ let ctx = bank_setup().await;
+ ctx.auth_routine(Method::GET, "/accounts/customer/tokens", Auth::User)
+ .await;
// Check OK
for account in ["merchant", "customer"] {
diff --git a/crates/libeufin-bank/src/api/tx.rs b/crates/libeufin-bank/src/api/tx.rs
@@ -29,9 +29,8 @@ use taler_common::{api_common::ShortHashCode, error_code::ErrorCode, types::amou
use crate::{
api::BankState,
- auth::UserRW,
db::tx::{TxResult, create},
- mfa::{BankTx, MfaReq},
+ mfa::{BankTxOp, MfaReq},
payto::{BankPayto, TransferBankPayto},
};
@@ -52,7 +51,7 @@ pub fn tx_api() -> Router<Arc<BankState>> {
"/accounts/{username}/transactions",
post(
async |State(state): State<Arc<BankState>>,
- MfaReq { mut auth, req, ctx }: MfaReq<UserRW, BankTx>| {
+ MfaReq { mut auth, req, mfa }: MfaReq<BankTxOp>| {
let subject = req
.payto_uri
.subject
@@ -72,7 +71,7 @@ pub fn tx_api() -> Router<Arc<BankState>> {
&subject,
amount,
&Timestamp::now(),
- ctx.is_2fa(),
+ mfa.is_2fa(),
req.request_uid,
state.cfg.wire_transfer_fees,
state.cfg.min_amount,
@@ -98,7 +97,7 @@ pub fn tx_api() -> Router<Arc<BankState>> {
"Amount either to high or too low",
)),
TxResult::TanRequired => {
- ctx.response_mfa(&mut auth, &state.db, &state.cfg.ctx).await
+ mfa.response_mfa(&mut auth, &state.db, &state.cfg.ctx).await
}
TxResult::RequestUidReuse => {
Err(failure_code(ErrorCode::BANK_TRANSFER_REQUEST_UID_REUSED))
@@ -113,32 +112,19 @@ pub fn tx_api() -> Router<Arc<BankState>> {
pub mod test {
use axum::http::Method;
- use taler_common::error_code::ErrorCode;
- use taler_test_utils::{json, server::TestResponse};
- use uuid::Uuid;
-
- use crate::{
- TanChannel,
- api::{
- tan::{Challenge, ChallengeRequestResponse, ChallengeResponse},
- test::{Auth, MfaRequest, auth_routine, bank_setup_conf, tan_code},
- tx::TransactionCreateResponse,
- },
- mfa::TALER_CHALLENGE_IDS,
+ use taler_test_utils::json;
+
+ use crate::api::{
+ test::{Auth, bank_setup},
+ tx::TransactionCreateResponse,
};
#[tokio::test]
async fn create() {
- let ctx = bank_setup_conf("test.conf").await;
-
- auth_routine(
- &ctx,
- Method::POST,
- "/accounts/customer/transactions",
- Auth::User,
- false,
- )
- .await;
+ let ctx = bank_setup().await;
+
+ ctx.auth_routine(Method::POST, "/accounts/customer/transactions", Auth::User)
+ .await;
let valid_req = json!({
"payto_uri": format!("{}&message=payout", ctx.exchange_payto),
diff --git a/crates/libeufin-bank/src/auth.rs b/crates/libeufin-bank/src/auth.rs
@@ -115,7 +115,9 @@ impl TokenScope {
}
}
-pub struct RegistrationAuth;
+pub struct RegistrationAuth {
+ pub is_admin: bool,
+}
impl FromRequestParts<Arc<BankState>> for RegistrationAuth {
type Rejection = ApiError;
@@ -124,10 +126,7 @@ impl FromRequestParts<Arc<BankState>> for RegistrationAuth {
parts: &mut Parts,
state: &Arc<BankState>,
) -> Result<Self, Self::Rejection> {
- if state.cfg.allow_registration {
- return Ok(RegistrationAuth);
- }
- let (info, _) = auth_request(
+ let res = auth_request(
&state.db,
&state.cfg.ctx,
&state.cfg.pw_crypto,
@@ -136,11 +135,23 @@ impl FromRequestParts<Arc<BankState>> for RegistrationAuth {
state.cfg.basic_auth_compat,
&parts.headers,
)
- .await?;
- if !info.is_admin() {
- return Err(forbidden("Only administrator allowed"));
+ .await;
+ if state.cfg.allow_registration {
+ if let Ok((info, _)) = res {
+ Ok(RegistrationAuth {
+ is_admin: info.is_admin(),
+ })
+ } else {
+ Ok(RegistrationAuth { is_admin: false })
+ }
+ } else {
+ let (info, _) = res?;
+ if !info.is_admin() {
+ Err(forbidden("Only administrator allowed"))
+ } else {
+ Ok(RegistrationAuth { is_admin: true })
+ }
}
- Ok(RegistrationAuth)
}
}
@@ -150,13 +161,39 @@ pub enum AuthKind {
UserOrAdmin,
}
-pub trait AuthScope: Send {
+pub trait UserAuthScope: Send {
const SCOPE: TokenLogicalScope;
const KIND: AuthKind;
const ALLOW_BASIC_AUTH: bool = false;
}
-pub struct Auth<S> {
+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;
+}
+
+pub struct UserAuth<S> {
pub username: CompactString,
pub token: Option<Vec<u8>>,
auth_info: BankInfo,
@@ -164,7 +201,7 @@ pub struct Auth<S> {
scope: PhantomData<S>,
}
-impl<S> Auth<S> {
+impl<S> UserAuth<S> {
/** Retrieve the bank account info for the selected username */
pub async fn bank_info(&mut self, db: &PgPool, ctx: &PaytoCtx) -> ApiResult<&BankInfo> {
if self.user_info.is_none() {
@@ -193,7 +230,7 @@ fn extract_username(url: &Uri) -> &str {
iter.next().unwrap()
}
-impl<S: AuthScope> FromRequestParts<Arc<BankState>> for Auth<S> {
+impl<S: UserAuthScope> FromRequestParts<Arc<BankState>> for UserAuth<S> {
type Rejection = ApiError;
async fn from_request_parts(
@@ -246,26 +283,53 @@ impl<S: AuthScope> FromRequestParts<Arc<BankState>> for Auth<S> {
}
}
-pub struct UserRW;
+pub trait RootAuthScope: Send {
+ const SCOPE: TokenLogicalScope;
+}
+
+pub type AdminRWAuth = AdminAuth<RootRWScope>;
+pub type AdminRAuth = AdminAuth<RootRScope>;
-impl AuthScope for UserRW {
+pub struct RootRWScope;
+
+impl RootAuthScope for RootRWScope {
const SCOPE: TokenLogicalScope = TokenLogicalScope::readwrite;
- const KIND: AuthKind = AuthKind::UserOrAdmin;
}
-pub struct UserR;
+pub struct RootRScope;
-impl AuthScope for UserR {
+impl RootAuthScope for RootRScope {
const SCOPE: TokenLogicalScope = TokenLogicalScope::readonly;
- const KIND: AuthKind = AuthKind::UserOrAdmin;
}
-pub struct Token;
+pub struct AdminAuth<S> {
+ scope: PhantomData<S>,
+}
-impl AuthScope for Token {
- const SCOPE: TokenLogicalScope = TokenLogicalScope::refreshable;
- const KIND: AuthKind = AuthKind::UserOrAdmin;
- const ALLOW_BASIC_AUTH: bool = true;
+impl<S: RootAuthScope> FromRequestParts<Arc<BankState>> for AdminAuth<S> {
+ type Rejection = ApiError;
+
+ async fn from_request_parts(
+ parts: &mut Parts,
+ state: &Arc<BankState>,
+ ) -> Result<Self, Self::Rejection> {
+ let (info, _) = auth_request(
+ &state.db,
+ &state.cfg.ctx,
+ &state.cfg.pw_crypto,
+ S::SCOPE,
+ state.cfg.basic_auth_compat,
+ state.cfg.basic_auth_compat,
+ &parts.headers,
+ )
+ .await?;
+
+ if !info.is_admin() {
+ return Err(forbidden("Only administrator allowed"));
+ }
+
+ Ok(Self { scope: PhantomData })
+ }
}
/**
diff --git a/crates/libeufin-bank/src/config.rs b/crates/libeufin-bank/src/config.rs
@@ -21,9 +21,13 @@ use std::collections::BTreeMap;
use compact_str::CompactString;
use jiff::Span;
-use taler_api::config::DbCfg;
+use taler_api::{
+ config::DbCfg,
+ error::{ApiResult, failure},
+};
use taler_common::{
config::{Config, ValueErr},
+ error_code::ErrorCode,
map_config,
types::amount::{Amount, Currency},
};
@@ -71,7 +75,7 @@ pub struct BankCfg {
pub registration_bonus: Amount,
pub suggested_withdrawal_exchange: Option<String>,
pub allow_conversion: bool,
- pub fiat_currency: Option<(Currency, CurrencySpecification)>,
+ pub fiat: Option<(Currency, CurrencySpecification)>,
pub spa_path: Option<String>,
pub tan_channels: BTreeMap<TanChannel, (String, BTreeMap<CompactString, CompactString>)>,
pub ctx: PaytoCtx,
@@ -125,7 +129,8 @@ impl BankCfg {
.default(WireMethod::iban)?;
let currency = s.currency("currency").require()?;
- let fiat = if s.boolean("WireMethod").default(false)? {
+ let allow_conversion = s.boolean("allow_conversion").default(false)?;
+ let fiat = if allow_conversion {
let currency = s.currency("fiat_currency").require()?;
let spec = Self::currency_specification(&cfg, ¤cy)?;
Some((currency, spec))
@@ -168,8 +173,8 @@ 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: s.boolean("allow_conversion").default(false)?,
- fiat_currency: fiat,
+ allow_conversion,
+ fiat,
spa_path: s.path("spa").opt()?,
tan_channels,
ctx: PaytoCtx {
@@ -189,6 +194,40 @@ impl BankCfg {
cfg,
})
}
+
+ pub fn check_regio(&self, amount: &Amount) -> ApiResult<()> {
+ if amount.currency != self.regional_currency {
+ Err(failure(
+ ErrorCode::GENERIC_CURRENCY_MISMATCH,
+ format_args!(
+ "Wrong currency: expected regional currency {} got {}",
+ self.regional_currency, amount.currency
+ ),
+ ))
+ } else {
+ Ok(())
+ }
+ }
+
+ pub fn check_fiat(&self, amount: &Amount) -> ApiResult<()> {
+ if let Some(fiat_currency) = self.fiat_currency()
+ && amount.currency != *fiat_currency
+ {
+ Err(failure(
+ ErrorCode::GENERIC_CURRENCY_MISMATCH,
+ format_args!(
+ "Wrong currency: expected fiat currency {fiat_currency} got {}",
+ amount.currency
+ ),
+ ))
+ } else {
+ Ok(())
+ }
+ }
+
+ pub fn fiat_currency(&self) -> Option<&Currency> {
+ self.fiat.as_ref().map(|it| &it.0)
+ }
}
#[derive(serde::Deserialize)]
diff --git a/crates/libeufin-bank/src/db.rs b/crates/libeufin-bank/src/db.rs
@@ -20,6 +20,7 @@
const SCHEMA: &str = "libeufin_bank";
pub mod account;
+pub mod conversion;
pub mod tan;
pub mod token;
pub mod tx;
diff --git a/crates/libeufin-bank/src/db/account.rs b/crates/libeufin-bank/src/db/account.rs
@@ -28,13 +28,17 @@ use taler_common::types::{
use crate::{
PaytoCtx, TanChannel,
- api::account::{AccountReconfiguration, TanInfo},
+ api::account::{
+ AccountData, AccountReconfiguration, AccountStatus, Balance, ChallengeContactData,
+ CreditDebitInfo, TanInfo,
+ },
+ db::conversion::{UserKind, user_rate},
mfa::Tans,
- payto::{FullBankPayto, LibeufinId, sql_bank_payto},
+ payto::{FullBankPayto, LibeufinId, sql_bank_payto, sql_opt_iban_payto},
pw::PwCrypto,
};
-const MAX_TOKEN_CREATION_ATTEMPTS: u16 = 5;
+pub const MAX_TOKEN_CREATION_ATTEMPTS: u16 = 5;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CreationResult {
@@ -166,7 +170,6 @@ pub async fn create(
.bind(tan_channels)
.fetch_one(&mut *tx)
.await?;
-
let res = sqlx::query(
"
INSERT INTO bank_accounts(
@@ -197,7 +200,7 @@ pub async fn create(
&& e.is_fk_err()
{
tx.rollback().await?;
- return sqlx::Result::Ok(CreationResult::PayToReuse);
+ return sqlx::Result::Ok(CreationResult::UnknownConversionClass);
}
res?;
@@ -436,7 +439,7 @@ pub async fn reconfig(
if let Some(v) = cashout_payto_uri.inner() {
separated
.push("cashout_payto=")
- .push_bind_unseparated(v.map(|it| it.as_payto().to_string()));
+ .push_bind_unseparated(v.map(|it| it.as_uri().to_string()));
}
if let Some(v) = req.contact_data.as_ref().and_then(|it| it.phone.inner()) {
separated.push("phone=").push_bind_unseparated(v);
@@ -620,3 +623,86 @@ pub async fn bank_info(
.fetch_optional(db)
.await
}
+
+pub async fn by_username(
+ db: &PgPool,
+ ctx: &PaytoCtx,
+ regional: &Currency,
+ fiat: Option<&Currency>,
+ username: &str,
+) -> sqlx::Result<Option<AccountData>> {
+ sqlx::query(
+ "
+ SELECT
+ customers.name,
+ email,
+ phone,
+ tan_channels,
+ cashout_payto,
+ internal_payto,
+ balance,
+ has_debt,
+ max_debt,
+ is_public,
+ is_taler_exchange,
+ CASE
+ WHEN deleted_at IS NOT NULL THEN 'deleted'
+ WHEN token_creation_counter > $1 THEN 'locked'
+ ELSE 'active'
+ END as status,
+ conversion_rate_class_id,
+ 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 customers
+ JOIN bank_accounts ON customer_id=owning_customer_id
+ CROSS JOIN LATERAL get_conversion_class_rate(conversion_rate_class_id)
+ WHERE username=$2
+ ",
+ )
+ .bind(MAX_TOKEN_CREATION_ATTEMPTS as i16)
+ .bind(username)
+ .try_map(|r: PgRow| {
+ let name: CompactString = r.try_get("name")?;
+ let status: AccountStatus = r.try_get("status")?;
+ let channels: Vec<TanChannel> = r.try_get("tan_channels")?;
+ let is_exchange: bool = r.try_get("is_taler_exchange")?;
+
+ Ok(AccountData {
+ payto_uri: sql_bank_payto(&r, ctx, "internal_payto", "name")?.as_uri(),
+ balance: Balance {
+ amount: r.try_get_amount("balance", regional)?,
+ credit_debit_indicator: if r.try_get("has_debt")? {
+ CreditDebitInfo::debit
+ } else {
+ CreditDebitInfo::credit
+ },
+ },
+ debit_threshold: r.try_get_amount("max_debt", regional)?,
+ contact_data: ChallengeContactData {
+ email: r.try_get("email")?,
+ phone: r.try_get("phone")?,
+ },
+ cashout_payto_uri: sql_opt_iban_payto(&r, "cashout_payto", "name")?
+ .map(|it| it.as_uri()),
+ tan_channel: channels.first().cloned(),
+ tan_channels: channels,
+ is_public: r.try_get("is_public")?,
+ is_taler_exchange: is_exchange,
+ 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))?,
+ name,
+ })
+ })
+ .fetch_optional(db)
+ .await
+}
diff --git a/crates/libeufin-bank/src/db/conversion.rs b/crates/libeufin-bank/src/db/conversion.rs
@@ -0,0 +1,152 @@
+/*
+* This file is part of LibEuFin.
+* Copyright (C) 2026 Taler Systems S.A.
+
+* LibEuFin is free software; you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation; either version 3, or
+* (at your option) any later version.
+
+* LibEuFin is distributed in the hope that it will be useful, but
+* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
+* Public License for more details.
+
+* You should have received a copy of the GNU Affero General Public
+* License along with LibEuFin; see the file COPYING. If not, see
+* <http://www.gnu.org/licenses/>
+*/
+
+//! Data access logic for conversion
+
+use sqlx::{PgPool, Row, postgres::PgRow};
+use taler_api::{
+ db::{PgError, TypeHelper},
+ 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
+ }
+ }
+}
+
+pub fn user_rate(
+ r: &PgRow,
+ regional: &Currency,
+ fiat: Option<&Currency>,
+ user: UserKind,
+) -> sqlx::Result<Option<ConversionRate>> {
+ let Some(fiat) = fiat else { return Ok(None) };
+ let rate = match user {
+ UserKind::Admin => ConversionRate {
+ cashin_ratio: Decimal::ZERO,
+ cashin_fee: Amount::zero(regional),
+ cashin_tiny_amount: Amount::zero(regional),
+ cashin_rounding_mode: RoundingMode::zero,
+ cashin_min_amount: Amount::zero(fiat),
+ cashout_ratio: Decimal::ZERO,
+ cashout_fee: Amount::zero(fiat),
+ cashout_tiny_amount: Amount::zero(fiat),
+ cashout_rounding_mode: RoundingMode::zero,
+ cashout_min_amount: Amount::zero(regional),
+ },
+ UserKind::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)?,
+ cashin_rounding_mode: r.try_get("cashin_rounding_mode")?,
+ cashin_min_amount: r.try_get_amount("cashin_min_amount", fiat)?,
+ cashout_ratio: Decimal::ZERO,
+ cashout_fee: Amount::zero(fiat),
+ cashout_tiny_amount: Amount::zero(fiat),
+ cashout_rounding_mode: RoundingMode::zero,
+ cashout_min_amount: Amount::zero(regional),
+ },
+ UserKind::User => ConversionRate {
+ cashin_ratio: Decimal::ZERO,
+ cashin_fee: Amount::zero(regional),
+ cashin_tiny_amount: Amount::zero(regional),
+ cashin_rounding_mode: RoundingMode::zero,
+ cashin_min_amount: Amount::zero(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)?,
+ },
+ };
+ Ok(Some(rate))
+}
+
+/** Result status of conversion rate class creation */
+pub enum CreateResult {
+ Success(u64),
+ NameReuse,
+}
+
+/** Create a new conversion rate class */
+pub async fn create(db: &PgPool, req: &ConversionRateClassInput) -> sqlx::Result<CreateResult> {
+ let res = serialized!(
+ sqlx::query(
+ "
+ INSERT INTO conversion_rate_classes (
+ name
+ ,description
+ ,cashin_ratio
+ ,cashin_fee
+ ,cashin_min_amount
+ ,cashin_rounding_mode
+ ,cashout_ratio
+ ,cashout_fee
+ ,cashout_min_amount
+ ,cashout_rounding_mode
+ ) VALUES (
+ $1,$2,$3,$4,$5,$6,$7,$8,$9,$10
+ )
+ RETURNING conversion_rate_class_id
+ "
+ )
+ .bind(&req.name)
+ .bind(&req.description)
+ .bind(req.cashin_ratio)
+ .bind(req.cashin_fee)
+ .bind(req.cashin_min_amount)
+ .bind(req.cashin_rounding_mode)
+ .bind(req.cashout_ratio)
+ .bind(req.cashout_fee)
+ .bind(req.cashout_min_amount)
+ .bind(req.cashout_rounding_mode)
+ .try_map(|r: PgRow| {
+ Ok(CreateResult::Success(
+ r.try_get_u64("conversion_rate_class_id")?,
+ ))
+ })
+ .fetch_one(db)
+ );
+ match res {
+ Ok(r) => Ok(r),
+ Err(e) => {
+ if e.is_unique_err() {
+ Ok(CreateResult::NameReuse)
+ } else {
+ Err(e)
+ }
+ }
+ }
+}
diff --git a/crates/libeufin-bank/src/db/tx.rs b/crates/libeufin-bank/src/db/tx.rs
@@ -59,7 +59,7 @@ pub async fn create(
let res = match parse_incoming_unstructured(subject) {
Ok(subject) => {
if IncomingSubject::AdminBalanceAdjust == subject {
- Err(format!("unsupported admin balance adjust"))
+ Err("unsupported admin balance adjust".to_string())
} else {
Ok(subject)
}
diff --git a/crates/libeufin-bank/src/mfa.rs b/crates/libeufin-bank/src/mfa.rs
@@ -46,9 +46,10 @@ use crate::{
BankState,
account::{AccountReconfiguration, TanInfo},
tan::{Challenge, ChallengeResponse},
+ token::TokenRequest,
tx::TransactionCreateRequest,
},
- auth::{Auth, AuthScope},
+ auth::{UserAuth, UserAuthScope, UserRWScope, UserTokenScope},
db::account::BankInfo,
payto::PaytoCtx,
};
@@ -71,8 +72,9 @@ pub enum Operation {
pub type Tans = Vec<(TanChannel, CompactString)>;
-pub trait MfaOperation {
+pub trait MfaOp {
const OP: Operation;
+ type Scope: UserAuthScope;
type Body: DeserializeOwned + Send;
fn required_validation(_: &Self::Body, _: &BankInfo) -> ApiResult<Option<Tans>> {
@@ -80,10 +82,11 @@ pub trait MfaOperation {
}
}
-pub struct AccountReconfig;
+pub struct AccountReconfigOp;
-impl MfaOperation for AccountReconfig {
+impl MfaOp for AccountReconfigOp {
const OP: Operation = Operation::account_reconfig;
+ type Scope = UserRWScope;
type Body = AccountReconfiguration;
fn required_validation(body: &Self::Body, info: &BankInfo) -> ApiResult<Option<Tans>> {
@@ -91,13 +94,22 @@ impl MfaOperation for AccountReconfig {
}
}
-pub struct BankTx;
+pub struct BankTxOp;
-impl MfaOperation for BankTx {
+impl MfaOp for BankTxOp {
const OP: Operation = Operation::bank_transaction;
+ type Scope = UserRWScope;
type Body = TransactionCreateRequest;
}
+pub struct TokenOp;
+
+impl MfaOp for TokenOp {
+ const OP: Operation = Operation::create_token;
+ type Scope = UserTokenScope;
+ type Body = TokenRequest;
+}
+
fn mfa_body_hash(body: &[u8], salt: &Base32<16>) -> Base32<64> {
let mut digest = aws_lc_rs::digest::Context::new(&SHA512);
digest.update(salt.as_ref());
@@ -113,13 +125,13 @@ enum Mfa {
}
#[derive(Debug)]
-pub struct MfaCtx<O: MfaOperation> {
+pub struct MfaCtx<O: MfaOp> {
raw: Bytes,
mfa: Mfa,
op: PhantomData<O>,
}
-impl<O: MfaOperation> MfaCtx<O> {
+impl<O: MfaOp> MfaCtx<O> {
async fn respond_challenges(
&self,
db: &PgPool,
@@ -171,9 +183,9 @@ impl<O: MfaOperation> MfaCtx<O> {
* If [channel] and [info] are present, they will be used
* to send the TAN code, otherwise defaults will be used.
*/
- pub async fn response_mfa<S>(
+ pub async fn response_mfa(
&self,
- auth: &mut Auth<S>,
+ auth: &mut UserAuth<O::Scope>,
db: &PgPool,
ctx: &PaytoCtx,
) -> ApiResult<axum::response::Response> {
@@ -191,9 +203,9 @@ impl<O: MfaOperation> MfaCtx<O> {
.into_response())
}
- pub async fn response_validation<S>(
+ pub async fn response_validation(
&self,
- auth: &Auth<S>,
+ auth: &UserAuth<O::Scope>,
db: &PgPool,
tans: &[(TanChannel, CompactString)],
) -> ApiResult<axum::response::Response> {
@@ -224,18 +236,18 @@ impl<O: MfaOperation> MfaCtx<O> {
pub const TALER_CHALLENGE_IDS: HeaderName = HeaderName::from_static("taler-challenge-ids");
#[must_use]
-pub struct MfaReq<S, O: MfaOperation> {
- pub auth: Auth<S>,
+pub struct MfaReq<O: MfaOp> {
+ pub auth: UserAuth<O::Scope>,
pub req: O::Body,
- pub ctx: MfaCtx<O>,
+ pub mfa: MfaCtx<O>,
}
-impl<S: AuthScope, O: MfaOperation> FromRequest<Arc<BankState>> for MfaReq<S, O> {
+impl<O: MfaOp> FromRequest<Arc<BankState>> for MfaReq<O> {
type Rejection = ApiError;
async fn from_request(req: Request, state: &Arc<BankState>) -> Result<Self, Self::Rejection> {
let (mut parts, body) = req.into_parts();
- let mut auth = Auth::from_request_parts(&mut parts, state).await?;
+ let mut auth = UserAuth::from_request_parts(&mut parts, state).await?;
let raw = decompressed_strict_body(&parts.headers, body).await?;
let Req(req) = Req::<O::Body>::try_from(&raw)?;
// Check if challenges are used
@@ -301,7 +313,7 @@ impl<S: AuthScope, O: MfaOperation> FromRequest<Arc<BankState>> for MfaReq<S, O>
Ok(Self {
auth,
req,
- ctx: MfaCtx {
+ mfa: MfaCtx {
raw,
mfa,
op: PhantomData,
diff --git a/crates/libeufin-bank/src/payto.rs b/crates/libeufin-bank/src/payto.rs
@@ -21,13 +21,13 @@ use compact_str::CompactString;
use sqlx::{Row as _, postgres::PgRow};
use taler_api::{
db::TypeHelper as _,
- error::{ApiResult, failure},
+ error::{ApiResult, bad_request},
};
-use taler_common::{
- error_code::ErrorCode,
- types::{
- iban::BIC,
- payto::{BankID, FullPayto, Payto, PaytoErr, PaytoImpl, PaytoURI, TransferPayto},
+use taler_common::types::{
+ iban::BIC,
+ payto::{
+ BankID, FullIbanPayto, FullPayto, IbanPayto, Payto, PaytoErr, PaytoImpl, PaytoURI,
+ TransferPayto,
},
};
@@ -40,7 +40,7 @@ pub struct XTalerBank {
}
impl PaytoImpl for XTalerBank {
- fn as_payto(&self) -> PaytoURI {
+ fn as_uri(&self) -> PaytoURI {
PaytoURI::from_parts(
X_TALER_BANK,
format_args!("/{}/{}", self.hostname, self.username),
@@ -103,29 +103,27 @@ impl LibeufinId {
pub fn expect_iban(&self) -> ApiResult<&BankID> {
match self {
LibeufinId::IBAN(bank_id) => Ok(bank_id),
- LibeufinId::XTalerBank(_) => Err(failure(
- ErrorCode::GENERIC_JSON_INVALID,
- format_args!("expected an IBAN payto URI got '{X_TALER_BANK}'"),
- )),
+ LibeufinId::XTalerBank(_) => Err(bad_request(format_args!(
+ "expected an IBAN payto URI got '{X_TALER_BANK}'"
+ ))),
}
}
pub fn expect_xtaler_bank(&self) -> ApiResult<&XTalerBank> {
match self {
- LibeufinId::IBAN(_) => Err(failure(
- ErrorCode::GENERIC_JSON_INVALID,
- format_args!("expected a {X_TALER_BANK} payto URI got 'iban'"),
- )),
+ LibeufinId::IBAN(_) => Err(bad_request(format_args!(
+ "expected a {X_TALER_BANK} payto URI got 'iban'"
+ ))),
LibeufinId::XTalerBank(xtaler_bank) => Ok(xtaler_bank),
}
}
}
impl PaytoImpl for LibeufinId {
- fn as_payto(&self) -> PaytoURI {
+ fn as_uri(&self) -> PaytoURI {
match self {
- LibeufinId::IBAN(bank_id) => bank_id.as_payto(),
- LibeufinId::XTalerBank(x_taler_bank) => x_taler_bank.as_payto(),
+ LibeufinId::IBAN(bank_id) => bank_id.as_uri(),
+ LibeufinId::XTalerBank(x_taler_bank) => x_taler_bank.as_uri(),
}
}
@@ -151,7 +149,21 @@ pub fn sql_bank_payto(
payto_idx: &str,
name_idx: &str,
) -> sqlx::Result<FullBankPayto> {
- let bank_payto: Payto<LibeufinId> = r.try_get_parse(payto_idx)?;
+ let bank_payto: BankPayto = r.try_get_parse(payto_idx)?;
let name = r.try_get(name_idx)?;
Ok(bank_payto.into_inner().bank(name, ctx))
}
+
+pub fn sql_opt_iban_payto(
+ r: &PgRow,
+ payto_idx: &str,
+ name_idx: &str,
+) -> sqlx::Result<Option<FullIbanPayto>> {
+ let payto: Option<IbanPayto> = r.try_get_opt_parse(payto_idx)?;
+ if let Some(payto) = payto {
+ let name = r.try_get(name_idx)?;
+ Ok(Some(FullPayto::new(payto.into_inner(), name)))
+ } else {
+ Ok(None)
+ }
+}
diff --git a/crates/libeufin-ebics/Cargo.toml b/crates/libeufin-ebics/Cargo.toml
@@ -27,12 +27,12 @@ jiff.workspace = true
clap.workspace = true
uuid.workspace = true
rand.workspace = true
+pretty_assertions.workspace = true
tempfile = "3"
flate2 = { version = "1.0", features = ["zlib-rs"], default-features = false }
zip = { version = "8.5", default-features = false, features = [
"deflate-flate2-zlib-rs",
] }
-pretty_assertions = "1"
futures-util = "0.3"
reqwest-websocket = "0.6.0"
roxmltree = "0.21.1"
diff --git a/crates/libeufin-ebics/src/ebics.rs b/crates/libeufin-ebics/src/ebics.rs
@@ -412,7 +412,7 @@ impl<'a> EbicsClient<'a> {
processing: impl AsyncFnOnce(Vec<u8>) -> Result<T, EbicsErrKind>,
) -> Result<T, EbicsError> {
let mut ctx = EbicsCtx::new(order);
- debug!(target: "ebics", "Downloading order {order} {}", std::fmt::from_fn(|f| {
+ debug!(target: "ebics", "Downloading order {order}{}", std::fmt::from_fn(|f| {
if let Some((start, end)) = range {
write!(f, " from {start} to {end}")?
}
diff --git a/crates/libeufin-ebics/src/iso20022/camt.rs b/crates/libeufin-ebics/src/iso20022/camt.rs
@@ -146,9 +146,9 @@ fn payto(n: Xml, prefix: &str) -> xml::Result<Option<PaytoURI>> {
.transpose()
.unwrap_or_else(|| p.one("Pty").one("Nm"))?
.text();
- IbanPayto::new(bank_id).as_full_payto(name)
+ IbanPayto::new(bank_id).as_full_uri(name)
} else {
- IbanPayto::new(bank_id).as_payto()
+ IbanPayto::new(bank_id).as_uri()
}))
}
@@ -554,7 +554,7 @@ pub mod test {
iban: iban.as_ref().parse().expect("invalid IBAN"),
bic: None,
})
- .as_full_payto(name.as_ref())
+ .as_full_uri(name.as_ref())
}
#[track_caller]
diff --git a/crates/libeufin-ebics/src/setup.rs b/crates/libeufin-ebics/src/setup.rs
@@ -73,7 +73,7 @@ pub async fn ebics_setup(
if !versions.contains(&VersionNumber {
number: "03.00".into(),
schema: "H005".into(),
- }) && versions.contains(&VersionNumber {
+ }) && !versions.contains(&VersionNumber {
number: "03.02".into(),
schema: "H005".into(),
}) {
diff --git a/crates/libeufin-nexus/src/lib.rs b/crates/libeufin-nexus/src/lib.rs
@@ -413,7 +413,7 @@ pub async fn run(cfg: Config, cmd: Cmd) -> anyhow::Result<()> {
&pool,
amount,
subject,
- &payto.as_payto(),
+ &payto.as_uri(),
&Timestamp::now(),
&end_to_end_id
.as_ref()
diff --git a/crates/libeufin-nexus/src/list.rs b/crates/libeufin-nexus/src/list.rs
@@ -79,7 +79,7 @@ impl ListCmd {
{
write!(out, "{date} {id} {amount}")?;
if let Some(credit_fee) = credit_fee
- && credit_fee != Decimal::zero()
+ && credit_fee != Decimal::ZERO
{
write!(out, "-{credit_fee}")?;
}
diff --git a/crates/libeufin-nexus/src/test.rs b/crates/libeufin-nexus/src/test.rs
@@ -63,7 +63,7 @@ pub fn gen_out_pay(subject: impl Into<String>) -> OutTx {
creditor: Some(
IbanPayto::from_str("payto://iban/CH4189144589712575493?receiver-name=Test")
.unwrap()
- .as_payto(),
+ .as_uri(),
),
subject: Some(subject.into()),
execution_time: Timestamp::now(),
@@ -80,7 +80,7 @@ pub fn gen_init_pay(
amount: Amount::new(&CURR, 44, 0),
creditor: IbanPayto::from_str("payto://iban/CH4189144589712575493?receiver-name=Test")
.unwrap()
- .as_payto(),
+ .as_uri(),
subject: subject.into(),
initiation_time: Timestamp::now(),
e2e_id: end_to_end_id.into(),
@@ -96,7 +96,7 @@ pub fn gen_in_pay(subject: impl Into<String>) -> InTx {
debtor: Some(
IbanPayto::from_str("payto://iban/DE84500105177118117964?receiver-name=John+Smith")
.unwrap()
- .as_payto(),
+ .as_uri(),
),
subject: Some(subject.into()),
execution_time: Timestamp::now(),
diff --git a/crates/libeufin-nexus/src/testing.rs b/crates/libeufin-nexus/src/testing.rs
@@ -165,7 +165,7 @@ impl TestingCmd {
credit_fee: credit_fee.unwrap_or(Amount::zero(&cfg.currency)),
subject: Some(subject.clone().into_string()),
execution_time: Timestamp::now(),
- debtor: Some(payto.as_payto()),
+ debtor: Some(payto.as_uri()),
},
)
.await?;
diff --git a/database-versioning/libeufin-bank-0013.sql b/database-versioning/libeufin-bank-0013.sql
@@ -83,7 +83,7 @@ CREATE TABLE conversion_rate_classes
,cashout_rounding_mode rounding_mode
);
COMMENT ON TABLE conversion_rate_classes IS 'TODO';
-ALTER TABLE bank_accounts ADD COLUMN conversion_rate_class_id INT4 REFERENCES conversion_rate_classes(conversion_rate_class_id);
+ALTER TABLE bank_accounts ADD COLUMN conversion_rate_class_id INT8 REFERENCES conversion_rate_classes(conversion_rate_class_id);
-- Migrate existing user config
INSERT INTO conversion_rate_classes(name, cashout_min_amount)