cashout.rs (15346B)
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 middleware::{self, Next}, 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_code, not_implemented}, 34 extract::{Path, Query}, 35 }; 36 use taler_common::{ 37 api::{ShortHashCode, params::PageParams}, 38 error_code::ErrorCode, 39 types::{amount::Amount, time::TalerTimestamp}, 40 }; 41 42 use crate::{ 43 api::BankState, 44 auth::{AdminRAuth, UserRAuth}, 45 db::cashout::{CreationResult, create, get_for_user, page_all, page_for_user}, 46 mfa::{CashoutOp, MfaReq}, 47 }; 48 49 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 50 #[allow(non_camel_case_types)] 51 pub enum CashoutStatus { 52 pending, 53 aborted, 54 confirmed, 55 } 56 57 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 58 pub struct CashoutRequest { 59 pub request_uid: ShortHashCode, 60 pub subject: Option<String>, 61 pub amount_debit: Amount, 62 pub amount_credit: Amount, 63 } 64 65 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 66 pub struct CashoutResponse { 67 pub cashout_id: u64, 68 } 69 70 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 71 pub struct Cashouts { 72 pub cashouts: Vec<CashoutInfo>, 73 } 74 75 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 76 pub struct CashoutInfo { 77 pub cashout_id: u64, 78 pub status: CashoutStatus, 79 } 80 81 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 82 pub struct GlobalCashouts { 83 pub cashouts: Vec<GlobalCashoutInfo>, 84 } 85 86 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 87 pub struct GlobalCashoutInfo { 88 pub cashout_id: u64, 89 pub username: CompactString, 90 pub status: CashoutStatus, 91 } 92 93 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 94 pub struct CashoutStatusResponse { 95 pub amount_debit: Amount, 96 pub amount_credit: Amount, 97 pub subject: String, 98 pub creation_time: TalerTimestamp, 99 pub confirmation_time: Option<TalerTimestamp>, 100 } 101 102 pub fn cashout_api(state: Arc<BankState>) -> Router<Arc<BankState>> { 103 Router::new() 104 .route( 105 "/cashouts", 106 get( 107 async |Query(params): Query<PageParams>, 108 _: AdminRAuth, 109 State(state): State<Arc<BankState>>| { 110 let params = params.check()?; 111 112 let cashouts = page_all(&state.db, ¶ms).await?; 113 if cashouts.is_empty() { 114 ApiResult::Ok(NoContent.into_response()) 115 } else { 116 Ok(Json(GlobalCashouts { cashouts }).into_response()) 117 } 118 }, 119 ), 120 ) 121 .route( 122 "/accounts/{username}/cashouts", 123 post( 124 async |State(state): State<Arc<BankState>>, req: MfaReq<CashoutOp>| { 125 let (mut auth, req, mfa) = req.solve(&state, &[]).await?; 126 state.cfg.check_regio(&req.amount_debit)?; 127 state.cfg.check_fiat(&req.amount_credit)?; 128 match create( 129 &state.db, 130 &auth.username, 131 &req.request_uid, 132 &req.amount_debit, 133 &req.amount_credit, 134 &req.subject.unwrap_or_default(), 135 &Timestamp::now(), 136 mfa.is_2fa(), 137 ) 138 .await? 139 { 140 CreationResult::Success(cashout_id) => { 141 Ok(Json(CashoutResponse { cashout_id }).into_response()) 142 } 143 CreationResult::UnderMin => { 144 Err(failure_code(ErrorCode::BANK_CONVERSION_AMOUNT_TO_SMALL)) 145 } 146 CreationResult::BadConversion => { 147 Err(failure_code(ErrorCode::BANK_BAD_CONVERSION)) 148 } 149 CreationResult::AccountNotFound => { 150 Err(failure_code(ErrorCode::BANK_UNKNOWN_ACCOUNT)) 151 } 152 CreationResult::AccountIsExchange => { 153 Err(failure_code(ErrorCode::BANK_ACCOUNT_IS_EXCHANGE)) 154 } 155 CreationResult::BalanceInsufficient => { 156 Err(failure_code(ErrorCode::BANK_UNALLOWED_DEBIT)) 157 } 158 CreationResult::RequestUidReuse => { 159 Err(failure_code(ErrorCode::BANK_TRANSFER_REQUEST_UID_REUSED)) 160 } 161 CreationResult::NoCashoutPayto => { 162 Err(failure_code(ErrorCode::BANK_CONFIRM_INCOMPLETE)) 163 } 164 CreationResult::TanRequired => Ok(mfa 165 .response_mfa(&mut auth, &state.db, &state.cfg.ctx) 166 .await? 167 .into_response()), 168 } 169 }, 170 ) 171 .get( 172 async |Query(params): Query<PageParams>, 173 auth: UserRAuth, 174 State(state): State<Arc<BankState>>| { 175 let params = params.check()?; 176 177 let cashouts = page_for_user(&state.db, &auth.username, ¶ms).await?; 178 if cashouts.is_empty() { 179 ApiResult::Ok(NoContent.into_response()) 180 } else { 181 Ok(Json(Cashouts { cashouts }).into_response()) 182 } 183 }, 184 ), 185 ) 186 .route( 187 "/accounts/{username}/cashouts/{id}", 188 get( 189 async |Path((_, id)): Path<((), u64)>, 190 auth: UserRAuth, 191 State(state): State<Arc<BankState>>| { 192 match get_for_user( 193 &state.db, 194 &state.cfg.regional_currency, 195 state.cfg.fiat_currency().unwrap(), 196 &auth.username, 197 id, 198 ) 199 .await? 200 { 201 Some(res) => Ok(Json(res)), 202 None => Err(failure_code(ErrorCode::BANK_TRANSACTION_NOT_FOUND)), 203 } 204 }, 205 ), 206 ) 207 .route_layer(middleware::from_fn_with_state( 208 state, 209 async |State(state): State<Arc<BankState>>, req, next: Next| { 210 if state.cfg.fiat.is_none() { 211 not_implemented().into_response() 212 } else { 213 next.run(req).await 214 } 215 }, 216 )) 217 } 218 219 #[cfg(test)] 220 pub mod test { 221 use axum::http::Method; 222 use sqlx::postgres::PgConnectOptions; 223 use taler_common::{api::ShortHashCode, error_code::ErrorCode, types::amount::amount}; 224 use taler_macros::db_test; 225 use taler_test_utils::{ 226 json, 227 routine::{Page, routine_pagination}, 228 server::TestServer, 229 tasks, 230 }; 231 232 use crate::api::{ 233 cashout::{CashoutResponse, CashoutStatusResponse, Cashouts, GlobalCashouts}, 234 conversion::ConversionRateClassResponse, 235 test::{Auth, MfaRequest, bank_setup}, 236 }; 237 238 #[db_test(raw)] 239 async fn cashout(db: PgConnectOptions) { 240 let ctx = bank_setup(db).await; 241 242 ctx.auth_routine(Method::POST, "/accounts/merchant/cashouts", Auth::UserOnly) 243 .await; 244 ctx.auth_routine( 245 Method::GET, 246 "/accounts/merchant/cashouts/42", 247 Auth::UserOrAdmin, 248 ) 249 .await; 250 ctx.auth_routine( 251 Method::GET, 252 "/accounts/merchant/cashouts", 253 Auth::UserOrAdmin, 254 ) 255 .await; 256 ctx.auth_routine(Method::GET, "/cashouts", Auth::Admin) 257 .await; 258 259 let str = "KUDOS:1.5"; 260 let debit = amount(str); 261 let credit = ctx.convert(str).await; 262 263 let req = json!({ 264 "request_uid": ShortHashCode::rand(), 265 "amount_debit": debit, 266 "amount_credit": credit, 267 "subject": "test subject" 268 }); 269 270 // Missing info 271 ctx.posta("/accounts/customer/cashouts") 272 .json(&req) 273 .await 274 .assert_error(ErrorCode::BANK_CONFIRM_INCOMPLETE); 275 276 ctx.fill_cashout_info("customer").await; 277 278 // Ok 279 let res = ctx 280 .posta("/accounts/customer/cashouts") 281 .json(&req) 282 .await 283 .assert_ok_json::<CashoutResponse>(); 284 // Idempotent 285 assert_eq!( 286 res, 287 ctx.posta("/accounts/customer/cashouts") 288 .json(&req) 289 .await 290 .assert_ok_json::<CashoutResponse>() 291 ); 292 293 // Trigger conflict due to reused request_uid 294 ctx.posta("/accounts/customer/cashouts") 295 .json(json!(req + { 296 "amount_debit": "KUDOS:2", 297 "amount_credit": ctx.convert("KUDOS:2").await 298 })) 299 .await 300 .assert_error(ErrorCode::BANK_TRANSFER_REQUEST_UID_REUSED); 301 302 // Check exchange account 303 ctx.posta("/accounts/exchange/cashouts") 304 .json(&req) 305 .await 306 .assert_error(ErrorCode::BANK_ACCOUNT_IS_EXCHANGE); 307 308 // Check insufficient fund 309 ctx.posta("/accounts/customer/cashouts") 310 .json(json!({ 311 "request_uid": ShortHashCode::rand(), 312 "amount_debit": "KUDOS:75", 313 "amount_credit": ctx.convert("KUDOS:75").await, 314 })) 315 .await 316 .assert_error(ErrorCode::BANK_UNALLOWED_DEBIT); 317 318 // Check wrong conversion 319 ctx.posta("/accounts/customer/cashouts") 320 .json(json!(req + { 321 "amount_credit": ctx.convert("KUDOS:2").await 322 })) 323 .await 324 .assert_error(ErrorCode::BANK_BAD_CONVERSION); 325 326 // Check min amount 327 ctx.posta("/accounts/customer/cashouts") 328 .json(json!(req + { "amount_debit": "KUDOS:0.09" })) 329 .await 330 .assert_error(ErrorCode::BANK_CONVERSION_AMOUNT_TO_SMALL); 331 332 // Check custom min account 333 let id = ctx 334 .post_admin("/conversion-rate-classes") 335 .json(json!({ 336 "name": "Custom class", 337 "cashout_min_amount": "KUDOS:10" 338 })) 339 .await 340 .assert_ok_json::<ConversionRateClassResponse>() 341 .conversion_rate_class_id; 342 ctx.patch_admin("/accounts/customer") 343 .json(json!({ 344 "conversion_rate_class_id": id 345 })) 346 .await 347 .assert_no_content(); 348 ctx.posta("/accounts/customer/cashouts") 349 .json(json!(req + { 350 "amount_debit": "KUDOS:5", 351 "amount_credit": ctx.convert("KUDOS:5").await 352 })) 353 .await 354 .assert_error(ErrorCode::BANK_CONVERSION_AMOUNT_TO_SMALL); 355 ctx.patch_admin("/accounts/customer") 356 .json(json!({ 357 "conversion_rate_class_id": () 358 })) 359 .await 360 .assert_no_content(); 361 362 // Wrong currency 363 ctx.posta("/accounts/customer/cashouts") 364 .json(json!(req + { "amount_debit": "EUR:1" })) 365 .await 366 .assert_error(ErrorCode::GENERIC_CURRENCY_MISMATCH); 367 ctx.posta("/accounts/customer/cashouts") 368 .json(json!(req + { "amount_credit": "KUDOS:1" })) 369 .await 370 .assert_error(ErrorCode::GENERIC_CURRENCY_MISMATCH); 371 372 // 2fa 373 ctx.fill_tan_info("merchant").await; 374 ctx.fill_cashout_info("merchant").await; 375 ctx.assert_balance("merchant", "0").await; 376 ctx.posta("/accounts/merchant/cashouts") 377 .json(json!(req + { "request_uid": ShortHashCode::rand() })) 378 .await 379 .assert_challenge_check(&ctx, async |_| ctx.assert_balance("merchant", "0").await) 380 .await 381 .assert_ok_json::<CashoutResponse>(); 382 ctx.assert_balance("merchant", "-1.5").await; 383 384 // Get 385 let c = ctx 386 .geta(format!("/accounts/customer/cashouts/{}", res.cashout_id)) 387 .await 388 .assert_ok_json::<CashoutStatusResponse>(); 389 assert_eq!( 390 c, 391 CashoutStatusResponse { 392 amount_debit: debit, 393 amount_credit: credit, 394 subject: "test subject".into(), 395 creation_time: c.creation_time, 396 confirmation_time: Some(c.creation_time) 397 } 398 ); 399 400 // Bad ID 401 ctx.geta("/accounts/customer/cashouts/chocolate") 402 .await 403 .assert_error(ErrorCode::GENERIC_PATH_SEGMENT_MALFORMED); 404 405 // Unknown 406 ctx.geta("/accounts/customer/cashouts/42") 407 .await 408 .assert_error(ErrorCode::BANK_TRANSACTION_NOT_FOUND); 409 410 // Another user's operation 411 ctx.geta(format!("/accounts/merchant/cashouts/{}", res.cashout_id)) 412 .await 413 .assert_error(ErrorCode::BANK_TRANSACTION_NOT_FOUND); 414 415 // History 416 let router = ctx.admin_router().await; 417 let c = &ctx; 418 routine_pagination::<Cashouts>( 419 &router.suffix("/accounts/customer/cashouts"), 420 tasks!({ 421 c.cashout("0.1").await; 422 }), 423 ) 424 .await; 425 routine_pagination::<Cashouts>( 426 &router.suffix("/cashouts"), 427 tasks!({ 428 c.cashout("0.1").await; 429 }), 430 ) 431 .await; 432 433 // Not implemented 434 let ctx = ctx.swap_cfg("test_no_conversion.conf").await; 435 ctx.get("/accounts/customer/cashouts") 436 .await 437 .assert_not_implemented(); 438 } 439 440 impl Page for Cashouts { 441 fn ids(&self) -> Vec<i64> { 442 self.cashouts 443 .iter() 444 .map(|it| it.cashout_id as i64) 445 .collect() 446 } 447 } 448 449 impl Page for GlobalCashouts { 450 fn ids(&self) -> Vec<i64> { 451 self.cashouts 452 .iter() 453 .map(|it| it.cashout_id as i64) 454 .collect() 455 } 456 } 457 }