open-standards/openpay/crates/op-fx/src/convert.rs
Transaction Science 70ecd6caa0 Initial import — three open standards under one shelf
Transaction Science open standards monorepo. Each subdirectory is a
self-contained reference protocol with its own README and licence.

- openpay/    Apache-2.0  payment-acceptance stack (Rust, 26 crates)
- smart-byte/ CC-BY-4.0   content-addressed value carrier (spec)
- eoc/        CC-BY-4.0   energy-optimised compute (spec)

The wire format and the right to fork are public. The optional hosted
services are the offer; the protocols are not.
2026-05-23 07:58:09 -04:00

223 lines
7.6 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Pure-function conversion of [`Money`] across currencies.
//!
//! Math is integer-exact:
//!
//! ```text
//! target_minor = round(source_minor × rate_ppm / 1_000_000)
//! ```
//!
//! using a caller-chosen [`RoundingMode`] for the remainder. We
//! use `i128` for the intermediate so even an `i64::MAX` minor
//! amount × a large `rate_ppm` doesn't overflow before we clamp
//! back to `i64`.
use op_core::Money;
use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
use crate::quote::Quote;
/// How to handle the integer remainder when
/// `source_minor × rate_ppm` is not exactly divisible by
/// `1_000_000`.
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum RoundingMode {
/// Banker's rounding (half-to-even). The default for accounting
/// systems — eliminates the systematic bias of always-up
/// rounding without statistically advantaging either side.
#[default]
HalfEven,
/// Truncate toward zero. Biased toward the **operator** when
/// converting *to* an operator-receiving currency; biased
/// toward the **customer** when converting away.
Down,
/// Round away from zero. Biased toward the **customer** in the
/// same scenarios.
Up,
}
/// Apply `quote` to `money`, producing the equivalent amount in
/// `quote.target_currency`.
///
/// # Errors
/// - [`Error::CurrencyMismatch`] if `money.currency != quote.source_currency`.
/// - [`Error::SameCurrency`] if source and target match (caller
/// should skip conversion).
/// - [`Error::InvalidRate`] if `quote.rate_ppm == 0`.
/// - [`Error::QuoteExpired`] if `quote` is past its validity.
/// - [`Error::Overflow`] only on pathological inputs.
pub fn convert(
money: Money,
quote: &Quote,
mode: RoundingMode,
now_unix_secs: u64,
) -> Result<Money> {
if quote.rate_ppm == 0 {
return Err(Error::InvalidRate);
}
if quote.source_currency == quote.target_currency {
return Err(Error::SameCurrency(quote.source_currency.code().to_owned()));
}
if money.currency != quote.source_currency {
return Err(Error::CurrencyMismatch {
money: money.currency.code().to_owned(),
quote_source: quote.source_currency.code().to_owned(),
});
}
if !quote.is_valid_at(now_unix_secs) {
return Err(Error::QuoteExpired {
from_currency: quote.source_currency.code().to_owned(),
to_currency: quote.target_currency.code().to_owned(),
valid_until: quote.valid_until_unix_secs,
now: now_unix_secs,
});
}
let scaled = i128::from(money.minor_units) * i128::from(quote.rate_ppm);
let divisor: i128 = 1_000_000;
let quotient = scaled / divisor;
let remainder = scaled % divisor;
let adjusted = apply_rounding(quotient, remainder, divisor, mode);
let minor = i64::try_from(adjusted).map_err(|_| Error::Overflow)?;
Ok(Money::from_minor(minor, quote.target_currency))
}
fn apply_rounding(quotient: i128, remainder: i128, divisor: i128, mode: RoundingMode) -> i128 {
if remainder == 0 {
return quotient;
}
let sign: i128 = if (quotient < 0) || (quotient == 0 && remainder < 0) {
-1
} else {
1
};
let abs_rem = remainder.abs();
let half = divisor / 2;
match mode {
RoundingMode::Down => quotient,
RoundingMode::Up => quotient + sign,
RoundingMode::HalfEven => match abs_rem.cmp(&half) {
std::cmp::Ordering::Less => quotient,
std::cmp::Ordering::Greater => quotient + sign,
std::cmp::Ordering::Equal => {
// Tie → round to nearest even quotient.
if quotient % 2 == 0 {
quotient
} else {
quotient + sign
}
}
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use op_core::Currency;
fn quote(rate_ppm: u64) -> Quote {
Quote::new(Currency::EUR, Currency::USD, rate_ppm, 0, u64::MAX, "test")
}
#[test]
fn exact_conversion_no_rounding_needed() {
// €100.00 at 1.000000 = $100.00.
let m = Money::from_minor(10_000, Currency::EUR);
let q = quote(1_000_000);
let r = convert(m, &q, RoundingMode::HalfEven, 0).unwrap();
assert_eq!(r, Money::from_minor(10_000, Currency::USD));
}
#[test]
fn standard_eur_usd_conversion() {
// €100.00 at 1.082500 = $108.25 exactly.
let m = Money::from_minor(10_000, Currency::EUR);
let q = quote(1_082_500);
let r = convert(m, &q, RoundingMode::HalfEven, 0).unwrap();
assert_eq!(r, Money::from_minor(10_825, Currency::USD));
}
#[test]
fn half_even_rounds_to_nearest_even() {
// 1 cent × 1.5 ppm-equivalent = halfway between 1 and 2 minor units.
// Setup: 1_500_000 ppm × 1 minor = 1_500_000 / 1_000_000 = 1.5
// Half-even on quotient=1, rem=500_000, divisor=1_000_000 → tie → nearest even → 2.
let m = Money::from_minor(1, Currency::EUR);
let q = quote(1_500_000);
let r = convert(m, &q, RoundingMode::HalfEven, 0).unwrap();
assert_eq!(r.minor_units, 2);
// Quotient=2, rem=500_000 → tie → nearest even → 2.
let m = Money::from_minor(2, Currency::EUR); // 2 * 1.5 = 3.0 exactly
let r = convert(m, &q, RoundingMode::HalfEven, 0).unwrap();
assert_eq!(r.minor_units, 3);
// 3 × 1.5 = 4.5 → tie → nearest even → 4.
let m = Money::from_minor(3, Currency::EUR);
let r = convert(m, &q, RoundingMode::HalfEven, 0).unwrap();
assert_eq!(r.minor_units, 4);
}
#[test]
fn down_truncates_toward_zero() {
let m = Money::from_minor(7, Currency::EUR);
let q = quote(1_500_000); // 7 * 1.5 = 10.5
let r = convert(m, &q, RoundingMode::Down, 0).unwrap();
assert_eq!(r.minor_units, 10);
}
#[test]
fn up_rounds_away_from_zero() {
let m = Money::from_minor(7, Currency::EUR);
let q = quote(1_500_000); // 7 * 1.5 = 10.5
let r = convert(m, &q, RoundingMode::Up, 0).unwrap();
assert_eq!(r.minor_units, 11);
}
#[test]
fn rejects_same_currency() {
let m = Money::from_minor(100, Currency::EUR);
let q = Quote::new(Currency::EUR, Currency::EUR, 1_000_000, 0, u64::MAX, "test");
assert!(matches!(
convert(m, &q, RoundingMode::HalfEven, 0),
Err(Error::SameCurrency(_))
));
}
#[test]
fn rejects_currency_mismatch() {
let m = Money::from_minor(100, Currency::USD);
let q = quote(1_082_500); // EUR/USD
assert!(matches!(
convert(m, &q, RoundingMode::HalfEven, 0),
Err(Error::CurrencyMismatch { .. })
));
}
#[test]
fn rejects_zero_rate() {
let m = Money::from_minor(100, Currency::EUR);
let q = quote(0);
assert!(matches!(
convert(m, &q, RoundingMode::HalfEven, 0),
Err(Error::InvalidRate)
));
}
#[test]
fn rejects_expired_quote() {
let m = Money::from_minor(100, Currency::EUR);
let q = Quote::new(Currency::EUR, Currency::USD, 1_082_500, 0, 1_000, "test");
assert!(matches!(
convert(m, &q, RoundingMode::HalfEven, 1_001),
Err(Error::QuoteExpired { .. })
));
}
#[test]
fn negative_amount_converts_correctly() {
// -€100.00 at 1.082500 = -$108.25.
let m = Money::from_minor(-10_000, Currency::EUR);
let q = quote(1_082_500);
let r = convert(m, &q, RoundingMode::HalfEven, 0).unwrap();
assert_eq!(r, Money::from_minor(-10_825, Currency::USD));
}
}