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 ¶ms, 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 ¶ms, 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 }