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:
swaitsandClaude Opus 5.5 committed 2026-10-21 14:04:11 -06:00
1 parent 1304b9c0ad
commit b0a9fd8ad4
69 files changed
+10529 -841

No files matched your search

+91
View File
@@ -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);