Files
wanderbooks/crates/ledger-core/src/money.rs
T
swaitsandClaude Opus 5.5 b0a9fd8ad4 P3: depreciation straight-line by days through the final month, schedules by day (defects 22, 9, D-37)
The final month takes what the months before leave, spread over its days;
a disposed asset stops on its disposal day. A skipped month is caught up
in the month the run is made; a reversed run un-posts its month. New
schedules spread by day; equal-months schedules must start on the 1st.
Amounts round to the currency's own minor unit. Kani and Lean proofs; an
exhaustive search over runs, reversals and disposals.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-21 14:04:11 -06:00

315 lines
11 KiB
Rust
Raw 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.
//! Exact money: [`Decimal`] amounts paired with an ISO 4217 currency code.
//!
//! There is no float anywhere in this module, and there never will be.
//! [`Money`] arithmetic is checked: adding different currencies is an error,
//! not a guess.
use crate::LedgerError;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use std::fmt;
/// An ISO 4217 alpha-3 currency code, stored as three uppercase ASCII bytes.
#[derive(Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Currency([u8; 3]);
impl Currency {
/// US dollar.
pub const USD: Currency = Currency(*b"USD");
/// Euro.
pub const EUR: Currency = Currency(*b"EUR");
/// British pound.
pub const GBP: Currency = Currency(*b"GBP");
/// Swiss franc.
pub const CHF: Currency = Currency(*b"CHF");
/// Japanese yen.
pub const JPY: Currency = Currency(*b"JPY");
/// Parse and validate a three-letter code. Input is uppercased, so
/// `"usd"` and `"USD"` both work; `"US"` or `"USDD"` fail.
pub fn new(code: &str) -> Result<Self, LedgerError> {
let bytes = code.as_bytes();
if bytes.len() != 3 || !bytes.iter().all(|b| b.is_ascii_alphabetic()) {
return Err(LedgerError::InvalidCurrencyCode(code.to_string()));
}
let mut upper = [0u8; 3];
for (i, b) in bytes.iter().enumerate() {
upper[i] = b.to_ascii_uppercase();
}
Ok(Currency(upper))
}
/// The currency's minor units: how many decimal places its smallest
/// unit has (2 for USD's cents, 0 for JPY, 3 for KWD), or `None` for
/// codes ISO 4217 gives none (gold, SDRs). Unlisted codes are 2.
///
/// SOURCE: ISO 4217 list one, published 2026-09-17
/// (docs/spec/sources/iso-4217.md); generated from it, not typed in.
pub fn minor_units(&self) -> Option<u32> {
match self.code() {
"BIF" | "CLP" | "DJF" | "GNF" | "ISK" | "JPY" | "KMF" | "KRW" | "PYG" | "RWF"
| "UGX" | "UYI" | "VND" | "VUV" | "XAF" | "XOF" | "XPF" => Some(0),
"BHD" | "IQD" | "JOD" | "KWD" | "LYD" | "OMR" | "TND" => Some(3),
"CLF" | "UYW" => Some(4),
"XAG" | "XAU" | "XBA" | "XBB" | "XBC" | "XBD" | "XDR" | "XPD" | "XPT" | "XSU"
| "XTS" | "XUA" | "XXX" => None,
_ => Some(2),
}
}
/// The decimal places a share of an amount in this currency is split
/// to (members' shares of earnings, periods::allocate_cumulative): its
/// minor units (cents, whole yen, thousandths of a dinar), or, for a
/// code ISO 4217 gives none (gold, SDRs), the most a line may carry
/// (kernel::MAX_LINE_SCALE). Every posted amount is then a whole number
/// of the unit split to, so no member's running share is ever a unit
/// from exact (rules.md B-13); split to cents, a finer amount's
/// leftover fraction would all go to one member.
pub fn allocation_places(&self) -> u32 {
self.minor_units().unwrap_or(crate::kernel::MAX_LINE_SCALE)
}
/// The three-letter code, e.g. `"USD"`.
pub fn code(&self) -> &str {
std::str::from_utf8(&self.0).expect("currency codes are ASCII by construction")
}
}
impl fmt::Debug for Currency {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Currency({})", self.code())
}
}
impl fmt::Display for Currency {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.code())
}
}
/// An exact decimal amount in a specific currency.
///
/// `Money` is `Copy` and cheap: pass it by value. All arithmetic is checked
/// for currency agreement and returns [`LedgerError::CurrencyMismatch`]
/// rather than silently converting.
#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Money {
/// The exact amount. Never a float.
pub amount: Decimal,
/// The currency this amount is denominated in.
pub currency: Currency,
}
impl Money {
/// Build money from an exact decimal amount.
pub fn new(amount: Decimal, currency: Currency) -> Self {
Self { amount, currency }
}
/// Zero in the given currency.
pub fn zero(currency: Currency) -> Self {
Self {
amount: Decimal::ZERO,
currency,
}
}
/// True when the amount is exactly zero.
pub fn is_zero(&self) -> bool {
self.amount.is_zero()
}
fn check_currency(&self, other: &Money) -> Result<(), LedgerError> {
if self.currency != other.currency {
return Err(LedgerError::CurrencyMismatch {
expected: self.currency.code().to_string(),
found: other.currency.code().to_string(),
});
}
Ok(())
}
/// Add two money values. Fails on currency mismatch.
pub fn checked_add(self, rhs: Money) -> Result<Money, LedgerError> {
self.check_currency(&rhs)?;
Ok(Money::new(self.amount + rhs.amount, self.currency))
}
/// Subtract two money values. Fails on currency mismatch.
pub fn checked_sub(self, rhs: Money) -> Result<Money, LedgerError> {
self.check_currency(&rhs)?;
Ok(Money::new(self.amount - rhs.amount, self.currency))
}
}
impl fmt::Debug for Money {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} {}", self.amount, self.currency)
}
}
/// A non-negative amount as a whole number of steps, for exact proration
/// by days (kernel::prorate_by_days): `units` steps of 10^-`scale` each,
/// where `scale` is the amount's own places or its currency's minor unit,
/// whichever is more, and `unit` is one minor unit in steps.
///
/// ```text
/// GOAL: depreciation and daily schedules round to the currency's own
/// smallest unit (cents, whole yen, thousandths of a dinar), never a
/// fixed two places.
/// SOURCE: docs/spec/rules.md B-3 (no fraction of the minor unit; ISO 4217,
/// sources/iso-4217.md).
/// INVARIANTS:
/// 1. amount = units × 10^-scale exactly; unit = 10^(scale − minor).
/// 2. A currency with no minor unit (gold, SDRs) counts as two places.
/// 3. NEVER rounds: an amount that doesn't fit is None.
/// ```
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct Steps {
/// The amount, in steps.
pub units: i128,
/// One minor unit, in steps.
pub unit: i128,
/// Decimal places of one step.
pub scale: u32,
/// The currency's minor unit (two when it has none).
pub minor: u32,
}
impl Steps {
/// `amount` (not negative) in `currency`'s steps.
pub fn of(amount: Decimal, currency: Currency) -> Option<Steps> {
if amount.is_sign_negative() && !amount.is_zero() {
return None;
}
let minor = currency.minor_units().unwrap_or(2);
let n = amount.normalize();
let scale = n.scale().max(minor);
let units = n
.mantissa()
.checked_mul(10i128.checked_pow(scale - n.scale())?)?;
Some(Steps {
units,
unit: 10i128.checked_pow(scale - minor)?,
scale,
minor,
})
}
/// `n` steps as a decimal, with the currency's minor places at least
/// (12,000 dollars prints 12000.00, 8,333 yen 8333). `None` past
/// Decimal's range.
pub fn decimal(&self, n: i128) -> Option<Decimal> {
let d = Decimal::try_from_i128_with_scale(n, self.scale).ok()?;
if self.scale == self.minor {
return Some(d);
}
let mut d = d.normalize();
if d.scale() < self.minor {
d.rescale(self.minor);
}
Some(d)
}
}
impl fmt::Display for Money {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} {}", self.amount, self.currency)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn usd(amount: Decimal) -> Money {
Money::new(amount, Currency::USD)
}
fn d(cents: i64) -> Decimal {
Decimal::new(cents, 2)
}
#[test]
fn checked_arithmetic() {
assert_eq!(
usd(d(1050)).checked_add(usd(d(425))).unwrap().amount,
d(1475)
);
assert_eq!(
usd(d(1050)).checked_sub(usd(d(425))).unwrap().amount,
d(625)
);
// Subtraction is not division, and not addition.
assert_ne!(
usd(d(1000)).checked_sub(usd(d(200))).unwrap().amount,
d(500)
);
}
/// ISO 4217 list one (docs/spec/sources/iso-4217.md): one code from
/// each exponent class, and the codes with no minor unit.
#[test]
fn minor_units_follow_iso_4217() {
let units = |c: &str| Currency::new(c).unwrap().minor_units();
assert_eq!(units("USD"), Some(2));
assert_eq!(units("JPY"), Some(0));
assert_eq!(units("BHD"), Some(3));
assert_eq!(units("KWD"), Some(3));
assert_eq!(units("CLF"), Some(4));
assert_eq!(units("UYW"), Some(4));
assert_eq!(units("XAU"), None);
assert_eq!(units("XDR"), None);
}
#[test]
fn currency_and_money_print_their_code_and_amount() {
assert_eq!(Currency::new("usd").unwrap().to_string(), "USD");
assert_eq!(format!("{:?}", Currency::EUR), "Currency(EUR)");
assert_eq!(usd(d(1250)).to_string(), "12.50 USD");
assert_eq!(format!("{:?}", usd(d(-5))), "-0.05 USD");
}
#[test]
fn is_zero_means_exactly_zero() {
assert!(Money::zero(Currency::USD).is_zero());
assert!(usd(d(0)).is_zero());
assert!(!usd(d(1)).is_zero());
assert!(!usd(d(-1)).is_zero());
}
/// Steps INVARIANTS 1-3: cents for dollars, whole yen, thousandths of
/// a dinar, two places for gold; an amount finer than its currency
/// keeps its own places, with the unit a minor unit.
#[test]
fn steps_are_the_currencys_minor_unit() {
let s =
|a: &str, c: &str| Steps::of(a.parse().unwrap(), Currency::new(c).unwrap()).unwrap();
let usd = s("12000", "USD");
assert_eq!((usd.units, usd.unit, usd.scale), (1_200_000, 1, 2));
assert_eq!(usd.decimal(1_148_387).unwrap().to_string(), "11483.87");
assert_eq!(usd.decimal(1_200_000).unwrap().to_string(), "12000.00");
let jpy = s("100000", "JPY");
assert_eq!((jpy.units, jpy.unit, jpy.scale), (100_000, 1, 0));
assert_eq!(jpy.decimal(8_333).unwrap().to_string(), "8333");
let kwd = s("1000.000", "KWD");
assert_eq!((kwd.units, kwd.unit, kwd.scale), (1_000_000, 1, 3));
let gold = s("5", "XAU");
assert_eq!((gold.units, gold.unit, gold.scale), (500, 1, 2));
let fine = s("100.006", "USD");
assert_eq!((fine.units, fine.unit, fine.scale), (100_006, 10, 3));
assert_eq!(fine.decimal(100_010).unwrap().to_string(), "100.01");
assert_eq!(fine.decimal(100_006).unwrap().to_string(), "100.006");
assert_eq!(fine.decimal(0).unwrap().to_string(), "0.00");
assert_eq!(Steps::of(d(-1), Currency::USD), None);
assert_eq!(usd.decimal(i128::MAX), None);
}
#[test]
fn checked_arithmetic_rejects_currency_mismatch() {
let eur = Money::new(d(100), Currency::EUR);
assert!(usd(d(100)).checked_add(eur).is_err());
assert!(usd(d(100)).checked_sub(eur).is_err());
}
}