withdrawal.rs (14962B)
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 compact_str::{CompactString, ToCompactString}; 21 use jiff::Timestamp; 22 use sqlx::{PgPool, Row, postgres::PgRow}; 23 use taler_api::{ 24 db::{BindHelper, TypeHelper, pooling}, 25 notification::NotificationChannel, 26 serialized, 27 }; 28 use taler_common::{ 29 api::EddsaPublicKey, 30 types::amount::{Amount, Currency}, 31 }; 32 use uuid::Uuid; 33 34 use crate::{ 35 api::withdrawal::{ 36 BankWithdrawalOperationStatus, Status, WithdrawalPublicInfo, WithdrawalStatus, 37 }, 38 config::WireMethod, 39 payto::{BankPayto, PaytoCtx, sql_opt_bank_payto}, 40 }; 41 42 /** Result status of withdrawal operation creation */ 43 pub enum CreationResult { 44 Success, 45 UnknownAccount, 46 AccountIsExchange, 47 BalanceInsufficient, 48 BadAmount, 49 } 50 51 /** Create a new withdrawal operation */ 52 pub async fn create( 53 db: &PgPool, 54 username: &str, 55 uuid: Uuid, 56 amount: Option<Amount>, 57 suggested_amount: Option<Amount>, 58 no_amount_to_wallet: bool, 59 timestamp: &Timestamp, 60 fees: Amount, 61 min: Amount, 62 max: Amount, 63 ) -> sqlx::Result<CreationResult> { 64 serialized!( 65 sqlx::query( 66 " 67 SELECT 68 out_account_not_found, 69 out_account_is_exchange, 70 out_balance_insufficient, 71 out_bad_amount 72 FROM create_taler_withdrawal( 73 $1,$2,$3,$4,$5,$6,$7,$8,$9 74 ) 75 ", 76 ) 77 .bind(username) 78 .bind(uuid) 79 .bind(amount) 80 .bind(suggested_amount) 81 .bind(no_amount_to_wallet) 82 .bind_timestamp(timestamp) 83 .bind(fees) 84 .bind(min) 85 .bind(max) 86 .try_map(|r: PgRow| { 87 Ok(if r.try_get_flag("out_account_not_found")? { 88 CreationResult::UnknownAccount 89 } else if r.try_get_flag("out_account_is_exchange")? { 90 CreationResult::AccountIsExchange 91 } else if r.try_get_flag("out_balance_insufficient")? { 92 CreationResult::BalanceInsufficient 93 } else if r.try_get_flag("out_bad_amount")? { 94 CreationResult::BadAmount 95 } else { 96 CreationResult::Success 97 }) 98 }) 99 .fetch_one(db) 100 ) 101 } 102 103 /** Result status of withdrawal or cashout operation abortion */ 104 pub enum AbortResult { 105 Success, 106 UnknownOperation, 107 AlreadyConfirmed, 108 } 109 110 /** Abort withdrawal operation [uuid] */ 111 pub async fn abort(db: &PgPool, uuid: Uuid) -> sqlx::Result<AbortResult> { 112 serialized!( 113 sqlx::query( 114 " 115 SELECT 116 out_no_op, 117 out_already_confirmed 118 FROM abort_taler_withdrawal($1) 119 ", 120 ) 121 .bind(uuid) 122 .try_map(|r: PgRow| { 123 Ok(if r.try_get_flag("out_no_op")? { 124 AbortResult::UnknownOperation 125 } else if r.try_get_flag("out_already_confirmed")? { 126 AbortResult::AlreadyConfirmed 127 } else { 128 AbortResult::Success 129 }) 130 }) 131 .fetch_one(db) 132 ) 133 } 134 135 /** Result withdrawal operation selection */ 136 pub enum SelectionResult { 137 Success(WithdrawalStatus), 138 UnknownOperation, 139 AlreadySelected, 140 ReservePubReuse, 141 UnknownAccount, 142 AccountIsNotExchange, 143 AmountDiffers, 144 BalanceInsufficient, 145 BadAmount, 146 AlreadyAborted, 147 } 148 149 /** Set details ([exchangePayto] & [reservePub] & [amount]) for withdrawal operation [uuid] */ 150 pub async fn set_details( 151 db: &PgPool, 152 uuid: Uuid, 153 exchange: &BankPayto, 154 reserve_pub: &EddsaPublicKey, 155 amount: Option<Amount>, 156 fees: Amount, 157 min: Amount, 158 max: Amount, 159 ) -> sqlx::Result<SelectionResult> { 160 serialized!( 161 sqlx::query( 162 " 163 SELECT 164 out_no_op, 165 out_already_selected, 166 out_reserve_pub_reuse, 167 out_account_not_found, 168 out_account_is_not_exchange, 169 out_status, 170 out_amount_differs, 171 out_balance_insufficient, 172 out_bad_amount, 173 out_aborted 174 FROM select_taler_withdrawal( 175 $1,$2,$3,$4,$5,$6,$7,$8 176 ) 177 ", 178 ) 179 .bind(uuid) 180 .bind(reserve_pub) 181 .bind(format!("Taler withdrawal {reserve_pub}")) 182 .bind(exchange.canonical()) 183 .bind(amount) 184 .bind(fees) 185 .bind(min) 186 .bind(max) 187 .try_map(|r: PgRow| { 188 Ok(if r.try_get_flag("out_aborted")? { 189 SelectionResult::AlreadyAborted 190 } else if r.try_get_flag("out_balance_insufficient")? { 191 SelectionResult::BalanceInsufficient 192 } else if r.try_get_flag("out_bad_amount")? { 193 SelectionResult::BadAmount 194 } else if r.try_get_flag("out_no_op")? { 195 SelectionResult::UnknownOperation 196 } else if r.try_get_flag("out_already_selected")? { 197 SelectionResult::AlreadySelected 198 } else if r.try_get_flag("out_amount_differs")? { 199 SelectionResult::AmountDiffers 200 } else if r.try_get_flag("out_reserve_pub_reuse")? { 201 SelectionResult::ReservePubReuse 202 } else if r.try_get_flag("out_account_not_found")? { 203 SelectionResult::UnknownAccount 204 } else if r.try_get_flag("out_account_is_not_exchange")? { 205 SelectionResult::AccountIsNotExchange 206 } else { 207 SelectionResult::Success(r.try_get("out_status")?) 208 }) 209 }) 210 .fetch_one(db) 211 ) 212 } 213 214 /** Result status of withdrawal operation confirmation */ 215 pub enum ConfirmationResult { 216 Success, 217 UnknownOperation, 218 BalanceInsufficient, 219 BadAmount, 220 NotSelected, 221 AlreadyAborted, 222 TanRequired, 223 MissingAmount, 224 AmountDiffers, 225 ReservePubReuse, 226 } 227 228 /** Confirm withdrawal operation [uuid] */ 229 pub async fn confirm( 230 db: &PgPool, 231 username: &str, 232 uuid: Uuid, 233 timestamp: &Timestamp, 234 amount: Option<Amount>, 235 is2fa: bool, 236 fees: Amount, 237 min: Amount, 238 max: Amount, 239 ) -> sqlx::Result<ConfirmationResult> { 240 serialized!( 241 sqlx::query( 242 " 243 SELECT 244 out_no_op, 245 out_balance_insufficient, 246 out_bad_amount, 247 out_not_selected, 248 out_aborted, 249 out_tan_required, 250 out_missing_amount, 251 out_amount_differs, 252 out_reserve_pub_reuse 253 FROM confirm_taler_withdrawal( 254 $1,$2,$3,$4,$5,$6,$7,$8 255 ) 256 ", 257 ) 258 .bind(username) 259 .bind(uuid) 260 .bind_timestamp(timestamp) 261 .bind(is2fa) 262 .bind(fees) 263 .bind(min) 264 .bind(max) 265 .bind(amount) 266 .try_map(|r: PgRow| { 267 Ok(if r.try_get_flag("out_no_op")? { 268 ConfirmationResult::UnknownOperation 269 } else if r.try_get_flag("out_balance_insufficient")? { 270 ConfirmationResult::BalanceInsufficient 271 } else if r.try_get_flag("out_bad_amount")? { 272 ConfirmationResult::BadAmount 273 } else if r.try_get_flag("out_not_selected")? { 274 ConfirmationResult::NotSelected 275 } else if r.try_get_flag("out_aborted")? { 276 ConfirmationResult::AlreadyAborted 277 } else if r.try_get_flag("out_tan_required")? { 278 ConfirmationResult::TanRequired 279 } else if r.try_get_flag("out_missing_amount")? { 280 ConfirmationResult::MissingAmount 281 } else if r.try_get_flag("out_amount_differs")? { 282 ConfirmationResult::AmountDiffers 283 } else if r.try_get_flag("out_reserve_pub_reuse")? { 284 ConfirmationResult::ReservePubReuse 285 } else { 286 ConfirmationResult::Success 287 }) 288 }) 289 .fetch_one(db) 290 ) 291 } 292 /** Get withdrawal operation [uuid] linked account username */ 293 pub async fn get_username(db: &PgPool, uuid: Uuid) -> sqlx::Result<Option<CompactString>> { 294 serialized!( 295 sqlx::query_scalar( 296 " 297 SELECT username 298 FROM taler_withdrawal_operations 299 JOIN bank_accounts ON wallet_bank_account=bank_account_id 300 JOIN customers ON customer_id=owning_customer_id 301 WHERE withdrawal_uuid=$1 302 ", 303 ) 304 .bind(uuid) 305 .fetch_optional(db) 306 ) 307 } 308 309 /** Pool public info of operation [uuid] */ 310 pub async fn poll_info( 311 db: &PgPool, 312 ctx: &PaytoCtx, 313 currency: &Currency, 314 channel: &NotificationChannel<Uuid, Option<WithdrawalStatus>>, 315 params: &Status, 316 uuid: Uuid, 317 ) -> sqlx::Result<Option<WithdrawalPublicInfo>> { 318 pooling( 319 ¶ms.polling, 320 || channel.subscribe(uuid), 321 |it| it.is_some_and(|v| v != params.status), 322 async || 323 serialized!( 324 sqlx::query( 325 " 326 SELECT 327 CASE 328 WHEN confirmation_done THEN 'confirmed' 329 WHEN aborted THEN 'aborted' 330 WHEN selection_done THEN 'selected' 331 ELSE 'pending' 332 END as status 333 ,amount 334 ,suggested_amount 335 ,selection_done 336 ,aborted 337 ,confirmation_done 338 ,reserve_pub 339 ,wallet_user.username 340 ,no_amount_to_wallet 341 ,exchange_account.internal_payto as exchange_payto 342 ,exchange_user.name as exchange_name 343 FROM taler_withdrawal_operations 344 LEFT JOIN bank_accounts AS wallet_account ON wallet_bank_account=wallet_account.bank_account_id 345 LEFT JOIN customers AS wallet_user ON wallet_user.customer_id=wallet_account.owning_customer_id 346 LEFT JOIN bank_accounts AS exchange_account ON exchange_bank_account=exchange_account.bank_account_id 347 LEFT JOIN customers AS exchange_user ON exchange_user.customer_id=exchange_account.owning_customer_id 348 WHERE withdrawal_uuid=$1 349 " 350 ).bind(uuid) 351 .try_map(|r: PgRow| Ok(WithdrawalPublicInfo { 352 status: r.try_get("status")?, 353 amount: r.try_get_opt_amount("amount", currency)?, 354 suggested_amount: r.try_get_opt_amount("suggested_amount", currency)?, 355 no_amount_to_wallet: r.try_get("no_amount_to_wallet")?, 356 username: r.try_get::<Option<CompactString>, _>("username")?.unwrap_or(CompactString::const_new("Uknonwn")), 357 selected_reserve_pub: r.try_get("reserve_pub")?, 358 selected_exchange_account: sql_opt_bank_payto(&r, ctx, "exchange_payto", "exchange_name")? 359 })) 360 361 .fetch_optional(db)) 362 , 363 |init| init.as_ref().map(|it| it.status != params.status).unwrap_or(true), 364 ).await 365 } 366 367 /** Pool public status of operation [uuid] */ 368 pub async fn poll_status( 369 db: &PgPool, 370 ctx: &PaytoCtx, 371 currency: &Currency, 372 channel: &NotificationChannel<Uuid, Option<WithdrawalStatus>>, 373 params: &Status, 374 uuid: Uuid, 375 wire: WireMethod, 376 max: Amount, 377 ) -> sqlx::Result<Option<BankWithdrawalOperationStatus>> { 378 pooling( 379 ¶ms.polling, 380 || channel.subscribe(uuid), 381 |it| it.is_some_and(|v| v != params.status), 382 async || 383 serialized!( 384 sqlx::query( 385 " 386 SELECT 387 CASE 388 WHEN confirmation_done THEN 'confirmed' 389 WHEN aborted THEN 'aborted' 390 WHEN selection_done THEN 'selected' 391 ELSE 'pending' 392 END as status 393 ,amount 394 ,suggested_amount 395 ,selection_done 396 ,aborted 397 ,confirmation_done 398 ,wallet_account.internal_payto 399 ,wallet_user.name 400 ,reserve_pub 401 ,exchange_account.internal_payto as exchange_payto 402 ,exchange_user.name as exchange_name 403 ,max_amount 404 ,no_amount_to_wallet 405 FROM taler_withdrawal_operations 406 LEFT JOIN bank_accounts AS wallet_account ON wallet_bank_account=wallet_account.bank_account_id 407 LEFT JOIN customers AS wallet_user ON wallet_user.customer_id=wallet_account.owning_customer_id 408 LEFT JOIN bank_accounts AS exchange_account ON exchange_bank_account=exchange_account.bank_account_id 409 LEFT JOIN customers AS exchange_user ON exchange_user.customer_id=exchange_account.owning_customer_id 410 ,account_max_amount(wallet_account.bank_account_id, $1) AS max_amount 411 WHERE withdrawal_uuid=$2 412 " 413 ) 414 .bind(max) 415 .bind(uuid) 416 .try_map(|r: PgRow| { 417 Ok(BankWithdrawalOperationStatus { 418 status: r.try_get("status")?, 419 amount: r.try_get_opt_amount("amount", currency)?, 420 suggested_amount: r.try_get_opt_amount("suggested_amount", currency)?, 421 max_amount: r.try_get_opt_amount("max_amount", currency)?, 422 no_amount_to_wallet: r.try_get("no_amount_to_wallet")?, 423 selected_reserve_pub: r.try_get("reserve_pub")?, 424 selected_exchange_account: sql_opt_bank_payto(&r, ctx, "exchange_payto", "exchange_name")?, 425 selection_done: r.try_get("selection_done")?, 426 transfer_done: r.try_get("confirmation_done")?, 427 aborted : r.try_get("aborted")?, 428 card_fees: None, 429 currency: Some(*currency), 430 min_amount: None, 431 confirm_transfer_url: None, 432 required_exchange: None, 433 sender_wire: sql_opt_bank_payto(&r, ctx, "internal_payto", "name")?, 434 suggested_exchange: None, 435 wire_types: vec![wire.to_compact_string()] 436 })}) 437 .fetch_optional(db)) 438 , 439 |init| init.as_ref().map(|it| it.status != params.status).unwrap_or(true), 440 ).await 441 }