libeufin

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

commit ec8de833da90f8d68b06beb157194e0b58f06408
parent 003364ba8128a3994dc1d2cb99d9d240eaf19ae7
Author: Antoine A <>
Date:   Thu,  7 May 2026 13:24:14 +0200

bank: finish account creation

Diffstat:
MCargo.lock | 85++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
MCargo.toml | 1+
Mcrates/libeufin-bank/Cargo.toml | 4++++
Mcrates/libeufin-bank/src/api.rs | 160++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Mcrates/libeufin-bank/src/api/account.rs | 723+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Acrates/libeufin-bank/src/api/conversion.rs | 156+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/libeufin-bank/src/api/tan.rs | 10+++++-----
Mcrates/libeufin-bank/src/api/token.rs | 188++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Mcrates/libeufin-bank/src/api/tx.rs | 40+++++++++++++---------------------------
Mcrates/libeufin-bank/src/auth.rs | 112++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Mcrates/libeufin-bank/src/config.rs | 49++++++++++++++++++++++++++++++++++++++++++++-----
Mcrates/libeufin-bank/src/db.rs | 1+
Mcrates/libeufin-bank/src/db/account.rs | 98++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Acrates/libeufin-bank/src/db/conversion.rs | 152+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/libeufin-bank/src/db/tx.rs | 2+-
Mcrates/libeufin-bank/src/mfa.rs | 48++++++++++++++++++++++++++++++------------------
Mcrates/libeufin-bank/src/payto.rs | 50+++++++++++++++++++++++++++++++-------------------
Mcrates/libeufin-ebics/Cargo.toml | 2+-
Mcrates/libeufin-ebics/src/ebics.rs | 2+-
Mcrates/libeufin-ebics/src/iso20022/camt.rs | 6+++---
Mcrates/libeufin-ebics/src/setup.rs | 2+-
Mcrates/libeufin-nexus/src/lib.rs | 2+-
Mcrates/libeufin-nexus/src/list.rs | 2+-
Mcrates/libeufin-nexus/src/test.rs | 6+++---
Mcrates/libeufin-nexus/src/testing.rs | 2+-
Mdatabase-versioning/libeufin-bank-0013.sql | 2+-
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, &currency)?; Some((currency, spec)) @@ -168,8 +173,8 @@ impl BankCfg { default_debt_limit: s.amount("default_debt_limit", &currency).default(zero)?, registration_bonus: s.amount("registration_bonus", &currency).default(zero)?, suggested_withdrawal_exchange: s.str("suggested_withdrawal_exchange").opt()?, - allow_conversion: 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)