libeufin

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

conversion.rs (17511B)


      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 conversion
     21 
     22 use sqlx::{PgPool, QueryBuilder, Row, postgres::PgRow};
     23 use taler_api::{
     24     db::{PgError, TypeHelper, page},
     25     serialized,
     26 };
     27 use taler_common::types::amount::{Amount, Currency, Decimal};
     28 
     29 use crate::api::conversion::{
     30     Class, ConversionRate, ConversionRateClass, ConversionRateClassInput, RoundingMode,
     31 };
     32 
     33 pub fn user_rate(
     34     r: &PgRow,
     35     regional: &Currency,
     36     fiat: Option<&Currency>,
     37     username: &str,
     38     is_exchange: bool,
     39 ) -> sqlx::Result<Option<ConversionRate>> {
     40     let Some(fiat) = fiat else { return Ok(None) };
     41     let rate = if username == "admin" {
     42         ConversionRate {
     43             cashin_ratio: Decimal::ZERO,
     44             cashin_fee: Amount::zero(regional),
     45             cashin_tiny_amount: Amount::zero(regional),
     46             cashin_rounding_mode: RoundingMode::zero,
     47             cashin_min_amount: Amount::zero(fiat),
     48             cashout_ratio: Decimal::ZERO,
     49             cashout_fee: Amount::zero(fiat),
     50             cashout_tiny_amount: Amount::zero(fiat),
     51             cashout_rounding_mode: RoundingMode::zero,
     52             cashout_min_amount: Amount::zero(regional),
     53         }
     54     } else if is_exchange {
     55         ConversionRate {
     56             cashin_ratio: r.try_get("cashin_ratio")?,
     57             cashin_fee: r.try_get_amount("cashin_fee", regional)?,
     58             cashin_tiny_amount: r.try_get_amount("cashin_tiny_amount", regional)?,
     59             cashin_rounding_mode: r.try_get("cashin_rounding_mode")?,
     60             cashin_min_amount: r.try_get_amount("cashin_min_amount", fiat)?,
     61             cashout_ratio: Decimal::ZERO,
     62             cashout_fee: Amount::zero(fiat),
     63             cashout_tiny_amount: Amount::zero(fiat),
     64             cashout_rounding_mode: RoundingMode::zero,
     65             cashout_min_amount: Amount::zero(regional),
     66         }
     67     } else {
     68         ConversionRate {
     69             cashin_ratio: Decimal::ZERO,
     70             cashin_fee: Amount::zero(regional),
     71             cashin_tiny_amount: Amount::zero(regional),
     72             cashin_rounding_mode: RoundingMode::zero,
     73             cashin_min_amount: Amount::zero(fiat),
     74             cashout_ratio: r.try_get("cashout_ratio")?,
     75             cashout_fee: r.try_get_amount("cashout_fee", fiat)?,
     76             cashout_tiny_amount: r.try_get_amount("cashout_tiny_amount", fiat)?,
     77             cashout_rounding_mode: r.try_get("cashout_rounding_mode")?,
     78             cashout_min_amount: r.try_get_amount("cashout_min_amount", regional)?,
     79         }
     80     };
     81     Ok(Some(rate))
     82 }
     83 
     84 /** Update in-db conversion config */
     85 pub async fn update_config(db: &PgPool, cfg: &ConversionRate) -> sqlx::Result<()> {
     86     serialized!(
     87         sqlx::query("CALL config_set_conversion_rate($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)")
     88             .bind(cfg.cashin_ratio)
     89             .bind(cfg.cashin_fee)
     90             .bind(cfg.cashin_tiny_amount)
     91             .bind(cfg.cashin_min_amount)
     92             .bind(cfg.cashin_rounding_mode)
     93             .bind(cfg.cashout_ratio)
     94             .bind(cfg.cashout_fee)
     95             .bind(cfg.cashout_tiny_amount)
     96             .bind(cfg.cashout_min_amount)
     97             .bind(cfg.cashout_rounding_mode)
     98             .execute(db)
     99     )?;
    100     Ok(())
    101 }
    102 
    103 /** Get default conversion rate */
    104 pub async fn get_default_rate(
    105     db: &PgPool,
    106     regional: &Currency,
    107     fiat: &Currency,
    108 ) -> sqlx::Result<ConversionRate> {
    109     serialized!(
    110         sqlx::query(
    111             "SELECT
    112         cashin_ratio,
    113         cashin_fee,
    114         cashin_tiny_amount,
    115         cashin_min_amount,
    116         cashin_rounding_mode,
    117         cashout_ratio,
    118         cashout_fee,
    119         cashout_tiny_amount,
    120         cashout_min_amount,
    121         cashout_rounding_mode
    122         FROM config_get_conversion_rate()",
    123         )
    124         .try_map(|r: PgRow| {
    125             Ok(ConversionRate {
    126                 cashin_ratio: r.try_get("cashin_ratio")?,
    127                 cashin_fee: r.try_get_amount("cashin_fee", regional)?,
    128                 cashin_tiny_amount: r.try_get_amount("cashin_tiny_amount", regional)?,
    129                 cashin_rounding_mode: r.try_get("cashin_rounding_mode")?,
    130                 cashin_min_amount: r.try_get_amount("cashin_min_amount", fiat)?,
    131                 cashout_ratio: r.try_get("cashout_ratio")?,
    132                 cashout_fee: r.try_get_amount("cashout_fee", fiat)?,
    133                 cashout_tiny_amount: r.try_get_amount("cashout_tiny_amount", fiat)?,
    134                 cashout_rounding_mode: r.try_get("cashout_rounding_mode")?,
    135                 cashout_min_amount: r.try_get_amount("cashout_min_amount", regional)?,
    136             })
    137         })
    138         .fetch_one(db)
    139     )
    140 }
    141 
    142 /** Get conversion class rate */
    143 pub async fn get_class_rate(
    144     db: &PgPool,
    145     regional: &Currency,
    146     fiat: &Currency,
    147     id: u64,
    148 ) -> sqlx::Result<ConversionRate> {
    149     serialized!(
    150         sqlx::query(
    151             "SELECT
    152          cashin_ratio,
    153          cashin_fee,
    154          cashin_tiny_amount,
    155          cashin_min_amount,
    156          cashin_rounding_mode,
    157          cashout_ratio,
    158          cashout_fee,
    159          cashout_tiny_amount,
    160          cashout_min_amount,
    161          cashout_rounding_mode
    162          FROM get_conversion_class_rate($1)",
    163         )
    164         .bind(id as i64)
    165         .try_map(|r: PgRow| {
    166             Ok(ConversionRate {
    167                 cashin_ratio: r.try_get("cashin_ratio")?,
    168                 cashin_fee: r.try_get_amount("cashin_fee", regional)?,
    169                 cashin_tiny_amount: r.try_get_amount("cashin_tiny_amount", regional)?,
    170                 cashin_rounding_mode: r.try_get("cashin_rounding_mode")?,
    171                 cashin_min_amount: r.try_get_amount("cashin_min_amount", fiat)?,
    172                 cashout_ratio: r.try_get("cashout_ratio")?,
    173                 cashout_fee: r.try_get_amount("cashout_fee", fiat)?,
    174                 cashout_tiny_amount: r.try_get_amount("cashout_tiny_amount", fiat)?,
    175                 cashout_rounding_mode: r.try_get("cashout_rounding_mode")?,
    176                 cashout_min_amount: r.try_get_amount("cashout_min_amount", regional)?,
    177             })
    178         })
    179         .fetch_one(db)
    180     )
    181 }
    182 
    183 /** Get user rate */
    184 pub async fn get_user_rate(
    185     db: &PgPool,
    186     regional: &Currency,
    187     fiat: Option<&Currency>,
    188     username: &str,
    189 ) -> sqlx::Result<(bool, ConversionRate)> {
    190     serialized!(
    191         sqlx::query(
    192             "SELECT
    193          cashin_ratio,
    194          cashin_fee,
    195          cashin_tiny_amount,
    196          cashin_min_amount,
    197          cashin_rounding_mode,
    198          cashout_ratio,
    199          cashout_fee,
    200          cashout_tiny_amount,
    201          cashout_min_amount,
    202          cashout_rounding_mode,
    203          is_taler_exchange
    204          FROM bank_accounts
    205              JOIN customers ON customer_id=owning_customer_id
    206              CROSS JOIN LATERAL get_conversion_class_rate(conversion_rate_class_id)
    207          WHERE username=$1",
    208         )
    209         .bind(username)
    210         .try_map(|r: PgRow| {
    211             let is_exchange = r.try_get("is_taler_exchange")?;
    212             Ok((
    213                 is_exchange,
    214                 user_rate(&r, regional, fiat, username, is_exchange)?.unwrap(),
    215             ))
    216         })
    217         .fetch_one(db)
    218     )
    219 }
    220 
    221 /** Clear in-db conversion config */
    222 pub async fn clear_config(db: &PgPool) -> sqlx::Result<()> {
    223     serialized!(
    224         sqlx::query("DELETE FROM config WHERE key LIKE 'cashin%' OR key like 'cashout%'")
    225             .execute(db)
    226     )?;
    227     Ok(())
    228 }
    229 
    230 /** Result of conversions operations */
    231 pub enum ConversionResult {
    232     Success(Amount),
    233     ToSmall,
    234     IsExchange,
    235     NotExchange,
    236 }
    237 
    238 /** Perform [direction] conversion of [amount] using in-db [function] */
    239 pub async fn conversion(
    240     db: &PgPool,
    241     regional: &Currency,
    242     fiat: &Currency,
    243     amount: &Amount,
    244     to: bool,
    245     cashin: bool,
    246     rate_id: Option<u64>,
    247 ) -> sqlx::Result<ConversionResult> {
    248     let lambda = if to { "to" } else { "from" };
    249     let direction = if cashin { "cashin" } else { "cashout" };
    250     serialized!(
    251         sqlx::query(&format!(
    252             "SELECT too_small, converted FROM conversion_{lambda}($1,$2,$3)"
    253         ))
    254         .bind(amount)
    255         .bind(direction)
    256         .bind(rate_id.map(|it| it as i64))
    257         .try_map(|r: PgRow| {
    258             Ok(if r.try_get_flag("too_small")? {
    259                 ConversionResult::ToSmall
    260             } else {
    261                 ConversionResult::Success(r.try_get_amount(
    262                     "converted",
    263                     if amount.currency == *regional {
    264                         fiat
    265                     } else {
    266                         regional
    267                     },
    268                 )?)
    269             })
    270         })
    271         .fetch_one(db)
    272     )
    273 }
    274 
    275 /** Perform [direction] conversion of [amount] using in-db [function] */
    276 pub async fn user_conversion(
    277     db: &PgPool,
    278     regional: &Currency,
    279     fiat: &Currency,
    280     amount: &Amount,
    281     to: bool,
    282     cashin: bool,
    283     username: &str,
    284 ) -> sqlx::Result<ConversionResult> {
    285     let lambda = if to { "to" } else { "from" };
    286     let direction = if cashin { "cashin" } else { "cashout" };
    287     serialized!(
    288         sqlx::query(&format!(
    289             "SELECT is_taler_exchange, too_small, converted
    290             FROM bank_accounts
    291                 JOIN customers ON customer_id=owning_customer_id,
    292             LATERAL conversion_{lambda}($1,$2,conversion_rate_class_id)
    293             WHERE username=$3"
    294         ))
    295         .bind(amount)
    296         .bind(direction)
    297         .bind(username)
    298         .try_map(|r: PgRow| {
    299             let is_exchange: bool = r.try_get("is_taler_exchange")?;
    300             Ok(if direction == "cashout" && is_exchange {
    301                 ConversionResult::IsExchange
    302             } else if direction == "cashin" && !is_exchange {
    303                 ConversionResult::NotExchange
    304             } else if r.try_get_flag("too_small")? {
    305                 ConversionResult::ToSmall
    306             } else {
    307                 ConversionResult::Success(r.try_get_amount(
    308                     "converted",
    309                     if amount.currency == *regional {
    310                         fiat
    311                     } else {
    312                         regional
    313                     },
    314                 )?)
    315             })
    316         })
    317         .fetch_one(db)
    318     )
    319 }
    320 
    321 /** Result status of conversion rate class creation */
    322 pub enum CreateResult {
    323     Success(u64),
    324     NameReuse,
    325 }
    326 
    327 /** Create a new conversion rate class */
    328 pub async fn create(db: &PgPool, req: &ConversionRateClassInput) -> sqlx::Result<CreateResult> {
    329     let res = serialized!(
    330         sqlx::query(
    331             "
    332     INSERT INTO conversion_rate_classes (
    333          name
    334         ,description
    335         ,cashin_ratio
    336         ,cashin_fee
    337         ,cashin_min_amount
    338         ,cashin_rounding_mode
    339         ,cashout_ratio
    340         ,cashout_fee
    341         ,cashout_min_amount
    342         ,cashout_rounding_mode
    343     ) VALUES (
    344         $1,$2,$3,$4,$5,$6,$7,$8,$9,$10
    345     )
    346     RETURNING conversion_rate_class_id
    347     "
    348         )
    349         .bind(&req.name)
    350         .bind(&req.description)
    351         .bind(req.cashin_ratio)
    352         .bind(req.cashin_fee)
    353         .bind(req.cashin_min_amount)
    354         .bind(req.cashin_rounding_mode)
    355         .bind(req.cashout_ratio)
    356         .bind(req.cashout_fee)
    357         .bind(req.cashout_min_amount)
    358         .bind(req.cashout_rounding_mode)
    359         .try_map(|r: PgRow| {
    360             Ok(CreateResult::Success(
    361                 r.try_get_u64("conversion_rate_class_id")?,
    362             ))
    363         })
    364         .fetch_one(db)
    365     );
    366     match res {
    367         Ok(r) => Ok(r),
    368         Err(e) => {
    369             if e.is_unique_err() {
    370                 Ok(CreateResult::NameReuse)
    371             } else {
    372                 Err(e)
    373             }
    374         }
    375     }
    376 }
    377 
    378 /** Result status of conversion rate class patching */
    379 pub enum PatchResult {
    380     Success,
    381     Unknown,
    382     NameReuse,
    383 }
    384 
    385 /** Patch a conversion rate class */
    386 pub async fn patch_class(
    387     db: &PgPool,
    388     id: u64,
    389     input: &ConversionRateClassInput,
    390 ) -> sqlx::Result<PatchResult> {
    391     let res = serialized!(
    392         sqlx::query(
    393             "UPDATE conversion_rate_classes SET
    394              name=$1
    395             ,description=$2
    396             ,cashin_ratio=$3
    397             ,cashin_fee=$4
    398             ,cashin_min_amount=$5
    399             ,cashin_rounding_mode=$6
    400             ,cashout_ratio=$7
    401             ,cashout_fee=$8
    402             ,cashout_min_amount=$9
    403             ,cashout_rounding_mode=$10
    404         WHERE conversion_rate_class_id=$11",
    405         )
    406         .bind(&input.name)
    407         .bind(&input.description)
    408         .bind(input.cashin_ratio)
    409         .bind(input.cashin_fee)
    410         .bind(input.cashin_min_amount)
    411         .bind(input.cashin_rounding_mode)
    412         .bind(input.cashout_ratio)
    413         .bind(input.cashout_fee)
    414         .bind(input.cashout_min_amount)
    415         .bind(input.cashout_rounding_mode)
    416         .bind(id as i64)
    417         .execute(db)
    418     );
    419     match res {
    420         Ok(r) => {
    421             if r.rows_affected() > 0 {
    422                 Ok(PatchResult::Success)
    423             } else {
    424                 Ok(PatchResult::Unknown)
    425             }
    426         }
    427         Err(e) => {
    428             if e.is_unique_err() {
    429                 Ok(PatchResult::NameReuse)
    430             } else {
    431                 Err(e)
    432             }
    433         }
    434     }
    435 }
    436 
    437 /** Delete a conversion rate class */
    438 pub async fn delete_class(db: &PgPool, id: u64) -> sqlx::Result<bool> {
    439     let res = serialized!(
    440         sqlx::query("DELETE FROM conversion_rate_classes WHERE conversion_rate_class_id=$1")
    441             .bind(id as i64)
    442             .execute(db)
    443     )?;
    444     Ok(res.rows_affected() > 0)
    445 }
    446 
    447 /** Get conversion rate class [id] */
    448 pub async fn get_class(
    449     db: &PgPool,
    450     regional: &Currency,
    451     fiat: &Currency,
    452     id: u64,
    453 ) -> sqlx::Result<Option<ConversionRateClass>> {
    454     serialized!(
    455         sqlx::query(
    456             "SELECT
    457             name,
    458             description,
    459             cashin_ratio,
    460             cashin_fee,
    461             cashin_min_amount,
    462             cashin_rounding_mode,
    463             cashout_ratio,
    464             cashout_fee,
    465             cashout_min_amount,
    466             cashout_rounding_mode,
    467             (SELECT count(*) FROM bank_accounts WHERE bank_accounts.conversion_rate_class_id=conversion_rate_classes.conversion_rate_class_id) as num_users
    468                 FROM conversion_rate_classes
    469                 WHERE conversion_rate_class_id=$1"
    470         ).bind(id as i64)
    471         .try_map(|r: PgRow| Ok(ConversionRateClass {
    472             name: r.try_get("name")?,
    473             description: r.try_get("description")?,
    474             conversion_rate_class_id: id,
    475             num_users: r.try_get_u64("num_users")?,
    476             cashin_ratio: r.try_get("cashin_ratio")?,
    477             cashin_fee: r.try_get_opt_amount("cashin_fee", regional)?,
    478             cashin_rounding_mode: r.try_get("cashin_rounding_mode")?,
    479             cashin_min_amount: r.try_get_opt_amount("cashin_min_amount", fiat)?,
    480             cashout_ratio: r.try_get("cashout_ratio")?,
    481             cashout_fee: r.try_get_opt_amount("cashout_fee", fiat)?,
    482             cashout_rounding_mode: r.try_get("cashout_rounding_mode")?,
    483             cashout_min_amount: r.try_get_opt_amount("cashout_min_amount", regional)?,
    484         })).fetch_optional(db)
    485     )
    486 }
    487 
    488 /** Get conversion rate class [id] */
    489 pub async fn page_class(
    490     db: &PgPool,
    491     regional: &Currency,
    492     fiat: &Currency,
    493     params: &Class,
    494 ) -> sqlx::Result<Vec<ConversionRateClass>> {
    495     page(db, &params.page, "conversion_rate_class_id", ||{
    496         let mut query = QueryBuilder::new(" SELECT
    497         name,
    498         description,
    499         cashin_ratio,
    500         cashin_fee,
    501         cashin_min_amount,
    502         cashin_rounding_mode,
    503         cashout_ratio,
    504         cashout_fee,
    505         cashout_min_amount,
    506         cashout_rounding_mode,
    507         (SELECT count(*) FROM bank_accounts WHERE bank_accounts.conversion_rate_class_id=conversion_rate_classes.conversion_rate_class_id) as num_users,
    508         conversion_rate_class_id
    509         FROM conversion_rate_classes
    510         WHERE ");
    511         if let Some(filter) = &params.filter_name {
    512             query.push("name ILIKE ").push_bind(filter).push(" AND ");
    513         }
    514         query
    515     }, |r: PgRow| Ok(ConversionRateClass {
    516         name: r.try_get("name")?,
    517         description: r.try_get("description")?,
    518         conversion_rate_class_id: r.try_get_u64("conversion_rate_class_id")?,
    519         num_users: r.try_get_u64("num_users")?,
    520         cashin_ratio: r.try_get("cashin_ratio")?,
    521         cashin_fee: r.try_get_opt_amount("cashin_fee", regional)?,
    522         cashin_rounding_mode: r.try_get("cashin_rounding_mode")?,
    523         cashin_min_amount: r.try_get_opt_amount("cashin_min_amount", fiat)?,
    524         cashout_ratio: r.try_get("cashout_ratio")?,
    525         cashout_fee: r.try_get_opt_amount("cashout_fee", fiat)?,
    526         cashout_rounding_mode: r.try_get("cashout_rounding_mode")?,
    527         cashout_min_amount: r.try_get_opt_amount("cashout_min_amount", regional)?,
    528     })
    529     ).await
    530 }