libeufin

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

commit 2ea416cb182f1b0990a6c6a203c3d909f1457d7f
parent 519d6229087f969ae987f9db60ee84236c571abf
Author: Antoine A <>
Date:   Sat, 16 May 2026 14:16:13 +0200

bank: transaction API

Diffstat:
Mcrates/libeufin-bank/src/api.rs | 114++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Mcrates/libeufin-bank/src/api/account.rs | 40++++++++++++++++++++--------------------
Mcrates/libeufin-bank/src/api/conversion.rs | 6+++---
Mcrates/libeufin-bank/src/api/token.rs | 12++++++++----
Mcrates/libeufin-bank/src/api/tx.rs | 543+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mcrates/libeufin-bank/src/auth.rs | 7+++++++
Mcrates/libeufin-bank/src/db.rs | 53++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/libeufin-bank/src/db/tx.rs | 103+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mcrates/libeufin-bank/src/mfa.rs | 4++--
Mcrates/libeufin-nexus/src/api.rs | 5+++--
10 files changed, 757 insertions(+), 130 deletions(-)

diff --git a/crates/libeufin-bank/src/api.rs b/crates/libeufin-bank/src/api.rs @@ -18,8 +18,9 @@ */ use sqlx::PgPool; +use taler_api::notification::NotificationChannel; -use crate::config::BankCfg; +use crate::{config::BankCfg, db::notification_listener}; pub mod account; pub mod conversion; @@ -30,6 +31,34 @@ pub mod tx; pub struct BankState { pub db: PgPool, pub cfg: BankCfg, + pub tx_channel: NotificationChannel<u64, i64>, + pub taler_out_channel: NotificationChannel<u64, i64>, + pub taler_in_channel: NotificationChannel<u64, i64>, + pub revenue_channel: NotificationChannel<u64, i64>, +} + +impl BankState { + pub async fn start(pool: PgPool, cfg: BankCfg) -> Self { + let tx_channel = NotificationChannel::new(); + let taler_in_channel = NotificationChannel::new(); + let taler_out_channel = NotificationChannel::new(); + let revenue_channel = NotificationChannel::new(); + tokio::spawn(notification_listener( + pool.clone(), + tx_channel.clone(), + taler_out_channel.clone(), + taler_in_channel.clone(), + revenue_channel.clone(), + )); + Self { + cfg, + db: pool, + tx_channel, + taler_in_channel, + taler_out_channel, + revenue_channel, + } + } } #[cfg(test)] @@ -38,7 +67,9 @@ pub mod test { use axum::{ Router, - http::{Method, StatusCode, header::AUTHORIZATION}, + extract::{Request, State}, + http::{HeaderValue, Method, StatusCode, header::AUTHORIZATION}, + middleware::{self, Next}, }; use compact_str::CompactString; use jiff::Timestamp; @@ -46,11 +77,12 @@ pub mod test { use sqlx::{PgPool, Pool, Postgres, pool::PoolConnection}; use taler_api::api::TalerRouter; use taler_common::{ + api_common::{HashCode, ShortHashCode}, config::Config, error_code::ErrorCode, types::{ amount::{Amount, Decimal}, - payto::PaytoURI, + payto::{IbanPayto, PaytoURI}, }, }; use taler_test_utils::{ @@ -81,7 +113,7 @@ pub mod test { pub enum Auth { Admin, Optional, - User, + UserOrAdmin, UserOnly, Token, } @@ -93,8 +125,8 @@ pub mod test { pub unknown_payto: BankPayto, pub tmp_payto: BankPayto, pub admin_payto: BankPayto, - pub db: PgPool, pub server: Router, + pub state: Arc<BankState>, tokens: BTreeMap<CompactString, String>, } @@ -106,10 +138,7 @@ pub mod test { ) .unwrap(); - let state = Arc::new(BankState { - db: db.clone(), - cfg: BankCfg::parse(cfg).unwrap(), - }); + let state = Arc::new(BankState::start(db.clone(), BankCfg::parse(cfg).unwrap()).await); let server = token_api() .merge(account_api()) .merge(tx_api()) @@ -202,30 +231,29 @@ pub mod test { tmp_payto: rand_iban_payto().convert(), admin_payto: admin_payto.into(), server, - db, + state, tokens: BTreeMap::new(), }; ctx.cache_tokens(&["admin", "merchant", "exchange", "customer"]) .await; - if state.cfg.fiat.is_some() { + if ctx.state.cfg.fiat.is_some() { ctx.create_conversion_rate_class().await; } ctx } - pub fn swap_cfg(mut self, conf: &str) -> Self { + pub async fn swap_cfg(mut self, conf: &str) -> Self { let cfg = Config::from_file( CONFIG_SOURCE, Some(format!("../../libeufin-bank/conf/{conf}")), ) .unwrap(); - let state = Arc::new(BankState { - db: self.db.clone(), - cfg: BankCfg::parse(cfg).unwrap(), - }); + let state = Arc::new( + BankState::start(self.state.db.clone(), BankCfg::parse(cfg).unwrap()).await, + ); self.server = token_api() .merge(account_api()) .merge(tx_api()) @@ -233,6 +261,7 @@ pub mod test { .merge(conversion_api(state.clone())) .with_state(state.clone()) .finalize(); + self.state = state; self } @@ -279,6 +308,18 @@ pub mod test { } } + pub async fn admin_router(&self) -> Router { + let token = self.tokens["admin"].clone(); + self.server.clone().layer(middleware::from_fn_with_state( + token, + async |State(token): State<String>, mut req: Request, next: Next| { + req.headers_mut() + .insert(AUTHORIZATION, HeaderValue::from_str(&token).unwrap()); + next.run(req).await + }, + )) + } + fn requesta( &self, method: Method, @@ -360,7 +401,7 @@ pub mod test { .await .assert_error(ErrorCode::GENERIC_FORBIDDEN); } - Auth::User | Auth::Token | Auth::Optional => {} + Auth::UserOrAdmin | Auth::Token | Auth::Optional => {} Auth::UserOnly => { self.requesta(method.clone(), path, Some("admin")) .await @@ -386,18 +427,18 @@ pub mod test { self.tmp_payto.as_uri() } - /** Perform a bank transaction of [amount] [from] account [to] account with [subject} */ - pub async fn tx(&self, from: &str, amount: &str, to: &str) { + pub async fn tx_s(&self, from: &str, amount: &str, to: &str, subject: &str) { let payto = match to { "admin" => &self.admin_payto, "merchant" => &self.merchant_payto, "customer" => &self.customer_payto, + "exchange" => &self.exchange_payto, _ => &self.tmp_payto, }; self.posta(format!("/accounts/{from}/transactions")) .json(json!({ - "payto_uri": format!("{payto}?message=payout"), - "amount": amount, + "payto_uri": format!("{payto}?message={subject}"), + "amount": format!("{}:{amount}", self.state.cfg.regional_currency), })) .await .maybe_challenge(&self) @@ -405,6 +446,31 @@ pub mod test { .assert_ok(); } + pub async fn tx(&self, from: &str, amount: &str, to: &str) { + self.tx_s(from, amount, to, "payout").await + } + + pub async fn transfer( + &self, + amount: &str, + payto: &IbanPayto, + metadata: Option<CompactString>, + ) { + self.posta("/accounts/exchange/taler-wire-gateway/transfer") + .json({ + json!({ + "request_uid": HashCode::rand(), + "amount": amount, + "exchange_base_url": "http://exchange.example.com/", + "wtid": ShortHashCode::rand(), + "credit_account": payto, + "metadata": metadata, + }) + }) + .await + .assert_ok() + } + pub async fn create_conversion_rate_class(&self) -> u64 { self.post_admin("/conversion-rate-classes") .json(json!({ @@ -436,10 +502,10 @@ pub mod test { credit_debit_indicator, } = res.balance; let prefix = match credit_debit_indicator { - CreditDebitInfo::credit => '+', - CreditDebitInfo::debit => '-', + CreditDebitInfo::credit => "", + CreditDebitInfo::debit => "-", }; - pretty_assertions::assert_eq!(format!("{prefix}{amount}"), expected); + pretty_assertions::assert_eq!(format!("{prefix}{}", amount.decimal()), expected); } } diff --git a/crates/libeufin-bank/src/api/account.rs b/crates/libeufin-bank/src/api/account.rs @@ -917,7 +917,7 @@ pub mod test { ctx.post("/accounts").json(&body).await.assert_error(error); ctx.post_admin("/accounts").json(&body).await.assert_ok(); }; - ctx.auth_routine(Method::GET, "/accounts/merchant", Auth::User) + ctx.auth_routine(Method::GET, "/accounts/merchant", Auth::UserOrAdmin) .await; // Check generated payto @@ -1296,7 +1296,7 @@ pub mod test { acc ); - let ctx = ctx.swap_cfg("test_bonus.conf"); + let ctx = ctx.swap_cfg("test_bonus.conf").await; // Create bonus { let req = json!({ @@ -1313,9 +1313,9 @@ pub mod test { .json(json!(req + { "username": username })) .await .assert_ok(); - ctx.assert_balance(&username, "+KUDOS:100").await; + ctx.assert_balance(&username, "100").await; } - ctx.assert_balance("admin", "-KUDOS:1000").await; + ctx.assert_balance("admin", "-1000").await; // Check insufficient fund ctx.post_admin("/accounts") @@ -1328,8 +1328,8 @@ pub mod test { } // Restricted account creation - let ctx = ctx.swap_cfg("test_restrict.conf"); - ctx.auth_routine(Method::POST, "/accounts", Auth::User) + let ctx = ctx.swap_cfg("test_restrict.conf").await; + ctx.auth_routine(Method::POST, "/accounts", Auth::UserOrAdmin) .await; ctx.post_admin("/accounts") .json(json!({ @@ -1341,7 +1341,7 @@ pub mod test { .assert_ok(); // Unsupported tan - let ctx = ctx.swap_cfg("test_tan_err.conf"); + let ctx = ctx.swap_cfg("test_tan_err.conf").await; ctx.post_admin("/accounts") .json(json!({ "username": "foo", @@ -1353,7 +1353,7 @@ pub mod test { .assert_error(ErrorCode::BANK_TAN_CHANNEL_NOT_SUPPORTED); // No password check - let ctx = ctx.swap_cfg("test_no_password_check.conf"); + let ctx = ctx.swap_cfg("test_no_password_check.conf").await; // Short password ctx.post("/accounts") .json(json!({ @@ -1403,12 +1403,12 @@ pub mod test { ctx.cache_tokens(&["john"]).await; ctx.fill_tan_info("john").await; // Fail to delete, due to a non-zero balance. - ctx.tx("customer", "KUDOS:1", "john").await; + ctx.tx("customer", "1", "john").await; ctx.deletea("/accounts/john") .await .assert_error(ErrorCode::BANK_ACCOUNT_BALANCE_NOT_ZERO); // Successful deletion - ctx.tx("john", "KUDOS:1", "customer").await; + ctx.tx("john", "1", "customer").await; ctx.deletea("/accounts/john") .await .assert_challenge(&ctx) @@ -1425,14 +1425,14 @@ pub mod test { // GC { let zero = Duration::default(); - collect(&ctx.db, &Timestamp::now(), &zero, &zero, &zero) + collect(&ctx.state.db, &Timestamp::now(), &zero, &zero, &zero) .await .unwrap(); // TODO need more operations } // Test admin-only account deletion - let ctx = ctx.swap_cfg("test_restrict.conf"); + let ctx = ctx.swap_cfg("test_restrict.conf").await; ctx.auth_routine(Method::DELETE, "/accounts/merchant", Auth::Admin) .await; // Exchange is still restricted @@ -1441,7 +1441,7 @@ pub mod test { .assert_error(ErrorCode::BANK_RESERVED_USERNAME_CONFLICT); // Test delete exchange account - let ctx = ctx.swap_cfg("test_no_conversion.conf"); + let ctx = ctx.swap_cfg("test_no_conversion.conf").await; // Exchange is no longer restricted ctx.deletea("/accounts/exchange").await.assert_no_content(); } @@ -1468,7 +1468,7 @@ pub mod test { async fn reconfig() { let mut ctx = bank_setup().await; - ctx.auth_routine(Method::PATCH, "/accounts/merchant", Auth::User) + ctx.auth_routine(Method::PATCH, "/accounts/merchant", Auth::UserOrAdmin) .await; for channel in TanChannel::entries { @@ -1651,7 +1651,7 @@ pub mod test { assert_eq!(acc.is_public, true); // Restriction - let ctx = ctx.swap_cfg("test_restrict.conf"); + let ctx = ctx.swap_cfg("test_restrict.conf").await; { check_admin_only( &ctx, @@ -1679,7 +1679,7 @@ pub mod test { } // Unsupported tan - let ctx = ctx.swap_cfg("test_tan_err.conf"); + let ctx = ctx.swap_cfg("test_tan_err.conf").await; ctx.patcha("/accounts/customer") .json(json!({ "tan_channel": "email" @@ -1691,7 +1691,7 @@ pub mod test { #[tokio::test] async fn password() { let ctx = bank_setup().await; - ctx.auth_routine(Method::PATCH, "/accounts/merchant/auth", Auth::User) + ctx.auth_routine(Method::PATCH, "/accounts/merchant/auth", Auth::UserOrAdmin) .await; // Changing the password @@ -1789,7 +1789,7 @@ pub mod test { .assert_error(ErrorCode::BANK_PATCH_BAD_OLD_PASSWORD); // No password check - let ctx = ctx.swap_cfg("test_no_password_check.conf"); + let ctx = ctx.swap_cfg("test_no_password_check.conf").await; ctx.patcha("/accounts/merchant/auth") .json(json!({ "old_password": "merchant-password", @@ -1832,13 +1832,13 @@ pub mod test { // Hard delete accounts let zero = Duration::default(); - collect(&ctx.db, &Timestamp::now(), &zero, &zero, &zero) + collect(&ctx.state.db, &Timestamp::now(), &zero, &zero, &zero) .await .unwrap(); ctx.get("/public-accounts").await.assert_no_content(); ctx.get_admin("/accounts").await.assert_ok(); - let ctx = ctx.swap_cfg("test.conf"); + let ctx = ctx.swap_cfg("test.conf").await; for _ in 0..3 { ctx.create_conversion_rate_class().await; diff --git a/crates/libeufin-bank/src/api/conversion.rs b/crates/libeufin-bank/src/api/conversion.rs @@ -640,7 +640,7 @@ pub mod test { ctx.auth_routine( Method::GET, "/accounts/merchant/conversion-info/cashout-rate?amount_debit=KUDOS:1", - Auth::User, + Auth::UserOrAdmin, ) .await; @@ -838,7 +838,7 @@ pub mod test { // No rate sqlx::query("DELETE FROM config WHERE key='conversion_rate'") - .execute(&ctx.db) + .execute(&ctx.state.db) .await .unwrap(); for prefix in prefixes { @@ -856,7 +856,7 @@ pub mod test { } // Not implemented - let ctx = ctx.swap_cfg("test_no_conversion.conf"); + let ctx = ctx.swap_cfg("test_no_conversion.conf").await; ctx.get_admin("/conversion-rate-classes") .await .assert_not_implemented(); diff --git a/crates/libeufin-bank/src/api/token.rs b/crates/libeufin-bank/src/api/token.rs @@ -222,8 +222,12 @@ pub mod test { ctx.auth_routine(Method::POST, "/accounts/merchant/token", Auth::Token) .await; - ctx.auth_routine(Method::DELETE, "/accounts/merchant/tokens/1", Auth::User) - .await; + ctx.auth_routine( + Method::DELETE, + "/accounts/merchant/tokens/1", + Auth::UserOrAdmin, + ) + .await; // Unknown account ctx.post("/accounts/merchant/token") @@ -251,7 +255,7 @@ pub mod test { .assert_ok_json(); // Checking that the token lifetime defaulted to 24 hours let token = access( - &ctx.db, + &ctx.state.db, Base32::<32>::from_str(res.access_token.strip_prefix(TOKEN_PREFIX).unwrap()) .unwrap() .as_ref(), @@ -504,7 +508,7 @@ pub mod test { #[tokio::test] async fn get() { let ctx = bank_setup().await; - ctx.auth_routine(Method::GET, "/accounts/merchant/tokens", Auth::User) + ctx.auth_routine(Method::GET, "/accounts/merchant/tokens", Auth::UserOrAdmin) .await; // Check OK diff --git a/crates/libeufin-bank/src/api/tx.rs b/crates/libeufin-bank/src/api/tx.rs @@ -21,21 +21,32 @@ use std::sync::Arc; -use axum::{Json, Router, extract::State, response::IntoResponse, routing::post}; +use axum::{ + Json, Router, + extract::State, + response::{IntoResponse, NoContent}, + routing::{get, post}, +}; +use compact_str::CompactString; use jiff::Timestamp; use serde::{Deserialize, Serialize}; -use taler_api::error::{bad_request, failure, failure_code}; +use taler_api::{ + error::{ApiResult, bad_request, failure, failure_code}, + extract::{Path, Query}, +}; use taler_common::{ api_common::ShortHashCode, + api_params::HistoryParams, error_code::ErrorCode, - types::{amount::Amount, payto::ParsedPayto}, + types::{amount::Amount, payto::ParsedPayto, timestamp::TalerTimestamp}, }; use crate::{ api::BankState, - db::tx::{TxResult, create}, + auth::UserRAuth, + db::tx::{TxResult, create, get_by_id, pool_history}, mfa::{BankTxOp, MfaReq}, - payto::{BankPayto, LibeufinId}, + payto::{BankPayto, FullBankPayto, LibeufinId}, }; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -50,96 +61,490 @@ pub struct TransactionCreateResponse { pub row_id: u64, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)] +#[sqlx(type_name = "direction_enum")] +#[allow(non_camel_case_types)] +pub enum TransactionDirection { + credit, + debit, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BankAccountTransactionInfo { + pub creditor_payto_uri: FullBankPayto, + pub debtor_payto_uri: FullBankPayto, + pub amount: Amount, + pub direction: TransactionDirection, + pub subject: String, + pub row_id: u64, + pub date: TalerTimestamp, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BankAccountTransactionsResponse { + pub transactions: Vec<BankAccountTransactionInfo>, +} + pub fn tx_api() -> Router<Arc<BankState>> { - Router::new().route( - "/accounts/{username}/transactions", - post( - async |State(state): State<Arc<BankState>>, - MfaReq { mut auth, req, mfa }: MfaReq<BankTxOp>| { - let subject = req - .payto_uri - .subject - .clone() - .ok_or_else(|| bad_request("Wire transfer lacks subject"))?; - let amount = req - .payto_uri - .amount - .or(req.amount) - .ok_or_else(|| bad_request("Wire transfer lacks amount"))?; - - // TODO check regional currency - match create( - &state.db, - BankPayto::new(req.payto_uri.into_inner()), - &auth.username, - &subject, - amount, - &Timestamp::now(), - mfa.is_2fa(), - req.request_uid, - state.cfg.wire_transfer_fees, - state.cfg.min_amount, - state.cfg.max_amount, - ) - .await? - { - TxResult::Success(row_id) => { - Ok(Json(TransactionCreateResponse { row_id }).into_response()) - } - TxResult::UnknownCreditor => { - Err(failure_code(ErrorCode::BANK_UNKNOWN_CREDITOR)) + Router::new() + .route( + "/accounts/{username}/transactions", + post( + async |State(state): State<Arc<BankState>>, + MfaReq { mut auth, req, mfa }: MfaReq<BankTxOp>| { + let subject = req + .payto_uri + .subject + .clone() + .ok_or_else(|| bad_request("Wire transfer lacks subject"))?; + let amount = req + .payto_uri + .amount + .or(req.amount) + .ok_or_else(|| bad_request("Wire transfer lacks amount"))?; + + state.cfg.check_regio(&amount)?; + + match create( + &state.db, + BankPayto::new(req.payto_uri.into_inner()), + &auth.username, + &subject, + amount, + &Timestamp::now(), + mfa.is_2fa(), + req.request_uid, + state.cfg.wire_transfer_fees, + state.cfg.min_amount, + state.cfg.max_amount, + ) + .await? + { + TxResult::Success(row_id) => { + Ok(Json(TransactionCreateResponse { row_id }).into_response()) + } + TxResult::UnknownCreditor => { + Err(failure_code(ErrorCode::BANK_UNKNOWN_CREDITOR)) + } + TxResult::AdminCreditor => { + Err(failure_code(ErrorCode::BANK_ADMIN_CREDITOR)) + } + TxResult::UnknownDebtor => { + Err(failure_code(ErrorCode::BANK_UNKNOWN_DEBTOR)) + } + TxResult::BothPartySame => Err(failure_code(ErrorCode::BANK_SAME_ACCOUNT)), + TxResult::BalanceInsufficient => Err(failure( + ErrorCode::BANK_UNALLOWED_DEBIT, + "Insufficient funds", + )), + TxResult::BadAmount => Err(failure( + ErrorCode::BANK_UNALLOWED_DEBIT, + "Amount either to high or too low", + )), + TxResult::TanRequired => { + mfa.response_mfa(&mut auth, &state.db, &state.cfg.ctx).await + } + TxResult::RequestUidReuse => { + Err(failure_code(ErrorCode::BANK_TRANSFER_REQUEST_UID_REUSED)) + } } - TxResult::AdminCreditor => Err(failure_code(ErrorCode::BANK_ADMIN_CREDITOR)), - TxResult::UnknownDebtor => Err(failure_code(ErrorCode::BANK_UNKNOWN_DEBTOR)), - TxResult::BothPartySame => Err(failure_code(ErrorCode::BANK_SAME_ACCOUNT)), - TxResult::BalanceInsufficient => Err(failure( - ErrorCode::BANK_UNALLOWED_DEBIT, - "Insufficient funds", - )), - TxResult::BadAmount => Err(failure( - ErrorCode::BANK_UNALLOWED_DEBIT, - "Amount either to high or too low", - )), - TxResult::TanRequired => { - mfa.response_mfa(&mut auth, &state.db, &state.cfg.ctx).await + }, + ) + .get( + async |mut auth: UserRAuth, + Query(params): Query<HistoryParams>, + State(state): State<Arc<BankState>>| { + let params = params.check()?; + let transactions = pool_history( + &state.db, + &state.cfg.ctx, + &state.cfg.regional_currency, + &state.tx_channel, + &params, + auth.bank_info(&state.db, &state.cfg.ctx) + .await? + .bank_account_id, + ) + .await?; + if transactions.is_empty() { + ApiResult::Ok(NoContent.into_response()) + } else { + Ok(Json(BankAccountTransactionsResponse { transactions }).into_response()) } - TxResult::RequestUidReuse => { - Err(failure_code(ErrorCode::BANK_TRANSFER_REQUEST_UID_REUSED)) + }, + ), + ) + .route( + "/accounts/{username}/transactions/{id}", + get( + async |auth: UserRAuth, + Path((_, id)): Path<(CompactString, u64)>, + State(state): State<Arc<BankState>>| { + if let Some(tx) = get_by_id( + &state.db, + &state.cfg.regional_currency, + &state.cfg.ctx, + id, + &auth.username, + ) + .await? + { + Ok(Json(tx)) + } else { + Err(failure_code(ErrorCode::BANK_TRANSACTION_NOT_FOUND)) } - } - }, - ), - ) + }, + ), + ) } #[cfg(test)] pub mod test { use axum::http::Method; - use taler_test_utils::json; + use taler_api::subject::{fmt_in_subject, fmt_out_subject}; + use taler_common::{ + api_common::{EddsaPublicKey, ShortHashCode}, + db::IncomingType, + error_code::ErrorCode, + types::amount::amount, + }; + use taler_test_utils::{ + json, + routine::{Page, routine_history}, + tasks, + }; + use url::Url; use crate::api::{ - test::{Auth, bank_setup}, - tx::TransactionCreateResponse, + test::{Auth, MfaRequest, bank_setup, bank_setup_conf}, + tx::{ + BankAccountTransactionInfo, BankAccountTransactionsResponse, TransactionCreateResponse, + }, }; #[tokio::test] - async fn create() { + async fn tx() { let ctx = bank_setup().await; - ctx.auth_routine(Method::POST, "/accounts/merchant/transactions", Auth::User) - .await; + ctx.auth_routine( + Method::POST, + "/accounts/merchant/transactions", + Auth::UserOnly, + ) + .await; + ctx.auth_routine( + Method::GET, + "/accounts/merchant/transactions/42", + Auth::UserOrAdmin, + ) + .await; let valid_req = json!({ "payto_uri": format!("{}?message=payout", ctx.exchange_payto), "amount": "KUDOS:0.3" }); - // Check OK - let res: TransactionCreateResponse = ctx + // OK + let id = ctx .posta("/accounts/merchant/transactions") .json(&valid_req) .await + .assert_ok_json::<TransactionCreateResponse>() + .row_id; + let tx: BankAccountTransactionInfo = ctx + .geta(format!("/accounts/merchant/transactions/{id}")) + .await .assert_ok_json(); + assert_eq!(tx.subject, "payout"); + assert_eq!(tx.amount, amount("KUDOS:0.3")); + + // Idempotency + let uid = ShortHashCode::rand(); + let id = ctx + .posta("/accounts/merchant/transactions") + .json(json!(valid_req + { "request_uid": uid })) + .await + .assert_ok_json::<TransactionCreateResponse>() + .row_id; + assert_eq!( + id, + ctx.posta("/accounts/merchant/transactions") + .json(json!(valid_req + { "request_uid": uid })) + .await + .assert_ok_json::<TransactionCreateResponse>() + .row_id + ); + ctx.posta("/accounts/merchant/transactions") + .json(json!(valid_req + { + "request_uid": uid, + "amount": "KUDOS:42" + })) + .await + .assert_error(ErrorCode::BANK_TRANSFER_REQUEST_UID_REUSED); + + // Amount in payto_uri + let id = ctx + .posta("/accounts/merchant/transactions") + .json(json!({ + "payto_uri": format!("{}?message=payout3&amount=KUDOS:1.05", ctx.exchange_payto), + "amount": "KUDOS:10.003" + })) + .await + .assert_ok_json::<TransactionCreateResponse>() + .row_id; + let tx: BankAccountTransactionInfo = ctx + .geta(format!("/accounts/merchant/transactions/{id}")) + .await + .assert_ok_json(); + assert_eq!(tx.subject, "payout3"); + assert_eq!(tx.amount, amount("KUDOS:1.05")); + + // Unknown tx + ctx.geta("/accounts/merchant/transactions/3") + .await + .assert_error(ErrorCode::BANK_TRANSACTION_NOT_FOUND); + // Other user tx + ctx.geta(format!("/accounts/customer/transactions/{id}")) + .await + .assert_error(ErrorCode::BANK_TRANSACTION_NOT_FOUND); + + // Wrong currency + ctx.posta("/accounts/merchant/transactions") + .json(json!(valid_req + { "amount": "EUR:3.3" })) + .await + .assert_error(ErrorCode::GENERIC_CURRENCY_MISMATCH); + // Surpassing the debt limit + ctx.posta("/accounts/merchant/transactions") + .json(json!(valid_req + { "amount": "KUDOS:555" })) + .await + .assert_error(ErrorCode::BANK_UNALLOWED_DEBIT); + // Missing message + ctx.posta("/accounts/merchant/transactions") + .json(json!(valid_req + { "payto_uri": ctx.exchange_payto })) + .await + .assert_bad_request(); + // Unknown creditor + ctx.posta("/accounts/merchant/transactions") + .json(json!(valid_req + { + "payto_uri": format!("{}?message=payout", ctx.unknown_payto) + })) + .await + .assert_error(ErrorCode::BANK_UNKNOWN_CREDITOR); + // Transaction to self + ctx.posta("/accounts/merchant/transactions") + .json(json!(valid_req + { + "payto_uri": format!("{}?message=payout", ctx.merchant_payto) + })) + .await + .assert_error(ErrorCode::BANK_SAME_ACCOUNT); + // Transaction to admin + ctx.posta("/accounts/merchant/transactions") + .json(json!(valid_req + { + "payto_uri": format!("{}?message=payout", ctx.admin_payto) + })) + .await + .assert_error(ErrorCode::BANK_ADMIN_CREDITOR); + + // Init state + ctx.assert_balance("merchant", "0").await; + ctx.assert_balance("customer", "0").await; + // Send 2 times 3 + for _ in 0..2 { + ctx.tx("merchant", "3", "customer").await; + } + ctx.posta("/accounts/merchant/transactions") + .json(json!(valid_req + { + "payto_uri": format!("{}?message=payout2&amount=KUDOS:5", ctx.customer_payto) + })) + .await + .assert_error(ErrorCode::BANK_UNALLOWED_DEBIT); + ctx.assert_balance("merchant", "-6").await; + ctx.assert_balance("customer", "6").await; + // Send through debt + ctx.tx("customer", "10", "merchant").await; + ctx.assert_balance("merchant", "4").await; + ctx.assert_balance("customer", "-4").await; + ctx.tx("merchant", "4", "customer").await; + + // Check bounce + ctx.assert_balance("merchant", "0").await; + ctx.assert_balance("exchange", "0").await; + ctx.tx_s("merchant", "1", "exchange", "").await; // Bounce common to transaction + ctx.tx_s("merchant", "1", "exchange", "Malformed").await; // Bounce malformed transaction + ctx.tx_s("merchant", "1", "exchange", "ADMIN BALANCE ADJUST") + .await; // Bounce admin balance adjust + let key = EddsaPublicKey::rand(); + ctx.tx_s( + "merchant", + "1", + "exchange", + &fmt_in_subject(IncomingType::reserve, &key), + ) + .await; // Accept incoming + ctx.tx_s( + "merchant", + "1", + "exchange", + &fmt_in_subject(IncomingType::reserve, &key), + ) + .await; // Bounce reserve_pub reuse + ctx.assert_balance("merchant", "-1").await; + ctx.assert_balance("exchange", "1").await; + + // Check warn + ctx.tx_s("exchange", "1", "merchant", "").await; // Warn common to transaction + ctx.tx_s("exchange", "1", "merchant", "Malformed").await; // Warn malformed transaction + let wtid = ShortHashCode::rand(); + let url = Url::parse("https://exchange.example.com").unwrap(); + ctx.tx_s( + "exchange", + "1", + "merchant", + &fmt_out_subject(&wtid, &url, None), + ) + .await; // Accept outgoing + ctx.tx_s( + "exchange", + "1", + "merchant", + &fmt_out_subject(&wtid, &url, None), + ) + .await; // Warn wtid reuse + ctx.assert_balance("merchant", "3").await; + ctx.assert_balance("exchange", "-3").await; + + // Check 2fa + ctx.fill_tan_info("merchant").await; + ctx.assert_balance("merchant", "3").await; + ctx.assert_balance("customer", "0").await; + ctx.posta("/accounts/merchant/transactions") + .json(json!(valid_req + { + "payto_uri": format!("{}?message=tan+check&amount=KUDOS:1", ctx.customer_payto) + })) + .await + .assert_challenge_check(&ctx, async |_| { + ctx.assert_balance("merchant", "3").await; + ctx.assert_balance("customer", "0").await; + }) + .await + .assert_ok(); + ctx.assert_balance("merchant", "2").await; + ctx.assert_balance("customer", "1").await; + + // Check 2fa idempotency + let req = json!({ + "payto_uri": format!("{}?message=tan+check&amount=KUDOS:1", ctx.customer_payto), + "request_uid": ShortHashCode::rand(), + }); + let res = ctx + .posta("/accounts/merchant/transactions") + .json(&req) + .await + .assert_challenge_check(&ctx, async |_| { + ctx.assert_balance("merchant", "2").await; + ctx.assert_balance("customer", "1").await; + }) + .await + .assert_ok_json::<TransactionCreateResponse>(); + ctx.assert_balance("merchant", "1").await; + ctx.assert_balance("customer", "2").await; + assert_eq!( + res, + ctx.posta("/accounts/merchant/transactions") + .json(&req) + .await + .assert_ok_json::<TransactionCreateResponse>() + ); + ctx.posta("/accounts/merchant/transactions") + .json(json!(req + { + "payto_uri": format!("{}?message=tan+chec2k&amount=KUDOS:1", ctx.customer_payto) + })) + .await + .assert_error(ErrorCode::BANK_TRANSFER_REQUEST_UID_REUSED); + } + + #[tokio::test] + async fn tx_with_fee() { + let ctx = bank_setup_conf("test_with_fees.conf").await; + + // Init state + ctx.assert_balance("merchant", "0").await; + ctx.assert_balance("customer", "0").await; + ctx.assert_balance("admin", "0").await; + + // Check fee are sent to admin + ctx.tx("merchant", "3", "customer").await; + ctx.assert_balance("merchant", "-3.1").await; + ctx.assert_balance("customer", "3").await; + ctx.assert_balance("admin", "0.1").await; + + // Check amount with fee and min & max are checked + for amount in ["KUDOS:7", "KUDOS:6.9", "KUDOS:0", "KUDOS:150"] { + ctx.posta("/accounts/merchant/transactions") + .json(json!({ + "payto_uri": format!("{}?message=payout2&amount={amount}", ctx.customer_payto) + })) + .await + .assert_error(ErrorCode::BANK_UNALLOWED_DEBIT); + } + + // Check empty account + ctx.tx("merchant", "6.8", "customer").await; + ctx.assert_balance("merchant", "-10").await; + ctx.assert_balance("customer", "9.8").await; + ctx.assert_balance("admin", "0.2").await; + + // Admin check no fee + ctx.tx("admin", "0.35", "merchant").await; + ctx.assert_balance("merchant", "-9.65").await; + ctx.assert_balance("admin", "-0.15").await; + + // Admin recover from debt + ctx.tx("customer", "1", "merchant").await; + ctx.assert_balance("admin", "-0.05").await; + ctx.tx("customer", "1", "merchant").await; + ctx.assert_balance("merchant", "-7.65").await; + ctx.assert_balance("customer", "7.6").await; + ctx.assert_balance("admin", "0.05").await; + } + + impl Page for BankAccountTransactionsResponse { + fn ids(&self) -> Vec<i64> { + self.transactions + .iter() + .map(|it| it.row_id as i64) + .collect() + } + } + + #[tokio::test] + async fn history() { + let ctx = &bank_setup().await; + + ctx.auth_routine( + Method::POST, + "/accounts/merchant/transactions", + Auth::UserOrAdmin, + ) + .await; + + routine_history::<BankAccountTransactionsResponse>( + &ctx.admin_router().await, + "/accounts/customer/transactions", + tasks!( + // Incoming + { ctx.tx("merchant", "0.1", "customer").await }, + // Outgoing + { ctx.tx("customer", "0.1", "merchant").await }, + // TODO cashout + ), + tasks!( + // Other account + { ctx.tx("merchant", "0.1", "exchange").await }, + { ctx.tx("exchange", "0.1", "merchant").await }, + ), + ) + .await; } } diff --git a/crates/libeufin-bank/src/auth.rs b/crates/libeufin-bank/src/auth.rs @@ -301,6 +301,13 @@ impl UserAuthScope for UserRWScope { const KIND: AuthKind = AuthKind::UserOrAdmin; } +pub struct UserORWScope; + +impl UserAuthScope for UserORWScope { + const SCOPE: TokenLogicalScope = TokenLogicalScope::readwrite; + const KIND: AuthKind = AuthKind::UserOnly; +} + pub struct UserRScope; impl UserAuthScope for UserRScope { diff --git a/crates/libeufin-bank/src/db.rs b/crates/libeufin-bank/src/db.rs @@ -17,7 +17,13 @@ * <http://www.gnu.org/licenses/> */ -const SCHEMA: &str = "libeufin_bank"; +use std::time::Duration; + +use compact_str::CompactString; +use sqlx::PgPool; +use taler_api::notification::NotificationChannel; +use tokio::join; +use uuid::Uuid; pub mod account; pub mod conversion; @@ -25,3 +31,48 @@ pub mod gc; pub mod tan; pub mod token; pub mod tx; + +const SCHEMA: &str = "libeufin_bank"; + +pub async fn notification_listener( + pool: PgPool, + tx_channel: NotificationChannel<u64, i64>, + taler_out_channel: NotificationChannel<u64, i64>, + taler_in_channel: NotificationChannel<u64, i64>, + revenue_channel: NotificationChannel<u64, i64>, +) -> sqlx::Result<()> { + // GC notifications channels very hours + let gc = async { + let mut interval = tokio::time::interval(Duration::from_hours(1)); + loop { + tx_channel.prune(); + taler_out_channel.prune(); + taler_in_channel.prune(); + revenue_channel.prune(); + + interval.tick().await; + } + }; + + let listener = async { + taler_api::notification::notification_listener!(&pool, + "bank_tx" => (debtor: u64, creditor: u64, debit: i64, credit: i64) { + tx_channel.dispatch(&debtor, debit); + tx_channel.dispatch(&creditor, credit); + revenue_channel.dispatch(&creditor, credit); + }, + "bank_outgoing_tx" => (account: u64, _a: u64, debit: i64, _d: i64) { + taler_out_channel.dispatch(&account, debit); + }, + "bank_incoming_tx" => (account: u64, row: i64) { + taler_in_channel.dispatch(&account, row); + }, + "bank_withdrawal_status" => (uuid: Uuid, status: CompactString) { + // TODO + } + ); + sqlx::Result::<_, sqlx::error::Error>::Ok(()) + }; + join!(gc, listener); + Ok(()) +} diff --git a/crates/libeufin-bank/src/db/tx.rs b/crates/libeufin-bank/src/db/tx.rs @@ -20,14 +20,23 @@ //! Data access logic for transactions use jiff::Timestamp; -use sqlx::{PgPool, postgres::PgRow}; +use sqlx::{PgPool, QueryBuilder, Row, postgres::PgRow}; use taler_api::{ - db::{BindHelper, TypeHelper}, + db::{BindHelper, TypeHelper, history}, + notification::NotificationChannel, + serialized, subject::{IncomingSubject, parse_incoming_unstructured}, }; -use taler_common::{api_common::ShortHashCode, types::amount::Amount}; +use taler_common::{ + api_common::ShortHashCode, + api_params::History, + types::amount::{Amount, Currency}, +}; -use crate::payto::BankPayto; +use crate::{ + api::tx::BankAccountTransactionInfo, + payto::{BankPayto, PaytoCtx, sql_bank_payto}, +}; /** Result status of bank transaction creation */ pub enum TxResult { @@ -132,4 +141,88 @@ pub async fn create( } // /** Get transaction [rowId] owned by [username] */ -//pub async fn get(db: &PgPool, id: u64, username: &str) -> sqlx::Result<Option<Ban>> +pub async fn get_by_id( + db: &PgPool, + currency: &Currency, + ctx: &PaytoCtx, + id: u64, + username: &str, +) -> sqlx::Result<Option<BankAccountTransactionInfo>> { + serialized!( + sqlx::query(" + SELECT + creditor_payto + ,creditor_name + ,debtor_payto + ,debtor_name + ,subject + ,amount + ,transaction_date + ,direction + ,bank_transaction_id + FROM bank_account_transactions + JOIN bank_accounts ON bank_account_transactions.bank_account_id=bank_accounts.bank_account_id + JOIN customers ON customer_id=owning_customer_id + WHERE bank_transaction_id=$1 AND username=$2 + ") + .bind(id as i64) + .bind(username) + .try_map(|r: PgRow| Ok(BankAccountTransactionInfo { + creditor_payto_uri: sql_bank_payto(&r, ctx, "creditor_payto", "creditor_name")?, + debtor_payto_uri: sql_bank_payto(&r, ctx, "debtor_payto", "debtor_name")?, + direction: r.try_get("direction")?, + subject: r.try_get("subject")?, + amount: r.try_get_amount("amount", currency)?, + date: r.try_get_timestamp("transaction_date")?.into(), + row_id: r.try_get_u64("bank_transaction_id")? + })) + .fetch_optional(db) + ) +} + +/** Pool [accountId] transactions history */ +pub async fn pool_history( + db: &PgPool, + ctx: &PaytoCtx, + currency: &Currency, + channel: &NotificationChannel<u64, i64>, + params: &History, + account_id: u64, +) -> sqlx::Result<Vec<BankAccountTransactionInfo>> { + history( + db, + "bank_transaction_id", + params, + || channel.subscribe(account_id), + || { + let mut query = QueryBuilder::new( + "SELECT + bank_transaction_id + ,transaction_date + ,amount + ,debtor_payto + ,debtor_name + ,creditor_payto + ,creditor_name + ,subject + ,direction + FROM bank_account_transactions + WHERE bank_account_id=", + ); + query.push_bind(account_id as i64).push(" AND "); + query + }, + |r| { + Ok(BankAccountTransactionInfo { + creditor_payto_uri: sql_bank_payto(&r, ctx, "creditor_payto", "creditor_name")?, + debtor_payto_uri: sql_bank_payto(&r, ctx, "debtor_payto", "debtor_name")?, + direction: r.try_get("direction")?, + subject: r.try_get("subject")?, + amount: r.try_get_amount("amount", currency)?, + date: r.try_get_timestamp("transaction_date")?.into(), + row_id: r.try_get_u64("bank_transaction_id")?, + }) + }, + ) + .await +} diff --git a/crates/libeufin-bank/src/mfa.rs b/crates/libeufin-bank/src/mfa.rs @@ -49,7 +49,7 @@ use crate::{ token::TokenRequest, tx::TransactionCreateRequest, }, - auth::{UserAuth, UserAuthScope, UserRWScope, UserTokenScope}, + auth::{UserAuth, UserAuthScope, UserORWScope, UserRWScope, UserTokenScope}, db::account::BankInfo, payto::PaytoCtx, }; @@ -98,7 +98,7 @@ pub struct BankTxOp; impl MfaOp for BankTxOp { const OP: Operation = Operation::bank_transaction; - type Scope = UserRWScope; + type Scope = UserORWScope; type Body = TransactionCreateRequest; } diff --git a/crates/libeufin-nexus/src/api.rs b/crates/libeufin-nexus/src/api.rs @@ -324,6 +324,7 @@ pub mod test { transfer_routine, }, server::TestServer as _, + tasks, }; use crate::{ @@ -384,7 +385,7 @@ pub mod test { routine_pagination::<OutgoingHistory>( &server, "/taler-wire-gateway/history/outgoing", - async |_| { + tasks!({ register_out_tx( &pool, &gen_out_pay("subject"), @@ -392,7 +393,7 @@ pub mod test { ) .await .unwrap(); - }, + }), ) .await; }