tx.rs (20640B)
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 //! Data access logic for transactions 21 22 use std::sync::Arc; 23 24 use axum::{ 25 Json, Router, 26 extract::State, 27 response::{IntoResponse, NoContent}, 28 routing::{get, post}, 29 }; 30 use jiff::Timestamp; 31 use serde::{Deserialize, Serialize}; 32 use taler_api::{ 33 error::{ApiResult, bad_request, failure, failure_code}, 34 extract::{Path, Query}, 35 }; 36 use taler_common::{ 37 api::{ShortHashCode, params::HistoryParams}, 38 error_code::ErrorCode, 39 types::{amount::Amount, payto::ParsedPayto, time::TalerTimestamp}, 40 }; 41 42 use crate::{ 43 api::BankState, 44 auth::{UserOptRAuth, UserRAuth}, 45 db::tx::{TxResult, create, get_by_id, pool_history}, 46 mfa::{BankTxOp, MfaReq}, 47 payto::{BankPayto, FullBankPayto, LibeufinId}, 48 }; 49 50 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 51 pub struct TransactionCreateRequest { 52 pub payto_uri: ParsedPayto<LibeufinId>, 53 pub amount: Option<Amount>, 54 pub request_uid: Option<ShortHashCode>, 55 } 56 57 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 58 pub struct TransactionCreateResponse { 59 pub row_id: u64, 60 } 61 62 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)] 63 #[sqlx(type_name = "direction_enum")] 64 #[allow(non_camel_case_types)] 65 pub enum TransactionDirection { 66 credit, 67 debit, 68 } 69 70 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 71 pub struct BankAccountTransactionInfo { 72 pub creditor_payto_uri: FullBankPayto, 73 pub debtor_payto_uri: FullBankPayto, 74 pub amount: Amount, 75 pub direction: TransactionDirection, 76 pub subject: String, 77 pub row_id: u64, 78 pub date: TalerTimestamp, 79 } 80 81 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 82 pub struct BankAccountTransactionsResponse { 83 pub transactions: Vec<BankAccountTransactionInfo>, 84 } 85 86 pub fn tx_api() -> Router<Arc<BankState>> { 87 Router::new() 88 .route( 89 "/accounts/{username}/transactions", 90 post( 91 async |State(state): State<Arc<BankState>>, req: MfaReq<BankTxOp>| { 92 let (mut auth, req, mfa) = req.solve(&state, &[]).await?; 93 let subject = req 94 .payto_uri 95 .subject 96 .clone() 97 .ok_or_else(|| bad_request("Wire transfer lacks subject"))?; 98 let amount = req 99 .payto_uri 100 .amount 101 .or(req.amount) 102 .ok_or_else(|| bad_request("Wire transfer lacks amount"))?; 103 104 state.cfg.check_regio(&amount)?; 105 106 match create( 107 &state.db, 108 BankPayto::new(req.payto_uri.into_inner()), 109 &auth.username, 110 &subject, 111 amount, 112 &Timestamp::now(), 113 mfa.is_2fa(), 114 &req.request_uid, 115 state.cfg.wire_transfer_fees, 116 state.cfg.min_amount, 117 state.cfg.max_amount, 118 ) 119 .await? 120 { 121 TxResult::Success(row_id) => { 122 Ok(Json(TransactionCreateResponse { row_id }).into_response()) 123 } 124 TxResult::UnknownCreditor => { 125 Err(failure_code(ErrorCode::BANK_UNKNOWN_CREDITOR)) 126 } 127 TxResult::AdminCreditor => { 128 Err(failure_code(ErrorCode::BANK_ADMIN_CREDITOR)) 129 } 130 TxResult::UnknownDebtor => { 131 Err(failure_code(ErrorCode::BANK_UNKNOWN_ACCOUNT)) 132 } 133 TxResult::BothPartySame => Err(failure_code(ErrorCode::BANK_SAME_ACCOUNT)), 134 TxResult::BalanceInsufficient => Err(failure( 135 ErrorCode::BANK_UNALLOWED_DEBIT, 136 "Insufficient funds", 137 )), 138 TxResult::BadAmount => Err(failure( 139 ErrorCode::BANK_UNALLOWED_DEBIT, 140 "Amount either to high or too low", 141 )), 142 TxResult::TanRequired => { 143 mfa.response_mfa(&mut auth, &state.db, &state.cfg.ctx).await 144 } 145 TxResult::RequestUidReuse => { 146 Err(failure_code(ErrorCode::BANK_TRANSFER_REQUEST_UID_REUSED)) 147 } 148 } 149 }, 150 ) 151 .get( 152 async |mut auth: UserOptRAuth, 153 Query(params): Query<HistoryParams>, 154 State(state): State<Arc<BankState>>| { 155 let params = params.check()?; 156 let info = auth 157 .bank_info_auth_or_public(&state.db, &state.cfg.ctx) 158 .await?; 159 let transactions = pool_history( 160 &state.db, 161 &state.cfg.ctx, 162 &state.cfg.regional_currency, 163 &state.tx_channel, 164 ¶ms, 165 info.bank_account_id, 166 ) 167 .await?; 168 if transactions.is_empty() { 169 ApiResult::Ok(NoContent.into_response()) 170 } else { 171 Ok(Json(BankAccountTransactionsResponse { transactions }).into_response()) 172 } 173 }, 174 ), 175 ) 176 .route( 177 "/accounts/{username}/transactions/{id}", 178 get( 179 async |auth: UserRAuth, 180 Path((_, id)): Path<((), u64)>, 181 State(state): State<Arc<BankState>>| { 182 if let Some(tx) = get_by_id( 183 &state.db, 184 &state.cfg.regional_currency, 185 &state.cfg.ctx, 186 id, 187 &auth.username, 188 ) 189 .await? 190 { 191 Ok(Json(tx)) 192 } else { 193 Err(failure_code(ErrorCode::BANK_TRANSACTION_NOT_FOUND)) 194 } 195 }, 196 ), 197 ) 198 } 199 200 #[cfg(test)] 201 pub mod test { 202 203 use axum::http::Method; 204 use sqlx::postgres::PgConnectOptions; 205 use taler_api::subject::{fmt_in_subject, fmt_out_subject}; 206 use taler_common::{ 207 api::{EddsaPublicKey, ShortHashCode}, 208 db::IncomingType, 209 error_code::ErrorCode, 210 types::amount::amount, 211 }; 212 use taler_macros::db_test; 213 use taler_test_utils::{ 214 json, 215 routine::{Page, routine_history}, 216 server::TestServer, 217 tasks, 218 }; 219 220 use crate::api::{ 221 test::{Auth, MfaRequest, bank_setup, bank_setup_conf}, 222 tx::{ 223 BankAccountTransactionInfo, BankAccountTransactionsResponse, TransactionCreateResponse, 224 }, 225 }; 226 227 #[db_test(raw)] 228 async fn tx(db: PgConnectOptions) { 229 let ctx = bank_setup(db).await; 230 231 ctx.auth_routine( 232 Method::POST, 233 "/accounts/merchant/transactions", 234 Auth::UserOnly, 235 ) 236 .await; 237 ctx.auth_routine( 238 Method::GET, 239 "/accounts/merchant/transactions/42", 240 Auth::UserOrAdmin, 241 ) 242 .await; 243 244 let valid_req = json!({ 245 "payto_uri": format!("{}?message=payout", ctx.exchange_payto), 246 "amount": "KUDOS:0.3" 247 }); 248 249 // OK 250 let id = ctx 251 .posta("/accounts/merchant/transactions") 252 .json(&valid_req) 253 .await 254 .assert_ok_json::<TransactionCreateResponse>() 255 .row_id; 256 let tx: BankAccountTransactionInfo = ctx 257 .geta(format!("/accounts/merchant/transactions/{id}")) 258 .await 259 .assert_ok_json(); 260 assert_eq!(tx.subject, "payout"); 261 assert_eq!(tx.amount, amount("KUDOS:0.3")); 262 263 // Idempotency 264 let uid = ShortHashCode::rand(); 265 let id = ctx 266 .posta("/accounts/merchant/transactions") 267 .json(json!(valid_req + { "request_uid": uid })) 268 .await 269 .assert_ok_json::<TransactionCreateResponse>() 270 .row_id; 271 assert_eq!( 272 id, 273 ctx.posta("/accounts/merchant/transactions") 274 .json(json!(valid_req + { "request_uid": uid })) 275 .await 276 .assert_ok_json::<TransactionCreateResponse>() 277 .row_id 278 ); 279 ctx.posta("/accounts/merchant/transactions") 280 .json(json!(valid_req + { 281 "request_uid": uid, 282 "amount": "KUDOS:42" 283 })) 284 .await 285 .assert_error(ErrorCode::BANK_TRANSFER_REQUEST_UID_REUSED); 286 287 // Amount in payto_uri 288 let id = ctx 289 .posta("/accounts/merchant/transactions") 290 .json(json!({ 291 "payto_uri": format!("{}?message=payout3&amount=KUDOS:1.05", ctx.exchange_payto), 292 "amount": "KUDOS:10.003" 293 })) 294 .await 295 .assert_ok_json::<TransactionCreateResponse>() 296 .row_id; 297 let tx: BankAccountTransactionInfo = ctx 298 .geta(format!("/accounts/merchant/transactions/{id}")) 299 .await 300 .assert_ok_json(); 301 assert_eq!(tx.subject, "payout3"); 302 assert_eq!(tx.amount, amount("KUDOS:1.05")); 303 304 // Unknown tx 305 ctx.geta("/accounts/merchant/transactions/3") 306 .await 307 .assert_error(ErrorCode::BANK_TRANSACTION_NOT_FOUND); 308 // Other user tx 309 ctx.geta(format!("/accounts/customer/transactions/{id}")) 310 .await 311 .assert_error(ErrorCode::BANK_TRANSACTION_NOT_FOUND); 312 313 // Wrong currency 314 ctx.posta("/accounts/merchant/transactions") 315 .json(json!(valid_req + { "amount": "EUR:3.3" })) 316 .await 317 .assert_error(ErrorCode::GENERIC_CURRENCY_MISMATCH); 318 // Surpassing the debt limit 319 ctx.posta("/accounts/merchant/transactions") 320 .json(json!(valid_req + { "amount": "KUDOS:555" })) 321 .await 322 .assert_error(ErrorCode::BANK_UNALLOWED_DEBIT); 323 // Missing message 324 ctx.posta("/accounts/merchant/transactions") 325 .json(json!(valid_req + { "payto_uri": ctx.exchange_payto })) 326 .await 327 .assert_bad_request(); 328 // Unknown creditor 329 ctx.posta("/accounts/merchant/transactions") 330 .json(json!(valid_req + { 331 "payto_uri": format!("{}?message=payout", ctx.unknown_payto) 332 })) 333 .await 334 .assert_error(ErrorCode::BANK_UNKNOWN_CREDITOR); 335 // Transaction to self 336 ctx.posta("/accounts/merchant/transactions") 337 .json(json!(valid_req + { 338 "payto_uri": format!("{}?message=payout", ctx.merchant_payto) 339 })) 340 .await 341 .assert_error(ErrorCode::BANK_SAME_ACCOUNT); 342 // Transaction to admin 343 ctx.posta("/accounts/merchant/transactions") 344 .json(json!(valid_req + { 345 "payto_uri": format!("{}?message=payout", ctx.admin_payto) 346 })) 347 .await 348 .assert_error(ErrorCode::BANK_ADMIN_CREDITOR); 349 350 // Init state 351 ctx.assert_balance("merchant", "0").await; 352 ctx.assert_balance("customer", "0").await; 353 // Send 2 times 3 354 for _ in 0..2 { 355 ctx.tx("merchant", "3", "customer").await; 356 } 357 ctx.posta("/accounts/merchant/transactions") 358 .json(json!(valid_req + { 359 "payto_uri": format!("{}?message=payout2&amount=KUDOS:5", ctx.customer_payto) 360 })) 361 .await 362 .assert_error(ErrorCode::BANK_UNALLOWED_DEBIT); 363 ctx.assert_balance("merchant", "-6").await; 364 ctx.assert_balance("customer", "6").await; 365 // Send through debt 366 ctx.tx("customer", "10", "merchant").await; 367 ctx.assert_balance("merchant", "4").await; 368 ctx.assert_balance("customer", "-4").await; 369 ctx.tx("merchant", "4", "customer").await; 370 371 // Check bounce 372 ctx.assert_balance("merchant", "0").await; 373 ctx.assert_balance("exchange", "0").await; 374 ctx.tx_s("merchant", "1", "exchange", "").await; // Bounce common to transaction 375 ctx.tx_s("merchant", "1", "exchange", "Malformed").await; // Bounce malformed transaction 376 ctx.tx_s("merchant", "1", "exchange", "ADMIN BALANCE ADJUST") 377 .await; // Bounce admin balance adjust 378 let key = EddsaPublicKey::rand(); 379 ctx.tx_s( 380 "merchant", 381 "1", 382 "exchange", 383 &fmt_in_subject(IncomingType::reserve, &key), 384 ) 385 .await; // Accept incoming 386 ctx.tx_s( 387 "merchant", 388 "1", 389 "exchange", 390 &fmt_in_subject(IncomingType::reserve, &key), 391 ) 392 .await; // Bounce reserve_pub reuse 393 ctx.assert_balance("merchant", "-1").await; 394 ctx.assert_balance("exchange", "1").await; 395 396 // Check warn 397 ctx.tx_s("exchange", "1", "merchant", "").await; // Warn common to transaction 398 ctx.tx_s("exchange", "1", "merchant", "Malformed").await; // Warn malformed transaction 399 let wtid = ShortHashCode::rand(); 400 let url = "https://exchange.example.com"; 401 ctx.tx_s( 402 "exchange", 403 "1", 404 "merchant", 405 &fmt_out_subject(&wtid, url, None), 406 ) 407 .await; // Accept outgoing 408 ctx.tx_s( 409 "exchange", 410 "1", 411 "merchant", 412 &fmt_out_subject(&wtid, url, None), 413 ) 414 .await; // Warn wtid reuse 415 ctx.assert_balance("merchant", "3").await; 416 ctx.assert_balance("exchange", "-3").await; 417 418 // Public history is public 419 ctx.patch_admin("/accounts/merchant") 420 .json(json!({ "is_public": true})) 421 .await 422 .assert_no_content(); 423 ctx.get("/accounts/merchant/transactions").await.assert_ok(); 424 ctx.get("/accounts/customer/transactions") 425 .await 426 .assert_error(ErrorCode::BANK_UNKNOWN_ACCOUNT); 427 ctx.patch_admin("/accounts/merchant") 428 .json(json!({ "is_public": false})) 429 .await 430 .assert_no_content(); 431 ctx.get("/accounts/merchant/transactions") 432 .await 433 .assert_error(ErrorCode::BANK_UNKNOWN_ACCOUNT); 434 435 // Check 2fa 436 ctx.fill_tan_info("merchant").await; 437 ctx.assert_balance("merchant", "3").await; 438 ctx.assert_balance("customer", "0").await; 439 ctx.posta("/accounts/merchant/transactions") 440 .json(json!(valid_req + { 441 "payto_uri": format!("{}?message=tan+check&amount=KUDOS:1", ctx.customer_payto) 442 })) 443 .await 444 .assert_challenge_check(&ctx, async |_| { 445 ctx.assert_balance("merchant", "3").await; 446 ctx.assert_balance("customer", "0").await; 447 }) 448 .await 449 .assert_ok(); 450 ctx.assert_balance("merchant", "2").await; 451 ctx.assert_balance("customer", "1").await; 452 453 // Check 2fa idempotency 454 let req = json!({ 455 "payto_uri": format!("{}?message=tan+check&amount=KUDOS:1", ctx.customer_payto), 456 "request_uid": ShortHashCode::rand(), 457 }); 458 let res = ctx 459 .posta("/accounts/merchant/transactions") 460 .json(&req) 461 .await 462 .assert_challenge_check(&ctx, async |_| { 463 ctx.assert_balance("merchant", "2").await; 464 ctx.assert_balance("customer", "1").await; 465 }) 466 .await 467 .assert_ok_json::<TransactionCreateResponse>(); 468 ctx.assert_balance("merchant", "1").await; 469 ctx.assert_balance("customer", "2").await; 470 assert_eq!( 471 res, 472 ctx.posta("/accounts/merchant/transactions") 473 .json(&req) 474 .await 475 .assert_ok_json::<TransactionCreateResponse>() 476 ); 477 ctx.posta("/accounts/merchant/transactions") 478 .json(json!(req + { 479 "payto_uri": format!("{}?message=tan+chec2k&amount=KUDOS:1", ctx.customer_payto) 480 })) 481 .await 482 .assert_error(ErrorCode::BANK_TRANSFER_REQUEST_UID_REUSED); 483 } 484 485 #[db_test(raw)] 486 async fn tx_with_fee(db: PgConnectOptions) { 487 let ctx = bank_setup_conf(db, "test_with_fees.conf").await; 488 489 // Init state 490 ctx.assert_balance("merchant", "0").await; 491 ctx.assert_balance("customer", "0").await; 492 ctx.assert_balance("admin", "0").await; 493 494 // Check fee are sent to admin 495 ctx.tx("merchant", "3", "customer").await; 496 ctx.assert_balance("merchant", "-3.1").await; 497 ctx.assert_balance("customer", "3").await; 498 ctx.assert_balance("admin", "0.1").await; 499 500 // Check amount with fee and min & max are checked 501 for amount in ["KUDOS:7", "KUDOS:6.9", "KUDOS:0", "KUDOS:150"] { 502 ctx.posta("/accounts/merchant/transactions") 503 .json(json!({ 504 "payto_uri": format!("{}?message=payout2&amount={amount}", ctx.customer_payto) 505 })) 506 .await 507 .assert_error(ErrorCode::BANK_UNALLOWED_DEBIT); 508 } 509 510 // Check empty account 511 ctx.tx("merchant", "6.8", "customer").await; 512 ctx.assert_balance("merchant", "-10").await; 513 ctx.assert_balance("customer", "9.8").await; 514 ctx.assert_balance("admin", "0.2").await; 515 516 // Admin check no fee 517 ctx.tx("admin", "0.35", "merchant").await; 518 ctx.assert_balance("merchant", "-9.65").await; 519 ctx.assert_balance("admin", "-0.15").await; 520 521 // Admin recover from debt 522 ctx.tx("customer", "1", "merchant").await; 523 ctx.assert_balance("admin", "-0.05").await; 524 ctx.tx("customer", "1", "merchant").await; 525 ctx.assert_balance("merchant", "-7.65").await; 526 ctx.assert_balance("customer", "7.6").await; 527 ctx.assert_balance("admin", "0.05").await; 528 } 529 530 impl Page for BankAccountTransactionsResponse { 531 fn ids(&self) -> Vec<i64> { 532 self.transactions 533 .iter() 534 .map(|it| it.row_id as i64) 535 .collect() 536 } 537 } 538 539 #[db_test(raw)] 540 async fn history(db: PgConnectOptions) { 541 let ctx = &bank_setup(db).await; 542 543 ctx.auth_routine( 544 Method::POST, 545 "/accounts/merchant/transactions", 546 Auth::UserOrAdmin, 547 ) 548 .await; 549 550 ctx.fill_cashout_info("customer").await; 551 552 routine_history::<BankAccountTransactionsResponse>( 553 &ctx.admin_router() 554 .await 555 .suffix("/accounts/customer/transactions"), 556 tasks!( 557 // Incoming 558 { ctx.tx("merchant", "0.1", "customer").await }, 559 // Outgoing 560 { ctx.tx("customer", "0.1", "merchant").await }, 561 // Cashout from merchant 562 { ctx.cashout("0.1").await } 563 ), 564 tasks!( 565 // Other account 566 { ctx.tx("merchant", "0.1", "exchange").await }, 567 { ctx.tx("exchange", "0.1", "merchant").await }, 568 ), 569 ) 570 .await; 571 } 572 }