libeufin

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

tx.rs (8836B)


      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 jiff::Timestamp;
     23 use sqlx::{PgPool, QueryBuilder, Row, postgres::PgRow};
     24 use taler_api::{
     25     db::{BindHelper, TypeHelper, history},
     26     notification::NotificationChannel,
     27     serialized,
     28     subject::{IncomingKey, IncomingSubject, parse_incoming_unstructured},
     29 };
     30 use taler_common::{
     31     api::{ShortHashCode, params::History, revenue::RevenueIncomingBankTransaction},
     32     types::amount::{Amount, Currency},
     33 };
     34 
     35 use crate::{
     36     api::tx::BankAccountTransactionInfo,
     37     payto::{BankPayto, PaytoCtx, sql_bank_payto},
     38 };
     39 
     40 /** Result status of bank transaction creation */
     41 pub enum TxResult {
     42     Success(u64),
     43     UnknownCreditor,
     44     AdminCreditor,
     45     UnknownDebtor,
     46     BothPartySame,
     47     BalanceInsufficient,
     48     BadAmount,
     49     TanRequired,
     50     RequestUidReuse,
     51 }
     52 
     53 /** Create a new transaction */
     54 pub async fn create(
     55     db: &PgPool,
     56     credit_account_payto: BankPayto,
     57     debit_account_username: &str,
     58     subject: &str,
     59     amount: Amount,
     60     timestamp: &Timestamp,
     61     is2fa: bool,
     62     request_uid: &Option<ShortHashCode>,
     63     wire_transfer_fees: Amount,
     64     min_amount: Amount,
     65     max_amount: Amount,
     66 ) -> sqlx::Result<TxResult> {
     67     let (ty, metadata, cause) = match parse_incoming_unstructured(subject) {
     68         Ok(IncomingSubject::Key(IncomingKey { ty, key })) => (Some(ty), Some(key), None),
     69         Ok(IncomingSubject::AdminBalanceAdjust) => (
     70             None,
     71             None,
     72             Some("unsupported admin balance adjust".to_string()),
     73         ),
     74         Err(e) => (None, None, Some(format!("malformed metadata - {e}"))),
     75     };
     76     serialized!(
     77         sqlx::query(
     78             "
     79             SELECT
     80                 out_creditor_not_found
     81                 ,out_debtor_not_found
     82                 ,out_same_account
     83                 ,out_balance_insufficient
     84                 ,out_bad_amount
     85                 ,out_request_uid_reuse
     86                 ,out_tan_required
     87                 ,out_credit_bank_account_id
     88                 ,out_debit_bank_account_id
     89                 ,out_credit_row_id
     90                 ,out_debit_row_id
     91                 ,out_creditor_is_exchange
     92                 ,out_debtor_is_exchange
     93                 ,out_creditor_admin
     94                 ,out_idempotent
     95             FROM bank_transaction($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::taler_incoming_type,$12,$13)
     96         ",
     97         )
     98         .bind(credit_account_payto.canonical())
     99         .bind(debit_account_username)
    100         .bind(subject)
    101         .bind(amount)
    102         .bind_timestamp(timestamp)
    103         .bind(is2fa)
    104         .bind(request_uid)
    105         .bind(wire_transfer_fees)
    106         .bind(min_amount)
    107         .bind(max_amount)
    108         .bind(ty.as_ref().map(|it| it.as_ref()))
    109         .bind(metadata)
    110         .bind(&cause)
    111         .try_map(|r: PgRow| {
    112             Ok(if r.try_get_flag("out_creditor_not_found")? {
    113                 TxResult::UnknownCreditor
    114             } else if r.try_get_flag("out_debtor_not_found")? {
    115                 TxResult::UnknownDebtor
    116             } else if r.try_get_flag("out_same_account")? {
    117                 TxResult::BothPartySame
    118             } else if r.try_get_flag("out_balance_insufficient")? {
    119                 TxResult::BalanceInsufficient
    120             } else if r.try_get_flag("out_bad_amount")? {
    121                 TxResult::BadAmount
    122             } else if r.try_get_flag("out_creditor_admin")? {
    123                 TxResult::AdminCreditor
    124             } else if r.try_get_flag("out_request_uid_reuse")? {
    125                 TxResult::RequestUidReuse
    126             } else if r.try_get_flag("out_idempotent")? {
    127                 TxResult::Success(r.try_get_u64("out_debit_row_id")?)
    128             } else if r.try_get_flag("out_tan_required")? {
    129                 TxResult::TanRequired
    130             } else {
    131                 TxResult::Success(r.try_get_u64("out_debit_row_id")?)
    132             })
    133         })
    134         .fetch_one(db)
    135     )
    136 }
    137 
    138 // /** Get transaction [rowId] owned by [username] */
    139 pub async fn get_by_id(
    140     db: &PgPool,
    141     currency: &Currency,
    142     ctx: &PaytoCtx,
    143     id: u64,
    144     username: &str,
    145 ) -> sqlx::Result<Option<BankAccountTransactionInfo>> {
    146     serialized!(
    147         sqlx::query("
    148             SELECT
    149             creditor_payto
    150             ,creditor_name
    151             ,debtor_payto
    152             ,debtor_name
    153             ,subject
    154             ,amount
    155             ,transaction_date
    156             ,direction
    157             ,bank_transaction_id
    158         FROM bank_account_transactions
    159             JOIN bank_accounts ON bank_account_transactions.bank_account_id=bank_accounts.bank_account_id
    160             JOIN customers ON customer_id=owning_customer_id
    161         WHERE bank_transaction_id=$1 AND username=$2
    162         ")
    163         .bind(id as i64)
    164         .bind(username)
    165         .try_map(|r: PgRow| Ok(BankAccountTransactionInfo {
    166             creditor_payto_uri: sql_bank_payto(&r, ctx, "creditor_payto", "creditor_name")?,
    167             debtor_payto_uri: sql_bank_payto(&r, ctx, "debtor_payto", "debtor_name")?,
    168             direction: r.try_get("direction")?,
    169             subject: r.try_get("subject")?,
    170             amount: r.try_get_amount("amount", currency)?,
    171             date: r.try_get_timestamp("transaction_date")?.into(),
    172             row_id: r.try_get_u64("bank_transaction_id")?
    173         }))
    174         .fetch_optional(db)
    175     )
    176 }
    177 
    178 /** Pool [accountId] transactions history */
    179 pub async fn pool_history(
    180     db: &PgPool,
    181     ctx: &PaytoCtx,
    182     currency: &Currency,
    183     channel: &NotificationChannel<u64, i64>,
    184     params: &History,
    185     account_id: u64,
    186 ) -> sqlx::Result<Vec<BankAccountTransactionInfo>> {
    187     history(
    188         db,
    189         "bank_transaction_id",
    190         params,
    191         || channel.subscribe(account_id),
    192         || {
    193             let mut query = QueryBuilder::new(
    194                 "SELECT
    195         bank_transaction_id
    196         ,transaction_date
    197         ,amount
    198         ,debtor_payto
    199         ,debtor_name
    200         ,creditor_payto
    201         ,creditor_name
    202         ,subject
    203         ,direction
    204         FROM bank_account_transactions
    205         WHERE bank_account_id=",
    206             );
    207             query.push_bind(account_id as i64).push(" AND ");
    208             query
    209         },
    210         |r| {
    211             Ok(BankAccountTransactionInfo {
    212                 creditor_payto_uri: sql_bank_payto(&r, ctx, "creditor_payto", "creditor_name")?,
    213                 debtor_payto_uri: sql_bank_payto(&r, ctx, "debtor_payto", "debtor_name")?,
    214                 direction: r.try_get("direction")?,
    215                 subject: r.try_get("subject")?,
    216                 amount: r.try_get_amount("amount", currency)?,
    217                 date: r.try_get_timestamp("transaction_date")?.into(),
    218                 row_id: r.try_get_u64("bank_transaction_id")?,
    219             })
    220         },
    221     )
    222     .await
    223 }
    224 
    225 /** Query [accountId] history of incoming transactions to its account */
    226 pub async fn revenue_history(
    227     db: &PgPool,
    228     ctx: &PaytoCtx,
    229     currency: &Currency,
    230     channel: &NotificationChannel<u64, i64>,
    231     params: &History,
    232     account_id: u64,
    233 ) -> sqlx::Result<Vec<RevenueIncomingBankTransaction>> {
    234     history(
    235         db,
    236         "bank_transaction_id",
    237         params,
    238         || channel.subscribe(account_id),
    239         || {
    240             let mut query = QueryBuilder::new(
    241                 "SELECT
    242                 bank_transaction_id
    243                 ,transaction_date
    244                 ,amount
    245                 ,debtor_payto
    246                 ,debtor_name
    247                 ,subject
    248                 FROM bank_account_transactions
    249                 WHERE direction='credit' AND bank_account_id=",
    250             );
    251             query.push_bind(account_id as i64).push(" AND ");
    252             query
    253         },
    254         |r| {
    255             Ok(RevenueIncomingBankTransaction {
    256                 debit_account: sql_bank_payto(&r, ctx, "debtor_payto", "debtor_name")?.as_uri(),
    257                 subject: r.try_get("subject")?,
    258                 amount: r.try_get_amount("amount", currency)?,
    259                 date: r.try_get_timestamp("transaction_date")?.into(),
    260                 row_id: r.try_get_u64("bank_transaction_id")?,
    261                 credit_fee: None,
    262             })
    263         },
    264     )
    265     .await
    266 }