libeufin

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

commit 003364ba8128a3994dc1d2cb99d9d240eaf19ae7
parent 133fdbb79b37bd8b27adec6c43bc15c0359ef2d4
Author: Antoine A <>
Date:   Tue,  5 May 2026 19:11:13 +0200

bank: finish TAN logic

Diffstat:
Mcrates/libeufin-bank/src/api.rs | 58++++++++++++++++++++++++++++++++--------------------------
Mcrates/libeufin-bank/src/api/tan.rs | 60+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Acrates/libeufin-bank/src/api/tx.rs | 155+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/libeufin-bank/src/db.rs | 2+-
Mcrates/libeufin-bank/src/db/account.rs | 16+++++++++++++---
Acrates/libeufin-bank/src/db/tx.rs | 135+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/libeufin-bank/src/mfa.rs | 9++++++++-
Mcrates/libeufin-bank/src/payto.rs | 3++-
Mcrates/libeufin-nexus/src/fetch.rs | 5++---
9 files changed, 407 insertions(+), 36 deletions(-)

diff --git a/crates/libeufin-bank/src/api.rs b/crates/libeufin-bank/src/api.rs @@ -24,6 +24,7 @@ use crate::config::BankCfg; pub mod account; pub mod tan; pub mod token; +pub mod tx; pub struct BankState { pub db: PgPool, @@ -47,7 +48,7 @@ pub mod test { error_code::ErrorCode, types::{ amount::{Amount, Decimal}, - payto::{IbanPayto, Payto}, + payto::{FullIbanPayto, FullPayto, IbanPayto, Payto}, }, }; use taler_test_utils::{ @@ -63,20 +64,21 @@ pub mod test { account::{account_api, create_admin_account, rand_iban_payto}, tan::{ChallengeResponse, tan_api}, token::token_api, + tx::tx_api, }, config::BankCfg, db::{self, account::CreationResult}, mfa::TALER_CHALLENGE_IDS, - payto::LibeufinId, + payto::{FullBankPayto, LibeufinId}, }; pub struct BankTestCtx { - pub merchant_payto: IbanPayto, - pub exchange_payto: IbanPayto, - pub customer_payto: IbanPayto, - pub unknown_payto: IbanPayto, - pub tmp_payto: IbanPayto, - pub admin_payto: IbanPayto, + pub merchant_payto: FullBankPayto, + pub exchange_payto: FullBankPayto, + pub customer_payto: FullBankPayto, + pub unknown_payto: FullBankPayto, + pub tmp_payto: FullBankPayto, + pub admin_payto: FullBankPayto, pub db: PgPool, pub server: Router, tokens: BTreeMap<CompactString, String>, @@ -210,17 +212,12 @@ pub mod test { let server = token_api() .merge(account_api()) + .merge(tx_api()) .merge(tan_api()) .with_state(state.clone()) .finalize(); - let merchant_payto = rand_iban_payto(); - let exchange_payto = rand_iban_payto(); - let customer_payto = rand_iban_payto(); - let unknown_payto = rand_iban_payto(); - let tmp_payto = rand_iban_payto(); - - db::account::create( + let merchant_payto = db::account::create( &db, &state.cfg.ctx, &state.cfg.pw_crypto, @@ -230,7 +227,7 @@ pub mod test { None, None, None, - LibeufinId::IBAN(merchant_payto.clone().into_inner()), + LibeufinId::IBAN(rand_iban_payto().into_inner()), false, false, Decimal::new(10, 0).to_amount(&state.cfg.regional_currency), @@ -240,8 +237,9 @@ pub mod test { None, ) .await - .unwrap(); - db::account::create( + .unwrap() + .assert_success(); + let exchange_payto = db::account::create( &db, &state.cfg.ctx, &state.cfg.pw_crypto, @@ -251,7 +249,7 @@ pub mod test { None, None, None, - LibeufinId::IBAN(exchange_payto.clone().into_inner()), + LibeufinId::IBAN(rand_iban_payto().into_inner()), false, false, Decimal::new(10, 0).to_amount(&state.cfg.regional_currency), @@ -261,8 +259,9 @@ pub mod test { None, ) .await - .unwrap(); - db::account::create( + .unwrap() + .assert_success(); + let customer_payto = db::account::create( &db, &state.cfg.ctx, &state.cfg.pw_crypto, @@ -272,7 +271,7 @@ pub mod test { None, None, None, - LibeufinId::IBAN(customer_payto.clone().into_inner()), + LibeufinId::IBAN(rand_iban_payto().into_inner()), false, false, Decimal::new(10, 0).to_amount(&state.cfg.regional_currency), @@ -282,7 +281,8 @@ pub mod test { None, ) .await - .unwrap(); + .unwrap() + .assert_success(); let res = create_admin_account(&db, &state.cfg, Some("admin-password")) .await @@ -297,9 +297,15 @@ pub mod test { merchant_payto, exchange_payto, customer_payto, - unknown_payto, - tmp_payto, - admin_payto: Payto::new(admin_payto.into_inner().expect_iban().unwrap().clone()), + unknown_payto: FullBankPayto::new( + LibeufinId::IBAN(rand_iban_payto().into_inner()), + "Unknown", + ), + tmp_payto: FullBankPayto::new( + LibeufinId::IBAN(rand_iban_payto().into_inner()), + "Unknown", + ), + admin_payto: admin_payto, server, db, tokens: BTreeMap::new(), diff --git a/crates/libeufin-bank/src/api/tan.rs b/crates/libeufin-bank/src/api/tan.rs @@ -249,7 +249,7 @@ pub mod test { use crate::{ TanChannel, api::{ - tan::{Challenge, ChallengeRequestResponse, ChallengeResponse}, + tan::{Challenge, ChallengeRequestResponse, ChallengeResponse, MAX_ACTIVE_CHALLENGES}, test::{MfaRequest, bank_setup_conf, tan_code}, }, mfa::TALER_CHALLENGE_IDS, @@ -491,6 +491,64 @@ pub mod test { ctx.fill_tan_info("merchant").await; // TODO need transaction API + let tx_challenge = async || { + ctx.posta("/accounts/merchant/transactions") + .json(json!({ + "payto_uri": format!("{}&message=tx&amount=KUDOS:0.1", ctx.customer_payto) + })) + .await + .assert_accepted_json::<ChallengeResponse>() + .challenges + .pop() + .unwrap() + }; + + let submit = async |c: &Challenge| { + ctx.posta(&format!("/accounts/merchant/challenge/{}", c.challenge_id)) + .await + .assert_ok_json::<ChallengeRequestResponse>() + }; + + // Start a legitimate challenge and submit it + let old = tx_challenge().await; + submit(&old).await; + let code = tan_code(&old.tan_info); + + // Challenge creation is not rate limited + for _ in 0..MAX_ACTIVE_CHALLENGES * 2 { + tx_challenge().await; + } + + // Challenge submission is rate limited + for _ in 0..MAX_ACTIVE_CHALLENGES - 1 { + submit(&tx_challenge().await).await; + } + let c = tx_challenge().await; + ctx.posta(&format!("/accounts/merchant/challenge/{}", c.challenge_id)) + .await + .assert_error(ErrorCode::BANK_TAN_RATE_LIMITED); + + // Old already submitted challenge still works + submit(&old).await; + ctx.posta(&format!( + "/accounts/merchant/challenge/{}/confirm", + old.challenge_id + )) + .json(json!({ "tan": code })) + .await + .assert_no_content(); + + // Now an active challenge slot have been freed + submit(&c).await; + + // We are still rate limited + let new = tx_challenge().await; + ctx.posta(&format!( + "/accounts/merchant/challenge/{}", + new.challenge_id + )) + .await + .assert_error(ErrorCode::BANK_TAN_RATE_LIMITED); } #[tokio::test] diff --git a/crates/libeufin-bank/src/api/tx.rs b/crates/libeufin-bank/src/api/tx.rs @@ -0,0 +1,155 @@ +/* +* 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 transactions + +use std::sync::Arc; + +use axum::{Json, Router, extract::State, response::IntoResponse, routing::post}; +use jiff::Timestamp; +use serde::{Deserialize, Serialize}; +use taler_api::error::{bad_request, failure, failure_code}; +use taler_common::{api_common::ShortHashCode, error_code::ErrorCode, types::amount::Amount}; + +use crate::{ + api::BankState, + auth::UserRW, + db::tx::{TxResult, create}, + mfa::{BankTx, MfaReq}, + payto::{BankPayto, TransferBankPayto}, +}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TransactionCreateRequest { + pub payto_uri: TransferBankPayto, + pub amount: Option<Amount>, + pub request_uid: Option<ShortHashCode>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TransactionCreateResponse { + pub row_id: u64, +} + +pub fn tx_api() -> Router<Arc<BankState>> { + Router::new().route( + "/accounts/{username}/transactions", + post( + async |State(state): State<Arc<BankState>>, + MfaReq { mut auth, req, ctx }: MfaReq<UserRW, BankTx>| { + 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(), + ctx.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 => { + ctx.response_mfa(&mut auth, &state.db, &state.cfg.ctx).await + } + TxResult::RequestUidReuse => { + Err(failure_code(ErrorCode::BANK_TRANSFER_REQUEST_UID_REUSED)) + } + } + }, + ), + ) +} + +#[cfg(test)] +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, + }; + + #[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 valid_req = json!({ + "payto_uri": format!("{}&message=payout", ctx.exchange_payto), + "amount": "KUDOS:0.3" + }); + + // Check OK + let res: TransactionCreateResponse = ctx + .posta("/accounts/merchant/transactions") + .json(&valid_req) + .await + .assert_ok_json(); + } +} diff --git a/crates/libeufin-bank/src/db.rs b/crates/libeufin-bank/src/db.rs @@ -20,6 +20,6 @@ const SCHEMA: &str = "libeufin_bank"; pub mod account; - 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 @@ -45,6 +45,18 @@ pub enum CreationResult { BonusBalanceInsufficient, } +impl CreationResult { + pub fn assert_success(self) -> FullBankPayto { + match self { + CreationResult::Success(full_payto) => full_payto, + CreationResult::UsernameReuse + | CreationResult::PayToReuse + | CreationResult::UnknownConversionClass + | CreationResult::BonusBalanceInsufficient => unreachable!(), + } + } +} + /** Create new account */ pub async fn create( db: &PgPool, @@ -271,15 +283,13 @@ pub async fn reconfig( allow_edit_cashout: bool, ) -> ApiResult<PatchResult> { let AccountReconfiguration { - contact_data, cashout_payto_uri, name, is_public, debit_threshold, - tan_channel, - tan_channels, is_taler_exchange, conversion_rate_class_id, + .. } = req; let mut tx = db.begin().await?; diff --git a/crates/libeufin-bank/src/db/tx.rs b/crates/libeufin-bank/src/db/tx.rs @@ -0,0 +1,135 @@ +/* +* 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 transactions + +use jiff::Timestamp; +use sqlx::{PgPool, postgres::PgRow}; +use taler_api::{ + db::{BindHelper, TypeHelper}, + subject::{IncomingSubject, parse_incoming_unstructured}, +}; +use taler_common::{api_common::ShortHashCode, types::amount::Amount}; + +use crate::payto::BankPayto; + +/** Result status of bank transaction creation */ +pub enum TxResult { + Success(u64), + UnknownCreditor, + AdminCreditor, + UnknownDebtor, + BothPartySame, + BalanceInsufficient, + BadAmount, + TanRequired, + RequestUidReuse, +} + +/** Create a new transaction */ +pub async fn create( + db: &PgPool, + credit_account_payto: BankPayto, + debit_account_username: &str, + subject: &str, + amount: Amount, + timestamp: &Timestamp, + is2fa: bool, + request_uid: Option<ShortHashCode>, + wire_transfer_fees: Amount, + min_amount: Amount, + max_amount: Amount, +) -> sqlx::Result<TxResult> { + let res = match parse_incoming_unstructured(subject) { + Ok(subject) => { + if IncomingSubject::AdminBalanceAdjust == subject { + Err(format!("unsupported admin balance adjust")) + } else { + Ok(subject) + } + } + Err(e) => Err(format!("malformed metadata - {e}")), + }; + let (ty, metadata, cause) = match &res { + Ok(s) => (Some(s.ty()), Some(s.key()), None), + Err(e) => (None, None, Some(e)), + }; + sqlx::query( + " + SELECT + out_creditor_not_found + ,out_debtor_not_found + ,out_same_account + ,out_balance_insufficient + ,out_bad_amount + ,out_request_uid_reuse + ,out_tan_required + ,out_credit_bank_account_id + ,out_debit_bank_account_id + ,out_credit_row_id + ,out_debit_row_id + ,out_creditor_is_exchange + ,out_debtor_is_exchange + ,out_creditor_admin + ,out_idempotent + FROM bank_transaction($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::taler_incoming_type,$12,$13) + ", + ) + .bind(credit_account_payto.canonical()) + .bind(debit_account_username) + .bind(subject) + .bind(amount) + .bind_timestamp(timestamp) + .bind(is2fa) + .bind(request_uid) + .bind(wire_transfer_fees) + .bind(min_amount) + .bind(max_amount) + .bind(ty) + .bind(metadata) + .bind(cause) + .try_map(|r: PgRow| { + Ok(if r.try_get_flag("out_creditor_not_found")? { + TxResult::UnknownCreditor + } else if r.try_get_flag("out_debtor_not_found")? { + TxResult::UnknownDebtor + } else if r.try_get_flag("out_same_account")? { + TxResult::BothPartySame + } else if r.try_get_flag("out_balance_insufficient")? { + TxResult::BalanceInsufficient + } else if r.try_get_flag("out_bad_amount")? { + TxResult::BadAmount + } else if r.try_get_flag("out_creditor_admin")? { + TxResult::AdminCreditor + } else if r.try_get_flag("out_request_uid_reuse")? { + TxResult::RequestUidReuse + } else if r.try_get_flag("out_idempotent")? { + TxResult::Success(r.try_get_u64("out_debit_row_id")?) + } else if r.try_get_flag("out_tan_required")? { + TxResult::TanRequired + } else { + TxResult::Success(r.try_get_u64("out_debit_row_id")?) + }) + }) + .fetch_one(db) + .await +} + +// /** Get transaction [rowId] owned by [username] */ +//pub async fn get(db: &PgPool, id: u64, username: &str) -> sqlx::Result<Option<Ban>> diff --git a/crates/libeufin-bank/src/mfa.rs b/crates/libeufin-bank/src/mfa.rs @@ -46,6 +46,7 @@ use crate::{ BankState, account::{AccountReconfiguration, TanInfo}, tan::{Challenge, ChallengeResponse}, + tx::TransactionCreateRequest, }, auth::{Auth, AuthScope}, db::account::BankInfo, @@ -83,7 +84,6 @@ pub struct AccountReconfig; impl MfaOperation for AccountReconfig { const OP: Operation = Operation::account_reconfig; - type Body = AccountReconfiguration; fn required_validation(body: &Self::Body, info: &BankInfo) -> ApiResult<Option<Tans>> { @@ -91,6 +91,13 @@ impl MfaOperation for AccountReconfig { } } +pub struct BankTx; + +impl MfaOperation for BankTx { + const OP: Operation = Operation::bank_transaction; + type Body = TransactionCreateRequest; +} + 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()); diff --git a/crates/libeufin-bank/src/payto.rs b/crates/libeufin-bank/src/payto.rs @@ -27,7 +27,7 @@ use taler_common::{ error_code::ErrorCode, types::{ iban::BIC, - payto::{BankID, FullPayto, Payto, PaytoErr, PaytoImpl, PaytoURI}, + payto::{BankID, FullPayto, Payto, PaytoErr, PaytoImpl, PaytoURI, TransferPayto}, }, }; @@ -80,6 +80,7 @@ pub enum LibeufinId { pub type BankPayto = Payto<LibeufinId>; pub type FullBankPayto = FullPayto<LibeufinId>; +pub type TransferBankPayto = TransferPayto<LibeufinId>; impl LibeufinId { pub fn canonical(&self) -> String { diff --git a/crates/libeufin-nexus/src/fetch.rs b/crates/libeufin-nexus/src/fetch.rs @@ -581,12 +581,11 @@ pub async fn register_incoming( } } else { match parse_incoming_unstructured(payment.subject.as_deref().unwrap_or_default()) { - Ok(None) => bounce("missing public key").await?, - Ok(Some(IncomingSubject::AdminBalanceAdjust)) => { + Ok(IncomingSubject::AdminBalanceAdjust) => { let res = register_in(db, payment).await?; log_res(res, "admin balance adjust", ""); } - Ok(Some(subject)) => match register_in_talerable(db, payment, &subject).await? { + Ok(subject) => match register_in_talerable(db, payment, &subject).await? { IncomingRegistrationResult::ReservePubReuse => bounce("reverse pub reuse").await?, IncomingRegistrationResult::MappingReuse => bounce("mapping reuse").await?, IncomingRegistrationResult::UnknownMapping => bounce("unknown mapping").await?,