Keep the REST contract; move domain logic out of route files so checkout, fulfillment, and identity can be reused across customer, shop, and admin surfaces. Co-authored-by: Cursor <cursoragent@cursor.com>
71 lines
2.1 KiB
Rust
71 lines
2.1 KiB
Rust
use rust_decimal::prelude::*;
|
|
use rust_decimal::{Decimal, RoundingStrategy};
|
|
|
|
use crate::error::ApiError;
|
|
use crate::models::Currency;
|
|
|
|
/// Convert integer minor units between currencies via base rates.
|
|
/// rate_to_base = units of currency per 1 unit of base. Rounds half-up
|
|
/// to whole minor units of the target currency.
|
|
pub fn convert_minor(amount_minor: i64, from: &Currency, to: &Currency) -> Result<i64, ApiError> {
|
|
if amount_minor < 0 {
|
|
return Err(ApiError::BadRequest("amount_minor must be >= 0".into()));
|
|
}
|
|
let amount = Decimal::from(amount_minor);
|
|
let from_scale = Decimal::from(10i64.pow(from.exponent as u32));
|
|
let to_scale = Decimal::from(10i64.pow(to.exponent as u32));
|
|
// minor(from) -> major(from) -> major(base) -> minor(to)
|
|
let base_major = amount / from_scale / from.rate_to_base;
|
|
let target = (base_major * to.rate_to_base * to_scale)
|
|
.round_dp_with_strategy(0, RoundingStrategy::MidpointAwayFromZero);
|
|
target
|
|
.to_i64()
|
|
.ok_or_else(|| ApiError::BadRequest("amount out of range".into()))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use rust_decimal_macros::dec;
|
|
|
|
fn usd() -> Currency {
|
|
Currency {
|
|
code: "USD".into(),
|
|
name: serde_json::json!({"en": "US Dollar"}),
|
|
symbol: "$".into(),
|
|
exponent: 2,
|
|
is_base: true,
|
|
rate_to_base: dec!(1),
|
|
enabled: true,
|
|
}
|
|
}
|
|
|
|
fn jpy() -> Currency {
|
|
Currency {
|
|
code: "JPY".into(),
|
|
name: serde_json::json!({"en": "Yen"}),
|
|
symbol: "¥".into(),
|
|
exponent: 0,
|
|
is_base: false,
|
|
rate_to_base: dec!(150),
|
|
enabled: true,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn same_currency_is_identity() {
|
|
assert_eq!(convert_minor(199, &usd(), &usd()).unwrap(), 199);
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_negative() {
|
|
assert!(convert_minor(-1, &usd(), &usd()).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn converts_via_base_half_up() {
|
|
// 1.00 USD -> 150 JPY at rate 150
|
|
assert_eq!(convert_minor(100, &usd(), &jpy()).unwrap(), 150);
|
|
}
|
|
}
|