libeufin

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

conversion.rs (37380B)


      1 /*
      2 * This file is part of LibEuFin.
      3 * Copyright (C) 2026 Taler Systems S.A.
      4 
      5 * LibEuFin is free software; you can redistribute it and/or modify
      6 * it under the terms of the GNU Affero General Public License as
      7 * published by the Free Software Foundation; either version 3, or
      8 * (at your option) any later version.
      9 
     10 * LibEuFin is distributed in the hope that it will be useful, but
     11 * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
     12 * or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Affero General
     13 * Public License for more details.
     14 
     15 * You should have received a copy of the GNU Affero General Public
     16 * License along with LibEuFin; see the file COPYING.  If not, see
     17 * <http://www.gnu.org/licenses/>
     18 */
     19 
     20 use std::sync::Arc;
     21 
     22 use axum::{
     23     Json, Router,
     24     extract::{FromRequestParts, State},
     25     middleware::{self, Next},
     26     response::{IntoResponse, NoContent, Response},
     27     routing::{get, patch, post},
     28 };
     29 use compact_str::{CompactString, format_compact};
     30 use serde::{Deserialize, Serialize};
     31 use sqlx::prelude::Type;
     32 use taler_api::{
     33     error::{ApiError, ApiResult, bad_request, failure, failure_code, forbidden, not_implemented},
     34     extract::{Path, Query, Req},
     35 };
     36 use taler_common::{
     37     api::params::{Page, PageParams, ParamsErr},
     38     error_code::ErrorCode,
     39     types::amount::{Amount, Currency, Decimal},
     40 };
     41 use taler_macros::{EnumMeta, api_config};
     42 
     43 use crate::{
     44     api::{BankState, CONVERSION_API_VERSION, IMPLEMENTATION},
     45     auth::{AdminRAuth, AdminRWAuth, UserOptRAuth, UserRAuth},
     46     config::{BankCfg, CurrencySpecification},
     47     db::conversion::{
     48         ConversionResult, CreateResult, PatchResult, conversion, create, delete_class, get_class,
     49         get_class_rate, get_default_rate, get_user_rate, page_class, patch_class, update_config,
     50         user_conversion,
     51     },
     52 };
     53 
     54 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, EnumMeta, Type)]
     55 #[enum_meta(Str)]
     56 #[sqlx(type_name = "rounding_mode")]
     57 #[allow(non_camel_case_types)]
     58 pub enum RoundingMode {
     59     zero,
     60     up,
     61     nearest,
     62 }
     63 
     64 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
     65 pub struct ConversionRate {
     66     pub cashin_ratio: Decimal,
     67     pub cashin_fee: Amount,
     68     pub cashin_tiny_amount: Amount,
     69     pub cashin_rounding_mode: RoundingMode,
     70     pub cashin_min_amount: Amount,
     71     pub cashout_ratio: Decimal,
     72     pub cashout_fee: Amount,
     73     pub cashout_tiny_amount: Amount,
     74     pub cashout_rounding_mode: RoundingMode,
     75     pub cashout_min_amount: Amount,
     76 }
     77 
     78 impl ConversionRate {
     79     pub fn check(&self, cfg: &BankCfg) -> ApiResult<()> {
     80         let Self {
     81             cashin_fee,
     82             cashin_tiny_amount,
     83             cashin_min_amount,
     84             cashout_fee,
     85             cashout_tiny_amount,
     86             cashout_min_amount,
     87             ..
     88         } = self;
     89         for amount in &[cashin_fee, cashin_tiny_amount, cashout_min_amount] {
     90             cfg.check_regio(amount)?;
     91         }
     92         for amount in &[cashout_fee, cashout_tiny_amount, cashin_min_amount] {
     93             cfg.check_fiat(amount)?;
     94         }
     95 
     96         if cashout_tiny_amount.is_zero() {
     97             Err(bad_request("cashout_tiny_amount must be > 0"))
     98         } else if cashin_tiny_amount.is_zero() {
     99             Err(bad_request("cashin_tiny_amount must be > 0"))
    100         } else if cashout_tiny_amount.is_sub_cent() {
    101             Err(bad_request(
    102                 "Sub-cent amounts no supported by cashout, cashout_tiny_amount must be >= 0.01",
    103             ))
    104         } else {
    105             Ok(())
    106         }
    107     }
    108 }
    109 
    110 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
    111 pub struct ConversionResponse {
    112     pub amount_debit: Amount,
    113     pub amount_credit: Amount,
    114 }
    115 
    116 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
    117 pub struct ConversionRateClassInput {
    118     pub name: CompactString,
    119     pub description: Option<CompactString>,
    120     pub cashin_ratio: Option<Decimal>,
    121     pub cashin_fee: Option<Amount>,
    122     pub cashin_rounding_mode: Option<RoundingMode>,
    123     pub cashin_min_amount: Option<Amount>,
    124     pub cashout_ratio: Option<Decimal>,
    125     pub cashout_fee: Option<Amount>,
    126     pub cashout_rounding_mode: Option<RoundingMode>,
    127     pub cashout_min_amount: Option<Amount>,
    128 }
    129 
    130 impl ConversionRateClassInput {
    131     pub fn check(&self, cfg: &BankCfg) -> ApiResult<()> {
    132         let Self {
    133             cashin_fee,
    134             cashin_min_amount,
    135             cashout_fee,
    136             cashout_min_amount,
    137             ..
    138         } = self;
    139         for amount in [cashin_fee, cashout_min_amount].into_iter().flatten() {
    140             cfg.check_regio(amount)?;
    141         }
    142         for amount in [cashout_fee, cashin_min_amount].into_iter().flatten() {
    143             cfg.check_fiat(amount)?;
    144         }
    145         Ok(())
    146     }
    147 }
    148 
    149 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
    150 pub struct ConversionRateClassResponse {
    151     pub conversion_rate_class_id: u64,
    152 }
    153 
    154 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
    155 pub struct ConversionRateClass {
    156     pub conversion_rate_class_id: u64,
    157     pub name: CompactString,
    158     pub description: Option<CompactString>,
    159     pub num_users: u64,
    160     pub cashin_ratio: Option<Decimal>,
    161     pub cashin_fee: Option<Amount>,
    162     pub cashin_rounding_mode: Option<RoundingMode>,
    163     pub cashin_min_amount: Option<Amount>,
    164     pub cashout_ratio: Option<Decimal>,
    165     pub cashout_fee: Option<Amount>,
    166     pub cashout_rounding_mode: Option<RoundingMode>,
    167     pub cashout_min_amount: Option<Amount>,
    168 }
    169 
    170 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
    171 pub struct ConversionRateClasses {
    172     pub classes: Vec<ConversionRateClass>,
    173 }
    174 
    175 #[api_config("taler-conversion-info")]
    176 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
    177 pub struct ConversionConfig<'a> {
    178     pub regional_currency: &'a Currency,
    179     pub regional_currency_specification: &'a CurrencySpecification,
    180     pub fiat_currency: &'a Currency,
    181     pub fiat_currency_specification: &'a CurrencySpecification,
    182     pub conversion_rate: &'a ConversionRate,
    183 }
    184 
    185 #[derive(Debug, Clone, Deserialize)]
    186 pub struct ClassParams {
    187     #[serde(flatten)]
    188     pub page: PageParams,
    189     pub filter_name: Option<CompactString>,
    190 }
    191 
    192 impl ClassParams {
    193     pub fn check(self) -> Result<Class, ParamsErr> {
    194         Ok(Class {
    195             page: self.page.check()?,
    196             filter_name: self.filter_name.map(|it| format_compact!("%{it}%")),
    197         })
    198     }
    199 }
    200 
    201 #[derive(Debug)]
    202 pub struct Class {
    203     pub page: Page,
    204     pub filter_name: Option<CompactString>,
    205 }
    206 
    207 pub struct Conversion;
    208 
    209 impl FromRequestParts<Arc<BankState>> for Conversion {
    210     type Rejection = ApiError;
    211 
    212     async fn from_request_parts(
    213         _: &mut axum::http::request::Parts,
    214         state: &Arc<BankState>,
    215     ) -> Result<Self, Self::Rejection> {
    216         if state.cfg.fiat.is_none() {
    217             Err(not_implemented())
    218         } else {
    219             Ok(Conversion)
    220         }
    221     }
    222 }
    223 
    224 #[derive(Debug, Clone, Deserialize)]
    225 pub struct RateParams {
    226     amount_debit: Option<Amount>,
    227     amount_credit: Option<Amount>,
    228 }
    229 
    230 impl RateParams {
    231     pub fn check(self, cfg: &BankCfg, cashin: bool) -> ApiResult<Rate> {
    232         let rate: Rate = {
    233             match (self.amount_credit, self.amount_debit) {
    234                 (None, None) => {
    235                     return Err(failure(
    236                         ErrorCode::GENERIC_PARAMETER_MISSING,
    237                         "Either param 'amount_debit' or 'amount_credit' is require",
    238                     ));
    239                 }
    240                 (Some(_), Some(_)) => {
    241                     return Err(failure(
    242                         ErrorCode::GENERIC_PARAMETER_MALFORMED,
    243                         "Cannot have both 'amount_debit' and 'amount_credit' params",
    244                     ));
    245                 }
    246                 (None, Some(amount_debit)) => Rate::Debit { amount_debit },
    247                 (Some(amount_credit), None) => Rate::Credit { amount_credit },
    248             }
    249         };
    250         if cashin {
    251             match &rate {
    252                 Rate::Debit { amount_debit } => cfg.check_fiat(amount_debit)?,
    253                 Rate::Credit { amount_credit } => cfg.check_regio(amount_credit)?,
    254             }
    255         } else {
    256             match &rate {
    257                 Rate::Debit { amount_debit } => cfg.check_regio(amount_debit)?,
    258                 Rate::Credit { amount_credit } => cfg.check_fiat(amount_credit)?,
    259             }
    260         };
    261         Ok(rate)
    262     }
    263 }
    264 
    265 #[derive(Debug, Clone)]
    266 pub enum Rate {
    267     Debit { amount_debit: Amount },
    268     Credit { amount_credit: Amount },
    269 }
    270 
    271 async fn config(State(state): State<Arc<BankState>>) -> ApiResult<Response> {
    272     let (fiat, spec) = state.cfg.fiat.as_ref().unwrap();
    273     let rate = get_default_rate(&state.db, &state.cfg.regional_currency, fiat).await?;
    274     ApiResult::Ok(
    275         Json(ConversionConfig {
    276             name: (),
    277             version: CONVERSION_API_VERSION,
    278             implementation: Some(IMPLEMENTATION),
    279             conversion_rate: &rate,
    280             fiat_currency: fiat,
    281             fiat_currency_specification: spec,
    282             regional_currency: &state.cfg.regional_currency,
    283             regional_currency_specification: &state.cfg.regional_currency_spec,
    284         })
    285         .into_response(),
    286     )
    287 }
    288 
    289 async fn update_cfg(
    290     State(state): State<Arc<BankState>>,
    291     _: AdminRWAuth,
    292     Req(req): Req<ConversionRate>,
    293 ) -> ApiResult<NoContent> {
    294     req.check(&state.cfg)?;
    295     update_config(&state.db, &req).await?;
    296     ApiResult::Ok(NoContent)
    297 }
    298 
    299 async fn convert(
    300     cfg: &BankCfg,
    301     params: RateParams,
    302     cashin: bool,
    303     lambda: impl AsyncFnOnce(bool, &Amount) -> sqlx::Result<ConversionResult>,
    304 ) -> ApiResult<Response> {
    305     let params = params.check(cfg, cashin)?;
    306 
    307     fn err_handling(res: sqlx::Result<ConversionResult>) -> ApiResult<Amount> {
    308         match res? {
    309             ConversionResult::Success(amount) => Ok(amount),
    310             ConversionResult::ToSmall => Err(failure(
    311                 ErrorCode::BANK_BAD_CONVERSION,
    312                 "amount is too small to be converted",
    313             )),
    314             ConversionResult::IsExchange => Err(failure(
    315                 ErrorCode::BANK_ACCOUNT_IS_EXCHANGE,
    316                 "exchange accounts cannot cashout",
    317             )),
    318             ConversionResult::NotExchange => Err(failure(
    319                 ErrorCode::BANK_ACCOUNT_IS_NOT_EXCHANGE,
    320                 "only exchange accounts can cashin",
    321             )),
    322         }
    323     }
    324 
    325     let (amount_debit, amount_credit) = match params {
    326         Rate::Debit { amount_debit } => (
    327             amount_debit,
    328             err_handling(lambda(true, &amount_debit).await)?,
    329         ),
    330         Rate::Credit { amount_credit } => (
    331             err_handling(lambda(false, &amount_credit).await)?,
    332             amount_credit,
    333         ),
    334     };
    335     Ok(Json(ConversionResponse {
    336         amount_debit,
    337         amount_credit,
    338     })
    339     .into_response())
    340 }
    341 
    342 pub fn conversion_api(state: Arc<BankState>) -> Router<Arc<BankState>> {
    343     Router::new()
    344         .route("/conversion-info/config", get(config))
    345         .route(
    346             "/conversion-rate-classes/{id}/conversion-info/config",
    347             get(config),
    348         )
    349         .route("/accounts/{username}/conversion-info/config", get(config))
    350         .route("/conversion-info/conversion-rate", post(update_cfg))
    351         .route(
    352             "/conversion-rate-classes/{id}/conversion-info/conversion-rate",
    353             post(update_cfg),
    354         )
    355         .route(
    356             "/accounts/{username}/conversion-info/conversion-rate",
    357             post(update_cfg),
    358         )
    359         .route(
    360             "/conversion-info/rate",
    361             get(async |State(state): State<Arc<BankState>>| {
    362                 ApiResult::Ok(Json(
    363                     get_default_rate(
    364                         &state.db,
    365                         &state.cfg.regional_currency,
    366                         state.cfg.fiat_currency().unwrap(),
    367                     )
    368                     .await?,
    369                 ))
    370             }),
    371         )
    372         .route(
    373             "/conversion-info/cashout-rate",
    374             get(
    375                 async |State(state): State<Arc<BankState>>, Query(params): Query<RateParams>| {
    376                     let regio = &state.cfg.regional_currency;
    377                     let fiat = state.cfg.fiat_currency().unwrap();
    378                     convert(&state.cfg, params, false, async |to, amount| {
    379                         conversion(&state.db, regio, fiat, amount, to, false, None).await
    380                     })
    381                     .await
    382                 },
    383             ),
    384         )
    385         .route(
    386             "/conversion-info/cashin-rate",
    387             get(
    388                 async |State(state): State<Arc<BankState>>, Query(params): Query<RateParams>| {
    389                     let regio = &state.cfg.regional_currency;
    390                     let fiat = state.cfg.fiat_currency().unwrap();
    391                     convert(&state.cfg, params, true, async |to, amount| {
    392                         conversion(&state.db, regio, fiat, amount, to, true, None).await
    393                     })
    394                     .await
    395                 },
    396             ),
    397         )
    398         .route(
    399             "/conversion-rate-classes/{id}/conversion-info/rate",
    400             get(
    401                 async |_: AdminRAuth, Path(id): Path<u64>, State(state): State<Arc<BankState>>| {
    402                     ApiResult::Ok(Json(
    403                         get_class_rate(
    404                             &state.db,
    405                             &state.cfg.regional_currency,
    406                             state.cfg.fiat_currency().unwrap(),
    407                             id,
    408                         )
    409                         .await?,
    410                     ))
    411                 },
    412             ),
    413         )
    414         .route(
    415             "/conversion-rate-classes/{id}/conversion-info/cashout-rate",
    416             get(
    417                 async |_: AdminRAuth,
    418                        Path(id): Path<u64>,
    419                        Query(params): Query<RateParams>,
    420                        State(state): State<Arc<BankState>>| {
    421                     let regio = &state.cfg.regional_currency;
    422                     let fiat = state.cfg.fiat_currency().unwrap();
    423                     convert(&state.cfg, params, false, async |to, amount| {
    424                         conversion(&state.db, regio, fiat, amount, to, false, Some(id)).await
    425                     })
    426                     .await
    427                 },
    428             ),
    429         )
    430         .route(
    431             "/conversion-rate-classes/{id}/conversion-info/cashin-rate",
    432             get(
    433                 async |_: AdminRAuth,
    434                        Path(id): Path<u64>,
    435                        Query(params): Query<RateParams>,
    436                        State(state): State<Arc<BankState>>| {
    437                     let regio = &state.cfg.regional_currency;
    438                     let fiat = state.cfg.fiat_currency().unwrap();
    439                     convert(&state.cfg, params, true, async |to, amount| {
    440                         conversion(&state.db, regio, fiat, amount, to, true, Some(id)).await
    441                     })
    442                     .await
    443                 },
    444             ),
    445         )
    446         .route(
    447             "/accounts/{username}/conversion-info/rate",
    448             get(
    449                 async |auth: UserOptRAuth, State(state): State<Arc<BankState>>| {
    450                     let (is_exchange, rate) = get_user_rate(
    451                         &state.db,
    452                         &state.cfg.regional_currency,
    453                         state.cfg.fiat_currency(),
    454                         &auth.username,
    455                     )
    456                     .await?;
    457                     if !is_exchange && !auth.is_authenticated() {
    458                         return Err(forbidden("Non exchange account rates are private"));
    459                     }
    460                     Ok(Json(rate))
    461                 },
    462             ),
    463         )
    464         .route(
    465             "/accounts/{username}/conversion-info/cashout-rate",
    466             get(
    467                 async |auth: UserRAuth,
    468                        Query(params): Query<RateParams>,
    469                        State(state): State<Arc<BankState>>| {
    470                     let regio = &state.cfg.regional_currency;
    471                     let fiat = state.cfg.fiat_currency().unwrap();
    472                     convert(&state.cfg, params, false, async |to, amount| {
    473                         user_conversion(&state.db, regio, fiat, amount, to, false, &auth.username)
    474                             .await
    475                     })
    476                     .await
    477                 },
    478             ),
    479         )
    480         .route(
    481             "/accounts/{username}/conversion-info/cashin-rate",
    482             get(
    483                 async |Path(username): Path<CompactString>,
    484                        Query(params): Query<RateParams>,
    485                        State(state): State<Arc<BankState>>| {
    486                     let regio = &state.cfg.regional_currency;
    487                     let fiat = state.cfg.fiat_currency().unwrap();
    488                     convert(&state.cfg, params, true, async |to, amount| {
    489                         user_conversion(&state.db, regio, fiat, amount, to, true, &username).await
    490                     })
    491                     .await
    492                 },
    493             ),
    494         )
    495         .route(
    496             "/conversion-rate-classes",
    497             post(
    498                 async |_: AdminRWAuth,
    499                        State(state): State<Arc<BankState>>,
    500                        Req(req): Req<ConversionRateClassInput>| {
    501                     req.check(&state.cfg)?;
    502                     match create(&state.db, &req).await? {
    503                         CreateResult::Success(id) => Ok(Json(ConversionRateClassResponse {
    504                             conversion_rate_class_id: id,
    505                         })),
    506                         CreateResult::NameReuse => Err(failure_code(ErrorCode::BANK_NAME_REUSE)),
    507                     }
    508                 },
    509             )
    510             .get(
    511                 async |_: AdminRAuth,
    512                        Query(params): Query<ClassParams>,
    513                        State(state): State<Arc<BankState>>| {
    514                     let params = params.check()?;
    515                     let classes = page_class(
    516                         &state.db,
    517                         &state.cfg.regional_currency,
    518                         state.cfg.fiat_currency().unwrap(),
    519                         &params,
    520                     )
    521                     .await?;
    522                     if classes.is_empty() {
    523                         ApiResult::Ok(NoContent.into_response())
    524                     } else {
    525                         ApiResult::Ok(Json(ConversionRateClasses { classes }).into_response())
    526                     }
    527                 },
    528             ),
    529         )
    530         .route(
    531             "/conversion-rate-classes/{id}",
    532             patch(
    533                 async |_: AdminRWAuth,
    534                        Path(id): Path<u64>,
    535                        State(state): State<Arc<BankState>>,
    536                        Req(req): Req<ConversionRateClassInput>| {
    537                     req.check(&state.cfg)?;
    538                     match patch_class(&state.db, id, &req).await? {
    539                         PatchResult::Success => Ok(NoContent),
    540                         PatchResult::Unknown => {
    541                             Err(failure_code(ErrorCode::BANK_TRANSACTION_NOT_FOUND))
    542                         }
    543                         PatchResult::NameReuse => Err(failure_code(ErrorCode::BANK_NAME_REUSE)),
    544                     }
    545                 },
    546             )
    547             .delete(
    548                 async |_: AdminRWAuth, Path(id): Path<u64>, State(state): State<Arc<BankState>>| {
    549                     if delete_class(&state.db, id).await? {
    550                         Ok(NoContent)
    551                     } else {
    552                         Err(failure_code(ErrorCode::BANK_TRANSACTION_NOT_FOUND))
    553                     }
    554                 },
    555             )
    556             .get(
    557                 async |_: AdminRAuth, Path(id): Path<u64>, State(state): State<Arc<BankState>>| {
    558                     if let Some(class) = get_class(
    559                         &state.db,
    560                         &state.cfg.regional_currency,
    561                         state.cfg.fiat_currency().unwrap(),
    562                         id,
    563                     )
    564                     .await?
    565                     {
    566                         Ok(Json(class))
    567                     } else {
    568                         Err(failure_code(ErrorCode::BANK_TRANSACTION_NOT_FOUND))
    569                     }
    570                 },
    571             ),
    572         )
    573         .route_layer(middleware::from_fn_with_state(
    574             state,
    575             async |State(state): State<Arc<BankState>>, req, next: Next| {
    576                 if state.cfg.fiat.is_none() {
    577                     not_implemented().into_response()
    578                 } else {
    579                     next.run(req).await
    580                 }
    581             },
    582         ))
    583 }
    584 
    585 #[cfg(test)]
    586 pub mod test {
    587 
    588     use axum::http::Method;
    589     use sqlx::postgres::PgConnectOptions;
    590     use taler_common::{error_code::ErrorCode, types::amount::amount};
    591     use taler_macros::db_test;
    592     use taler_test_utils::{json, server::TestServer};
    593 
    594     use crate::api::{
    595         conversion::{
    596             ConversionRate, ConversionRateClass, ConversionRateClassResponse,
    597             ConversionRateClasses, ConversionResponse,
    598         },
    599         test::{Auth, bank_setup},
    600     };
    601 
    602     #[db_test(raw)]
    603     async fn rates(db: PgConnectOptions) {
    604         let ctx = bank_setup(db).await;
    605 
    606         // Auth
    607         for path in [
    608             "/conversion-rate-classes",
    609             "/conversion-info/conversion-rate",
    610             "/accounts/merchant/conversion-info/conversion-rate",
    611             "/conversion-rate-classes/1/conversion-info/conversion-rate",
    612         ] {
    613             ctx.auth_routine(Method::POST, path, Auth::Admin).await;
    614         }
    615         ctx.auth_routine(
    616             Method::GET,
    617             "/accounts/merchant/conversion-info/rate",
    618             Auth::Optional,
    619         )
    620         .await;
    621         ctx.auth_routine(
    622             Method::GET,
    623             "/conversion-rate-classes/1/conversion-info/rate",
    624             Auth::Admin,
    625         )
    626         .await;
    627         for path in [
    628             "/conversion-rate-classes",
    629             "/conversion-rate-classes/1",
    630             "/conversion-rate-classes/1/conversion-info/cashout-rate?amount_debit=KUDOS:1",
    631         ] {
    632             ctx.auth_routine(Method::GET, path, Auth::Admin).await;
    633         }
    634         ctx.auth_routine(
    635             Method::GET,
    636             "/accounts/merchant/conversion-info/cashout-rate?amount_debit=KUDOS:1",
    637             Auth::UserOrAdmin,
    638         )
    639         .await;
    640 
    641         // Conversion rate
    642         {
    643             let ok = json!({
    644                 "cashin_ratio": "0.8",
    645                 "cashin_fee": "KUDOS:0.02",
    646                 "cashin_tiny_amount": "KUDOS:0.01",
    647                 "cashin_rounding_mode": "nearest",
    648                 "cashin_min_amount": "EUR:0",
    649                 "cashout_ratio": "1.25",
    650                 "cashout_fee": "EUR:0.003",
    651                 "cashout_tiny_amount": "EUR:0.01",
    652                 "cashout_rounding_mode": "zero",
    653                 "cashout_min_amount": "KUDOS:0.1"
    654             });
    655             for prefix in ["", "/conversion-rate-classes/1", "/accounts/merchant"] {
    656                 // Good rates
    657                 ctx.post_admin(format!("{prefix}/conversion-info/conversion-rate"))
    658                     .json(&ok)
    659                     .await
    660                     .assert_no_content();
    661                 // Bad currency
    662                 ctx.post_admin(format!("{prefix}/conversion-info/conversion-rate"))
    663                     .json(json!(ok + { "cashout_fee": "CHF:0.003" }))
    664                     .await
    665                     .assert_error(ErrorCode::GENERIC_CURRENCY_MISMATCH);
    666                 // Zero tiny amount
    667                 ctx.post_admin(format!("{prefix}/conversion-info/conversion-rate"))
    668                     .json(json!(ok + { "cashout_tiny_amount": "EUR:0" }))
    669                     .await
    670                     .assert_bad_request();
    671                 ctx.post_admin(format!("{prefix}/conversion-info/conversion-rate"))
    672                     .json(json!(ok + { "cashin_tiny_amount": "KUDOS:0" }))
    673                     .await
    674                     .assert_bad_request();
    675                 // Subcent cashout tiny amount
    676                 ctx.post_admin(format!("{prefix}/conversion-info/conversion-rate"))
    677                     .json(json!(ok + { "cashout_tiny_amount": "EUR:0.0001" }))
    678                     .await
    679                     .assert_bad_request()
    680             }
    681         }
    682 
    683         // User rate
    684         {
    685             ctx.get("/conversion-info/rate")
    686                 .await
    687                 .assert_ok_json::<ConversionRate>();
    688             ctx.geta("/accounts/merchant/conversion-info/rate")
    689                 .await
    690                 .assert_ok_json::<ConversionRate>();
    691             ctx.get("/accounts/exchange/conversion-info/rate")
    692                 .await
    693                 .assert_ok_json::<ConversionRate>();
    694             ctx.get_admin("/conversion-rate-classes/1/conversion-info/rate")
    695                 .await
    696                 .assert_ok_json::<ConversionRate>();
    697         }
    698 
    699         // Cashout rate
    700         {
    701             for prefix in ["", "/conversion-rate-classes/1", "/accounts/merchant"] {
    702                 // Check conversion to
    703                 let res: ConversionResponse = ctx
    704                     .get_admin(format!(
    705                         "{prefix}/conversion-info/cashout-rate?amount_debit=KUDOS:1"
    706                     ))
    707                     .await
    708                     .assert_ok_json();
    709                 assert_eq!(res.amount_debit, amount("KUDOS:1"));
    710                 assert_eq!(res.amount_credit, amount("EUR:1.24"));
    711 
    712                 // Check conversion from
    713                 let res: ConversionResponse = ctx
    714                     .get_admin(format!(
    715                         "{prefix}/conversion-info/cashout-rate?amount_credit=EUR:1.257"
    716                     ))
    717                     .await
    718                     .assert_ok_json();
    719                 assert_eq!(res.amount_debit, amount("KUDOS:1.01"));
    720                 assert_eq!(res.amount_credit, amount("EUR:1.257"));
    721 
    722                 // Too small
    723                 ctx.get_admin(format!(
    724                     "{prefix}/conversion-info/cashout-rate?amount_debit=KUDOS:0.0008"
    725                 ))
    726                 .await
    727                 .assert_error(ErrorCode::BANK_BAD_CONVERSION);
    728                 // No amount
    729                 ctx.get_admin(format!("{prefix}/conversion-info/cashout-rate"))
    730                     .await
    731                     .assert_error(ErrorCode::GENERIC_PARAMETER_MISSING);
    732                 // Both amount
    733                 ctx.get_admin(format!(
    734                     "{prefix}/conversion-info/cashout-rate?amount_debit=EUR:1&amount_credit=KUDOS:1"
    735                 ))
    736                 .await
    737                 .assert_error(ErrorCode::GENERIC_PARAMETER_MALFORMED);
    738                 // Wrong format
    739                 ctx.get_admin(format!(
    740                     "{prefix}/conversion-info/cashout-rate?amount_debit=1"
    741                 ))
    742                 .await
    743                 .assert_error(ErrorCode::GENERIC_PARAMETER_MALFORMED);
    744                 ctx.get_admin(format!(
    745                     "{prefix}/conversion-info/cashout-rate?amount_credit=1"
    746                 ))
    747                 .await
    748                 .assert_error(ErrorCode::GENERIC_PARAMETER_MALFORMED);
    749                 // Wrong currency
    750                 ctx.get_admin(format!(
    751                     "{prefix}/conversion-info/cashout-rate?amount_debit=EUR:1"
    752                 ))
    753                 .await
    754                 .assert_error(ErrorCode::GENERIC_CURRENCY_MISMATCH);
    755                 ctx.get_admin(format!(
    756                     "{prefix}/conversion-info/cashout-rate?amount_credit=KUDOS:1"
    757                 ))
    758                 .await
    759                 .assert_error(ErrorCode::GENERIC_CURRENCY_MISMATCH);
    760             }
    761             ctx.geta("/accounts/exchange/conversion-info/cashout-rate?amount_debit=KUDOS:1")
    762                 .await
    763                 .assert_error(ErrorCode::BANK_ACCOUNT_IS_EXCHANGE);
    764         }
    765 
    766         // Cashin rate
    767         {
    768             for prefix in ["", "/conversion-rate-classes/1", "/accounts/exchange"] {
    769                 for (from, to) in [(0.75, 0.58), (0.32, 0.24), (0.66, 0.51)] {
    770                     // Check conversion to
    771                     let res: ConversionResponse = ctx
    772                         .get_admin(format!(
    773                             "{prefix}/conversion-info/cashin-rate?amount_debit=EUR:{from}"
    774                         ))
    775                         .await
    776                         .assert_ok_json();
    777                     assert_eq!(res.amount_credit, amount(format!("KUDOS:{to}")));
    778                     assert_eq!(res.amount_debit, amount(format!("EUR:{from}")));
    779 
    780                     // Check conversion from
    781                     let res: ConversionResponse = ctx
    782                         .get_admin(format!(
    783                             "{prefix}/conversion-info/cashin-rate?amount_credit=KUDOS:{to}"
    784                         ))
    785                         .await
    786                         .assert_ok_json();
    787                     assert_eq!(res.amount_credit, amount(format!("KUDOS:{to}")));
    788                     assert_eq!(res.amount_debit, amount(format!("EUR:{from}")));
    789                 }
    790 
    791                 // No amount
    792                 ctx.get_admin(format!("{prefix}/conversion-info/cashin-rate"))
    793                     .await
    794                     .assert_error(ErrorCode::GENERIC_PARAMETER_MISSING);
    795                 // Both amount
    796                 ctx.get_admin(format!(
    797                     "{prefix}/conversion-info/cashin-rate?amount_debit=EUR:1&amount_credit=KUDOS:1"
    798                 ))
    799                 .await
    800                 .assert_error(ErrorCode::GENERIC_PARAMETER_MALFORMED);
    801                 // Wrong format
    802                 ctx.get_admin(format!(
    803                     "{prefix}/conversion-info/cashin-rate?amount_debit=1"
    804                 ))
    805                 .await
    806                 .assert_error(ErrorCode::GENERIC_PARAMETER_MALFORMED);
    807                 ctx.get_admin(format!(
    808                     "{prefix}/conversion-info/cashin-rate?amount_credit=1"
    809                 ))
    810                 .await
    811                 .assert_error(ErrorCode::GENERIC_PARAMETER_MALFORMED);
    812                 // Wrong currency
    813                 ctx.get_admin(format!(
    814                     "{prefix}/conversion-info/cashin-rate?amount_debit=KUDOS:1"
    815                 ))
    816                 .await
    817                 .assert_error(ErrorCode::GENERIC_CURRENCY_MISMATCH);
    818                 ctx.get_admin(format!(
    819                     "{prefix}/conversion-info/cashin-rate?amount_credit=EUR:1"
    820                 ))
    821                 .await
    822                 .assert_error(ErrorCode::GENERIC_CURRENCY_MISMATCH);
    823             }
    824             ctx.geta("/accounts/merchant/conversion-info/cashin-rate?amount_debit=EUR:1")
    825                 .await
    826                 .assert_error(ErrorCode::BANK_ACCOUNT_IS_NOT_EXCHANGE);
    827         }
    828 
    829         let prefixes = ["", "/conversion-rate-classes/1", "/accounts/merchant"];
    830 
    831         // No rate
    832         sqlx::query("DELETE FROM config WHERE key='conversion_rate'")
    833             .execute(&ctx.state.db)
    834             .await
    835             .unwrap();
    836         for prefix in prefixes {
    837             ctx.get(format!("{prefix}/conversion-info/config"))
    838                 .await
    839                 .assert_ok();
    840             ctx.get_admin(format!("{prefix}/conversion-info/cashin-rate"))
    841                 .await
    842                 .assert_error(ErrorCode::GENERIC_PARAMETER_MISSING);
    843             ctx.get_admin(format!(
    844                 "{prefix}/conversion-info/cashout-rate?amount_credit=EUR:1"
    845             ))
    846             .await
    847             .assert_error(ErrorCode::BANK_BAD_CONVERSION);
    848         }
    849 
    850         // Not implemented
    851         let ctx = ctx.swap_cfg("test_no_conversion.conf").await;
    852         ctx.get_admin("/conversion-rate-classes")
    853             .await
    854             .assert_not_implemented();
    855         ctx.get_admin("/conversion-rate-classes/1")
    856             .await
    857             .assert_not_implemented();
    858 
    859         for prefix in prefixes {
    860             ctx.get(format!("{prefix}/conversion-info/rate"))
    861                 .await
    862                 .assert_not_implemented();
    863             ctx.get(format!("{prefix}/conversion-info/cashin-rate"))
    864                 .await
    865                 .assert_not_implemented();
    866             ctx.get(format!("{prefix}/conversion-info/cashout-rate"))
    867                 .await
    868                 .assert_not_implemented();
    869         }
    870     }
    871 
    872     #[db_test(raw)]
    873     async fn classes(db: PgConnectOptions) {
    874         let ctx = bank_setup(db).await;
    875 
    876         // Config
    877         for prefix in ["", "/conversion-rate-classes/1", "/accounts/merchant"] {
    878             ctx.get(format!("{prefix}/conversion-info/config"))
    879                 .await
    880                 .assert_ok();
    881         }
    882 
    883         let full = json!({
    884             "description": "A nice little class",
    885             "cashin_ratio": "0.1",
    886             "cashin_fee": "KUDOS:0.2",
    887             "cashin_tiny_amount": "KUDOS:0.3",
    888             "cashin_rounding_mode": "nearest",
    889             "cashin_min_amount": "EUR:0",
    890             "cashout_ratio": "0.4",
    891             "cashout_fee": "EUR:0.5",
    892             "cashout_tiny_amount": "EUR:0.6",
    893             "cashout_rounding_mode": "zero",
    894             "cashout_min_amount": "KUDOS:0.7"
    895         });
    896 
    897         // Create full
    898         let res: ConversionRateClassResponse = ctx
    899             .post_admin("/conversion-rate-classes")
    900             .json(json!(full + { "name": "Class n°1" }))
    901             .await
    902             .assert_ok_json();
    903         assert_eq!(res.conversion_rate_class_id, 2);
    904         let rate: ConversionRateClass = ctx
    905             .get_admin("/conversion-rate-classes/2")
    906             .await
    907             .assert_ok_json();
    908         ctx.patch_admin("/conversion-rate-classes/2")
    909             .json(json!(full + { "name": "Class n°1" }))
    910             .await
    911             .assert_no_content();
    912         assert_eq!(
    913             rate,
    914             ctx.get_admin("/conversion-rate-classes/2")
    915                 .await
    916                 .assert_ok_json()
    917         );
    918 
    919         // Create empty
    920         let res: ConversionRateClassResponse = ctx
    921             .post_admin("/conversion-rate-classes")
    922             .json(json!({ "name": "Class n°2" }))
    923             .await
    924             .assert_ok_json();
    925         assert_eq!(res.conversion_rate_class_id, 3);
    926         let rate: ConversionRateClass = ctx
    927             .get_admin("/conversion-rate-classes/3")
    928             .await
    929             .assert_ok_json();
    930         ctx.patch_admin("/conversion-rate-classes/3")
    931             .json(json!({ "name": "Class n°2" }))
    932             .await
    933             .assert_no_content();
    934         assert_eq!(
    935             rate,
    936             ctx.get_admin("/conversion-rate-classes/3")
    937                 .await
    938                 .assert_ok_json()
    939         );
    940 
    941         // Bad currency
    942         ctx.post_admin("/conversion-rate-classes")
    943             .json(json!(full + {
    944                 "name": "Bad currency",
    945                 "cashout_fee": "CHF:0.003"
    946             }))
    947             .await
    948             .assert_error(ErrorCode::GENERIC_CURRENCY_MISMATCH);
    949 
    950         // Name reuse currency
    951         ctx.post_admin("/conversion-rate-classes")
    952             .json(json!(full + { "name": "Class n°1" }))
    953             .await
    954             .assert_error(ErrorCode::BANK_NAME_REUSE);
    955         ctx.patch_admin("/conversion-rate-classes/3")
    956             .json(json!(full + { "name": "Class n°1" }))
    957             .await
    958             .assert_error(ErrorCode::BANK_NAME_REUSE);
    959 
    960         // Page
    961         assert_eq!(
    962             3,
    963             ctx.get_admin("/conversion-rate-classes")
    964                 .await
    965                 .assert_ok_json::<ConversionRateClasses>()
    966                 .classes
    967                 .len()
    968         );
    969         for _ in 0..4 {
    970             ctx.create_conversion_rate_class().await;
    971         }
    972         assert_eq!(
    973             7,
    974             ctx.get_admin("/conversion-rate-classes")
    975                 .await
    976                 .assert_ok_json::<ConversionRateClasses>()
    977                 .classes
    978                 .len()
    979         );
    980         assert_eq!(
    981             5,
    982             ctx.get_admin("/conversion-rate-classes?filter_name=Gen")
    983                 .await
    984                 .assert_ok_json::<ConversionRateClasses>()
    985                 .classes
    986                 .len()
    987         );
    988 
    989         // Delete all
    990         for id in (1..4).chain(5..9) {
    991             ctx.delete_admin(format!("/conversion-rate-classes/{id}"))
    992                 .await
    993                 .assert_no_content();
    994             ctx.delete_admin(format!("/conversion-rate-classes/{id}"))
    995                 .await
    996                 .assert_error(ErrorCode::BANK_TRANSACTION_NOT_FOUND);
    997         }
    998         ctx.get_admin("/conversion-rate-classes?filter_name=Gen")
    999             .await
   1000             .assert_no_content();
   1001 
   1002         // Check no classes
   1003         ctx.get_admin("/conversion-rate-classes")
   1004             .await
   1005             .assert_no_content();
   1006         ctx.get_admin("/conversion-rate-classes/1")
   1007             .await
   1008             .assert_error(ErrorCode::BANK_TRANSACTION_NOT_FOUND);
   1009         ctx.patch_admin("/conversion-rate-classes/1")
   1010             .json(json!(full + { "name": "Class" }))
   1011             .await
   1012             .assert_error(ErrorCode::BANK_TRANSACTION_NOT_FOUND);
   1013         ctx.delete_admin("/conversion-rate-classes/1")
   1014             .await
   1015             .assert_error(ErrorCode::BANK_TRANSACTION_NOT_FOUND);
   1016     }
   1017 }