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