commit 074ca9f6bfe70ed563ed52ea70b5963b6b14fbe6
parent 2ea416cb182f1b0990a6c6a203c3d909f1457d7f
Author: Antoine A <>
Date: Tue, 19 May 2026 12:41:50 +0200
bank: cashout API
Diffstat:
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, ¶ms).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, ¶ms).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"