libeufin

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

withdrawal.rs (45543B)


      1 /*
      2 * This file is part of LibEuFin.
      3 * Copyright (C) 2026 Taler Systems S.A.
      4 
      5 * LibEuFin is free software; you can redistribute it and/or modify
      6 * it under the terms of the GNU Affero General Public License as
      7 * published by the Free Software Foundation; either version 3, or
      8 * (at your option) any later version.
      9 
     10 * LibEuFin is distributed in the hope that it will be useful, but
     11 * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
     12 * or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Affero General
     13 * Public License for more details.
     14 
     15 * You should have received a copy of the GNU Affero General Public
     16 * License along with LibEuFin; see the file COPYING.  If not, see
     17 * <http://www.gnu.org/licenses/>
     18 */
     19 
     20 use std::sync::Arc;
     21 
     22 use axum::{
     23     Json, Router,
     24     extract::State,
     25     http::StatusCode,
     26     response::{IntoResponse, NoContent},
     27     routing::{get, post},
     28 };
     29 use compact_str::CompactString;
     30 use jiff::Timestamp;
     31 use serde::{Deserialize, Serialize};
     32 use taler_api::{
     33     error::{ApiResult, failure, failure_code},
     34     extract::{Path, Query, Req},
     35 };
     36 use taler_common::{
     37     api::{
     38         EddsaPublicKey,
     39         params::{Pooling, PoolingParams},
     40     },
     41     error_code::ErrorCode,
     42     types::amount::{Amount, Currency},
     43 };
     44 use taler_macros::api_config;
     45 use url::Url;
     46 use uuid::Uuid;
     47 
     48 use crate::{
     49     api::{BankState, IMPLEMENTATION, INTEGRATION_API_VERSION},
     50     auth::UserORWAuth,
     51     config::CurrencySpecification,
     52     db::withdrawal::{
     53         AbortResult, ConfirmationResult, CreationResult, SelectionResult, abort, confirm, create,
     54         poll_info, poll_status, set_details,
     55     },
     56     mfa::{MfaReq, WithdrawalOp},
     57     payto::{BankPayto, FullBankPayto},
     58 };
     59 
     60 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
     61 #[sqlx(type_name = "TEXT")]
     62 #[allow(non_camel_case_types)]
     63 pub enum WithdrawalStatus {
     64     pending,
     65     aborted,
     66     selected,
     67     confirmed,
     68 }
     69 
     70 // Taler withdrawal request.
     71 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
     72 pub struct BankAccountCreateWithdrawalRequest {
     73     pub amount: Option<Amount>,
     74     pub suggested_amount: Option<Amount>,
     75     #[serde(default)]
     76     pub no_amount_to_wallet: bool,
     77 }
     78 
     79 // Taler withdrawal response.
     80 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
     81 pub struct BankAccountCreateWithdrawalResponse {
     82     pub withdrawal_id: Uuid,
     83     pub taler_withdraw_uri: Url,
     84 }
     85 
     86 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
     87 pub struct WithdrawalPublicInfo {
     88     pub status: WithdrawalStatus,
     89     pub amount: Option<Amount>,
     90     pub suggested_amount: Option<Amount>,
     91     #[serde(default)]
     92     pub no_amount_to_wallet: bool,
     93     pub username: CompactString,
     94     pub selected_reserve_pub: Option<EddsaPublicKey>,
     95     pub selected_exchange_account: Option<FullBankPayto>,
     96 }
     97 
     98 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
     99 pub struct BankWithdrawalOperationStatus {
    100     pub status: WithdrawalStatus,
    101     pub amount: Option<Amount>,
    102     pub suggested_amount: Option<Amount>,
    103     pub min_amount: Option<Amount>,
    104     pub max_amount: Option<Amount>,
    105     pub card_fees: Option<Amount>,
    106     pub sender_wire: Option<FullBankPayto>,
    107     pub suggested_exchange: Option<String>,
    108     pub required_exchange: Option<FullBankPayto>,
    109     pub confirm_transfer_url: Option<String>,
    110     pub wire_types: Vec<CompactString>,
    111     pub selected_reserve_pub: Option<EddsaPublicKey>,
    112     pub selected_exchange_account: Option<FullBankPayto>,
    113     #[serde(default)]
    114     pub no_amount_to_wallet: bool,
    115     pub currency: Option<Currency>,
    116     // TODO deprecated remove in the next breaking release
    117     pub aborted: bool,
    118     pub selection_done: bool,
    119     pub transfer_done: bool,
    120 }
    121 
    122 /**
    123  * Selection request on a Taler withdrawal.
    124  */
    125 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
    126 pub struct BankWithdrawalOperationPostRequest {
    127     pub reserve_pub: EddsaPublicKey,
    128     pub selected_exchange: BankPayto,
    129     pub amount: Option<Amount>,
    130 }
    131 
    132 /**
    133  * Response to the wallet after it selects the exchange
    134  * and the reserve pub.
    135  */
    136 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
    137 pub struct BankWithdrawalOperationPostResponse {
    138     pub status: WithdrawalStatus,
    139     pub confirm_transfer_url: Option<String>,
    140     // TODO deprecated remove in the next breaking release
    141     pub transfer_done: bool,
    142 }
    143 
    144 // Request POST /accounts/{USERNAME}/withdrawals/{WITHDRAWAL_ID}/confirm
    145 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
    146 pub struct BankAccountConfirmWithdrawalRequest {
    147     pub amount: Option<Amount>,
    148 }
    149 
    150 #[api_config("taler-bank-integration")]
    151 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
    152 pub struct TalerIntegrationConfigResponse<'a> {
    153     pub currency: &'a Currency,
    154     pub currency_specification: &'a CurrencySpecification,
    155 }
    156 
    157 #[derive(Debug, Clone, Deserialize)]
    158 pub struct StatusParams {
    159     #[serde(flatten)]
    160     pub polling: PoolingParams,
    161     pub old_state: Option<WithdrawalStatus>,
    162 }
    163 
    164 impl StatusParams {
    165     pub fn check(self) -> ApiResult<Status> {
    166         Ok(Status {
    167             polling: self.polling.check()?,
    168             status: self.old_state.unwrap_or(WithdrawalStatus::pending),
    169         })
    170     }
    171 }
    172 
    173 pub struct Status {
    174     pub polling: Pooling,
    175     pub status: WithdrawalStatus,
    176 }
    177 
    178 pub fn withdrawal_api() -> Router<Arc<BankState>> {
    179     Router::new()
    180         .route(
    181             "/accounts/{username}/withdrawals",
    182             post(
    183                 async |auth: UserORWAuth,
    184                        State(state): State<Arc<BankState>>,
    185                        Req(req): Req<BankAccountCreateWithdrawalRequest>| {
    186                     if let Some(amount) = &req.amount {
    187                         state.cfg.check_regio(amount)?;
    188                     }
    189                     if let Some(amount) = &req.suggested_amount {
    190                         state.cfg.check_regio(amount)?;
    191                     }
    192                     let uuid = Uuid::new_v4();
    193                     match create(
    194                         &state.db,
    195                         &auth.username,
    196                         uuid,
    197                         req.amount,
    198                         req.suggested_amount,
    199                         req.no_amount_to_wallet,
    200                         &Timestamp::now(),
    201                         state.cfg.wire_transfer_fees,
    202                         state.cfg.min_amount,
    203                         state.cfg.max_amount,
    204                     )
    205                     .await?
    206                     {
    207                         CreationResult::Success => Ok(Json(BankAccountCreateWithdrawalResponse {
    208                             withdrawal_id: uuid,
    209                             taler_withdraw_uri: state.cfg.taler_withdraw_uri(uuid),
    210                         })),
    211                         CreationResult::UnknownAccount => {
    212                             Err(failure_code(ErrorCode::BANK_UNKNOWN_ACCOUNT))
    213                         }
    214                         CreationResult::AccountIsExchange => {
    215                             Err(failure_code(ErrorCode::BANK_ACCOUNT_IS_EXCHANGE))
    216                         }
    217                         CreationResult::BalanceInsufficient => Err(failure(
    218                             ErrorCode::BANK_UNALLOWED_DEBIT,
    219                             "Insufficient funds to withdraw with Taler",
    220                         )),
    221                         CreationResult::BadAmount => Err(failure(
    222                             ErrorCode::BANK_UNALLOWED_DEBIT,
    223                             "Amount either to high or too low",
    224                         )),
    225                     }
    226                 },
    227             ),
    228         )
    229         .route(
    230             "/accounts/{username}/withdrawals/{uuid}/confirm",
    231             post(
    232                 async |Path((_, uuid)): Path<((), Uuid)>,
    233                        State(state): State<Arc<BankState>>,
    234                        req: MfaReq<WithdrawalOp>| {
    235                     let (mut auth, req, mfa) = req.solve(&state, uuid.as_bytes()).await?;
    236                     if let Some(amount) = &req.amount {
    237                         state.cfg.check_regio(amount)?;
    238                     }
    239                     match confirm(
    240                         &state.db,
    241                         &auth.username,
    242                         uuid,
    243                         &Timestamp::now(),
    244                         req.amount,
    245                         mfa.is_2fa(),
    246                         state.cfg.wire_transfer_fees,
    247                         state.cfg.min_amount,
    248                         state.cfg.max_amount,
    249                     )
    250                     .await?
    251                     {
    252                         ConfirmationResult::Success => Ok(NoContent.into_response()),
    253                         ConfirmationResult::UnknownOperation => {
    254                             Err(failure_code(ErrorCode::BANK_TRANSACTION_NOT_FOUND))
    255                         }
    256                         ConfirmationResult::BalanceInsufficient => Err(failure(
    257                             ErrorCode::BANK_UNALLOWED_DEBIT,
    258                             "Insufficient funds to withdraw with Taler",
    259                         )),
    260                         ConfirmationResult::BadAmount => Err(failure(
    261                             ErrorCode::BANK_UNALLOWED_DEBIT,
    262                             "Amount either to high or too low",
    263                         )),
    264                         ConfirmationResult::NotSelected => {
    265                             Err(failure_code(ErrorCode::BANK_CONFIRM_INCOMPLETE))
    266                         }
    267                         ConfirmationResult::AlreadyAborted => {
    268                             Err(failure_code(ErrorCode::BANK_CONFIRM_ABORT_CONFLICT))
    269                         }
    270                         ConfirmationResult::TanRequired => Ok(mfa
    271                             .response_mfa(&mut auth, &state.db, &state.cfg.ctx)
    272                             .await?
    273                             .into_response()),
    274                         ConfirmationResult::MissingAmount => {
    275                             Err(failure_code(ErrorCode::BANK_AMOUNT_REQUIRED))
    276                         }
    277                         ConfirmationResult::AmountDiffers => {
    278                             Err(failure_code(ErrorCode::BANK_AMOUNT_DIFFERS))
    279                         }
    280                         ConfirmationResult::ReservePubReuse => {
    281                             Err(failure_code(ErrorCode::BANK_DUPLICATE_RESERVE_PUB_SUBJECT))
    282                         }
    283                     }
    284                 },
    285             ),
    286         )
    287         .route(
    288             "/accounts/{username}/withdrawals/{uuid}/abort",
    289             post(
    290                 async |Path((_, uuid)): Path<((), Uuid)>,
    291                        _: UserORWAuth,
    292                        State(state): State<Arc<BankState>>| {
    293                     match abort(&state.db, uuid).await? {
    294                         AbortResult::UnknownOperation => {
    295                             Err(failure_code(ErrorCode::BANK_TRANSACTION_NOT_FOUND))
    296                         }
    297                         AbortResult::AlreadyConfirmed => {
    298                             Err(failure_code(ErrorCode::BANK_ABORT_CONFIRM_CONFLICT))
    299                         }
    300                         AbortResult::Success => Ok(NoContent),
    301                     }
    302                 },
    303             ),
    304         )
    305         .route(
    306             "/withdrawals/{uuid}",
    307             get(
    308                 async |Path(uuid): Path<Uuid>,
    309                        Query(params): Query<StatusParams>,
    310                        State(state): State<Arc<BankState>>| {
    311                     let params = params.check()?;
    312                     match poll_info(
    313                         &state.db,
    314                         &state.cfg.ctx,
    315                         &state.cfg.regional_currency,
    316                         &state.withdrawal_channel,
    317                         &params,
    318                         uuid,
    319                     )
    320                     .await?
    321                     {
    322                         Some(status) => Ok(Json(status)),
    323                         None => Err(failure_code(ErrorCode::BANK_TRANSACTION_NOT_FOUND)),
    324                     }
    325                 },
    326             ),
    327         )
    328         .route(
    329             "/taler-integration/config",
    330             get(async |State(state): State<Arc<BankState>>| {
    331                 Json(TalerIntegrationConfigResponse {
    332                     name: (),
    333                     version: INTEGRATION_API_VERSION,
    334                     implementation: Some(IMPLEMENTATION),
    335                     currency: &state.cfg.regional_currency,
    336                     currency_specification: &state.cfg.regional_currency_spec,
    337                 })
    338                 .into_response()
    339             }),
    340         )
    341         .route(
    342             "/taler-integration/withdrawal-operation/{wopid}",
    343             post(
    344                 async |Path(uuid): Path<Uuid>,
    345                        State(state): State<Arc<BankState>>,
    346                        Req(req): Req<BankWithdrawalOperationPostRequest>| {
    347                     if let Some(amount) = &req.amount {
    348                         state.cfg.check_regio(amount)?;
    349                     }
    350 
    351                     match set_details(
    352                         &state.db,
    353                         uuid,
    354                         &req.selected_exchange,
    355                         &req.reserve_pub,
    356                         req.amount,
    357                         state.cfg.wire_transfer_fees,
    358                         state.cfg.min_amount,
    359                         state.cfg.max_amount,
    360                     )
    361                     .await?
    362                     {
    363                         SelectionResult::Success(status) => {
    364                             Ok(Json(BankWithdrawalOperationPostResponse {
    365                                 confirm_transfer_url: if matches!(
    366                                     status,
    367                                     WithdrawalStatus::pending | WithdrawalStatus::selected
    368                                 ) {
    369                                     Some(state.cfg.withdraw_confirm_url(uuid))
    370                                 } else {
    371                                     None
    372                                 },
    373                                 transfer_done: status == WithdrawalStatus::confirmed,
    374                                 status,
    375                             }))
    376                         }
    377                         SelectionResult::UnknownOperation => {
    378                             Err(failure_code(ErrorCode::BANK_TRANSACTION_NOT_FOUND))
    379                         }
    380                         SelectionResult::AlreadySelected => Err(failure_code(
    381                             ErrorCode::BANK_WITHDRAWAL_OPERATION_RESERVE_SELECTION_CONFLICT,
    382                         )),
    383                         SelectionResult::ReservePubReuse => {
    384                             Err(failure_code(ErrorCode::BANK_DUPLICATE_RESERVE_PUB_SUBJECT))
    385                         }
    386                         SelectionResult::UnknownAccount => {
    387                             Err(failure_code(ErrorCode::BANK_UNKNOWN_ACCOUNT)
    388                                 .with_status(StatusCode::CONFLICT))
    389                         }
    390                         SelectionResult::AccountIsNotExchange => {
    391                             Err(failure_code(ErrorCode::BANK_ACCOUNT_IS_NOT_EXCHANGE))
    392                         }
    393                         SelectionResult::AmountDiffers => {
    394                             Err(failure_code(ErrorCode::BANK_AMOUNT_DIFFERS))
    395                         }
    396                         SelectionResult::BalanceInsufficient => Err(failure(
    397                             ErrorCode::BANK_UNALLOWED_DEBIT,
    398                             "Insufficient funds to withdraw with Taler",
    399                         )),
    400                         SelectionResult::BadAmount => Err(failure(
    401                             ErrorCode::BANK_UNALLOWED_DEBIT,
    402                             "Amount either to high or too low",
    403                         )),
    404                         SelectionResult::AlreadyAborted => {
    405                             Err(failure_code(ErrorCode::BANK_UPDATE_ABORT_CONFLICT))
    406                         }
    407                     }
    408                 },
    409             )
    410             .get(
    411                 async |Path(uuid): Path<Uuid>,
    412                        Query(params): Query<StatusParams>,
    413                        State(state): State<Arc<BankState>>| {
    414                     let params = params.check()?;
    415                     match poll_status(
    416                         &state.db,
    417                         &state.cfg.ctx,
    418                         &state.cfg.regional_currency,
    419                         &state.withdrawal_channel,
    420                         &params,
    421                         uuid,
    422                         state.cfg.wire_method,
    423                         state.cfg.max_amount,
    424                     )
    425                     .await?
    426                     {
    427                         Some(mut w) => {
    428                             w.suggested_exchange = state.cfg.suggested_withdrawal_exchange.clone();
    429                             if matches!(
    430                                 w.status,
    431                                 WithdrawalStatus::pending | WithdrawalStatus::selected
    432                             ) {
    433                                 w.confirm_transfer_url = Some(state.cfg.withdraw_confirm_url(uuid));
    434                             }
    435                             Ok(Json(w))
    436                         }
    437                         None => Err(failure_code(ErrorCode::BANK_TRANSACTION_NOT_FOUND)),
    438                     }
    439                 },
    440             ),
    441         )
    442         .route(
    443             "/taler-integration/withdrawal-operation/{uuid}/abort",
    444             post(
    445                 async |Path(uuid): Path<Uuid>, State(state): State<Arc<BankState>>| match abort(
    446                     &state.db, uuid,
    447                 )
    448                 .await?
    449                 {
    450                     AbortResult::UnknownOperation => {
    451                         Err(failure_code(ErrorCode::BANK_TRANSACTION_NOT_FOUND))
    452                     }
    453                     AbortResult::AlreadyConfirmed => {
    454                         Err(failure_code(ErrorCode::BANK_ABORT_CONFIRM_CONFLICT))
    455                     }
    456                     AbortResult::Success => Ok(NoContent),
    457                 },
    458             ),
    459         )
    460 }
    461 
    462 #[cfg(test)]
    463 mod test {
    464 
    465     use std::time::Duration;
    466 
    467     use axum::http::{Method, StatusCode};
    468     use serde::de::DeserializeOwned;
    469     use sqlx::postgres::PgConnectOptions;
    470     use taler_common::{
    471         api::EddsaPublicKey,
    472         error_code::ErrorCode,
    473         types::amount::{Currency, amount},
    474     };
    475     use taler_macros::db_test;
    476     use taler_test_utils::{json, routine::assert_time, server::TestServer};
    477     use tokio::join;
    478     use uuid::Uuid;
    479 
    480     use crate::{
    481         api::{
    482             test::{Auth, BankTestCtx, MfaRequest, bank_setup},
    483             withdrawal::{
    484                 BankAccountCreateWithdrawalResponse, BankWithdrawalOperationPostResponse,
    485                 BankWithdrawalOperationStatus, WithdrawalPublicInfo, WithdrawalStatus,
    486             },
    487         },
    488         config::WireMethod,
    489     };
    490 
    491     async fn status_routine<T: DeserializeOwned>(
    492         ctx: &BankTestCtx,
    493         path: &str,
    494         status: impl Fn(&T) -> WithdrawalStatus,
    495     ) {
    496         let amount = "KUDOS:0.04";
    497         let aborted = ctx
    498             .posta("/accounts/merchant/withdrawals")
    499             .json(json!({
    500                 "amount": amount
    501             }))
    502             .await
    503             .assert_ok_json::<BankAccountCreateWithdrawalResponse>()
    504             .withdrawal_id;
    505         let confirmed = ctx
    506             .posta("/accounts/merchant/withdrawals")
    507             .json(json!({
    508                 "amount": amount
    509             }))
    510             .await
    511             .assert_ok_json::<BankAccountCreateWithdrawalResponse>()
    512             .withdrawal_id;
    513 
    514         // Check no useless polling
    515         assert_time(0..5000, async {
    516             let res = ctx
    517                 .get(format!(
    518                     "{path}/{confirmed}?timeout_ms=1000&old_state=selected"
    519                 ))
    520                 .await
    521                 .assert_ok_json::<T>();
    522             assert_eq!(status(&res), WithdrawalStatus::pending);
    523         })
    524         .await;
    525 
    526         // Polling selected
    527         join!(
    528             assert_time(100..500, async {
    529                 let res = ctx
    530                     .get(format!("{path}/{confirmed}?timeout_ms=1000"))
    531                     .await
    532                     .assert_ok_json::<T>();
    533                 assert_eq!(status(&res), WithdrawalStatus::selected);
    534             }),
    535             assert_time(100..500, async {
    536                 let res = ctx
    537                     .get(format!("{path}/{aborted}?timeout_ms=1000"))
    538                     .await
    539                     .assert_ok_json::<T>();
    540                 assert_eq!(status(&res), WithdrawalStatus::selected);
    541             }),
    542             async {
    543                 tokio::time::sleep(Duration::from_millis(100)).await;
    544                 ctx.withdraw_select(confirmed).await;
    545                 ctx.withdraw_select(aborted).await;
    546             }
    547         );
    548 
    549         // Polling confirmed
    550         join!(
    551             assert_time(100..500, async {
    552                 let res = ctx
    553                     .get(format!(
    554                         "{path}/{confirmed}?timeout_ms=1000&old_state=selected"
    555                     ))
    556                     .await
    557                     .assert_ok_json::<T>();
    558                 assert_eq!(status(&res), WithdrawalStatus::confirmed);
    559             }),
    560             assert_time(200..500, async {
    561                 let res = ctx
    562                     .get(format!(
    563                         "{path}/{aborted}?timeout_ms=200&old_state=selected"
    564                     ))
    565                     .await
    566                     .assert_ok_json::<T>();
    567                 assert_eq!(status(&res), WithdrawalStatus::selected);
    568             }),
    569             async {
    570                 tokio::time::sleep(Duration::from_millis(100)).await;
    571                 ctx.posta(format!(
    572                     "/accounts/merchant/withdrawals/{confirmed}/confirm"
    573                 ))
    574                 .json(json!({}))
    575                 .await
    576                 .assert_no_content();
    577             }
    578         );
    579 
    580         // Polling abort
    581         join!(
    582             assert_time(200..500, async {
    583                 let res = ctx
    584                     .get(format!(
    585                         "{path}/{confirmed}?timeout_ms=200&old_state=confirmed"
    586                     ))
    587                     .await
    588                     .assert_ok_json::<T>();
    589                 assert_eq!(status(&res), WithdrawalStatus::confirmed);
    590             }),
    591             assert_time(100..500, async {
    592                 let res = ctx
    593                     .get(format!(
    594                         "{path}/{aborted}?timeout_ms=1000&old_state=selected"
    595                     ))
    596                     .await
    597                     .assert_ok_json::<T>();
    598                 assert_eq!(status(&res), WithdrawalStatus::aborted);
    599             }),
    600             async {
    601                 tokio::time::sleep(Duration::from_millis(100)).await;
    602                 ctx.posta(format!("/accounts/customer/withdrawals/{aborted}/abort"))
    603                     .await
    604                     .assert_no_content();
    605             }
    606         );
    607     }
    608 
    609     #[db_test(raw)]
    610     async fn withdrawal(db: PgConnectOptions) {
    611         let ctx = bank_setup(db).await;
    612         let unknown = Uuid::new_v4();
    613 
    614         ctx.auth_routine(
    615             Method::POST,
    616             "/accounts/merchant/withdrawals",
    617             Auth::UserOrAdmin,
    618         )
    619         .await;
    620         ctx.auth_routine(
    621             Method::POST,
    622             format!("/accounts/merchant/withdrawals/{unknown}/abort"),
    623             Auth::UserOrAdmin,
    624         )
    625         .await;
    626         ctx.auth_routine(
    627             Method::POST,
    628             format!("/accounts/merchant/withdrawals/{unknown}/confirm"),
    629             Auth::UserOrAdmin,
    630         )
    631         .await;
    632 
    633         ctx.get("/taler-integration/config").await.assert_ok();
    634 
    635         // Create
    636         {
    637             for (am, suggested) in [
    638                 (None, None),
    639                 (Some("KUDOS:1.0"), None),
    640                 (None, Some("KUDOS:2.0")),
    641                 (Some("KUDOS:3.0"), Some("KUDOS:4.0")),
    642             ] {
    643                 let res: BankAccountCreateWithdrawalResponse = ctx
    644                     .posta("/accounts/merchant/withdrawals")
    645                     .json(json!({
    646                         "amount": am,
    647                         "suggested_amount": suggested
    648                     }))
    649                     .await
    650                     .assert_ok_json();
    651                 let uuid = res.taler_withdraw_uri.path().rsplit('/').next().unwrap();
    652                 let w: BankWithdrawalOperationStatus = ctx
    653                     .get(format!("/taler-integration/withdrawal-operation/{uuid}"))
    654                     .await
    655                     .assert_ok_json();
    656                 assert!(!w.selection_done);
    657                 assert!(!w.aborted);
    658                 assert!(!w.transfer_done);
    659                 assert_eq!(w.card_fees, None);
    660                 assert_eq!(w.min_amount, None);
    661                 assert_eq!(w.max_amount, Some(amount("KUDOS:10")));
    662                 assert_eq!(w.amount, am.map(|it| it.parse().unwrap()));
    663                 assert_eq!(w.suggested_amount, suggested.map(|it| it.parse().unwrap()));
    664                 assert_eq!(w.wire_types, &[WireMethod::iban.as_ref()]);
    665                 assert_eq!(w.currency, Some(Currency::KUDOS));
    666             }
    667 
    668             // Exchange account
    669             ctx.posta("/accounts/exchange/withdrawals")
    670                 .json(json!({ "amount": "KUDOS:9" }))
    671                 .await
    672                 .assert_error(ErrorCode::BANK_ACCOUNT_IS_EXCHANGE);
    673             // Check insufficient fund
    674             ctx.posta("/accounts/merchant/withdrawals")
    675                 .json(json!({ "amount": "KUDOS:90" }))
    676                 .await
    677                 .assert_error(ErrorCode::BANK_UNALLOWED_DEBIT);
    678             ctx.posta("/accounts/merchant/withdrawals")
    679                 .json(json!({ "suggested_amount": "KUDOS:90" }))
    680                 .await
    681                 .assert_error(ErrorCode::BANK_UNALLOWED_DEBIT);
    682             // Check wrong currency
    683             ctx.posta("/accounts/merchant/withdrawals")
    684                 .json(json!({ "amount": "EUR:90" }))
    685                 .await
    686                 .assert_error(ErrorCode::GENERIC_CURRENCY_MISMATCH);
    687             ctx.posta("/accounts/merchant/withdrawals")
    688                 .json(json!({ "suggested_amount": "EUR:90" }))
    689                 .await
    690                 .assert_error(ErrorCode::GENERIC_CURRENCY_MISMATCH);
    691         }
    692 
    693         // Bad UUID
    694         ctx.get("/taler-integration/withdrawal-operation/chocolate")
    695             .await
    696             .assert_error(ErrorCode::GENERIC_PATH_SEGMENT_MALFORMED);
    697         ctx.get("/withdrawals/chocolate")
    698             .await
    699             .assert_error(ErrorCode::GENERIC_PATH_SEGMENT_MALFORMED);
    700 
    701         // Unknown
    702         ctx.get(format!("/taler-integration/withdrawal-operation/{unknown}"))
    703             .await
    704             .assert_error(ErrorCode::BANK_TRANSACTION_NOT_FOUND);
    705         ctx.get(format!("/withdrawals/{unknown}"))
    706             .await
    707             .assert_error(ErrorCode::BANK_TRANSACTION_NOT_FOUND);
    708 
    709         // Select
    710         {
    711             let key = EddsaPublicKey::rand();
    712             let req = json!({
    713                 "reserve_pub": key,
    714                 "selected_exchange": ctx.exchange_payto
    715             });
    716 
    717             // Bad UUID
    718             ctx.post("/taler-integration/withdrawal-operation/chocolate")
    719                 .json(&req)
    720                 .await
    721                 .assert_error(ErrorCode::GENERIC_PATH_SEGMENT_MALFORMED);
    722 
    723             // Unknown
    724             ctx.post(format!("/taler-integration/withdrawal-operation/{unknown}"))
    725                 .json(&req)
    726                 .await
    727                 .assert_error(ErrorCode::BANK_TRANSACTION_NOT_FOUND);
    728 
    729             let uuid = ctx
    730                 .posta("/accounts/merchant/withdrawals")
    731                 .json(json!({ "amount": "KUDOS:1"}))
    732                 .await
    733                 .assert_ok_json::<BankAccountCreateWithdrawalResponse>()
    734                 .withdrawal_id;
    735             // OK
    736             let res = ctx
    737                 .post(format!("/taler-integration/withdrawal-operation/{uuid}"))
    738                 .json(&req)
    739                 .await
    740                 .assert_ok_json::<BankWithdrawalOperationPostResponse>();
    741             assert_eq!(res.status, WithdrawalStatus::selected);
    742             assert_eq!(
    743                 res.confirm_transfer_url,
    744                 Some(format!("http://localhost:8080/webui/#/operation/{uuid}"))
    745             );
    746             // Idempotent
    747             assert_eq!(
    748                 res,
    749                 ctx.post(format!("/taler-integration/withdrawal-operation/{uuid}"))
    750                     .json(&req)
    751                     .await
    752                     .assert_ok_json()
    753             );
    754             // Already selected
    755             ctx.post(format!("/taler-integration/withdrawal-operation/{uuid}"))
    756                 .json(json!(req + { "reserve_pub": EddsaPublicKey::rand() }))
    757                 .await
    758                 .assert_error(ErrorCode::BANK_WITHDRAWAL_OPERATION_RESERVE_SELECTION_CONFLICT);
    759 
    760             let uuid = ctx
    761                 .posta("/accounts/merchant/withdrawals")
    762                 .json(json!({ "amount": "KUDOS:1"}))
    763                 .await
    764                 .assert_ok_json::<BankAccountCreateWithdrawalResponse>()
    765                 .withdrawal_id;
    766             // Reserve pub reuse
    767             ctx.post(format!("/taler-integration/withdrawal-operation/{uuid}"))
    768                 .json(json!(req))
    769                 .await
    770                 .assert_error(ErrorCode::BANK_DUPLICATE_RESERVE_PUB_SUBJECT);
    771             // Amount differs
    772             ctx.post(format!("/taler-integration/withdrawal-operation/{uuid}"))
    773                 .json(json!(req + { "amount": "KUDOS:2" }))
    774                 .await
    775                 .assert_error(ErrorCode::BANK_AMOUNT_DIFFERS);
    776             // Unknown account
    777             ctx.post(format!("/taler-integration/withdrawal-operation/{uuid}"))
    778                 .json(json!({
    779                     "reserve_pub": EddsaPublicKey::rand(),
    780                     "selected_exchange": ctx.unknown_payto
    781                 }))
    782                 .await
    783                 .assert_error_status(ErrorCode::BANK_UNKNOWN_ACCOUNT, StatusCode::CONFLICT);
    784             // Not exchange
    785             ctx.post(format!("/taler-integration/withdrawal-operation/{uuid}"))
    786                 .json(json!({
    787                     "reserve_pub": EddsaPublicKey::rand(),
    788                     "selected_exchange": ctx.merchant_payto
    789                 }))
    790                 .await
    791                 .assert_error(ErrorCode::BANK_ACCOUNT_IS_NOT_EXCHANGE);
    792 
    793             ctx.post(format!("/taler-integration/withdrawal-operation/{uuid}"))
    794                 .json(json!({
    795                     "reserve_pub": EddsaPublicKey::rand(),
    796                     "selected_exchange": ctx.exchange_payto,
    797                     "amount": "KUDOS:1"
    798                 }))
    799                 .await
    800                 .assert_ok();
    801 
    802             // Check select aborted
    803             let uuid = ctx
    804                 .posta("/accounts/merchant/withdrawals")
    805                 .json(json!({ "amount": "KUDOS:1"}))
    806                 .await
    807                 .assert_ok_json::<BankAccountCreateWithdrawalResponse>()
    808                 .withdrawal_id;
    809             ctx.post(format!(
    810                 "/taler-integration/withdrawal-operation/{uuid}/abort"
    811             ))
    812             .await
    813             .assert_no_content();
    814             ctx.post(format!("/taler-integration/withdrawal-operation/{uuid}"))
    815                 .json(json!({
    816                     "reserve_pub": EddsaPublicKey::rand(),
    817                     "selected_exchange": ctx.exchange_payto
    818                 }))
    819                 .await
    820                 .assert_error(ErrorCode::BANK_UPDATE_ABORT_CONFLICT);
    821 
    822             // Insufficient fund
    823             let uuid = ctx
    824                 .posta("/accounts/merchant/withdrawals")
    825                 .json(json!({}))
    826                 .await
    827                 .assert_ok_json::<BankAccountCreateWithdrawalResponse>()
    828                 .withdrawal_id;
    829             ctx.post(format!("/taler-integration/withdrawal-operation/{uuid}"))
    830                 .json(json!({
    831                     "reserve_pub": EddsaPublicKey::rand(),
    832                     "selected_exchange": ctx.exchange_payto,
    833                     "amount": "KUDOS:11"
    834                 }))
    835                 .await
    836                 .assert_error(ErrorCode::BANK_UNALLOWED_DEBIT);
    837             ctx.post(format!("/taler-integration/withdrawal-operation/{uuid}"))
    838                 .json(json!({
    839                     "reserve_pub": EddsaPublicKey::rand(),
    840                     "selected_exchange": ctx.exchange_payto,
    841                     "amount": "KUDOS:1.1"
    842                 }))
    843                 .await
    844                 .assert_ok();
    845             let w = ctx
    846                 .get(format!("/taler-integration/withdrawal-operation/{uuid}"))
    847                 .await
    848                 .assert_ok_json::<BankWithdrawalOperationStatus>();
    849             assert_eq!(w.amount, Some(amount("KUDOS:1.1")));
    850             assert_eq!(w.max_amount, Some(amount("KUDOS:10")));
    851         }
    852 
    853         // Abort
    854         {
    855             // Bad UUID
    856             ctx.post("/taler-integration/withdrawal-operation/chocolate/abort")
    857                 .await
    858                 .assert_error(ErrorCode::GENERIC_PATH_SEGMENT_MALFORMED);
    859             ctx.posta("/accounts/merchant/withdrawals/chocolate/abort")
    860                 .await
    861                 .assert_error(ErrorCode::GENERIC_PATH_SEGMENT_MALFORMED);
    862 
    863             // Unknown
    864             ctx.post(format!(
    865                 "/taler-integration/withdrawal-operation/{unknown}/abort"
    866             ))
    867             .await
    868             .assert_error(ErrorCode::BANK_TRANSACTION_NOT_FOUND);
    869             ctx.posta(format!("/accounts/merchant/withdrawals/{unknown}/abort"))
    870                 .await
    871                 .assert_error(ErrorCode::BANK_TRANSACTION_NOT_FOUND);
    872 
    873             // Abort created
    874             let uuid = ctx
    875                 .posta("/accounts/merchant/withdrawals")
    876                 .json(json!({ "amount": "KUDOS:1"}))
    877                 .await
    878                 .assert_ok_json::<BankAccountCreateWithdrawalResponse>()
    879                 .withdrawal_id;
    880             for _ in 0..2 {
    881                 ctx.post(format!(
    882                     "/taler-integration/withdrawal-operation/{uuid}/abort"
    883                 ))
    884                 .await
    885                 .assert_no_content();
    886             }
    887             let uuid = ctx
    888                 .posta("/accounts/merchant/withdrawals")
    889                 .json(json!({ "amount": "KUDOS:1"}))
    890                 .await
    891                 .assert_ok_json::<BankAccountCreateWithdrawalResponse>()
    892                 .withdrawal_id;
    893             for _ in 0..2 {
    894                 ctx.posta(format!("/accounts/merchant/withdrawals/{uuid}/abort"))
    895                     .await
    896                     .assert_no_content();
    897             }
    898 
    899             // Abort selected
    900             let uuid = ctx
    901                 .posta("/accounts/merchant/withdrawals")
    902                 .json(json!({ "amount": "KUDOS:1"}))
    903                 .await
    904                 .assert_ok_json::<BankAccountCreateWithdrawalResponse>()
    905                 .withdrawal_id;
    906             ctx.withdraw_select(uuid).await;
    907             for _ in 0..2 {
    908                 ctx.post(format!(
    909                     "/taler-integration/withdrawal-operation/{uuid}/abort"
    910                 ))
    911                 .await
    912                 .assert_no_content();
    913             }
    914             let uuid = ctx
    915                 .posta("/accounts/merchant/withdrawals")
    916                 .json(json!({ "amount": "KUDOS:1"}))
    917                 .await
    918                 .assert_ok_json::<BankAccountCreateWithdrawalResponse>()
    919                 .withdrawal_id;
    920             ctx.withdraw_select(uuid).await;
    921             for _ in 0..2 {
    922                 ctx.posta(format!("/accounts/merchant/withdrawals/{uuid}/abort"))
    923                     .await
    924                     .assert_no_content();
    925             }
    926 
    927             // Abort confirmed
    928             let uuid = ctx
    929                 .posta("/accounts/merchant/withdrawals")
    930                 .json(json!({ "amount": "KUDOS:1"}))
    931                 .await
    932                 .assert_ok_json::<BankAccountCreateWithdrawalResponse>()
    933                 .withdrawal_id;
    934             ctx.withdraw_select(uuid).await;
    935             ctx.posta(format!("/accounts/merchant/withdrawals/{uuid}/confirm"))
    936                 .json(json!({}))
    937                 .await
    938                 .assert_no_content();
    939             ctx.post(format!(
    940                 "/taler-integration/withdrawal-operation/{uuid}/abort"
    941             ))
    942             .await
    943             .assert_error(ErrorCode::BANK_ABORT_CONFIRM_CONFLICT);
    944             ctx.posta(format!("/accounts/merchant/withdrawals/{uuid}/abort"))
    945                 .await
    946                 .assert_error(ErrorCode::BANK_ABORT_CONFIRM_CONFLICT);
    947         }
    948 
    949         // Confirm
    950         {
    951             // Bad UUID
    952             ctx.posta("/accounts/merchant/withdrawals/chocolate/confirm")
    953                 .await
    954                 .assert_error(ErrorCode::GENERIC_PATH_SEGMENT_MALFORMED);
    955             // Unknown
    956             ctx.posta(format!("/accounts/merchant/withdrawals/{unknown}/confirm"))
    957                 .json(json!({}))
    958                 .await
    959                 .assert_error(ErrorCode::BANK_TRANSACTION_NOT_FOUND);
    960 
    961             // Confirm created
    962             let uuid = ctx
    963                 .posta("/accounts/merchant/withdrawals")
    964                 .json(json!({ "amount": "KUDOS:0.1"}))
    965                 .await
    966                 .assert_ok_json::<BankAccountCreateWithdrawalResponse>()
    967                 .withdrawal_id;
    968             ctx.posta(format!("/accounts/merchant/withdrawals/{uuid}/confirm"))
    969                 .json(json!({}))
    970                 .await
    971                 .assert_error(ErrorCode::BANK_CONFIRM_INCOMPLETE);
    972 
    973             // Confirm selected
    974             ctx.withdraw_select(uuid).await;
    975             // Amount differs
    976             ctx.posta(format!("/accounts/merchant/withdrawals/{uuid}/confirm"))
    977                 .json(json!({ "amount": "KUDOS:0.2" }))
    978                 .await
    979                 .assert_error(ErrorCode::BANK_AMOUNT_DIFFERS);
    980             // Idempotent
    981             for _ in 0..2 {
    982                 ctx.posta(format!("/accounts/merchant/withdrawals/{uuid}/confirm"))
    983                     .json(json!({}))
    984                     .await
    985                     .assert_no_content();
    986             }
    987             // Amount still differs
    988             ctx.posta(format!("/accounts/merchant/withdrawals/{uuid}/confirm"))
    989                 .json(json!({ "amount": "KUDOS:0.2" }))
    990                 .await
    991                 .assert_error(ErrorCode::BANK_AMOUNT_DIFFERS);
    992 
    993             // A selected operation is bound to the account that created it.
    994             let uuid = ctx
    995                 .posta("/accounts/merchant/withdrawals")
    996                 .json(json!({ "amount": "KUDOS:1" }))
    997                 .await
    998                 .assert_ok_json::<BankAccountCreateWithdrawalResponse>()
    999                 .withdrawal_id;
   1000             ctx.withdraw_select(uuid).await;
   1001             ctx.posta(format!("/accounts/customer/withdrawals/{uuid}/confirm"))
   1002                 .json(json!({}))
   1003                 .await
   1004                 .assert_error(ErrorCode::BANK_TRANSACTION_NOT_FOUND);
   1005             ctx.posta(format!("/accounts/merchant/withdrawals/{uuid}/abort"))
   1006                 .json(json!({}))
   1007                 .await
   1008                 .assert_no_content();
   1009 
   1010             // Confirm with amount
   1011             let uuid = ctx
   1012                 .posta("/accounts/merchant/withdrawals")
   1013                 .json(json!({}))
   1014                 .await
   1015                 .assert_ok_json::<BankAccountCreateWithdrawalResponse>()
   1016                 .withdrawal_id;
   1017             ctx.withdraw_select(uuid).await;
   1018             // Missing amount
   1019             ctx.posta(format!("/accounts/merchant/withdrawals/{uuid}/confirm"))
   1020                 .json(json!({}))
   1021                 .await
   1022                 .assert_error(ErrorCode::BANK_AMOUNT_REQUIRED);
   1023             // Idempotent
   1024             for _ in 0..2 {
   1025                 ctx.posta(format!("/accounts/merchant/withdrawals/{uuid}/confirm"))
   1026                     .json(json!({ "amount": "KUDOS:0.1" }))
   1027                     .await
   1028                     .assert_no_content();
   1029             }
   1030             // Amount differs
   1031             ctx.posta(format!("/accounts/merchant/withdrawals/{uuid}/confirm"))
   1032                 .json(json!({ "amount": "KUDOS:0.2" }))
   1033                 .await
   1034                 .assert_error(ErrorCode::BANK_AMOUNT_DIFFERS);
   1035 
   1036             // Confirm aborted
   1037             let uuid = ctx
   1038                 .posta("/accounts/merchant/withdrawals")
   1039                 .json(json!({ "amount": "KUDOS:0.1" }))
   1040                 .await
   1041                 .assert_ok_json::<BankAccountCreateWithdrawalResponse>()
   1042                 .withdrawal_id;
   1043             ctx.withdraw_select(uuid).await;
   1044             ctx.posta(format!("/accounts/merchant/withdrawals/{uuid}/abort"))
   1045                 .await
   1046                 .assert_no_content();
   1047             ctx.posta(format!("/accounts/merchant/withdrawals/{uuid}/confirm"))
   1048                 .json(json!({}))
   1049                 .await
   1050                 .assert_error(ErrorCode::BANK_CONFIRM_ABORT_CONFLICT);
   1051 
   1052             // Reserve pub reuse
   1053             let uuid = ctx
   1054                 .posta("/accounts/merchant/withdrawals")
   1055                 .json(json!({ "amount": "KUDOS:0.1" }))
   1056                 .await
   1057                 .assert_ok_json::<BankAccountCreateWithdrawalResponse>()
   1058                 .withdrawal_id;
   1059             let key = ctx.withdraw_select(uuid).await;
   1060             ctx.tx_s("customer", "5", "exchange", &format!("Taler {key}"))
   1061                 .await;
   1062             ctx.posta(format!("/accounts/merchant/withdrawals/{uuid}/confirm"))
   1063                 .json(json!({}))
   1064                 .await
   1065                 .assert_error(ErrorCode::BANK_DUPLICATE_RESERVE_PUB_SUBJECT);
   1066 
   1067             // Balance insufficient
   1068             let uuid = ctx
   1069                 .posta("/accounts/merchant/withdrawals")
   1070                 .json(json!({ "amount": "KUDOS:5" }))
   1071                 .await
   1072                 .assert_ok_json::<BankAccountCreateWithdrawalResponse>()
   1073                 .withdrawal_id;
   1074             ctx.withdraw_select(uuid).await;
   1075             ctx.tx("merchant", "5", "customer").await;
   1076             ctx.posta(format!("/accounts/merchant/withdrawals/{uuid}/confirm"))
   1077                 .json(json!({}))
   1078                 .await
   1079                 .assert_error(ErrorCode::BANK_UNALLOWED_DEBIT);
   1080             // Can abort because not confirmed
   1081             ctx.posta(format!("/accounts/merchant/withdrawals/{uuid}/abort"))
   1082                 .await
   1083                 .assert_no_content();
   1084         }
   1085 
   1086         // With fee
   1087         let ctx = ctx.swap_cfg("test_with_fees.conf").await;
   1088         let uuid = ctx
   1089             .posta("/accounts/merchant/withdrawals")
   1090             .json(json!({}))
   1091             .await
   1092             .assert_ok_json::<BankAccountCreateWithdrawalResponse>()
   1093             .withdrawal_id;
   1094         ctx.assert_balance("merchant", "-6.2").await;
   1095         for amount in ["KUDOS:11", "KUDOS:7", "KUDOS:4", "KUDOS:0", "KUDOS:150"] {
   1096             ctx.post(format!("/taler-integration/withdrawal-operation/{uuid}"))
   1097                 .json(json!({
   1098                     "reserve_pub": EddsaPublicKey::rand(),
   1099                     "selected_exchange": ctx.exchange_payto,
   1100                     "amount": amount
   1101                 }))
   1102                 .await
   1103                 .assert_error(ErrorCode::BANK_UNALLOWED_DEBIT);
   1104         }
   1105         ctx.post(format!("/taler-integration/withdrawal-operation/{uuid}"))
   1106             .json(json!({
   1107                 "reserve_pub": EddsaPublicKey::rand(),
   1108                 "selected_exchange": ctx.exchange_payto,
   1109                 "amount": "KUDOS:3"
   1110             }))
   1111             .await
   1112             .assert_ok();
   1113 
   1114         // Pooling
   1115         status_routine(
   1116             &ctx,
   1117             "/taler-integration/withdrawal-operation",
   1118             |it: &BankWithdrawalOperationStatus| it.status,
   1119         )
   1120         .await;
   1121         status_routine(&ctx, "/withdrawals", |it: &WithdrawalPublicInfo| it.status).await;
   1122 
   1123         // 2FA without body
   1124         ctx.fill_tan_info("merchant").await;
   1125         ctx.assert_balance("merchant", "-6.48").await;
   1126         let uuid = ctx
   1127             .posta("/accounts/merchant/withdrawals")
   1128             .json(json!({ "amount": "KUDOS:1" }))
   1129             .await
   1130             .assert_ok_json::<BankAccountCreateWithdrawalResponse>()
   1131             .withdrawal_id;
   1132         ctx.withdraw_select(uuid).await;
   1133         ctx.posta(format!("/accounts/merchant/withdrawals/{uuid}/confirm"))
   1134             .json(json!({}))
   1135             .await
   1136             .assert_challenge_check(&ctx, async |_| {
   1137                 ctx.assert_balance("merchant", "-6.48").await
   1138             })
   1139             .await
   1140             .assert_no_content();
   1141 
   1142         // 2FA with body
   1143         ctx.assert_balance("merchant", "-7.58").await;
   1144         let uuid = ctx
   1145             .posta("/accounts/merchant/withdrawals")
   1146             .json(json!({}))
   1147             .await
   1148             .assert_ok_json::<BankAccountCreateWithdrawalResponse>()
   1149             .withdrawal_id;
   1150         ctx.withdraw_select(uuid).await;
   1151         ctx.posta(format!("/accounts/merchant/withdrawals/{uuid}/confirm"))
   1152             .json(json!({ "amount": "KUDOS:1" }))
   1153             .await
   1154             .assert_challenge_check(&ctx, async |_| {
   1155                 ctx.assert_balance("merchant", "-7.58").await
   1156             })
   1157             .await
   1158             .assert_no_content();
   1159         ctx.assert_balance("merchant", "-8.68").await;
   1160     }
   1161 }