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
This commit is contained in:
69 files changed
+10529
-841
No files matched your search
@@ -148,6 +148,70 @@ impl fmt::Debug for Money {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)
|
||||
@@ -214,6 +278,33 @@ mod tests {
|
||||
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);
|
||||
|
||||
Reference in new issue
Block a user