account.rs (30067B)
1 /* 2 This file is part of TALER 3 Copyright (C) 2026 Taler Systems SA 4 5 TALER is free software; you can redistribute it and/or modify it under the 6 terms of the GNU Affero General Public License as published by the Free Software 7 Foundation; either version 3, or (at your option) any later version. 8 9 TALER is distributed in the hope that it will be useful, but WITHOUT ANY 10 WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR 11 A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. 12 13 You should have received a copy of the GNU Affero General Public License along with 14 TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> 15 */ 16 17 use compact_str::CompactString; 18 use jiff::Timestamp; 19 use sqlx::{ 20 Arguments, PgPool, QueryBuilder, Row as _, 21 postgres::{PgArguments, PgRow}, 22 }; 23 use taler_api::{ 24 db::{BindHelper as _, TypeHelper as _, page}, 25 error::ApiError, 26 serialized, 27 }; 28 use taler_common::{ 29 api::wire::AccountInfo, 30 types::{ 31 amount::{Amount, Currency}, 32 payto::{BankID, IbanPayto, PaytoImpl}, 33 }, 34 }; 35 36 use crate::{ 37 PaytoCtx, TanChannel, 38 api::account::{ 39 Account, AccountData, AccountMinimalData, AccountReconfiguration, AccountStatus, Balance, 40 ChallengeContactData, CreditDebitInfo, PublicAccount, TanInfo, 41 }, 42 db::conversion::user_rate, 43 mfa::Tans, 44 payto::{BankPayto, FullBankPayto, LibeufinId, sql_bank_payto, sql_opt_iban_payto}, 45 pw::PwCrypto, 46 }; 47 48 pub const MAX_TOKEN_CREATION_ATTEMPTS: u16 = 5; 49 50 #[derive(Debug, Clone, PartialEq, Eq)] 51 pub enum CreationResult { 52 Success(FullBankPayto), 53 UsernameReuse, 54 PayToReuse, 55 UnknownConversionClass, 56 BonusBalanceInsufficient, 57 } 58 59 impl CreationResult { 60 pub fn assert_success(self) -> FullBankPayto { 61 match self { 62 CreationResult::Success(full_payto) => full_payto, 63 CreationResult::UsernameReuse 64 | CreationResult::PayToReuse 65 | CreationResult::UnknownConversionClass 66 | CreationResult::BonusBalanceInsufficient => unreachable!(), 67 } 68 } 69 } 70 71 /** Create new account */ 72 pub async fn create( 73 db: &PgPool, 74 ctx: &PaytoCtx, 75 pw_crypto: &PwCrypto, 76 username: &str, 77 password: &str, 78 name: &str, 79 email: Option<&str>, 80 phone: Option<&str>, 81 cashout: Option<&IbanPayto>, 82 internal: &LibeufinId, 83 is_public: bool, 84 is_exchange: bool, 85 max_debt: Amount, 86 bonus: Amount, 87 tan_channels: &[TanChannel], 88 check_payto_idempotent: bool, 89 conversion_rate_class_id: Option<u64>, 90 ) -> sqlx::Result<CreationResult> { 91 let canonical = &internal.canonical(); 92 serialized!(async { 93 let mut tx = db.begin().await?; 94 let now = Timestamp::now(); 95 let cashout = cashout.map(|it| it.to_string()); 96 let idempotent = sqlx::query( 97 " 98 SELECT password_hash, name=$1 99 AND email IS NOT DISTINCT FROM $2 100 AND phone IS NOT DISTINCT FROM $3 101 AND cashout_payto IS NOT DISTINCT FROM $4 102 AND tan_channels = sort_uniq($5) 103 AND (NOT $6 OR internal_payto=$7) 104 AND is_public=$8 105 AND is_taler_exchange=$9 106 AND max_debt=$10 107 AND conversion_rate_class_id IS NOT DISTINCT FROM $11 108 ,internal_payto, name 109 FROM customers 110 JOIN bank_accounts 111 ON customer_id=owning_customer_id 112 WHERE username=$12 113 ", 114 ) 115 .bind(name) 116 .bind(email) 117 .bind(phone) 118 .bind(&cashout) 119 .bind(tan_channels) 120 .bind(check_payto_idempotent) 121 .bind(canonical) 122 .bind(is_public) 123 .bind(is_exchange) 124 .bind(max_debt) 125 .bind(conversion_rate_class_id.map(|it| it as i64)) 126 .bind(username) 127 .try_map(|r: PgRow| { 128 Ok(( 129 r.try_get(1)? && pw_crypto.checkpw(password, r.try_get(0)?).unwrap().matches, 130 sql_bank_payto(&r, ctx, "internal_payto", "name")?, 131 )) 132 }) 133 .fetch_optional(&mut *tx) 134 .await?; 135 let res = if let Some((matches, payto)) = idempotent { 136 if matches { 137 CreationResult::Success(payto) 138 } else { 139 CreationResult::UsernameReuse 140 } 141 } else { 142 if let LibeufinId::IBAN(BankID { iban, .. }) = &internal { 143 let res = 144 sqlx::query("INSERT INTO iban_history(iban,creation_time) VALUES ($1, $2)") 145 .bind(iban.as_ref()) 146 .bind_timestamp(&now) 147 .execute(&mut *tx) 148 .await; 149 if let Err(e) = &res 150 && e.is_unique_err() 151 { 152 tx.rollback().await?; 153 return Ok(CreationResult::PayToReuse); 154 } 155 res?; 156 } 157 158 let customer_id: i64 = sqlx::query_scalar( 159 " 160 INSERT INTO customers ( 161 username 162 ,password_hash 163 ,name 164 ,email 165 ,phone 166 ,cashout_payto 167 ,tan_channels 168 ) VALUES ($1, $2, $3, $4, $5, $6, sort_uniq($7)) 169 RETURNING customer_id 170 ", 171 ) 172 .bind(username) 173 .bind(pw_crypto.hashpw(password)) 174 .bind(name) 175 .bind(email) 176 .bind(phone) 177 .bind(&cashout) 178 .bind(tan_channels) 179 .fetch_one(&mut *tx) 180 .await?; 181 let res = sqlx::query( 182 " 183 INSERT INTO bank_accounts( 184 internal_payto 185 ,owning_customer_id 186 ,is_public 187 ,is_taler_exchange 188 ,max_debt 189 ,conversion_rate_class_id 190 ) VALUES ($1, $2, $3, $4, $5, $6) 191 ", 192 ) 193 .bind(canonical) 194 .bind(customer_id) 195 .bind(is_public) 196 .bind(is_exchange) 197 .bind(max_debt) 198 .bind(conversion_rate_class_id.map(|it| it as i64)) 199 .execute(&mut *tx) 200 .await; 201 202 if let Err(e) = &res 203 && e.is_unique_err() 204 { 205 tx.rollback().await?; 206 return Ok(CreationResult::PayToReuse); 207 } else if let Err(e) = &res 208 && e.is_fk_err() 209 { 210 tx.rollback().await?; 211 return Ok(CreationResult::UnknownConversionClass); 212 } 213 res?; 214 215 if !bonus.is_zero() { 216 let insufisient = sqlx::query_scalar(" 217 SELECT out_balance_insufficient 218 FROM bank_transaction($1,'admin','bonus',$2,$3,true,NULL,NULL,NULL,NULL, NULL, NULL, NULL) 219 ").bind(canonical).bind(bonus).bind(now.as_microsecond()).fetch_one(&mut *tx).await?; 220 if insufisient { 221 tx.rollback().await?; 222 return Ok(CreationResult::BonusBalanceInsufficient); 223 } 224 } 225 226 CreationResult::Success(internal.clone().bank(ctx).into_inner().full(name)) 227 }; 228 tx.commit().await?; 229 Ok(res) 230 }) 231 } 232 233 /** Result status of account deletion */ 234 pub enum DeletionResult { 235 Success, 236 UnknownAccount, 237 BalanceNotZero, 238 TanRequired, 239 } 240 241 /** Delete account [username] */ 242 pub async fn delete( 243 db: &sqlx::PgPool, 244 username: &str, 245 is2fa: bool, 246 ) -> sqlx::Result<DeletionResult> { 247 serialized!( 248 sqlx::query( 249 " 250 SELECT 251 out_not_found, 252 out_balance_not_zero, 253 out_tan_required 254 FROM account_delete($1,$2,$3) 255 ", 256 ) 257 .bind(username) 258 .bind_timestamp(&Timestamp::now()) 259 .bind(is2fa) 260 .try_map(|r: PgRow| { 261 Ok(if r.try_get_flag("out_not_found")? { 262 DeletionResult::UnknownAccount 263 } else if r.try_get_flag("out_balance_not_zero")? { 264 DeletionResult::BalanceNotZero 265 } else if r.try_get_flag("out_tan_required")? { 266 DeletionResult::TanRequired 267 } else { 268 DeletionResult::Success 269 }) 270 }) 271 .fetch_one(db) 272 ) 273 } 274 275 /** Result status of customer account patch */ 276 pub enum PatchResult { 277 UnknownAccount, 278 NonAdminName, 279 NonAdminCashout, 280 NonAdminDebtLimit, 281 NonAdminConversionRateClass, 282 UnknownConversionClass, 283 Challenges(Tans), 284 MissingTanInfo(ApiError), 285 Success, 286 } 287 288 /** Change account [username] information */ 289 pub async fn reconfig( 290 db: &PgPool, 291 currency: &Currency, 292 username: &str, 293 req: &AccountReconfiguration, 294 is_admin: bool, 295 is2fa: bool, 296 allow_edit_name: bool, 297 allow_edit_cashout: bool, 298 ) -> sqlx::Result<PatchResult> { 299 let AccountReconfiguration { 300 cashout_payto_uri, 301 name, 302 is_public, 303 debit_threshold, 304 is_taler_exchange, 305 conversion_rate_class_id, 306 .. 307 } = req; 308 309 #[derive(Debug)] 310 struct CurrentAccount { 311 id: u64, 312 channels: Vec<TanChannel>, 313 email: Option<CompactString>, 314 phone: Option<CompactString>, 315 name: String, 316 cashout_pay_to: Option<IbanPayto>, 317 debt_limit: Amount, 318 conversion_rate_class_id: Option<u64>, 319 } 320 321 impl TanInfo for CurrentAccount { 322 fn email(&self) -> Option<&str> { 323 self.email.as_deref() 324 } 325 326 fn phone(&self) -> Option<&str> { 327 self.phone.as_deref() 328 } 329 330 fn channels(&self) -> &[TanChannel] { 331 &self.channels 332 } 333 } 334 serialized!(async { 335 let mut tx = db.begin().await?; 336 337 // Get user ID and current data 338 let curr = sqlx::query( 339 " 340 SELECT 341 customer_id, 342 tan_channels, 343 phone, 344 email, 345 name, 346 cashout_payto, 347 max_debt, 348 conversion_rate_class_id::int8 349 FROM customers 350 JOIN bank_accounts 351 ON customer_id=owning_customer_id 352 WHERE username=$1 AND deleted_at IS NULL 353 ", 354 ) 355 .bind(username) 356 .try_map(|r: PgRow| { 357 Ok(CurrentAccount { 358 id: r.try_get_u64("customer_id")?, 359 channels: r.try_get("tan_channels")?, 360 email: r.try_get("email")?, 361 phone: r.try_get("phone")?, 362 name: r.try_get("name")?, 363 cashout_pay_to: r.try_get_opt_parse("cashout_payto")?, 364 debt_limit: r.try_get_amount("max_debt", currency)?, 365 conversion_rate_class_id: r.try_get_opt_u64("conversion_rate_class_id")?, 366 }) 367 }) 368 .fetch_optional(&mut *tx) 369 .await?; 370 let Some(curr) = curr else { 371 return Ok(PatchResult::UnknownAccount); 372 }; 373 374 let validation = match req.required_validation(&curr) { 375 Ok(v) => v, 376 Err(e) => return Ok(PatchResult::MissingTanInfo(e)), 377 }; 378 379 // Check performed 2fa check 380 if !is_admin && !is2fa { 381 // Check if mfa is required 382 if !curr.channels.is_empty() { 383 let mut tans = curr.mfa(); 384 385 if tans.len() == 1 { 386 // Performs mfa and validation at the same time 387 tans.extend(validation); 388 return Ok(PatchResult::Challenges(tans)); 389 } else { 390 return Ok(PatchResult::Challenges(Vec::new())); 391 } 392 } 393 394 // Check if validation is required 395 if !validation.is_empty() { 396 return Ok(PatchResult::Challenges(validation)); 397 } 398 } 399 400 // Check reconfig rights 401 if !is_admin { 402 if !allow_edit_name 403 && let Some(name) = name 404 && name != curr.name 405 { 406 return Ok(PatchResult::NonAdminName); 407 } else if !allow_edit_cashout 408 && let Some(cashout) = cashout_payto_uri.inner() 409 && cashout != curr.cashout_pay_to.as_ref() 410 { 411 return Ok(PatchResult::NonAdminCashout); 412 } else if let Some(limit) = debit_threshold 413 && limit != &curr.debt_limit 414 { 415 return Ok(PatchResult::NonAdminDebtLimit); 416 } else if let Some(id) = conversion_rate_class_id.inner() 417 && id != curr.conversion_rate_class_id.as_ref() 418 { 419 return Ok(PatchResult::NonAdminConversionRateClass); 420 } 421 } 422 423 // Update bank info 424 let mut sql = QueryBuilder::new("Update bank_accounts SET "); 425 let mut separated = sql.separated(','); 426 if let Some(v) = is_public { 427 separated.push("is_public=").push_bind_unseparated(v); 428 } 429 if let Some(v) = is_taler_exchange { 430 separated 431 .push("is_taler_exchange=") 432 .push_bind_unseparated(v); 433 } 434 if let Some(v) = debit_threshold { 435 separated.push("max_debt=").push_bind_unseparated(v); 436 } 437 if let Some(v) = conversion_rate_class_id.inner() { 438 separated 439 .push("conversion_rate_class_id=") 440 .push_bind_unseparated(v.map(|it| *it as i64)); 441 } 442 if !sql.sql().ends_with("SET ") { 443 sql.push(" WHERE owning_customer_id=") 444 .push_bind(curr.id as i64); 445 let res = sql.build().execute(&mut *tx).await; 446 if let Err(e) = &res 447 && e.is_fk_err() 448 { 449 tx.rollback().await?; 450 return Ok(PatchResult::UnknownConversionClass); 451 } 452 res?; 453 } 454 455 // Update customer info 456 let mut sql = QueryBuilder::new("UPDATE customers SET "); 457 let mut separated = sql.separated(','); 458 if let Some(v) = cashout_payto_uri.inner() { 459 separated 460 .push("cashout_payto=") 461 .push_bind_unseparated(v.map(|it| it.as_uri().to_string())); 462 } 463 if let Some(v) = req.contact_data.phone.inner() { 464 separated.push("phone=").push_bind_unseparated(v); 465 } 466 if let Some(v) = req.contact_data.email.inner() { 467 separated.push("email=").push_bind_unseparated(v); 468 } 469 if let Some(v) = req.channels() { 470 separated 471 .push("tan_channels=sort_uniq(") 472 .push_bind_unseparated(v) 473 .push_unseparated(')'); 474 } 475 if let Some(v) = &req.name { 476 separated.push("name=").push_bind_unseparated(v); 477 } 478 if !sql.sql().ends_with("SET ") { 479 sql.push(" WHERE customer_id=") 480 .push_bind(curr.id as i64) 481 .build() 482 .execute(&mut *tx) 483 .await?; 484 } 485 486 if !validation.is_empty() { 487 sqlx::query("UPDATE tan_challenges SET expiration_date=0 WHERE customer=$1") 488 .bind(curr.id as i64) 489 .execute(&mut *tx) 490 .await?; 491 } 492 493 tx.commit().await?; 494 495 Ok(PatchResult::Success) 496 }) 497 } 498 499 /** Result status of customer account auth patch */ 500 pub enum PatchAuthResult { 501 UnknownAccount, 502 OldPasswordMismatch, 503 TanRequired, 504 Success, 505 } 506 507 /** Change account [username] password to [newPw] if current match [oldPw] */ 508 pub async fn reconfig_password( 509 db: &PgPool, 510 pw_crypto: &PwCrypto, 511 username: &str, 512 new_pw: &str, 513 old_pw: Option<&str>, 514 is2fa: bool, 515 ) -> sqlx::Result<PatchAuthResult> { 516 let Some((customer_id, currenc_pwh, tan_required)): Option<(i64, String, bool)> = serialized!( 517 sqlx::query_as( 518 " 519 SELECT customer_id, password_hash, NOT $1 AND cardinality(tan_channels) > 0 520 FROM customers WHERE username=$2 AND deleted_at IS NULL 521 ", 522 ) 523 .bind(is2fa) 524 .bind(username) 525 .fetch_optional(db) 526 )? 527 else { 528 return Ok(PatchAuthResult::UnknownAccount); 529 }; 530 531 let res = if let Some(old_pw) = old_pw 532 && !pw_crypto.checkpw(old_pw, ¤c_pwh).unwrap().matches 533 { 534 PatchAuthResult::OldPasswordMismatch 535 } else if tan_required { 536 PatchAuthResult::TanRequired 537 } else { 538 let new_pwh = pw_crypto.hashpw(new_pw); 539 if serialized!( 540 sqlx::query( 541 "UPDATE customers SET password_hash=$1, token_creation_counter=0 WHERE customer_id=$2 AND password_hash=$3", 542 ) 543 .bind(&new_pwh) 544 .bind(customer_id) 545 .bind(¤c_pwh) 546 .execute(db) 547 )?.rows_affected() > 0 { 548 PatchAuthResult::Success 549 } else { 550 // If the password hash has changed, it was updated concurrently 551 PatchAuthResult::OldPasswordMismatch 552 } 553 }; 554 555 Ok(res) 556 } 557 558 /** Result status of customer account password check */ 559 pub enum CheckPasswordResult { 560 UnknownAccount, 561 PasswordMismatch, 562 Locked, 563 Success(BankInfo), 564 } 565 566 pub async fn check_password( 567 db: &PgPool, 568 ctx: &PaytoCtx, 569 pw_crypto: &PwCrypto, 570 username: &str, 571 pw: &str, 572 ) -> sqlx::Result<CheckPasswordResult> { 573 // Get user current password hash 574 let Some((info, ref pwh, counter)): Option<(_, CompactString, _)> = serialized!( 575 sqlx::query( 576 " 577 SELECT 578 password_hash, 579 token_creation_counter, 580 bank_account_id, 581 internal_payto, 582 is_taler_exchange, 583 is_public, 584 name, 585 tan_channels, 586 email, 587 phone 588 FROM bank_accounts 589 JOIN customers ON customer_id=owning_customer_id 590 WHERE username=$1 AND deleted_at IS NULL 591 ", 592 ) 593 .bind(username) 594 .try_map(|r: PgRow| { 595 let info = BankInfo { 596 username: username.into(), 597 payto: sql_bank_payto(&r, ctx, "internal_payto", "name")?, 598 bank_account_id: r.try_get_u64("bank_account_id")?, 599 is_exchange: r.try_get("is_taler_exchange")?, 600 is_public: r.try_get("is_public")?, 601 phone: r.try_get("phone")?, 602 email: r.try_get("email")?, 603 channels: r.try_get("tan_channels")?, 604 }; 605 Ok(( 606 info, 607 r.try_get("password_hash")?, 608 r.try_get_u16("token_creation_counter")?, 609 )) 610 }) 611 .fetch_optional(db) 612 )? 613 else { 614 return Ok(CheckPasswordResult::UnknownAccount); 615 }; 616 617 // Check locked 618 if counter >= MAX_TOKEN_CREATION_ATTEMPTS { 619 return Ok(CheckPasswordResult::Locked); 620 } 621 622 // Check password 623 let check = pw_crypto.checkpw(pw, pwh).unwrap(); // TODO handle this 624 if !check.matches { 625 return Ok(CheckPasswordResult::PasswordMismatch); 626 } 627 628 // Rehash if outdated 629 if check.outdated { 630 let new = &pw_crypto.hashpw(pw); 631 serialized!( 632 sqlx::query( 633 "UPDATE customers SET password_hash=$1 where username=$2 AND password_hash=$3" 634 ) 635 .bind(new) 636 .bind(username) 637 .bind(pwh) 638 .execute(db) 639 )?; 640 } 641 Ok(CheckPasswordResult::Success(info)) 642 } 643 644 #[derive(Debug, Clone, PartialEq, Eq)] 645 pub struct BankInfo { 646 pub username: CompactString, 647 pub payto: FullBankPayto, 648 pub bank_account_id: u64, 649 pub is_exchange: bool, 650 pub is_public: bool, 651 pub phone: Option<CompactString>, 652 pub email: Option<CompactString>, 653 pub channels: Vec<TanChannel>, 654 } 655 656 impl TanInfo for BankInfo { 657 fn phone(&self) -> Option<&str> { 658 self.phone.as_deref() 659 } 660 661 fn email(&self) -> Option<&str> { 662 self.email.as_deref() 663 } 664 665 fn channels(&self) -> &[TanChannel] { 666 &self.channels 667 } 668 } 669 670 impl BankInfo { 671 pub fn is_admin(&self) -> bool { 672 self.username == "admin" 673 } 674 } 675 676 /** Get bank info of account [username] */ 677 pub async fn bank_info( 678 db: &PgPool, 679 ctx: &PaytoCtx, 680 username: &str, 681 ) -> sqlx::Result<Option<BankInfo>> { 682 serialized!( 683 sqlx::query( 684 " 685 SELECT 686 bank_account_id, 687 internal_payto, 688 name, 689 is_taler_exchange, 690 is_public, 691 tan_channels, 692 email, 693 phone 694 FROM bank_accounts 695 JOIN customers ON customer_id=owning_customer_id 696 WHERE username=$1 697 ", 698 ) 699 .bind(username) 700 .try_map(|r: PgRow| { 701 Ok(BankInfo { 702 username: username.into(), 703 payto: sql_bank_payto(&r, ctx, "internal_payto", "name")?, 704 bank_account_id: r.try_get_u64("bank_account_id")?, 705 is_exchange: r.try_get("is_taler_exchange")?, 706 is_public: r.try_get("is_public")?, 707 phone: r.try_get("phone")?, 708 email: r.try_get("email")?, 709 channels: r.try_get("tan_channels")?, 710 }) 711 }) 712 .fetch_optional(db) 713 ) 714 } 715 716 /** Check bank info of account [payto] */ 717 pub async fn check_info(db: &PgPool, payto: &BankPayto) -> sqlx::Result<Option<AccountInfo>> { 718 serialized!( 719 sqlx::query("SELECT FROM bank_accounts WHERE internal_payto=$1") 720 .bind(payto.canonical()) 721 .try_map(|_: PgRow| Ok(AccountInfo {})) 722 .fetch_optional(db) 723 ) 724 } 725 726 /** Get data of account [username] */ 727 pub async fn by_username( 728 db: &PgPool, 729 ctx: &PaytoCtx, 730 regional: &Currency, 731 fiat: Option<&Currency>, 732 username: &str, 733 ) -> sqlx::Result<Option<AccountData>> { 734 serialized!( 735 sqlx::query( 736 " 737 SELECT 738 customers.name, 739 email, 740 phone, 741 tan_channels, 742 cashout_payto, 743 internal_payto, 744 balance, 745 has_debt, 746 max_debt, 747 is_public, 748 is_taler_exchange, 749 CASE 750 WHEN deleted_at IS NOT NULL THEN 'deleted' 751 WHEN token_creation_counter > $1 THEN 'locked' 752 ELSE 'active' 753 END as status, 754 conversion_rate_class_id::int8, 755 cashin_ratio, 756 cashin_fee, 757 cashin_tiny_amount, 758 cashin_min_amount, 759 cashin_rounding_mode, 760 cashout_ratio, 761 cashout_fee, 762 cashout_tiny_amount, 763 cashout_min_amount, 764 cashout_rounding_mode 765 FROM customers 766 JOIN bank_accounts ON customer_id=owning_customer_id 767 CROSS JOIN LATERAL get_conversion_class_rate(conversion_rate_class_id) 768 WHERE username=$2 769 ", 770 ) 771 .bind(MAX_TOKEN_CREATION_ATTEMPTS as i16) 772 .bind(username) 773 .try_map(|r: PgRow| { 774 let status: AccountStatus = r.try_get("status")?; 775 let channels: Vec<TanChannel> = r.try_get("tan_channels")?; 776 let is_exchange: bool = r.try_get("is_taler_exchange")?; 777 778 Ok(AccountData { 779 payto_uri: sql_bank_payto(&r, ctx, "internal_payto", "name")?, 780 balance: Balance { 781 amount: r.try_get_amount("balance", regional)?, 782 credit_debit_indicator: if r.try_get("has_debt")? { 783 CreditDebitInfo::debit 784 } else { 785 CreditDebitInfo::credit 786 }, 787 }, 788 debit_threshold: r.try_get_amount("max_debt", regional)?, 789 contact_data: ChallengeContactData { 790 email: r.try_get("email")?, 791 phone: r.try_get("phone")?, 792 }, 793 cashout_payto_uri: sql_opt_iban_payto(&r, "cashout_payto", "name")? 794 .map(|it| it.as_uri()), 795 tan_channel: channels.first().cloned(), 796 tan_channels: channels, 797 is_public: r.try_get("is_public")?, 798 is_taler_exchange: is_exchange, 799 is_locked: status == AccountStatus::locked, 800 status, 801 conversion_rate_class_id: r.try_get_opt_u64("conversion_rate_class_id")?, 802 conversion_rate: user_rate(&r, regional, fiat, username, is_exchange)?, 803 name: r.try_get("name")?, 804 }) 805 }) 806 .fetch_optional(db) 807 ) 808 } 809 810 /** Get a page of all public accounts */ 811 pub async fn page_public( 812 db: &PgPool, 813 ctx: &PaytoCtx, 814 currency: &Currency, 815 params: &Account, 816 ) -> sqlx::Result<Vec<PublicAccount>> { 817 page( 818 db, 819 ¶ms.page, 820 "bank_account_id", 821 || { 822 let mut builder = QueryBuilder::new( 823 " 824 SELECT 825 balance, 826 has_debt, 827 internal_payto, 828 username, 829 is_taler_exchange, 830 name, 831 bank_account_id 832 FROM bank_accounts JOIN customers 833 ON owning_customer_id = customer_id 834 WHERE is_public=true AND deleted_at IS NULL AND ", 835 ); 836 if let Some(pattern) = ¶ms.filter_name { 837 builder.push("name ILIKE ").push_bind(pattern).push(" AND"); 838 } 839 builder 840 }, 841 |r: PgRow| { 842 Ok(PublicAccount { 843 username: r.try_get("username")?, 844 payto_uri: sql_bank_payto(&r, ctx, "internal_payto", "name")?, 845 balance: Balance { 846 amount: r.try_get_amount("balance", currency)?, 847 credit_debit_indicator: if r.try_get("has_debt")? { 848 CreditDebitInfo::debit 849 } else { 850 CreditDebitInfo::credit 851 }, 852 }, 853 is_taler_exchange: r.try_get("is_taler_exchange")?, 854 row_id: r.try_get_u64("bank_account_id")?, 855 }) 856 }, 857 ) 858 .await 859 } 860 861 /** Get a page of accounts */ 862 pub async fn page_admin( 863 db: &PgPool, 864 ctx: &PaytoCtx, 865 regional: &Currency, 866 fiat: Option<&Currency>, 867 params: &Account, 868 ) -> sqlx::Result<Vec<AccountMinimalData>> { 869 page( 870 db, 871 ¶ms.page, 872 "bank_account_id", 873 || { 874 let mut args = PgArguments::default(); 875 args.add(MAX_TOKEN_CREATION_ATTEMPTS as i16).unwrap(); 876 let mut builder = QueryBuilder::with_arguments( 877 " 878 SELECT 879 username 880 ,name 881 ,balance 882 ,has_debt 883 ,max_debt 884 ,is_public 885 ,is_taler_exchange 886 ,internal_payto 887 ,bank_account_id 888 ,CASE 889 WHEN deleted_at IS NOT NULL THEN 'deleted' 890 WHEN token_creation_counter > $1 THEN 'locked' 891 ELSE 'active' 892 END as status, 893 conversion_rate_class_id::int8, 894 cashin_ratio, 895 cashin_fee, 896 cashin_tiny_amount, 897 cashin_min_amount, 898 cashin_rounding_mode, 899 cashout_ratio, 900 cashout_fee, 901 cashout_tiny_amount, 902 cashout_min_amount, 903 cashout_rounding_mode 904 FROM bank_accounts 905 JOIN customers ON owning_customer_id = customer_id 906 CROSS JOIN LATERAL get_conversion_class_rate(conversion_rate_class_id) 907 WHERE 908 ", 909 args, 910 ); 911 if let Some(pattern) = ¶ms.filter_name { 912 builder.push("name ILIKE ").push_bind(pattern).push(" AND "); 913 } 914 if let Some(id) = params.conversion_rate_class_id { 915 if id == 0 { 916 builder.push("conversion_rate_class_id IS NULL AND "); 917 } else { 918 builder 919 .push("conversion_rate_class_id=") 920 .push_bind(id as i64) 921 .push(" AND "); 922 } 923 } 924 builder 925 }, 926 |r: PgRow| { 927 let status: AccountStatus = r.try_get("status")?; 928 let is_exchange: bool = r.try_get("is_taler_exchange")?; 929 let username: CompactString = r.try_get("username")?; 930 Ok(AccountMinimalData { 931 row_id: r.try_get_u64("bank_account_id")?, 932 payto_uri: sql_bank_payto(&r, ctx, "internal_payto", "name")?, 933 balance: Balance { 934 amount: r.try_get_amount("balance", regional)?, 935 credit_debit_indicator: if r.try_get("has_debt")? { 936 CreditDebitInfo::debit 937 } else { 938 CreditDebitInfo::credit 939 }, 940 }, 941 debit_threshold: r.try_get_amount("max_debt", regional)?, 942 is_public: r.try_get("is_public")?, 943 is_taler_exchange: is_exchange, 944 is_locked: status == AccountStatus::locked, 945 status, 946 conversion_rate_class_id: r.try_get_opt_u64("conversion_rate_class_id")?, 947 conversion_rate: user_rate(&r, regional, fiat, &username, is_exchange)?, 948 name: r.try_get("name")?, 949 username, 950 }) 951 }, 952 ) 953 .await 954 }