libeufin

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

commit 074ca9f6bfe70ed563ed52ea70b5963b6b14fbe6
parent 2ea416cb182f1b0990a6c6a203c3d909f1457d7f
Author: Antoine A <>
Date:   Tue, 19 May 2026 12:41:50 +0200

bank: cashout API

Diffstat:
MCargo.lock | 32++++++++++++++++----------------
Mcrates/libeufin-bank/Cargo.toml | 6------
Mcrates/libeufin-bank/src/api.rs | 53++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/libeufin-bank/src/api/account.rs | 39++++++++++++++++++---------------------
Acrates/libeufin-bank/src/api/cashout.rs | 458+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/libeufin-bank/src/api/tx.rs | 5++++-
Mcrates/libeufin-bank/src/db.rs | 1+
Acrates/libeufin-bank/src/db/cashout.rs | 212+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/libeufin-bank/src/mfa.rs | 9+++++++++
Mcrates/libeufin-nexus/Cargo.toml | 2+-
10 files changed, 771 insertions(+), 46 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -90,9 +90,9 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "asn1-rs" -version = "0.7.1" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56624a96882bb8c26d61312ae18cb45868e5a9992ea73c58e45c3101e56a1e60" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" dependencies = [ "asn1-rs-derive", "asn1-rs-impl", @@ -378,7 +378,7 @@ dependencies = [ "find-msvc-tools", "jobserver", "libc", - "shlex", + "shlex 1.3.0", ] [[package]] @@ -727,9 +727,9 @@ dependencies = [ [[package]] name = "dashmap" -version = "6.1.0" +version = "6.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" dependencies = [ "cfg-if", "crossbeam-utils", @@ -1681,18 +1681,13 @@ dependencies = [ "bcrypt", "clap", "compact_str", - "const_format", "futures", "jiff", - "libeufin-ebics", - "owo-colors", "pretty_assertions", "rand 0.10.1", - "reedline", "regex", "serde", "serde_json", - "shlex", "sqlx", "taler-api", "taler-build", @@ -1701,7 +1696,6 @@ dependencies = [ "taler-test-utils", "tokio", "tracing", - "tracing-subscriber", "url", "uuid", ] @@ -1757,7 +1751,7 @@ dependencies = [ "regex", "serde", "serde_json", - "shlex", + "shlex 2.0.1", "sqlx", "taler-api", "taler-build", @@ -1953,9 +1947,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-integer" @@ -2827,6 +2821,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] name = "signal-hook" version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3521,9 +3521,9 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.10" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "bitflags", "bytes", diff --git a/crates/libeufin-bank/Cargo.toml b/crates/libeufin-bank/Cargo.toml @@ -8,7 +8,6 @@ repository.workspace = true license-file.workspace = true [dependencies] -libeufin-ebics.workspace = true tokio.workspace = true tracing.workspace = true anyhow.workspace = true @@ -29,12 +28,7 @@ axum.workspace = true rand.workspace = true futures = "0.3" url = "2.5" -reedline = "0.47" regex = "1.12" -const_format = { version = "0.2", features = ["rust_1_83"] } -tracing-subscriber = "0.3" -owo-colors = "4.3" -shlex = "1.3" bcrypt = "0.19.0" [dev-dependencies] diff --git a/crates/libeufin-bank/src/api.rs b/crates/libeufin-bank/src/api.rs @@ -23,6 +23,7 @@ use taler_api::notification::NotificationChannel; use crate::{config::BankCfg, db::notification_listener}; pub mod account; +pub mod cashout; pub mod conversion; pub mod tan; pub mod token; @@ -99,7 +100,8 @@ pub mod test { AccountData, Balance, CreditDebitInfo, account_api, create_admin_account, rand_iban_payto, }, - conversion::{ConversionRateClassResponse, conversion_api}, + cashout::cashout_api, + conversion::{ConversionRateClassResponse, ConversionResponse, conversion_api}, tan::{ChallengeResponse, tan_api}, token::token_api, tx::tx_api, @@ -144,6 +146,7 @@ pub mod test { .merge(tx_api()) .merge(tan_api()) .merge(conversion_api(state.clone())) + .merge(cashout_api(state.clone())) .with_state(state.clone()) .finalize(); @@ -238,6 +241,22 @@ pub mod test { .await; if ctx.state.cfg.fiat.is_some() { + // Set conversion rates + ctx.post_admin("/conversion-info/conversion-rate") + .json(json!({ + "cashin_ratio" :"0.8", + "cashin_fee" :"KUDOS:0.02", + "cashin_tiny_amount" :"KUDOS:0.01", + "cashin_rounding_mode" :"nearest", + "cashin_min_amount" :"EUR:0", + "cashout_ratio" :"1.26", + "cashout_fee" :"EUR:0.003", + "cashout_tiny_amount" :"EUR:0.01", + "cashout_rounding_mode" :"zero", + "cashout_min_amount" :"KUDOS:0.1" + })) + .await + .assert_no_content(); ctx.create_conversion_rate_class().await; } @@ -259,6 +278,7 @@ pub mod test { .merge(tx_api()) .merge(tan_api()) .merge(conversion_api(state.clone())) + .merge(cashout_api(state.clone())) .with_state(state.clone()) .finalize(); self.state = state; @@ -422,6 +442,15 @@ pub mod test { .assert_no_content(); } + pub async fn fill_cashout_info(&self, username: &str) { + self.patch_admin(&format!("/accounts/{username}")) + .json(json!({ + "cashout_payto_uri": self.unknown_payto, + })) + .await + .assert_no_content(); + } + pub async fn tmp_payto(&mut self) -> PaytoURI { self.tmp_payto = rand_iban_payto().convert(); self.tmp_payto.as_uri() @@ -450,6 +479,19 @@ pub mod test { self.tx_s(from, amount, to, "payout").await } + pub async fn cashout(&self, amount: &str) { + self.posta("/accounts/customer/cashouts") + .json({ + json!({ + "request_uid": ShortHashCode::rand(), + "amount_debit": amount, + "amount_credit": self.convert(amount).await + }) + }) + .await + .assert_ok() + } + pub async fn transfer( &self, amount: &str, @@ -481,6 +523,15 @@ pub mod test { .conversion_rate_class_id } + pub async fn convert(&self, amount: &str) -> Amount { + self.get(format!( + "/conversion-info/cashout-rate?amount_debit={amount}" + )) + .await + .assert_ok_json::<ConversionResponse>() + .amount_credit + } + /** Set [account] debit threshold to [maxDebt] amount */ pub async fn set_max_debt(&self, username: &str, amount: &str) { self.patch_admin(&format!("/accounts/{username}")) diff --git a/crates/libeufin-bank/src/api/account.rs b/crates/libeufin-bank/src/api/account.rs @@ -68,7 +68,9 @@ use crate::{ #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ChallengeContactData { + #[serde(default)] pub email: Maybe<CompactString>, + #[serde(default)] pub phone: Maybe<CompactString>, } @@ -153,8 +155,9 @@ pub struct RegisterAccountResponse { pub internal_payto_uri: FullBankPayto, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Default)] pub enum Maybe<T> { + #[default] Missing, Null, Some(T), @@ -181,27 +184,14 @@ 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>, -{ +impl<'de, T: Deserialize<'de>> Deserialize<'de> for Maybe<T> { fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> where D: serde::Deserializer<'de>, { - let opt = Option::<Option<T>>::deserialize(deserializer)?; - Ok(match opt { - None => Maybe::Missing, - Some(v) => v.into(), + Ok(match Option::<T>::deserialize(deserializer)? { + None => Maybe::Null, + Some(v) => Maybe::Some(v), }) } } @@ -214,7 +204,10 @@ impl<DB: Database, T: sqlx::Type<DB>> sqlx::Type<DB> for Maybe<T> { 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()) + Ok(match Option::<T>::decode(value)? { + None => Maybe::Null, + Some(v) => Maybe::Some(v), + }) } } @@ -245,13 +238,17 @@ pub trait TanInfo: Debug { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AccountReconfiguration { pub contact_data: Option<ChallengeContactData>, + #[serde(default)] pub cashout_payto_uri: Maybe<IbanPayto>, pub name: Option<CompactString>, pub is_public: Option<bool>, pub debit_threshold: Option<Amount>, + #[serde(default)] pub tan_channel: Maybe<TanChannel>, + #[serde(default)] pub tan_channels: Maybe<Vec<TanChannel>>, pub is_taler_exchange: Option<bool>, + #[serde(default)] pub conversion_rate_class_id: Maybe<u64>, } @@ -1253,8 +1250,8 @@ pub mod test { }, cashout_payto_uri: None, contact_data: ChallengeContactData { - phone: Maybe::Missing, - email: Maybe::Missing, + phone: Maybe::Null, + email: Maybe::Null, }, conversion_rate: Some(acc.conversion_rate.clone().unwrap()), conversion_rate_class_id: None, diff --git a/crates/libeufin-bank/src/api/cashout.rs b/crates/libeufin-bank/src/api/cashout.rs @@ -0,0 +1,458 @@ +/* +* 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, + middleware::{self, Next}, + response::{IntoResponse, NoContent}, + routing::{get, post}, +}; +use compact_str::CompactString; +use jiff::Timestamp; +use serde::{Deserialize, Serialize}; +use taler_api::{ + error::{ApiResult, failure_code, not_implemented}, + extract::{Path, Query}, +}; +use taler_common::{ + api_common::ShortHashCode, + api_params::PageParams, + error_code::ErrorCode, + types::{amount::Amount, timestamp::TalerTimestamp}, +}; + +use crate::{ + api::BankState, + auth::{AdminRAuth, UserRWAuth}, + db::cashout::{CreationResult, create, get_for_user, page_all, page_for_user}, + mfa::{CashoutOp, MfaReq}, +}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[allow(non_camel_case_types)] +pub enum CashoutStatus { + pending, + aborted, + confirmed, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CashoutRequest { + pub request_uid: ShortHashCode, + pub subject: Option<String>, + pub amount_debit: Amount, + pub amount_credit: Amount, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CashoutResponse { + pub cashout_id: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Cashouts { + pub cashouts: Vec<CashoutInfo>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CashoutInfo { + pub cashout_id: u64, + pub status: CashoutStatus, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct GlobalCashouts { + pub cashouts: Vec<GlobalCashoutInfo>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct GlobalCashoutInfo { + pub cashout_id: u64, + pub username: CompactString, + pub status: CashoutStatus, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CashoutStatusResponse { + pub amount_debit: Amount, + pub amount_credit: Amount, + pub subject: String, + pub creation_time: TalerTimestamp, + pub confirmation_time: Option<TalerTimestamp>, +} + +pub fn cashout_api(state: Arc<BankState>) -> Router<Arc<BankState>> { + Router::new() + .route( + "/cashouts", + get( + async |_: AdminRAuth, + Query(params): Query<PageParams>, + State(state): State<Arc<BankState>>| { + let params = params.check()?; + + let cashouts = page_all(&state.db, &params).await?; + if cashouts.is_empty() { + ApiResult::Ok(NoContent.into_response()) + } else { + Ok(Json(GlobalCashouts { cashouts }).into_response()) + } + }, + ), + ) + .route( + "/accounts/{username}/cashouts", + post( + async |State(state): State<Arc<BankState>>, + MfaReq { mut auth, req, mfa }: MfaReq<CashoutOp>| { + state.cfg.check_regio(&req.amount_debit)?; + state.cfg.check_fiat(&req.amount_credit)?; + match create( + &state.db, + &auth.username, + &req.request_uid, + &req.amount_debit, + &req.amount_credit, + &req.subject.unwrap_or_default(), + &Timestamp::now(), + mfa.is_2fa(), + ) + .await? + { + CreationResult::Success(cashout_id) => { + Ok(Json(CashoutResponse { cashout_id }).into_response()) + } + CreationResult::UnderMin => { + Err(failure_code(ErrorCode::BANK_CONVERSION_AMOUNT_TO_SMALL)) + } + CreationResult::BadConversion => { + Err(failure_code(ErrorCode::BANK_BAD_CONVERSION)) + } + CreationResult::AccountNotFound => { + Err(failure_code(ErrorCode::BANK_UNKNOWN_ACCOUNT)) + } + CreationResult::AccountIsExchange => { + Err(failure_code(ErrorCode::BANK_ACCOUNT_IS_EXCHANGE)) + } + CreationResult::BalanceInsufficient => { + Err(failure_code(ErrorCode::BANK_UNALLOWED_DEBIT)) + } + CreationResult::RequestUidReuse => { + Err(failure_code(ErrorCode::BANK_TRANSFER_REQUEST_UID_REUSED)) + } + CreationResult::NoCashoutPayto => { + Err(failure_code(ErrorCode::BANK_CONFIRM_INCOMPLETE)) + } + CreationResult::TanRequired => Ok(mfa + .response_mfa(&mut auth, &state.db, &state.cfg.ctx) + .await? + .into_response()), + } + }, + ) + .get( + async |auth: UserRWAuth, + Query(params): Query<PageParams>, + State(state): State<Arc<BankState>>| { + let params = params.check()?; + + let cashouts = page_for_user(&state.db, &auth.username, &params).await?; + if cashouts.is_empty() { + ApiResult::Ok(NoContent.into_response()) + } else { + Ok(Json(Cashouts { cashouts }).into_response()) + } + }, + ), + ) + .route( + "/accounts/{username}/cashouts/{id}", + get( + async |Path((_, id)): Path<(CompactString, u64)>, + auth: UserRWAuth, + State(state): State<Arc<BankState>>| { + match get_for_user( + &state.db, + &state.cfg.regional_currency, + state.cfg.fiat_currency().unwrap(), + &auth.username, + id, + ) + .await? + { + Some(res) => Ok(Json(res)), + None => Err(failure_code(ErrorCode::BANK_TRANSACTION_NOT_FOUND)), + } + }, + ), + ) + .layer(middleware::from_fn_with_state( + state, + async |State(state): State<Arc<BankState>>, req, next: Next| { + if state.cfg.fiat.is_none() { + not_implemented().into_response() + } else { + next.run(req).await + } + }, + )) +} + +#[cfg(test)] +pub mod test { + use axum::http::Method; + use taler_common::{api_common::ShortHashCode, error_code::ErrorCode, types::amount::amount}; + use taler_test_utils::{ + json, + routine::{Page, routine_pagination}, + server::TestServer, + tasks, + }; + + use crate::api::{ + cashout::{CashoutResponse, CashoutStatusResponse, Cashouts, GlobalCashouts}, + conversion::ConversionRateClassResponse, + test::{Auth, MfaRequest, bank_setup}, + }; + + #[tokio::test] + async fn cashout() { + let ctx = bank_setup().await; + + ctx.auth_routine(Method::POST, "/accounts/merchant/cashouts", Auth::UserOnly) + .await; + ctx.auth_routine( + Method::GET, + "/accounts/merchant/cashouts/42", + Auth::UserOrAdmin, + ) + .await; + ctx.auth_routine( + Method::GET, + "/accounts/merchant/cashouts", + Auth::UserOrAdmin, + ) + .await; + ctx.auth_routine(Method::GET, "/cashouts", Auth::Admin) + .await; + + let str = "KUDOS:1.5"; + let debit = amount(str); + let credit = ctx.convert(str).await; + + let req = json!({ + "request_uid": ShortHashCode::rand(), + "amount_debit": debit, + "amount_credit": credit, + "subject": "test subject" + }); + + // Missing info + ctx.posta("/accounts/customer/cashouts") + .json(&req) + .await + .assert_error(ErrorCode::BANK_CONFIRM_INCOMPLETE); + + ctx.fill_cashout_info("customer").await; + + // Ok + let res = ctx + .posta("/accounts/customer/cashouts") + .json(&req) + .await + .assert_ok_json::<CashoutResponse>(); + // Idempotent + assert_eq!( + res, + ctx.posta("/accounts/customer/cashouts") + .json(&req) + .await + .assert_ok_json::<CashoutResponse>() + ); + + // Trigger conflict due to reused request_uid + ctx.posta("/accounts/customer/cashouts") + .json(json!(req + { + "amount_debit": "KUDOS:2", + "amount_credit": ctx.convert("KUDOS:2").await + })) + .await + .assert_error(ErrorCode::BANK_TRANSFER_REQUEST_UID_REUSED); + + // Check exchange account + ctx.posta("/accounts/exchange/cashouts") + .json(&req) + .await + .assert_error(ErrorCode::BANK_ACCOUNT_IS_EXCHANGE); + + // Check insufficient fund + ctx.posta("/accounts/customer/cashouts") + .json(json!({ + "request_uid": ShortHashCode::rand(), + "amount_debit": "KUDOS:75", + "amount_credit": ctx.convert("KUDOS:75").await, + })) + .await + .assert_error(ErrorCode::BANK_UNALLOWED_DEBIT); + + // Check wrong conversion + ctx.posta("/accounts/customer/cashouts") + .json(json!(req + { + "amount_credit": ctx.convert("KUDOS:2").await + })) + .await + .assert_error(ErrorCode::BANK_BAD_CONVERSION); + + // Check min amount + ctx.posta("/accounts/customer/cashouts") + .json(json!(req + { "amount_debit": "KUDOS:0.09" })) + .await + .assert_error(ErrorCode::BANK_CONVERSION_AMOUNT_TO_SMALL); + + // Check custom min account + let id = ctx + .post_admin("/conversion-rate-classes") + .json(json!({ + "name": "Custom class", + "cashout_min_amount": "KUDOS:10" + })) + .await + .assert_ok_json::<ConversionRateClassResponse>() + .conversion_rate_class_id; + ctx.patch_admin("/accounts/customer") + .json(json!({ + "conversion_rate_class_id": id + })) + .await + .assert_no_content(); + ctx.posta("/accounts/customer/cashouts") + .json(json!(req + { + "amount_debit": "KUDOS:5", + "amount_credit": ctx.convert("KUDOS:5").await + })) + .await + .assert_error(ErrorCode::BANK_CONVERSION_AMOUNT_TO_SMALL); + ctx.patch_admin("/accounts/customer") + .json(json!({ + "conversion_rate_class_id": () + })) + .await + .assert_no_content(); + + // Wrong currency + ctx.posta("/accounts/customer/cashouts") + .json(json!(req + { "amount_debit": "EUR:1" })) + .await + .assert_error(ErrorCode::GENERIC_CURRENCY_MISMATCH); + ctx.posta("/accounts/customer/cashouts") + .json(json!(req + { "amount_credit": "KUDOS:1" })) + .await + .assert_error(ErrorCode::GENERIC_CURRENCY_MISMATCH); + + // 2fa + ctx.fill_tan_info("merchant").await; + ctx.fill_cashout_info("merchant").await; + ctx.assert_balance("merchant", "0").await; + ctx.posta("/accounts/merchant/cashouts") + .json(json!(req + { "request_uid": ShortHashCode::rand() })) + .await + .assert_challenge_check(&ctx, async |_| ctx.assert_balance("merchant", "0").await) + .await + .assert_ok_json::<CashoutResponse>(); + ctx.assert_balance("merchant", "-1.5").await; + + // Get + let c = ctx + .geta(format!("/accounts/customer/cashouts/{}", res.cashout_id)) + .await + .assert_ok_json::<CashoutStatusResponse>(); + assert_eq!( + c, + CashoutStatusResponse { + amount_debit: debit, + amount_credit: credit, + subject: "test subject".into(), + creation_time: c.creation_time, + confirmation_time: Some(c.creation_time) + } + ); + + // Bad ID + ctx.geta("/accounts/customer/cashouts/chocolate") + .await + .assert_error(ErrorCode::GENERIC_PATH_SEGMENT_MALFORMED); + + // Unknown + ctx.geta("/accounts/customer/cashouts/42") + .await + .assert_error(ErrorCode::BANK_TRANSACTION_NOT_FOUND); + + // Another user's operation + ctx.geta(format!("/accounts/merchant/cashouts/{}", res.cashout_id)) + .await + .assert_error(ErrorCode::BANK_TRANSACTION_NOT_FOUND); + + // History + let router = ctx.admin_router().await; + let c = &ctx; + routine_pagination::<Cashouts>( + &router, + "/accounts/customer/cashouts", + tasks!({ + c.cashout("KUDOS:0.1").await; + }), + ) + .await; + routine_pagination::<Cashouts>( + &router, + "/cashouts", + tasks!({ + c.cashout("KUDOS:0.1").await; + }), + ) + .await; + + // Not implemnted + let ctx = ctx.swap_cfg("test_no_conversion.conf").await; + ctx.get("/accounts/customer/cashouts") + .await + .assert_not_implemented(); + } + + impl Page for Cashouts { + fn ids(&self) -> Vec<i64> { + self.cashouts + .iter() + .map(|it| it.cashout_id as i64) + .collect() + } + } + + impl Page for GlobalCashouts { + fn ids(&self) -> Vec<i64> { + self.cashouts + .iter() + .map(|it| it.cashout_id as i64) + .collect() + } + } +} diff --git a/crates/libeufin-bank/src/api/tx.rs b/crates/libeufin-bank/src/api/tx.rs @@ -529,6 +529,8 @@ pub mod test { ) .await; + ctx.fill_cashout_info("customer").await; + routine_history::<BankAccountTransactionsResponse>( &ctx.admin_router().await, "/accounts/customer/transactions", @@ -537,7 +539,8 @@ pub mod test { { ctx.tx("merchant", "0.1", "customer").await }, // Outgoing { ctx.tx("customer", "0.1", "merchant").await }, - // TODO cashout + // Cashout from merchant + { ctx.cashout("KUDOS:0.1").await } ), tasks!( // Other account diff --git a/crates/libeufin-bank/src/db.rs b/crates/libeufin-bank/src/db.rs @@ -26,6 +26,7 @@ use tokio::join; use uuid::Uuid; pub mod account; +pub mod cashout; pub mod conversion; pub mod gc; pub mod tan; diff --git a/crates/libeufin-bank/src/db/cashout.rs b/crates/libeufin-bank/src/db/cashout.rs @@ -0,0 +1,212 @@ +/* +* 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 jiff::Timestamp; +use sqlx::{ + Arguments, PgPool, QueryBuilder, Row, + postgres::{PgArguments, PgRow}, +}; +use taler_api::{ + db::{BindHelper, TypeHelper, page}, + serialized, +}; +use taler_common::{ + api_common::ShortHashCode, + api_params::Page, + types::amount::{Amount, Currency}, +}; + +use crate::api::cashout::{CashoutInfo, CashoutStatus, CashoutStatusResponse, GlobalCashoutInfo}; + +/** Result of cashout operation creation */ +pub enum CreationResult { + Success(u64), + UnderMin, + BadConversion, + AccountNotFound, + AccountIsExchange, + BalanceInsufficient, + RequestUidReuse, + NoCashoutPayto, + TanRequired, +} + +/** Create a new cashout operation */ +pub async fn create( + db: &PgPool, + username: &str, + request_uid: &ShortHashCode, + debit: &Amount, + credit: &Amount, + subject: &str, + timestamp: &Timestamp, + is2fa: bool, +) -> sqlx::Result<CreationResult> { + serialized!( + sqlx::query( + " + SELECT + out_bad_conversion, + out_account_not_found, + out_account_is_exchange, + out_balance_insufficient, + out_request_uid_reuse, + out_no_cashout_payto, + out_tan_required, + out_cashout_id, + out_under_min + FROM cashout_create($1,$2,$3,$4,$5,$6,$7) + ", + ) + .bind(username) + .bind(request_uid) + .bind(debit) + .bind(credit) + .bind(subject) + .bind_timestamp(timestamp) + .bind(is2fa) + .try_map(|r: PgRow| { + Ok(if r.try_get_flag("out_under_min")? { + CreationResult::UnderMin + } else if r.try_get_flag("out_bad_conversion")? { + CreationResult::BadConversion + } else if r.try_get_flag("out_account_not_found")? { + CreationResult::AccountNotFound + } else if r.try_get_flag("out_account_is_exchange")? { + CreationResult::AccountIsExchange + } else if r.try_get_flag("out_balance_insufficient")? { + CreationResult::BalanceInsufficient + } else if r.try_get_flag("out_request_uid_reuse")? { + CreationResult::RequestUidReuse + } else if r.try_get_flag("out_no_cashout_payto")? { + CreationResult::NoCashoutPayto + } else if r.try_get_flag("out_tan_required")? { + CreationResult::TanRequired + } else { + CreationResult::Success(r.try_get_u64("out_cashout_id")?) + }) + }) + .fetch_one(db) + ) +} + +/** Get status of cashout operation [id] owned by [username] */ +pub async fn get_for_user( + db: &PgPool, + regional: &Currency, + fiat: &Currency, + username: &str, + id: u64, +) -> sqlx::Result<Option<CashoutStatusResponse>> { + serialized!( + sqlx::query( + " + SELECT + amount_debit + ,amount_credit + ,cashout_operations.subject + ,creation_time + ,transaction_date as confirmation_date + FROM cashout_operations + JOIN bank_accounts ON bank_account=bank_account_id + JOIN customers ON owning_customer_id=customer_id + LEFT JOIN bank_account_transactions ON local_transaction=bank_transaction_id + WHERE cashout_id=$1 AND username=$2 + ", + ) + .bind(id as i64) + .bind(username) + .try_map(|r: PgRow| { + Ok(CashoutStatusResponse { + amount_debit: r.try_get_amount("amount_debit", regional)?, + amount_credit: r.try_get_amount("amount_credit", fiat)?, + subject: r.try_get("subject")?, + creation_time: r.try_get_timestamp("creation_time")?.into(), + confirmation_time: r + .try_get_opt_timestamp("confirmation_date")? + .map(Into::into), + }) + }) + .fetch_optional(db) + ) +} + +/** Get a page of all cashout operations */ +pub async fn page_all(db: &PgPool, params: &Page) -> sqlx::Result<Vec<GlobalCashoutInfo>> { + page( + db, + params, + "cashout_id", + || { + QueryBuilder::new( + " + SELECT cashout_id, username + FROM cashout_operations + JOIN bank_accounts ON bank_account=bank_account_id + JOIN customers ON owning_customer_id=customer_id + WHERE + ", + ) + }, + |r| { + Ok(GlobalCashoutInfo { + cashout_id: r.try_get_u64("cashout_id")?, + username: r.try_get("username")?, + status: CashoutStatus::confirmed, + }) + }, + ) + .await +} + +/** Get a page of all cashout operations */ +pub async fn page_for_user( + db: &PgPool, + username: &str, + params: &Page, +) -> sqlx::Result<Vec<CashoutInfo>> { + page( + db, + params, + "cashout_id", + || { + let mut params = PgArguments::default(); + params.add(username).unwrap(); + QueryBuilder::with_arguments( + " + SELECT cashout_id + FROM cashout_operations + WHERE bank_account=( + SELECT bank_account_id + FROM bank_accounts JOIN customers ON owning_customer_id=customer_id + WHERE deleted_at IS NULL AND username = $1 + ) AND + ", + params, + ) + }, + |r| { + Ok(CashoutInfo { + cashout_id: r.try_get_u64("cashout_id")?, + status: CashoutStatus::confirmed, + }) + }, + ) + .await +} diff --git a/crates/libeufin-bank/src/mfa.rs b/crates/libeufin-bank/src/mfa.rs @@ -45,6 +45,7 @@ use crate::{ api::{ BankState, account::{AccountPasswordChange, AccountReconfiguration, TanInfo}, + cashout::CashoutRequest, tan::{Challenge, ChallengeResponse}, token::TokenRequest, tx::TransactionCreateRequest, @@ -126,6 +127,14 @@ impl MfaOp for AccountPasswordOp { type Body = AccountPasswordChange; } +pub struct CashoutOp; + +impl MfaOp for CashoutOp { + const OP: Operation = Operation::cashout; + type Scope = UserORWScope; + type Body = CashoutRequest; +} + 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-nexus/Cargo.toml b/crates/libeufin-nexus/Cargo.toml @@ -34,4 +34,4 @@ zip = { version = "8.5", default-features = false, features = [ ] } tracing-subscriber = "0.3" owo-colors = "4.3" -shlex = "1.3" +shlex = "2.0"