libeufin

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

commit 8e4cb6c709c79554eb5de5ad980aea669cc639b4
parent 074ca9f6bfe70ed563ed52ea70b5963b6b14fbe6
Author: Antoine A <>
Date:   Wed, 20 May 2026 11:44:59 +0200

bank: add withdrawal API

Diffstat:
MCargo.lock | 10+++++-----
Mcrates/libeufin-bank/src/api.rs | 26++++++++++++++++++++++++--
Mcrates/libeufin-bank/src/api/cashout.rs | 10+++++-----
Mcrates/libeufin-bank/src/api/tan.rs | 6+++---
Mcrates/libeufin-bank/src/api/token.rs | 3+--
Mcrates/libeufin-bank/src/api/tx.rs | 3+--
Acrates/libeufin-bank/src/api/withdrawal.rs | 1142+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/libeufin-bank/src/auth.rs | 1+
Mcrates/libeufin-bank/src/config.rs | 31+++++++++++++++++++++++++++++++
Mcrates/libeufin-bank/src/db.rs | 69++++++++++++++++++++++++++++++++++++---------------------------------
Acrates/libeufin-bank/src/db/withdrawal.rs | 440+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/libeufin-bank/src/mfa.rs | 9+++++++++
Mcrates/libeufin-bank/src/payto.rs | 19+++++++++++++++++--
13 files changed, 1715 insertions(+), 54 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -418,11 +418,11 @@ dependencies = [ [[package]] name = "cipher" -version = "0.5.1" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e34d8227fe1ba289043aeb13792056ff80fd6de1a9f49137a5f499de8e8c78ea" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ - "crypto-common 0.2.1", + "crypto-common 0.2.2", "inout", ] @@ -684,9 +684,9 @@ dependencies = [ [[package]] name = "crypto-common" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ "hybrid-array", ] diff --git a/crates/libeufin-bank/src/api.rs b/crates/libeufin-bank/src/api.rs @@ -19,8 +19,9 @@ use sqlx::PgPool; use taler_api::notification::NotificationChannel; +use uuid::Uuid; -use crate::{config::BankCfg, db::notification_listener}; +use crate::{api::withdrawal::WithdrawalStatus, config::BankCfg, db::notification_listener}; pub mod account; pub mod cashout; @@ -28,6 +29,7 @@ pub mod conversion; pub mod tan; pub mod token; pub mod tx; +pub mod withdrawal; pub struct BankState { pub db: PgPool, @@ -36,6 +38,7 @@ pub struct BankState { pub taler_out_channel: NotificationChannel<u64, i64>, pub taler_in_channel: NotificationChannel<u64, i64>, pub revenue_channel: NotificationChannel<u64, i64>, + pub withdrawal_channel: NotificationChannel<Uuid, WithdrawalStatus>, } impl BankState { @@ -44,12 +47,14 @@ impl BankState { let taler_in_channel = NotificationChannel::new(); let taler_out_channel = NotificationChannel::new(); let revenue_channel = NotificationChannel::new(); + let withdrawal_channel = NotificationChannel::new(); tokio::spawn(notification_listener( pool.clone(), tx_channel.clone(), taler_out_channel.clone(), taler_in_channel.clone(), revenue_channel.clone(), + withdrawal_channel.clone(), )); Self { cfg, @@ -58,6 +63,7 @@ impl BankState { taler_in_channel, taler_out_channel, revenue_channel, + withdrawal_channel, } } } @@ -78,7 +84,7 @@ pub mod test { use sqlx::{PgPool, Pool, Postgres, pool::PoolConnection}; use taler_api::api::TalerRouter; use taler_common::{ - api_common::{HashCode, ShortHashCode}, + api_common::{EddsaPublicKey, HashCode, ShortHashCode}, config::Config, error_code::ErrorCode, types::{ @@ -91,6 +97,7 @@ pub mod test { json, server::{TestRequest, TestResponse, TestServer as _}, }; + use uuid::Uuid; use crate::{ CONFIG_SOURCE, @@ -105,6 +112,7 @@ pub mod test { tan::{ChallengeResponse, tan_api}, token::token_api, tx::tx_api, + withdrawal::withdrawal_api, }, config::BankCfg, db::{self, account::CreationResult}, @@ -147,6 +155,7 @@ pub mod test { .merge(tan_api()) .merge(conversion_api(state.clone())) .merge(cashout_api(state.clone())) + .merge(withdrawal_api()) .with_state(state.clone()) .finalize(); @@ -279,6 +288,7 @@ pub mod test { .merge(tan_api()) .merge(conversion_api(state.clone())) .merge(cashout_api(state.clone())) + .merge(withdrawal_api()) .with_state(state.clone()) .finalize(); self.state = state; @@ -532,6 +542,18 @@ pub mod test { .amount_credit } + pub async fn withdraw_select(&self, uuid: Uuid) -> EddsaPublicKey { + let key = EddsaPublicKey::rand(); + self.post(format!("/taler-integration/withdrawal-operation/{uuid}")) + .json(json!({ + "reserve_pub": key, + "selected_exchange": self.exchange_payto + })) + .await + .assert_ok(); + key + } + /** 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/cashout.rs b/crates/libeufin-bank/src/api/cashout.rs @@ -105,8 +105,8 @@ pub fn cashout_api(state: Arc<BankState>) -> Router<Arc<BankState>> { .route( "/cashouts", get( - async |_: AdminRAuth, - Query(params): Query<PageParams>, + async |Query(params): Query<PageParams>, + _: AdminRAuth, State(state): State<Arc<BankState>>| { let params = params.check()?; @@ -170,8 +170,8 @@ pub fn cashout_api(state: Arc<BankState>) -> Router<Arc<BankState>> { }, ) .get( - async |auth: UserRWAuth, - Query(params): Query<PageParams>, + async |Query(params): Query<PageParams>, + auth: UserRWAuth, State(state): State<Arc<BankState>>| { let params = params.check()?; @@ -187,7 +187,7 @@ pub fn cashout_api(state: Arc<BankState>) -> Router<Arc<BankState>> { .route( "/accounts/{username}/cashouts/{id}", get( - async |Path((_, id)): Path<(CompactString, u64)>, + async |Path((_, id)): Path<((), u64)>, auth: UserRWAuth, State(state): State<Arc<BankState>>| { match get_for_user( diff --git a/crates/libeufin-bank/src/api/tan.rs b/crates/libeufin-bank/src/api/tan.rs @@ -72,7 +72,7 @@ pub struct ChallengeSolve { #[axum::debug_handler] pub async fn tmp( State(state): State<Arc<BankState>>, - Path((_, id)): Path<(CompactString, Uuid)>, + Path((_, id)): Path<((), Uuid)>, ) -> ApiResult<Response> { match send(&state.db, &id, &Timestamp::now(), MAX_ACTIVE_CHALLENGES).await? { SendResult::NotFound => Err(failure_code(ErrorCode::BANK_TRANSACTION_NOT_FOUND)), @@ -148,7 +148,7 @@ pub fn tan_api() -> Router<Arc<BankState>> { "/accounts/{username}/challenge/{id}", post( async |State(state): State<Arc<BankState>>, - Path((_, id)): Path<(CompactString, Uuid)>| { + Path((_, id)): Path<((), Uuid)>| { match send(&state.db, &id, &Timestamp::now(), MAX_ACTIVE_CHALLENGES).await? { SendResult::NotFound => Err(failure_code(ErrorCode::BANK_TRANSACTION_NOT_FOUND)), SendResult::Expired => Err(failure_code(ErrorCode::BANK_TAN_CHALLENGE_EXPIRED)), @@ -223,7 +223,7 @@ pub fn tan_api() -> Router<Arc<BankState>> { post( async | State(state): State<Arc<BankState>>, - Path((_, id)): Path<(CompactString, Uuid)>, + Path((_, id)): Path<((), Uuid)>, Req(req): Req<ChallengeSolve> | { let code = req.tan.strip_prefix("T-").unwrap_or(&req.tan); diff --git a/crates/libeufin-bank/src/api/token.rs b/crates/libeufin-bank/src/api/token.rs @@ -25,7 +25,6 @@ use axum::{ response::{IntoResponse, NoContent}, routing::{delete, get, post}, }; -use compact_str::CompactString; use jiff::Timestamp; use serde::{Deserialize, Serialize}; use taler_api::{ @@ -159,7 +158,7 @@ pub fn token_api() -> Router<Arc<BankState>> { "/accounts/{username}/tokens/{id}", delete( async |UserAuth { username, .. }: UserRWAuth, - Path((_, id)): Path<(CompactString, u64)>, + Path((_, id)): Path<((), u64)>, State(state): State<Arc<BankState>>| { if db::token::delete_by_id(&state.db, &username, id).await? { ApiResult::Ok(NoContent) diff --git a/crates/libeufin-bank/src/api/tx.rs b/crates/libeufin-bank/src/api/tx.rs @@ -27,7 +27,6 @@ use axum::{ response::{IntoResponse, NoContent}, routing::{get, post}, }; -use compact_str::CompactString; use jiff::Timestamp; use serde::{Deserialize, Serialize}; use taler_api::{ @@ -178,7 +177,7 @@ pub fn tx_api() -> Router<Arc<BankState>> { "/accounts/{username}/transactions/{id}", get( async |auth: UserRAuth, - Path((_, id)): Path<(CompactString, u64)>, + Path((_, id)): Path<((), u64)>, State(state): State<Arc<BankState>>| { if let Some(tx) = get_by_id( &state.db, diff --git a/crates/libeufin-bank/src/api/withdrawal.rs b/crates/libeufin-bank/src/api/withdrawal.rs @@ -0,0 +1,1142 @@ +/* +* 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, + response::{IntoResponse, NoContent}, + routing::{get, post}, +}; +use compact_str::CompactString; +use jiff::Timestamp; +use serde::{Deserialize, Serialize}; +use taler_api::{ + error::{ApiResult, failure, failure_code}, + extract::{Path, Query, Req}, +}; +use taler_common::{ + api_common::EddsaPublicKey, + api_params::{Pooling, PoolingParams}, + error_code::ErrorCode, + types::amount::{Amount, Currency}, +}; +use uuid::Uuid; + +use crate::{ + api::BankState, + auth::UserORWAuth, + config::CurrencySpecification, + db::withdrawal::{ + AbortResult, ConfirmationResult, CreationResult, SelectionResult, abort, confirm, create, + poll_info, poll_status, set_details, + }, + mfa::{MfaReq, WithdrawalOp}, + payto::{BankPayto, FullBankPayto}, +}; + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)] +#[sqlx(type_name = "TEXT")] +#[allow(non_camel_case_types)] +pub enum WithdrawalStatus { + #[default] + pending, + aborted, + selected, + confirmed, +} + +// Taler withdrawal request. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BankAccountCreateWithdrawalRequest { + pub amount: Option<Amount>, + pub suggested_amount: Option<Amount>, + #[serde(default)] + pub no_amount_to_wallet: bool, +} + +// Taler withdrawal response. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BankAccountCreateWithdrawalResponse { + pub withdrawal_id: Uuid, + pub taler_withdraw_uri: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WithdrawalPublicInfo { + pub status: WithdrawalStatus, + pub amount: Option<Amount>, + pub suggested_amount: Option<Amount>, + #[serde(default)] + pub no_amount_to_wallet: bool, + pub username: CompactString, + pub selected_reserve_pub: Option<EddsaPublicKey>, + pub selected_exchange_account: Option<FullBankPayto>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BankWithdrawalOperationStatus { + pub status: WithdrawalStatus, + pub amount: Option<Amount>, + pub suggested_amount: Option<Amount>, + pub min_amount: Option<Amount>, + pub max_amount: Option<Amount>, + pub card_fees: Option<Amount>, + pub sender_wire: Option<FullBankPayto>, + pub suggested_exchange: Option<String>, + pub required_exchange: Option<FullBankPayto>, + pub confirm_transfer_url: Option<String>, + pub wire_types: Vec<CompactString>, + pub selected_reserve_pub: Option<EddsaPublicKey>, + pub selected_exchange_account: Option<FullBankPayto>, + #[serde(default)] + pub no_amount_to_wallet: bool, + pub currency: Option<Currency>, + // TODO deprecated remove in the next breaking release + pub aborted: bool, + pub selection_done: bool, + pub transfer_done: bool, +} + +/** + * Selection request on a Taler withdrawal. + */ +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BankWithdrawalOperationPostRequest { + pub reserve_pub: EddsaPublicKey, + pub selected_exchange: BankPayto, + pub amount: Option<Amount>, +} + +/** + * Response to the wallet after it selects the exchange + * and the reserve pub. + */ +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BankWithdrawalOperationPostResponse { + pub status: WithdrawalStatus, + pub confirm_transfer_url: Option<String>, + // TODO deprecated remove in the next breaking release + pub transfer_done: bool, +} + +// Request POST /accounts/{USERNAME}/withdrawals/{WITHDRAWAL_ID}/confirm +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BankAccountConfirmWithdrawalRequest { + pub amount: Option<Amount>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct TalerIntegrationConfigResponse<'a> { + pub name: &'a str, + pub version: &'a str, + pub currency: &'a Currency, + pub currency_specification: &'a CurrencySpecification, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct StatusParams { + #[serde(flatten)] + pub polling: PoolingParams, + pub old_state: Option<WithdrawalStatus>, +} + +impl StatusParams { + pub fn check(self) -> ApiResult<Status> { + Ok(Status { + polling: self.polling.check()?, + status: self.old_state.unwrap_or(WithdrawalStatus::pending), + }) + } +} + +pub struct Status { + pub polling: Pooling, + pub status: WithdrawalStatus, +} + +pub fn withdrawal_api() -> Router<Arc<BankState>> { + Router::new() + .route( + "/accounts/{username}/withdrawals", + post( + async |auth: UserORWAuth, + State(state): State<Arc<BankState>>, + Req(req): Req<BankAccountCreateWithdrawalRequest>| { + if let Some(amount) = &req.amount { + state.cfg.check_regio(amount)?; + } + if let Some(amount) = &req.suggested_amount { + state.cfg.check_regio(amount)?; + } + let uuid = Uuid::new_v4(); + match create( + &state.db, + &auth.username, + uuid, + req.amount, + req.suggested_amount, + req.no_amount_to_wallet, + &Timestamp::now(), + state.cfg.wire_transfer_fees, + state.cfg.min_amount, + state.cfg.max_amount, + ) + .await? + { + CreationResult::Success => Ok(Json(BankAccountCreateWithdrawalResponse { + withdrawal_id: uuid, + taler_withdraw_uri: state.cfg.taler_withdraw_uri(uuid), + })), + CreationResult::UnknownAccount => { + Err(failure_code(ErrorCode::BANK_UNKNOWN_ACCOUNT)) + } + CreationResult::AccountIsExchange => { + Err(failure_code(ErrorCode::BANK_ACCOUNT_IS_EXCHANGE)) + } + CreationResult::BalanceInsufficient => Err(failure( + ErrorCode::BANK_UNALLOWED_DEBIT, + "Insufficient funds to withdraw with Taler", + )), + CreationResult::BadAmount => Err(failure( + ErrorCode::BANK_UNALLOWED_DEBIT, + "Amount either to high or too low", + )), + } + }, + ), + ) + .route( + "/accounts/{username}/withdrawals/{uuid}/confirm", + post( + async |Path((_, uuid)): Path<((), Uuid)>, + State(state): State<Arc<BankState>>, + MfaReq { mut auth, req, mfa }: MfaReq<WithdrawalOp>| { + if let Some(amount) = &req.amount { + state.cfg.check_regio(amount)?; + } + match confirm( + &state.db, + &auth.username, + uuid, + &Timestamp::now(), + req.amount, + mfa.is_2fa(), + state.cfg.wire_transfer_fees, + state.cfg.min_amount, + state.cfg.max_amount, + ) + .await? + { + ConfirmationResult::Success => Ok(NoContent.into_response()), + ConfirmationResult::UnknownOperation => { + Err(failure_code(ErrorCode::BANK_TRANSACTION_NOT_FOUND)) + } + ConfirmationResult::BalanceInsufficient => Err(failure( + ErrorCode::BANK_UNALLOWED_DEBIT, + "Insufficient funds to withdraw with Taler", + )), + ConfirmationResult::BadAmount => Err(failure( + ErrorCode::BANK_UNALLOWED_DEBIT, + "Amount either to high or too low", + )), + ConfirmationResult::NotSelected => { + Err(failure_code(ErrorCode::BANK_CONFIRM_INCOMPLETE)) + } + ConfirmationResult::AlreadyAborted => { + Err(failure_code(ErrorCode::BANK_CONFIRM_ABORT_CONFLICT)) + } + ConfirmationResult::TanRequired => Ok(mfa + .response_mfa(&mut auth, &state.db, &state.cfg.ctx) + .await? + .into_response()), + ConfirmationResult::MissingAmount => { + Err(failure_code(ErrorCode::BANK_AMOUNT_REQUIRED)) + } + ConfirmationResult::AmountDiffers => { + Err(failure_code(ErrorCode::BANK_AMOUNT_DIFFERS)) + } + ConfirmationResult::ReservePubReuse => { + Err(failure_code(ErrorCode::BANK_DUPLICATE_RESERVE_PUB_SUBJECT)) + } + } + }, + ), + ) + .route( + "/accounts/{username}/withdrawals/{uuid}/abort", + post( + async |Path((_, uuid)): Path<((), Uuid)>, + _: UserORWAuth, + State(state): State<Arc<BankState>>| { + match abort(&state.db, uuid).await? { + AbortResult::UnknownOperation => { + Err(failure_code(ErrorCode::BANK_TRANSACTION_NOT_FOUND)) + } + AbortResult::AlreadyConfirmed => { + Err(failure_code(ErrorCode::BANK_ABORT_CONFIRM_CONFLICT)) + } + AbortResult::Success => Ok(NoContent), + } + }, + ), + ) + .route( + "/withdrawals/{uuid}", + get( + async |Path(uuid): Path<Uuid>, + Query(params): Query<StatusParams>, + State(state): State<Arc<BankState>>| { + let params = params.check()?; + match poll_info( + &state.db, + &state.cfg.ctx, + &state.cfg.regional_currency, + &state.withdrawal_channel, + &params, + uuid, + ) + .await? + { + Some(status) => Ok(Json(status)), + None => Err(failure_code(ErrorCode::BANK_TRANSACTION_NOT_FOUND)), + } + }, + ), + ) + .route( + "/taler-integration/config", + get(async |State(state): State<Arc<BankState>>| { + Json(TalerIntegrationConfigResponse { + name: "taler-bank-integration", + version: "5:0:5", + currency: &state.cfg.regional_currency, + currency_specification: &state.cfg.regional_currency_spec, + }) + .into_response() + }), + ) + .route( + "/taler-integration/withdrawal-operation/{wopid}", + post( + async |Path(uuid): Path<Uuid>, + State(state): State<Arc<BankState>>, + Req(req): Req<BankWithdrawalOperationPostRequest>| { + if let Some(amount) = &req.amount { + state.cfg.check_regio(amount)?; + } + + match set_details( + &state.db, + uuid, + &req.selected_exchange, + &req.reserve_pub, + req.amount, + state.cfg.wire_transfer_fees, + state.cfg.min_amount, + state.cfg.max_amount, + ) + .await? + { + SelectionResult::Success(status) => { + Ok(Json(BankWithdrawalOperationPostResponse { + confirm_transfer_url: if matches!( + status, + WithdrawalStatus::pending | WithdrawalStatus::selected + ) { + Some(state.cfg.withdraw_confirm_url(uuid)) + } else { + None + }, + transfer_done: status == WithdrawalStatus::confirmed, + status, + })) + } + SelectionResult::UnknownOperation => { + Err(failure_code(ErrorCode::BANK_TRANSACTION_NOT_FOUND)) + } + SelectionResult::AlreadySelected => Err(failure_code( + ErrorCode::BANK_WITHDRAWAL_OPERATION_RESERVE_SELECTION_CONFLICT, + )), + SelectionResult::ReservePubReuse => { + Err(failure_code(ErrorCode::BANK_DUPLICATE_RESERVE_PUB_SUBJECT)) + } + SelectionResult::UnknownAccount => { + Err(failure_code(ErrorCode::BANK_UNKNOWN_ACCOUNT)) + } + SelectionResult::AccountIsNotExchange => { + Err(failure_code(ErrorCode::BANK_ACCOUNT_IS_NOT_EXCHANGE)) + } + SelectionResult::AmountDiffers => { + Err(failure_code(ErrorCode::BANK_AMOUNT_DIFFERS)) + } + SelectionResult::BalanceInsufficient => Err(failure( + ErrorCode::BANK_UNALLOWED_DEBIT, + "Insufficient funds to withdraw with Taler", + )), + SelectionResult::BadAmount => Err(failure( + ErrorCode::BANK_UNALLOWED_DEBIT, + "Amount either to high or too low", + )), + SelectionResult::AlreadyAborted => { + Err(failure_code(ErrorCode::BANK_UPDATE_ABORT_CONFLICT)) + } + } + }, + ) + .get( + async |Path(uuid): Path<Uuid>, + Query(params): Query<StatusParams>, + State(state): State<Arc<BankState>>| { + let params = params.check()?; + match poll_status( + &state.db, + &state.cfg.ctx, + &state.cfg.regional_currency, + &state.withdrawal_channel, + &params, + uuid, + state.cfg.wire_method, + state.cfg.max_amount, + ) + .await? + { + Some(mut w) => { + w.suggested_exchange = state.cfg.suggested_withdrawal_exchange.clone(); + if matches!( + w.status, + WithdrawalStatus::pending | WithdrawalStatus::selected + ) { + w.confirm_transfer_url = Some(state.cfg.withdraw_confirm_url(uuid)); + } + Ok(Json(w)) + } + None => Err(failure_code(ErrorCode::BANK_TRANSACTION_NOT_FOUND)), + } + }, + ), + ) + .route( + "/taler-integration/withdrawal-operation/{uuid}/abort", + post( + async |Path(uuid): Path<Uuid>, State(state): State<Arc<BankState>>| match abort( + &state.db, uuid, + ) + .await? + { + AbortResult::UnknownOperation => { + Err(failure_code(ErrorCode::BANK_TRANSACTION_NOT_FOUND)) + } + AbortResult::AlreadyConfirmed => { + Err(failure_code(ErrorCode::BANK_ABORT_CONFIRM_CONFLICT)) + } + AbortResult::Success => Ok(NoContent), + }, + ), + ) +} + +#[cfg(test)] +mod test { + + use std::time::Duration; + + use axum::http::Method; + use serde::de::DeserializeOwned; + use taler_common::{ + api_common::EddsaPublicKey, + error_code::ErrorCode, + types::amount::{Currency, amount}, + }; + use taler_test_utils::{json, routine::assert_time, server::TestServer}; + use tokio::join; + use uuid::Uuid; + + use crate::{ + api::{ + test::{Auth, BankTestCtx, MfaRequest, bank_setup}, + withdrawal::{ + BankAccountCreateWithdrawalResponse, BankWithdrawalOperationPostResponse, + BankWithdrawalOperationStatus, WithdrawalPublicInfo, WithdrawalStatus, + }, + }, + config::WireMethod, + }; + + async fn status_routine<T: DeserializeOwned>( + ctx: &BankTestCtx, + path: &str, + status: impl Fn(&T) -> WithdrawalStatus, + ) { + let amount = "KUDOS:0.04"; + let aborted = ctx + .posta("/accounts/merchant/withdrawals") + .json(json!({ + "amount": amount + })) + .await + .assert_ok_json::<BankAccountCreateWithdrawalResponse>() + .taler_withdraw_uri + .rsplit('/') + .next() + .unwrap() + .parse() + .unwrap(); + let confirmed = ctx + .posta("/accounts/merchant/withdrawals") + .json(json!({ + "amount": amount + })) + .await + .assert_ok_json::<BankAccountCreateWithdrawalResponse>() + .taler_withdraw_uri + .rsplit('/') + .next() + .unwrap() + .parse() + .unwrap(); + + // Check no useless polling + assert_time(0..5000, async { + let res = ctx + .get(format!( + "{path}/{confirmed}?timeout_ms=1000&old_state=selected" + )) + .await + .assert_ok_json::<T>(); + assert_eq!(status(&res), WithdrawalStatus::pending); + }) + .await; + + // Polling selected + join!( + assert_time(100..500, async { + let res = ctx + .get(format!("{path}/{confirmed}?timeout_ms=1000")) + .await + .assert_ok_json::<T>(); + assert_eq!(status(&res), WithdrawalStatus::selected); + }), + assert_time(100..500, async { + let res = ctx + .get(format!("{path}/{aborted}?timeout_ms=1000")) + .await + .assert_ok_json::<T>(); + assert_eq!(status(&res), WithdrawalStatus::selected); + }), + async { + tokio::time::sleep(Duration::from_millis(100)).await; + ctx.withdraw_select(confirmed).await; + ctx.withdraw_select(aborted).await; + } + ); + + // Polling confirmed + join!( + assert_time(100..500, async { + let res = ctx + .get(format!( + "{path}/{confirmed}?timeout_ms=1000&old_state=selected" + )) + .await + .assert_ok_json::<T>(); + assert_eq!(status(&res), WithdrawalStatus::confirmed); + }), + assert_time(200..500, async { + let res = ctx + .get(format!( + "{path}/{aborted}?timeout_ms=200&old_state=selected" + )) + .await + .assert_ok_json::<T>(); + assert_eq!(status(&res), WithdrawalStatus::selected); + }), + async { + tokio::time::sleep(Duration::from_millis(100)).await; + ctx.posta(format!( + "/accounts/customer/withdrawals/{confirmed}/confirm" + )) + .json(json!({})) + .await + .assert_no_content(); + } + ); + + // Polling abort + join!( + assert_time(200..500, async { + let res = ctx + .get(format!( + "{path}/{confirmed}?timeout_ms=200&old_state=confirmed" + )) + .await + .assert_ok_json::<T>(); + assert_eq!(status(&res), WithdrawalStatus::confirmed); + }), + assert_time(100..500, async { + let res = ctx + .get(format!( + "{path}/{aborted}?timeout_ms=1000&old_state=selected" + )) + .await + .assert_ok_json::<T>(); + assert_eq!(status(&res), WithdrawalStatus::aborted); + }), + async { + tokio::time::sleep(Duration::from_millis(100)).await; + ctx.posta(format!("/accounts/customer/withdrawals/{aborted}/abort")) + .await + .assert_no_content(); + } + ); + } + + #[tokio::test] + async fn withdrawal() { + let ctx = bank_setup().await; + let unknown = Uuid::new_v4(); + + ctx.auth_routine( + Method::POST, + "/accounts/merchant/withdrawals", + Auth::UserOrAdmin, + ) + .await; + ctx.auth_routine( + Method::POST, + format!("/accounts/merchant/withdrawals/{unknown}/abort"), + Auth::UserOrAdmin, + ) + .await; + ctx.auth_routine( + Method::POST, + format!("/accounts/merchant/withdrawals/{unknown}/confirm"), + Auth::UserOrAdmin, + ) + .await; + + ctx.get("/taler-integration/config").await.assert_ok(); + + // Create + { + for (am, suggested) in [ + (None, None), + (Some("KUDOS:1.0"), None), + (None, Some("KUDOS:2.0")), + (Some("KUDOS:3.0"), Some("KUDOS:4.0")), + ] { + let res: BankAccountCreateWithdrawalResponse = ctx + .posta("/accounts/merchant/withdrawals") + .json(json!({ + "amount": am, + "suggested_amount": suggested + })) + .await + .assert_ok_json(); + let uuid = res.taler_withdraw_uri.rsplit('/').next().unwrap(); + let w: BankWithdrawalOperationStatus = ctx + .get(format!("/taler-integration/withdrawal-operation/{uuid}")) + .await + .assert_ok_json(); + assert!(!w.selection_done); + assert!(!w.aborted); + assert!(!w.transfer_done); + assert_eq!(w.card_fees, None); + assert_eq!(w.min_amount, None); + assert_eq!(w.max_amount, Some(amount("KUDOS:10"))); + assert_eq!(w.amount, am.map(|it| it.parse().unwrap())); + assert_eq!(w.suggested_amount, suggested.map(|it| it.parse().unwrap())); + assert_eq!(w.wire_types, &[WireMethod::iban.as_ref()]); + assert_eq!(w.currency, Some(Currency::KUDOS)); + } + + // Exchange account + ctx.posta("/accounts/exchange/withdrawals") + .json(json!({ "amount": "KUDOS:9" })) + .await + .assert_error(ErrorCode::BANK_ACCOUNT_IS_EXCHANGE); + // Check insufficient fund + ctx.posta("/accounts/merchant/withdrawals") + .json(json!({ "amount": "KUDOS:90" })) + .await + .assert_error(ErrorCode::BANK_UNALLOWED_DEBIT); + ctx.posta("/accounts/merchant/withdrawals") + .json(json!({ "suggested_amount": "KUDOS:90" })) + .await + .assert_error(ErrorCode::BANK_UNALLOWED_DEBIT); + // Check wrong currency + ctx.posta("/accounts/merchant/withdrawals") + .json(json!({ "amount": "EUR:90" })) + .await + .assert_error(ErrorCode::GENERIC_CURRENCY_MISMATCH); + ctx.posta("/accounts/merchant/withdrawals") + .json(json!({ "suggested_amount": "EUR:90" })) + .await + .assert_error(ErrorCode::GENERIC_CURRENCY_MISMATCH); + } + + // Bad UUID + ctx.get("/taler-integration/withdrawal-operation/chocolate") + .await + .assert_error(ErrorCode::GENERIC_PATH_SEGMENT_MALFORMED); + ctx.get("/withdrawals/chocolate") + .await + .assert_error(ErrorCode::GENERIC_PATH_SEGMENT_MALFORMED); + + // Unknown + ctx.get(format!("/taler-integration/withdrawal-operation/{unknown}")) + .await + .assert_error(ErrorCode::BANK_TRANSACTION_NOT_FOUND); + ctx.get(format!("/withdrawals/{unknown}")) + .await + .assert_error(ErrorCode::BANK_TRANSACTION_NOT_FOUND); + + // Select + { + let key = EddsaPublicKey::rand(); + let req = json!({ + "reserve_pub": key, + "selected_exchange": ctx.exchange_payto + }); + + // Bad UUID + ctx.post("/taler-integration/withdrawal-operation/chocolate") + .json(&req) + .await + .assert_error(ErrorCode::GENERIC_PATH_SEGMENT_MALFORMED); + + // Unknown + ctx.post(format!("/taler-integration/withdrawal-operation/{unknown}")) + .json(&req) + .await + .assert_error(ErrorCode::BANK_TRANSACTION_NOT_FOUND); + + let uuid = ctx + .posta("/accounts/merchant/withdrawals") + .json(json!({ "amount": "KUDOS:1"})) + .await + .assert_ok_json::<BankAccountCreateWithdrawalResponse>() + .withdrawal_id; + // OK + let res = ctx + .post(format!("/taler-integration/withdrawal-operation/{uuid}")) + .json(&req) + .await + .assert_ok_json::<BankWithdrawalOperationPostResponse>(); + assert_eq!(res.status, WithdrawalStatus::selected); + assert_eq!( + res.confirm_transfer_url, + Some(format!("http://localhost:8080/webui/#/operation/{uuid}")) + ); + // Idempotent + assert_eq!( + res, + ctx.post(format!("/taler-integration/withdrawal-operation/{uuid}")) + .json(&req) + .await + .assert_ok_json() + ); + // Already selected + ctx.post(format!("/taler-integration/withdrawal-operation/{uuid}")) + .json(json!(req + { "reserve_pub": EddsaPublicKey::rand() })) + .await + .assert_error(ErrorCode::BANK_WITHDRAWAL_OPERATION_RESERVE_SELECTION_CONFLICT); + + let uuid = ctx + .posta("/accounts/merchant/withdrawals") + .json(json!({ "amount": "KUDOS:1"})) + .await + .assert_ok_json::<BankAccountCreateWithdrawalResponse>() + .withdrawal_id; + // Reserve pub reuse + ctx.post(format!("/taler-integration/withdrawal-operation/{uuid}")) + .json(json!(req)) + .await + .assert_error(ErrorCode::BANK_DUPLICATE_RESERVE_PUB_SUBJECT); + // Amount differs + ctx.post(format!("/taler-integration/withdrawal-operation/{uuid}")) + .json(json!(req + { "amount": "KUDOS:2" })) + .await + .assert_error(ErrorCode::BANK_AMOUNT_DIFFERS); + // Unknown account + ctx.post(format!("/taler-integration/withdrawal-operation/{uuid}")) + .json(json!({ + "reserve_pub": EddsaPublicKey::rand(), + "selected_exchange": ctx.unknown_payto + })) + .await + .assert_error(ErrorCode::BANK_UNKNOWN_ACCOUNT); + // Not exchange + ctx.post(format!("/taler-integration/withdrawal-operation/{uuid}")) + .json(json!({ + "reserve_pub": EddsaPublicKey::rand(), + "selected_exchange": ctx.merchant_payto + })) + .await + .assert_error(ErrorCode::BANK_ACCOUNT_IS_NOT_EXCHANGE); + + ctx.post(format!("/taler-integration/withdrawal-operation/{uuid}")) + .json(json!({ + "reserve_pub": EddsaPublicKey::rand(), + "selected_exchange": ctx.exchange_payto, + "amount": "KUDOS:1" + })) + .await + .assert_ok(); + + // Check select aborted + let uuid = ctx + .posta("/accounts/merchant/withdrawals") + .json(json!({ "amount": "KUDOS:1"})) + .await + .assert_ok_json::<BankAccountCreateWithdrawalResponse>() + .withdrawal_id; + ctx.post(format!( + "/taler-integration/withdrawal-operation/{uuid}/abort" + )) + .await + .assert_no_content(); + ctx.post(format!("/taler-integration/withdrawal-operation/{uuid}")) + .json(json!({ + "reserve_pub": EddsaPublicKey::rand(), + "selected_exchange": ctx.exchange_payto + })) + .await + .assert_error(ErrorCode::BANK_UPDATE_ABORT_CONFLICT); + + // Insufficient fund + let uuid = ctx + .posta("/accounts/merchant/withdrawals") + .json(json!({})) + .await + .assert_ok_json::<BankAccountCreateWithdrawalResponse>() + .withdrawal_id; + ctx.post(format!("/taler-integration/withdrawal-operation/{uuid}")) + .json(json!({ + "reserve_pub": EddsaPublicKey::rand(), + "selected_exchange": ctx.exchange_payto, + "amount": "KUDOS:11" + })) + .await + .assert_error(ErrorCode::BANK_UNALLOWED_DEBIT); + ctx.post(format!("/taler-integration/withdrawal-operation/{uuid}")) + .json(json!({ + "reserve_pub": EddsaPublicKey::rand(), + "selected_exchange": ctx.exchange_payto, + "amount": "KUDOS:1.1" + })) + .await + .assert_ok(); + let w = ctx + .get(format!("/taler-integration/withdrawal-operation/{uuid}")) + .await + .assert_ok_json::<BankWithdrawalOperationStatus>(); + assert_eq!(w.amount, Some(amount("KUDOS:1.1"))); + assert_eq!(w.max_amount, Some(amount("KUDOS:10"))); + } + + // Abort + { + // Bad UUID + ctx.post("/taler-integration/withdrawal-operation/chocolate/abort") + .await + .assert_error(ErrorCode::GENERIC_PATH_SEGMENT_MALFORMED); + ctx.posta("/accounts/merchant/withdrawals/chocolate/abort") + .await + .assert_error(ErrorCode::GENERIC_PATH_SEGMENT_MALFORMED); + + // Unknown + ctx.post(format!( + "/taler-integration/withdrawal-operation/{unknown}/abort" + )) + .await + .assert_error(ErrorCode::BANK_TRANSACTION_NOT_FOUND); + ctx.posta(format!("/accounts/merchant/withdrawals/{unknown}/abort")) + .await + .assert_error(ErrorCode::BANK_TRANSACTION_NOT_FOUND); + + // Abort created + let uuid = ctx + .posta("/accounts/merchant/withdrawals") + .json(json!({ "amount": "KUDOS:1"})) + .await + .assert_ok_json::<BankAccountCreateWithdrawalResponse>() + .withdrawal_id; + for _ in 0..2 { + ctx.post(format!( + "/taler-integration/withdrawal-operation/{uuid}/abort" + )) + .await + .assert_no_content(); + } + let uuid = ctx + .posta("/accounts/merchant/withdrawals") + .json(json!({ "amount": "KUDOS:1"})) + .await + .assert_ok_json::<BankAccountCreateWithdrawalResponse>() + .withdrawal_id; + for _ in 0..2 { + ctx.posta(format!("/accounts/merchant/withdrawals/{uuid}/abort")) + .await + .assert_no_content(); + } + + // Abort selected + let uuid = ctx + .posta("/accounts/merchant/withdrawals") + .json(json!({ "amount": "KUDOS:1"})) + .await + .assert_ok_json::<BankAccountCreateWithdrawalResponse>() + .withdrawal_id; + ctx.withdraw_select(uuid).await; + for _ in 0..2 { + ctx.post(format!( + "/taler-integration/withdrawal-operation/{uuid}/abort" + )) + .await + .assert_no_content(); + } + let uuid = ctx + .posta("/accounts/merchant/withdrawals") + .json(json!({ "amount": "KUDOS:1"})) + .await + .assert_ok_json::<BankAccountCreateWithdrawalResponse>() + .withdrawal_id; + ctx.withdraw_select(uuid).await; + for _ in 0..2 { + ctx.posta(format!("/accounts/merchant/withdrawals/{uuid}/abort")) + .await + .assert_no_content(); + } + + // Abort confirmed + let uuid = ctx + .posta("/accounts/merchant/withdrawals") + .json(json!({ "amount": "KUDOS:1"})) + .await + .assert_ok_json::<BankAccountCreateWithdrawalResponse>() + .withdrawal_id; + ctx.withdraw_select(uuid).await; + ctx.posta(format!("/accounts/merchant/withdrawals/{uuid}/confirm")) + .json(json!({})) + .await + .assert_no_content(); + ctx.post(format!( + "/taler-integration/withdrawal-operation/{uuid}/abort" + )) + .await + .assert_error(ErrorCode::BANK_ABORT_CONFIRM_CONFLICT); + ctx.posta(format!("/accounts/merchant/withdrawals/{uuid}/abort")) + .await + .assert_error(ErrorCode::BANK_ABORT_CONFIRM_CONFLICT); + } + + // Confirm + { + // Bad UUID + ctx.posta("/accounts/merchant/withdrawals/chocolate/confirm") + .await + .assert_error(ErrorCode::GENERIC_PATH_SEGMENT_MALFORMED); + // Unknown + ctx.posta(format!("/accounts/merchant/withdrawals/{unknown}/confirm")) + .json(json!({})) + .await + .assert_error(ErrorCode::BANK_TRANSACTION_NOT_FOUND); + + // Confirm created + let uuid = ctx + .posta("/accounts/merchant/withdrawals") + .json(json!({ "amount": "KUDOS:0.1"})) + .await + .assert_ok_json::<BankAccountCreateWithdrawalResponse>() + .withdrawal_id; + ctx.posta(format!("/accounts/merchant/withdrawals/{uuid}/confirm")) + .json(json!({})) + .await + .assert_error(ErrorCode::BANK_CONFIRM_INCOMPLETE); + + // Confirm selected + ctx.withdraw_select(uuid).await; + // Amount differs + ctx.posta(format!("/accounts/merchant/withdrawals/{uuid}/confirm")) + .json(json!({ "amount": "KUDOS:0.2" })) + .await + .assert_error(ErrorCode::BANK_AMOUNT_DIFFERS); + // Idempotent + for _ in 0..2 { + ctx.posta(format!("/accounts/merchant/withdrawals/{uuid}/confirm")) + .json(json!({})) + .await + .assert_no_content(); + } + // Amount still differs + ctx.posta(format!("/accounts/merchant/withdrawals/{uuid}/confirm")) + .json(json!({ "amount": "KUDOS:0.2" })) + .await + .assert_error(ErrorCode::BANK_AMOUNT_DIFFERS); + + // Confirm with amount + let uuid = ctx + .posta("/accounts/merchant/withdrawals") + .json(json!({})) + .await + .assert_ok_json::<BankAccountCreateWithdrawalResponse>() + .withdrawal_id; + ctx.withdraw_select(uuid).await; + // Missing amount + ctx.posta(format!("/accounts/merchant/withdrawals/{uuid}/confirm")) + .json(json!({})) + .await + .assert_error(ErrorCode::BANK_AMOUNT_REQUIRED); + // Idempotent + for _ in 0..2 { + ctx.posta(format!("/accounts/merchant/withdrawals/{uuid}/confirm")) + .json(json!({ "amount": "KUDOS:0.1" })) + .await + .assert_no_content(); + } + // Amount differs + ctx.posta(format!("/accounts/merchant/withdrawals/{uuid}/confirm")) + .json(json!({ "amount": "KUDOS:0.2" })) + .await + .assert_error(ErrorCode::BANK_AMOUNT_DIFFERS); + + // Confirm aborted + let uuid = ctx + .posta("/accounts/merchant/withdrawals") + .json(json!({ "amount": "KUDOS:0.1" })) + .await + .assert_ok_json::<BankAccountCreateWithdrawalResponse>() + .withdrawal_id; + ctx.withdraw_select(uuid).await; + ctx.posta(format!("/accounts/merchant/withdrawals/{uuid}/abort")) + .await + .assert_no_content(); + ctx.posta(format!("/accounts/merchant/withdrawals/{uuid}/confirm")) + .json(json!({})) + .await + .assert_error(ErrorCode::BANK_CONFIRM_ABORT_CONFLICT); + + // Reserve pub reuse + let uuid = ctx + .posta("/accounts/merchant/withdrawals") + .json(json!({ "amount": "KUDOS:0.1" })) + .await + .assert_ok_json::<BankAccountCreateWithdrawalResponse>() + .withdrawal_id; + let key = ctx.withdraw_select(uuid).await; + ctx.tx_s("customer", "5", "exchange", &format!("Taler {key}")) + .await; + ctx.posta(format!("/accounts/merchant/withdrawals/{uuid}/confirm")) + .json(json!({})) + .await + .assert_error(ErrorCode::BANK_DUPLICATE_RESERVE_PUB_SUBJECT); + + // Balance insufficient + let uuid = ctx + .posta("/accounts/merchant/withdrawals") + .json(json!({ "amount": "KUDOS:5" })) + .await + .assert_ok_json::<BankAccountCreateWithdrawalResponse>() + .withdrawal_id; + ctx.withdraw_select(uuid).await; + ctx.tx("merchant", "5", "customer").await; + ctx.posta(format!("/accounts/merchant/withdrawals/{uuid}/confirm")) + .json(json!({})) + .await + .assert_error(ErrorCode::BANK_UNALLOWED_DEBIT); + // Can abort because not confirmed + ctx.posta(format!("/accounts/merchant/withdrawals/{uuid}/abort")) + .await + .assert_no_content(); + } + + // With fee + let ctx = ctx.swap_cfg("test_with_fees.conf").await; + let uuid = ctx + .posta("/accounts/merchant/withdrawals") + .json(json!({})) + .await + .assert_ok_json::<BankAccountCreateWithdrawalResponse>() + .withdrawal_id; + ctx.assert_balance("merchant", "-6.2").await; + for amount in ["KUDOS:11", "KUDOS:7", "KUDOS:4", "KUDOS:0", "KUDOS:150"] { + ctx.post(format!("/taler-integration/withdrawal-operation/{uuid}")) + .json(json!({ + "reserve_pub": EddsaPublicKey::rand(), + "selected_exchange": ctx.exchange_payto, + "amount": amount + })) + .await + .assert_error(ErrorCode::BANK_UNALLOWED_DEBIT); + } + ctx.post(format!("/taler-integration/withdrawal-operation/{uuid}")) + .json(json!({ + "reserve_pub": EddsaPublicKey::rand(), + "selected_exchange": ctx.exchange_payto, + "amount": "KUDOS:3" + })) + .await + .assert_ok(); + + // Pooling + status_routine( + &ctx, + "/taler-integration/withdrawal-operation", + |it: &BankWithdrawalOperationStatus| it.status, + ) + .await; + status_routine(&ctx, "/withdrawals", |it: &WithdrawalPublicInfo| it.status).await; + + // 2FA without body + ctx.fill_tan_info("merchant").await; + ctx.assert_balance("merchant", "-6.2").await; + let uuid = ctx + .posta("/accounts/merchant/withdrawals") + .json(json!({ "amount": "KUDOS:1" })) + .await + .assert_ok_json::<BankAccountCreateWithdrawalResponse>() + .withdrawal_id; + ctx.withdraw_select(uuid).await; + ctx.posta(format!("/accounts/merchant/withdrawals/{uuid}/confirm")) + .json(json!({})) + .await + .assert_challenge_check(&ctx, async |_| ctx.assert_balance("merchant", "-6.2").await) + .await + .assert_no_content(); + + // 2FA with body + ctx.assert_balance("merchant", "-7.3").await; + let uuid = ctx + .posta("/accounts/merchant/withdrawals") + .json(json!({})) + .await + .assert_ok_json::<BankAccountCreateWithdrawalResponse>() + .withdrawal_id; + ctx.withdraw_select(uuid).await; + ctx.posta(format!("/accounts/merchant/withdrawals/{uuid}/confirm")) + .json(json!({ "amount": "KUDOS:1" })) + .await + .assert_challenge_check(&ctx, async |_| ctx.assert_balance("merchant", "-7.3").await) + .await + .assert_no_content(); + ctx.assert_balance("merchant", "-8.4").await; + } +} diff --git a/crates/libeufin-bank/src/auth.rs b/crates/libeufin-bank/src/auth.rs @@ -325,6 +325,7 @@ impl UserAuthScope for UserTokenScope { pub type UserRWAuth = UserAuth<UserRWScope>; pub type UserRAuth = UserAuth<UserRScope>; +pub type UserORWAuth = UserAuth<UserORWScope>; pub type UserTokenAuth = UserAuth<UserTokenScope>; pub type UserOptRAuth = UserOptAuth<UserRScope>; diff --git a/crates/libeufin-bank/src/config.rs b/crates/libeufin-bank/src/config.rs @@ -35,6 +35,7 @@ use taler_common::{ use taler_macros::EnumMeta; use tracing::warn; use url::Url; +use uuid::Uuid; use crate::{TanChannel, payto::PaytoCtx, pw::PwCrypto}; @@ -228,6 +229,36 @@ impl BankCfg { pub fn fiat_currency(&self) -> Option<&Currency> { self.fiat.as_ref().map(|it| &it.0) } + + /// Builds the taler://withdraw-URI. Such URI will serve the requests + /// from wallets, when they need to manage the operation. + pub fn taler_withdraw_uri(&self, id: Uuid) -> String { + let base = &self.base_url; + + // Determine the correct schema/protocol + let protocol = if base.scheme() == "http" { + "taler+http" + } else { + "taler" + }; + + // Extract host and optional port + let host = base.host_str().unwrap_or(""); + let port_suffix = base.port().map(|p| format!(":{}", p)).unwrap_or_default(); + + // Get the path and ensure it handles trailing slashes properly + let path = base.path(); + + format!( + "{}://withdraw/{}{}{}taler-integration/{}", + protocol, host, port_suffix, path, id + ) + } + + /// Builds the confirmation web UI URL. + pub fn withdraw_confirm_url(&self, id: Uuid) -> String { + format!("{}webui/#/operation/{}", self.base_url, id) + } } #[derive(serde::Deserialize)] diff --git a/crates/libeufin-bank/src/db.rs b/crates/libeufin-bank/src/db.rs @@ -19,12 +19,13 @@ use std::time::Duration; -use compact_str::CompactString; use sqlx::PgPool; use taler_api::notification::NotificationChannel; use tokio::join; use uuid::Uuid; +use crate::api::withdrawal::WithdrawalStatus; + pub mod account; pub mod cashout; pub mod conversion; @@ -32,6 +33,7 @@ pub mod gc; pub mod tan; pub mod token; pub mod tx; +pub mod withdrawal; const SCHEMA: &str = "libeufin_bank"; @@ -41,39 +43,40 @@ pub async fn notification_listener( taler_out_channel: NotificationChannel<u64, i64>, taler_in_channel: NotificationChannel<u64, i64>, revenue_channel: NotificationChannel<u64, i64>, + withdrawal_channel: NotificationChannel<Uuid, WithdrawalStatus>, ) -> 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 + join!( + async { + // GC notifications channels every hours + 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(); + withdrawal_channel.prune(); + interval.tick().await; } - ); - sqlx::Result::<_, sqlx::error::Error>::Ok(()) - }; - join!(gc, listener); + }, + async { + // And listen for notification + 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: WithdrawalStatus) { + withdrawal_channel.dispatch(&uuid, status); + } + ) + } + ); Ok(()) } diff --git a/crates/libeufin-bank/src/db/withdrawal.rs b/crates/libeufin-bank/src/db/withdrawal.rs @@ -0,0 +1,440 @@ +/* +* 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 compact_str::{CompactString, ToCompactString}; +use jiff::Timestamp; +use sqlx::{PgPool, Row, postgres::PgRow}; +use taler_api::{ + db::{BindHelper, TypeHelper, pooling}, + notification::NotificationChannel, + serialized, +}; +use taler_common::{ + api_common::EddsaPublicKey, + types::amount::{Amount, Currency}, +}; +use uuid::Uuid; + +use crate::{ + api::withdrawal::{ + BankWithdrawalOperationStatus, Status, WithdrawalPublicInfo, WithdrawalStatus, + }, + config::WireMethod, + payto::{BankPayto, PaytoCtx, sql_opt_bank_payto}, +}; + +/** Result status of withdrawal operation creation */ +pub enum CreationResult { + Success, + UnknownAccount, + AccountIsExchange, + BalanceInsufficient, + BadAmount, +} + +/** Create a new withdrawal operation */ +pub async fn create( + db: &PgPool, + username: &str, + uuid: Uuid, + amount: Option<Amount>, + suggested_amount: Option<Amount>, + no_amount_to_wallet: bool, + timestamp: &Timestamp, + fees: Amount, + min: Amount, + max: Amount, +) -> sqlx::Result<CreationResult> { + serialized!( + sqlx::query( + " + SELECT + out_account_not_found, + out_account_is_exchange, + out_balance_insufficient, + out_bad_amount + FROM create_taler_withdrawal( + $1,$2,$3,$4,$5,$6,$7,$8,$9 + ) + ", + ) + .bind(username) + .bind(uuid) + .bind(amount) + .bind(suggested_amount) + .bind(no_amount_to_wallet) + .bind_timestamp(timestamp) + .bind(fees) + .bind(min) + .bind(max) + .try_map(|r: PgRow| { + Ok(if r.try_get_flag("out_account_not_found")? { + CreationResult::UnknownAccount + } 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_bad_amount")? { + CreationResult::BadAmount + } else { + CreationResult::Success + }) + }) + .fetch_one(db) + ) +} + +/** Result status of withdrawal or cashout operation abortion */ +pub enum AbortResult { + Success, + UnknownOperation, + AlreadyConfirmed, +} + +/** Abort withdrawal operation [uuid] */ +pub async fn abort(db: &PgPool, uuid: Uuid) -> sqlx::Result<AbortResult> { + serialized!( + sqlx::query( + " + SELECT + out_no_op, + out_already_confirmed + FROM abort_taler_withdrawal($1) + ", + ) + .bind(uuid) + .try_map(|r: PgRow| { + Ok(if r.try_get_flag("out_no_op")? { + AbortResult::UnknownOperation + } else if r.try_get_flag("out_already_confirmed")? { + AbortResult::AlreadyConfirmed + } else { + AbortResult::Success + }) + }) + .fetch_one(db) + ) +} + +/** Result withdrawal operation selection */ +pub enum SelectionResult { + Success(WithdrawalStatus), + UnknownOperation, + AlreadySelected, + ReservePubReuse, + UnknownAccount, + AccountIsNotExchange, + AmountDiffers, + BalanceInsufficient, + BadAmount, + AlreadyAborted, +} + +/** Set details ([exchangePayto] & [reservePub] & [amount]) for withdrawal operation [uuid] */ +pub async fn set_details( + db: &PgPool, + uuid: Uuid, + exchange: &BankPayto, + reserve_pub: &EddsaPublicKey, + amount: Option<Amount>, + fees: Amount, + min: Amount, + max: Amount, +) -> sqlx::Result<SelectionResult> { + serialized!( + sqlx::query( + " + SELECT + out_no_op, + out_already_selected, + out_reserve_pub_reuse, + out_account_not_found, + out_account_is_not_exchange, + out_status, + out_amount_differs, + out_balance_insufficient, + out_bad_amount, + out_aborted + FROM select_taler_withdrawal( + $1,$2,$3,$4,$5,$6,$7,$8 + ) + ", + ) + .bind(uuid) + .bind(reserve_pub) + .bind(format!("Taler withdrawal {reserve_pub}")) + .bind(exchange.canonical()) + .bind(amount) + .bind(fees) + .bind(min) + .bind(max) + .try_map(|r: PgRow| { + Ok(if r.try_get_flag("out_aborted")? { + SelectionResult::AlreadyAborted + } else if r.try_get_flag("out_balance_insufficient")? { + SelectionResult::BalanceInsufficient + } else if r.try_get_flag("out_bad_amount")? { + SelectionResult::BadAmount + } else if r.try_get_flag("out_no_op")? { + SelectionResult::UnknownOperation + } else if r.try_get_flag("out_already_selected")? { + SelectionResult::AlreadySelected + } else if r.try_get_flag("out_amount_differs")? { + SelectionResult::AmountDiffers + } else if r.try_get_flag("out_reserve_pub_reuse")? { + SelectionResult::ReservePubReuse + } else if r.try_get_flag("out_account_not_found")? { + SelectionResult::UnknownAccount + } else if r.try_get_flag("out_account_is_not_exchange")? { + SelectionResult::AccountIsNotExchange + } else { + SelectionResult::Success(r.try_get("out_status")?) + }) + }) + .fetch_one(db) + ) +} + +/** Result status of withdrawal operation confirmation */ +pub enum ConfirmationResult { + Success, + UnknownOperation, + BalanceInsufficient, + BadAmount, + NotSelected, + AlreadyAborted, + TanRequired, + MissingAmount, + AmountDiffers, + ReservePubReuse, +} + +/** Confirm withdrawal operation [uuid] */ +pub async fn confirm( + db: &PgPool, + username: &str, + uuid: Uuid, + timestamp: &Timestamp, + amount: Option<Amount>, + is2fa: bool, + fees: Amount, + min: Amount, + max: Amount, +) -> sqlx::Result<ConfirmationResult> { + serialized!( + sqlx::query( + " + SELECT + out_no_op, + out_balance_insufficient, + out_bad_amount, + out_not_selected, + out_aborted, + out_tan_required, + out_missing_amount, + out_amount_differs, + out_reserve_pub_reuse + FROM confirm_taler_withdrawal( + $1,$2,$3,$4,$5,$6,$7,$8 + ) + ", + ) + .bind(username) + .bind(uuid) + .bind_timestamp(timestamp) + .bind(is2fa) + .bind(fees) + .bind(min) + .bind(max) + .bind(amount) + .try_map(|r: PgRow| { + Ok(if r.try_get_flag("out_no_op")? { + ConfirmationResult::UnknownOperation + } else if r.try_get_flag("out_balance_insufficient")? { + ConfirmationResult::BalanceInsufficient + } else if r.try_get_flag("out_bad_amount")? { + ConfirmationResult::BadAmount + } else if r.try_get_flag("out_not_selected")? { + ConfirmationResult::NotSelected + } else if r.try_get_flag("out_aborted")? { + ConfirmationResult::AlreadyAborted + } else if r.try_get_flag("out_tan_required")? { + ConfirmationResult::TanRequired + } else if r.try_get_flag("out_missing_amount")? { + ConfirmationResult::MissingAmount + } else if r.try_get_flag("out_amount_differs")? { + ConfirmationResult::AmountDiffers + } else if r.try_get_flag("out_reserve_pub_reuse")? { + ConfirmationResult::ReservePubReuse + } else { + ConfirmationResult::Success + }) + }) + .fetch_one(db) + ) +} +/** Get withdrawal operation [uuid] linked account username */ +pub async fn get_username(db: &PgPool, uuid: Uuid) -> sqlx::Result<Option<CompactString>> { + serialized!( + sqlx::query_scalar( + " + SELECT username + FROM taler_withdrawal_operations + JOIN bank_accounts ON wallet_bank_account=bank_account_id + JOIN customers ON customer_id=owning_customer_id + WHERE withdrawal_uuid=$1 + ", + ) + .bind(uuid) + .fetch_optional(db) + ) +} + +/** Pool public info of operation [uuid] */ +pub async fn poll_info( + db: &PgPool, + ctx: &PaytoCtx, + currency: &Currency, + channel: &NotificationChannel<Uuid, WithdrawalStatus>, + params: &Status, + uuid: Uuid, +) -> sqlx::Result<Option<WithdrawalPublicInfo>> { + pooling( + &params.polling, + || channel.subscribe(uuid), + |init| *init != params.status, + async || + serialized!( + sqlx::query( + " + SELECT + CASE + WHEN confirmation_done THEN 'confirmed' + WHEN aborted THEN 'aborted' + WHEN selection_done THEN 'selected' + ELSE 'pending' + END as status + ,amount + ,suggested_amount + ,selection_done + ,aborted + ,confirmation_done + ,reserve_pub + ,wallet_user.username + ,no_amount_to_wallet + ,exchange_account.internal_payto as exchange_payto + ,exchange_user.name as exchange_name + FROM taler_withdrawal_operations + JOIN bank_accounts AS wallet_account ON wallet_bank_account=wallet_account.bank_account_id + JOIN customers AS wallet_user ON wallet_user.customer_id=wallet_account.owning_customer_id + LEFT JOIN bank_accounts AS exchange_account ON exchange_bank_account=exchange_account.bank_account_id + LEFT JOIN customers AS exchange_user ON exchange_user.customer_id=exchange_account.owning_customer_id + WHERE withdrawal_uuid=$1 + " + ).bind(uuid) + .try_map(|r: PgRow| Ok(WithdrawalPublicInfo { + status: r.try_get("status")?, + amount: r.try_get_opt_amount("amount", currency)?, + suggested_amount: r.try_get_opt_amount("suggested_amount", currency)?, + no_amount_to_wallet: r.try_get("no_amount_to_wallet")?, + username: r.try_get("username")?, + selected_reserve_pub: r.try_get("reserve_pub")?, + selected_exchange_account: sql_opt_bank_payto(&r, ctx, "exchange_payto", "exchange_name")? + })) + + .fetch_optional(db)) + , + |init| init.as_ref().map(|it| it.status != params.status).unwrap_or(true), + ).await +} + +/** Pool public status of operation [uuid] */ +pub async fn poll_status( + db: &PgPool, + ctx: &PaytoCtx, + currency: &Currency, + channel: &NotificationChannel<Uuid, WithdrawalStatus>, + params: &Status, + uuid: Uuid, + wire: WireMethod, + max: Amount, +) -> sqlx::Result<Option<BankWithdrawalOperationStatus>> { + pooling( + &params.polling, + || channel.subscribe(uuid), + |init| *init != params.status, + async || + serialized!( + sqlx::query( + " + SELECT + CASE + WHEN confirmation_done THEN 'confirmed' + WHEN aborted THEN 'aborted' + WHEN selection_done THEN 'selected' + ELSE 'pending' + END as status + ,amount + ,suggested_amount + ,selection_done + ,aborted + ,confirmation_done + ,wallet_account.internal_payto + ,wallet_user.name + ,reserve_pub + ,exchange_account.internal_payto as exchange_payto + ,exchange_user.name as exchange_name + ,max_amount + ,no_amount_to_wallet + FROM taler_withdrawal_operations + JOIN bank_accounts AS wallet_account ON wallet_bank_account=wallet_account.bank_account_id + JOIN customers AS wallet_user ON wallet_user.customer_id=wallet_account.owning_customer_id + LEFT JOIN bank_accounts AS exchange_account ON exchange_bank_account=exchange_account.bank_account_id + LEFT JOIN customers AS exchange_user ON exchange_user.customer_id=exchange_account.owning_customer_id + ,account_max_amount(wallet_account.bank_account_id, $1) AS max_amount + WHERE withdrawal_uuid=$2 + " + ) + .bind(max) + .bind(uuid) + .try_map(|r: PgRow| Ok(BankWithdrawalOperationStatus { + status: r.try_get("status")?, + amount: r.try_get_opt_amount("amount", currency)?, + suggested_amount: r.try_get_opt_amount("suggested_amount", currency)?, + max_amount: r.try_get_opt_amount("max_amount", currency)?, + no_amount_to_wallet: r.try_get("no_amount_to_wallet")?, + selected_reserve_pub: r.try_get("reserve_pub")?, + selected_exchange_account: sql_opt_bank_payto(&r, ctx, "exchange_payto", "exchange_name")?, + selection_done: r.try_get("selection_done")?, + transfer_done: r.try_get("confirmation_done")?, + aborted : r.try_get("aborted")?, + card_fees: None, + currency: Some(*currency), + min_amount: None, + confirm_transfer_url: None, + required_exchange: None, + sender_wire: sql_opt_bank_payto(&r, ctx, "internal_payto", "name")?, + suggested_exchange: None, + wire_types: vec![wire.to_compact_string()] + })) + .fetch_optional(db)) + , + |init| init.as_ref().map(|it| it.status != params.status).unwrap_or(true), + ).await +} diff --git a/crates/libeufin-bank/src/mfa.rs b/crates/libeufin-bank/src/mfa.rs @@ -49,6 +49,7 @@ use crate::{ tan::{Challenge, ChallengeResponse}, token::TokenRequest, tx::TransactionCreateRequest, + withdrawal::BankAccountConfirmWithdrawalRequest, }, auth::{UserAuth, UserAuthScope, UserORWScope, UserRWScope, UserTokenScope}, db::account::BankInfo, @@ -135,6 +136,14 @@ impl MfaOp for CashoutOp { type Body = CashoutRequest; } +pub struct WithdrawalOp; + +impl MfaOp for WithdrawalOp { + const OP: Operation = Operation::withdrawal; + type Scope = UserORWScope; + type Body = BankAccountConfirmWithdrawalRequest; +} + 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 @@ -170,9 +170,24 @@ pub fn sql_bank_payto( payto_idx: &str, name_idx: &str, ) -> sqlx::Result<FullBankPayto> { - let bank_payto: BankPayto = r.try_get_parse(payto_idx)?; + let payto: BankPayto = r.try_get_parse(payto_idx)?; let name = r.try_get(name_idx)?; - Ok(bank_payto.into_inner().bank(name, ctx)) + Ok(payto.into_inner().bank(name, ctx)) +} + +pub fn sql_opt_bank_payto( + r: &PgRow, + ctx: &PaytoCtx, + payto_idx: &str, + name_idx: &str, +) -> sqlx::Result<Option<FullBankPayto>> { + let payto: Option<BankPayto> = r.try_get_opt_parse(payto_idx)?; + if let Some(payto) = payto { + let name = r.try_get(name_idx)?; + Ok(Some(payto.into_inner().bank(name, ctx))) + } else { + Ok(None) + } } pub fn sql_opt_iban_payto(