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 ¶ms, 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 }