libeufin

Integration and sandbox testing for FinTech APIs and data formats
Log | Files | Refs | Submodules | README | LICENSE

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, &currenc_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(&currenc_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         &params.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) = &params.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         &params.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) = &params.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 }