commit 8e4cb6c709c79554eb5de5ad980aea669cc639b4
parent 074ca9f6bfe70ed563ed52ea70b5963b6b14fbe6
Author: Antoine A <>
Date: Wed, 20 May 2026 11:44:59 +0200
bank: add withdrawal API
Diffstat:
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,
+ ¶ms,
+ 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,
+ ¶ms,
+ 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(
+ ¶ms.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(
+ ¶ms.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(