Files
wanderbooks/crates/ledger-core/src/periods.rs
T
swaitsandClaude Opus 5.5 a7f3cc09a9
check / check (push) Canceled after 0s
P3: odd cents split the same running way everywhere (defect 17, D-38)
Members' shares of earnings are split cumulatively by largest remainder in
the closes, the members' equity statement, the K-1 worksheet and the
viewer, so a member's capital on a given day never moves by a cent when its
month is later closed. Old closes replay as they were; the first P3 close
carries any true-up. Feature members_cumulative. Kani and Lean proofs of
the bounded drift; bookspec examples.

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

625 lines
25 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.
//! Accounting periods and period close.
//!
//! A [`Period`] is a named date range (a month, a quarter, a year). Periods
//! start open; closing one is a one-way operation that:
//!
//! 1. Rejects the close if any draft entries have dates inside the period.
//! 2. Posts a closing entry dated at period end: revenue accounts are
//! debited to zero, expense accounts are credited to zero, and the net
//! difference lands in Retained Earnings (created as `3999` if missing).
//! 3. Marks the period closed.
//!
//! Once closed, [`Ledger::try_create_entry`](crate::Ledger::try_create_entry) and [`Ledger::post_entry`](crate::Ledger::post_entry) reject
//! any entry dated inside the period. Corrections to closed periods go
//! through the *next* open period (or a reversal dated in an open period).
use crate::LedgerError;
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
/// Opaque period handle. Created by [`crate::Ledger`]; never forged.
pub type PeriodId = u64;
/// Lifecycle of an accounting period. `Open -> Closed` is one-way.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
pub enum PeriodStatus {
/// Accepting entries.
Open,
/// Closed. Entries dated inside are rejected.
Closed,
}
/// Where a period close puts the period's net income, and which owner
/// accounts (draws, distributions, dividends) it zeroes into equity too.
#[derive(Clone, Debug, PartialEq)]
pub enum CloseTo {
/// The Retained Earnings account ([`RETAINED_EARNINGS_CODE`], created if
/// missing): the corporate convention. `sweep` lists dividend or
/// distribution accounts to close into it.
RetainedEarnings {
/// Equity accounts zeroed into Retained Earnings.
sweep: Vec<crate::AccountId>,
},
/// One equity account, e.g. a sole proprietor's capital, with `sweep`
/// (e.g. the owner's draws) zeroed into it.
Account {
/// The equity account receiving the net.
into: crate::AccountId,
/// Equity accounts zeroed into it.
sweep: Vec<crate::AccountId>,
},
/// Members' capital accounts by share (shares sum to 1): the
/// partnership convention, where each member's capital account carries
/// their allocated earnings and absorbs their distributions. Odd cents
/// go by largest remainder, cumulatively from the first members close
/// with these members and shares ([`allocate_cumulative`]): each close
/// gives a member their share of everything closed to the members so
/// far, to the currency's minor unit, less what earlier closes gave
/// them, so no member is ever a minor unit from their exact share
/// (rules.md B-13; D-10). What new closes are made with; the books
/// journal it as [`CloseTo::MembersCumulative`].
Members(Vec<MemberClose>),
/// [`CloseTo::Members`] as journaled: the count of members closes
/// starts at the close numbered `since_seq` (its
/// [`Period::close_seq`]). Replay produces it and checks the start is
/// the one the books derive.
MembersCumulative {
/// The members and shares.
members: Vec<MemberClose>,
/// The close the count starts at: the first members close with
/// these members and shares since the last with others.
since_seq: u64,
},
/// The run rule of 2026-10-13 to P3 (journaled `"cumulative"`): a
/// back-to-back run of members closes with the same members and shares
/// in the same order splits cumulatively, and any other close ends the
/// run. Only replay of those journals produces it.
MembersRun(Vec<MemberClose>),
/// [`CloseTo::Members`] rounding each close on its own (largest
/// remainder), the rule for closes journaled on 2026-10-13 before the
/// cumulative one. Only replay of those journals produces it.
MembersPerClose(Vec<MemberClose>),
/// [`CloseTo::Members`] under the rounding closes used before
/// 2026-10-13 ([`allocate_last_absorbs`]). Only replay of those journals
/// produces it; new closes use `Members`.
MembersLastAbsorbs(Vec<MemberClose>),
}
impl CloseTo {
/// The members a close splits the net among, under whichever rule;
/// `None` for a close to retained earnings or one account.
pub fn members(&self) -> Option<&[MemberClose]> {
match self {
CloseTo::RetainedEarnings { .. } | CloseTo::Account { .. } => None,
CloseTo::Members(members)
| CloseTo::MembersCumulative { members, .. }
| CloseTo::MembersRun(members)
| CloseTo::MembersPerClose(members)
| CloseTo::MembersLastAbsorbs(members) => Some(members),
}
}
}
/// One member in a partnership close.
#[derive(Clone, Debug, PartialEq)]
pub struct MemberClose {
/// The member's capital account.
pub capital: crate::AccountId,
/// Their share of net income.
pub share: rust_decimal::Decimal,
/// Their distributions account, zeroed into their capital, if they
/// have one.
pub distributions: Option<crate::AccountId>,
}
/// Split `amount` by `shares` (which sum to 1) to the cent, by largest
/// remainder: every part is its exact share rounded toward zero to the
/// cent, and the leftover cents go one each to the largest remainders,
/// ties to the earlier member ([`crate::kernel::apportion`]). The parts
/// always sum to `amount` exactly, and no member is favored by position
/// (owner, 2026-10-13). A loss splits as the negation of the same gain.
/// Closes journaled under the rules of 2026-10-13 replay with it, in cents
/// whatever the currency; new closes split with [`allocate_cumulative`].
pub fn allocate(
amount: rust_decimal::Decimal,
shares: &[rust_decimal::Decimal],
) -> Vec<rust_decimal::Decimal> {
use rust_decimal::{Decimal, RoundingStrategy::ToZero};
let magnitude = amount.abs();
let exact: Vec<Decimal> = shares.iter().map(|s| magnitude * s).collect();
let mut parts: Vec<Decimal> = exact
.iter()
.map(|e| e.round_dp_with_strategy(2, ToZero))
.collect();
let remainders: Vec<Decimal> = exact.iter().zip(&parts).map(|(e, p)| e - p).collect();
crate::kernel::apportion(magnitude, &mut parts, &remainders, Decimal::new(1, 2))
.expect("shares sum to 1, so the shortfall is under one cent per member");
if amount.is_sign_negative() {
for p in &mut parts {
*p = -*p;
}
}
parts
}
/// The rule closes used before 2026-10-13: each share rounded half away
/// from zero to the cent, the last absorbing the rounding. Kept only so
/// closes journaled under it replay exactly ([`CloseTo::MembersLastAbsorbs`]).
pub fn allocate_last_absorbs(
amount: rust_decimal::Decimal,
shares: &[rust_decimal::Decimal],
) -> Vec<rust_decimal::Decimal> {
use rust_decimal::RoundingStrategy::MidpointAwayFromZero;
let mut parts: Vec<_> = shares
.iter()
.map(|s| (amount * s).round_dp_with_strategy(2, MidpointAwayFromZero))
.collect();
crate::kernel::plug_last(amount, &mut parts).expect("allocated amounts are in range");
parts
}
/// What one members close gives each member: [`allocate`]'s split of the
/// running `total` (everything closed to these members and shares,
/// this close included), to `places` decimal places
/// ([`crate::Currency::allocation_places`]) instead of cents, less what
/// earlier closes gave each member (`given`, in the order of `shares`).
/// Each member's running total is then one split of the running total
/// (rules.md B-13; [`crate::kernel::apportion_cumulative`]). A loss is
/// the negation of the same gain: it splits |total| against `given`
/// negated, then negates.
///
/// ```text
/// GOAL: no member's running share of earnings is ever a minor unit from
/// their exact share, whatever earlier closes gave them.
/// SOURCE: docs/spec/rules.md B-13 (26 U.S.C. §704(a); Treas. Reg.
/// §1.704-1(b)(2)(iv)(b); largest remainder: DECISION, owner
/// 2026-10-13; from the first close: D-10); B-3 (the minor unit).
/// INVARIANTS:
/// 1. The parts MUST sum to total − Σ given exactly.
/// 2. given[i] + part[i] MUST be member i's largest-remainder split of
/// total to `places` (ties to the earlier member): one split.
/// 3. So when total is a whole number of units of `places` (every posted
/// line is at its currency's allocation_places: its minor unit, B-3,
/// or for a currency without one the finest a line carries),
/// |given[i] + part[i] − total × share[i]| is under one unit (Lean
/// allocW_drift, cumulative_drift; Kani
/// cumulative_close_never_drifts_a_unit_at_*).
/// 4. NEVER rounds a product: if |total| × share isn't exact in Decimal
/// (a share of many decimal places times a large total), refuse
/// (TooLargeToCompute): a rounded product could rank the remainders
/// wrong, and the bound above assumes exact ones (P3 review R-5).
/// SCENARIOS:
/// 1. IF 101 yen splits 0.5 / 0.25 / 0.25 with nothing given: SHOULD be
/// 51 / 25 / 25 (exact 50.5 / 25.25 / 25.25; the yen to the largest
/// remainder), never fractions of a yen.
/// 2. IF 0.03 was given as A 0.03 / B 0.00 (50/50) and the total is now
/// 0.04: SHOULD be A −0.01, B +0.02.
/// 3. IF the total is a loss: SHOULD be the negation of the same gain
/// against the negated given.
/// 4. IF the shares and given don't pair up, or places exceeds what a
/// line may carry: SHOULD refuse.
/// ```
pub fn allocate_cumulative(
total: rust_decimal::Decimal,
shares: &[rust_decimal::Decimal],
given: &[rust_decimal::Decimal],
places: u32,
) -> Result<Vec<rust_decimal::Decimal>, LedgerError> {
use rust_decimal::{Decimal, RoundingStrategy::ToZero};
if shares.len() != given.len() {
return Err(LedgerError::InvalidMembers(format!(
"{} shares but {} earlier parts",
shares.len(),
given.len()
)));
}
if places > crate::kernel::MAX_LINE_SCALE {
return Err(LedgerError::TooLargeToCompute(format!(
"a split to {places} decimal places"
)));
}
let loss = total < Decimal::ZERO;
let magnitude = total.abs().normalize();
let given: Vec<Decimal> = given.iter().map(|&g| if loss { -g } else { g }).collect();
let mut parts = Vec::with_capacity(shares.len());
let mut remainders = Vec::with_capacity(shares.len());
for share in shares {
let share = share.normalize();
// Exact exactly when Decimal kept every digit: a product it had to
// round comes back with fewer places than its factors carry (a
// zero product, which it returns without places, is exact).
let exact = magnitude
.checked_mul(share)
.filter(|e| e.is_zero() || e.scale() == magnitude.scale() + share.scale())
.ok_or_else(|| {
LedgerError::TooLargeToCompute(format!(
"a member's share {share} of {magnitude}, to its last digit,"
))
})?;
let floor = exact.round_dp_with_strategy(places, ToZero);
parts.push(floor);
remainders.push(exact - floor);
}
crate::kernel::apportion_cumulative(
magnitude,
&mut parts,
&remainders,
Decimal::new(1, places),
&given,
)
.ok_or_else(|| LedgerError::TooLargeToCompute(format!("the members' shares of {total}")))?;
if loss {
for p in parts.iter_mut().filter(|p| !p.is_zero()) {
*p = -*p;
}
}
Ok(parts)
}
/// Code of the Retained Earnings account that closing entries credit (or
/// debit), created on the first close if the chart lacks it.
pub const RETAINED_EARNINGS_CODE: &str = "3999";
/// A named accounting period: a month, quarter, or year.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct Period {
/// Opaque handle.
pub id: PeriodId,
/// Human name, e.g. `"2026-10"` or `"Q4 2026"`.
pub name: String,
/// First date in the period (inclusive).
pub start: NaiveDate,
/// Last date in the period (inclusive).
pub end: NaiveDate,
/// Open or closed.
pub status: PeriodStatus,
/// The closing entry's id, once closed.
pub closing_entry: Option<u64>,
/// The order it closed in among all closes (1 for the first), once
/// closed.
#[serde(default)]
pub close_seq: Option<u64>,
/// What a close under the run rule ([`CloseTo::MembersRun`], journaled
/// `"cumulative"`) allocated, once closed that way. Only those closes
/// have one: its serialized form is what earlier versions wrote, and
/// stays so (rules.md B-28). Every members close's split, under any
/// rule, is [`Period::members_closed`].
#[serde(default)]
pub member_close: Option<MemberCloseRecord>,
/// What a members close (any rule) split, once closed to the members.
/// Derived on replay; never serialized, so a period reads as earlier
/// versions wrote it.
#[serde(skip)]
pub members_closed: Option<MembersClosed>,
}
/// A run-rule members close ([`CloseTo::MembersRun`]): who shared, and the
/// net it closed. The next run-rule close with the same members continues
/// the run.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct MemberCloseRecord {
/// (capital account, share), in the order given.
pub members: Vec<(crate::AccountId, rust_decimal::Decimal)>,
/// The net income this close allocated (negative for a loss).
pub net: rust_decimal::Decimal,
}
/// The rounding rule a members close was made under, as journaled.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MemberRule {
/// The last member absorbed the rounding (closes before 2026-10-13;
/// [`CloseTo::MembersLastAbsorbs`]).
LastAbsorbs,
/// Largest remainder, each close on its own (2026-10-13;
/// [`CloseTo::MembersPerClose`]).
PerClose,
/// Cumulative over a back-to-back run (2026-10-13 to P3;
/// [`CloseTo::MembersRun`]).
Run,
/// Cumulative from the members close numbered `since_seq`
/// ([`CloseTo::Members`], [`CloseTo::MembersCumulative`]).
Cumulative {
/// The close the count starts at.
since_seq: u64,
},
}
/// A members close's split, under any rule ([`Period::members_closed`]).
#[derive(Clone, Debug, PartialEq)]
pub struct MembersClosed {
/// (capital account, share), in the close's order.
pub members: Vec<(crate::AccountId, rust_decimal::Decimal)>,
/// The net income this close allocated (negative for a loss).
pub net: rust_decimal::Decimal,
/// The income it credited each member's capital account (debited for
/// a negative part), in the same order: their share of `net` plus any
/// true-up. All zero when the close posted no entry.
pub parts: Vec<rust_decimal::Decimal>,
/// The rule it was made under.
pub rule: MemberRule,
}
/// One member's part of a close under the cumulative rule
/// ([`crate::Ledger::member_close_split`]).
#[derive(Clone, Debug, PartialEq)]
pub struct MemberSplit {
/// The member's capital account.
pub capital: crate::AccountId,
/// Their share.
pub share: rust_decimal::Decimal,
/// What this close credited them (negative: debited), any true-up of
/// earlier rounding included.
pub part: rust_decimal::Decimal,
/// What the count's closes have credited them in all, this one
/// included.
pub running: rust_decimal::Decimal,
/// Their exact share of everything the count has closed, this close
/// included (share × total, unrounded): `running` is within one minor
/// unit of it once a close of the count has posted under the
/// cumulative rule (until then it still carries the rounding of closes
/// made under an earlier rule, which that close trues up).
pub exact: rust_decimal::Decimal,
}
impl Period {
/// True when `date` falls inside the period (inclusive).
pub fn contains(&self, date: NaiveDate) -> bool {
date >= self.start && date <= self.end
}
}
/// Validate a period's date range before creation.
/// [`validate_period_order`], and both ends inside the calendar the books
/// keep (crate::calendar; P0 review round 3, S1). For new periods; replay
/// checks only the order (rules.md B-28).
pub fn validate_period_range(start: NaiveDate, end: NaiveDate) -> Result<(), LedgerError> {
crate::calendar::check(start)?;
crate::calendar::check(end)?;
validate_period_order(start, end)
}
/// The check every journaled `period.created` passed when it was written
/// (the rule through 2026-10-16): the end is not before the start.
pub fn validate_period_order(start: NaiveDate, end: NaiveDate) -> Result<(), LedgerError> {
if end < start {
return Err(LedgerError::InvalidPeriodRange {
start: start.to_string(),
end: end.to_string(),
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Currency, Ledger};
use rust_decimal::Decimal;
fn dec(s: &str) -> Decimal {
s.parse().unwrap()
}
fn decs(v: &[&str]) -> Vec<Decimal> {
v.iter().map(|s| dec(s)).collect()
}
/// allocate_cumulative scenario 1: 101 yen at 0.5 / 0.25 / 0.25 is
/// exactly 50.5 / 25.25 / 25.25; floors 50 / 25 / 25 leave one yen,
/// which goes to the largest remainder (0.5): 51 / 25 / 25. Never a
/// fraction of a yen. A dinar splits to thousandths.
#[test]
fn allocate_cumulative_splits_to_the_currencys_minor_unit() {
let shares = decs(&["0.5", "0.25", "0.25"]);
let none = decs(&["0", "0", "0"]);
assert_eq!(
allocate_cumulative(dec("101"), &shares, &none, 0).unwrap(),
decs(&["51", "25", "25"])
);
// KWD: 0.101 at 50/50 is 0.0505 each; floors 0.050 / 0.050 and
// the tied thousandth to the first.
let half = decs(&["0.5", "0.5"]);
assert_eq!(
allocate_cumulative(dec("0.101"), &half, &decs(&["0", "0"]), 3).unwrap(),
decs(&["0.051", "0.050"])
);
// Cents, as allocate splits them.
assert_eq!(
allocate_cumulative(dec("0.07"), &half, &decs(&["0", "0"]), 2).unwrap(),
allocate(dec("0.07"), &half)
);
}
/// allocate_cumulative scenarios 2 and 3: three cents given A 0.03 /
/// B 0.00 and a running 0.04 give A −0.01, B +0.02; a loss of the
/// same is the negation against the negated given.
#[test]
fn allocate_cumulative_trues_up_and_mirrors_a_loss() {
let half = decs(&["0.5", "0.5"]);
assert_eq!(
allocate_cumulative(dec("0.04"), &half, &decs(&["0.03", "0"]), 2).unwrap(),
decs(&["-0.01", "0.02"])
);
assert_eq!(
allocate_cumulative(dec("-0.04"), &half, &decs(&["-0.03", "0"]), 2).unwrap(),
decs(&["0.01", "-0.02"])
);
// Given that sums to the earlier total: the parts sum to the new
// amount (0.05 − 0.03 = 0.02); running totals 0.03 / 0.02.
let parts = allocate_cumulative(dec("0.05"), &half, &decs(&["0.01", "0.02"]), 2).unwrap();
assert_eq!(parts, decs(&["0.02", "0.00"]));
assert_eq!(parts.iter().sum::<Decimal>(), dec("0.02"));
}
/// allocate_cumulative INVARIANT 4 (P3 review R-5): a share of 28
/// decimal places times 100.00 has 30, more than Decimal keeps, so the
/// product would be rounded: refused, never split from a rounded
/// product. Four places (33.33%) are exact at any line amount.
#[test]
fn allocate_cumulative_refuses_a_product_it_cannot_compute_exactly() {
let third = dec("0.3333333333333333333333333333");
let last = Decimal::ONE - third - third;
let shares = [third, third, last];
let none = decs(&["0", "0", "0"]);
assert!(matches!(
allocate_cumulative(dec("100.00"), &shares, &none, 2),
Err(LedgerError::TooLargeToCompute(_))
));
let shares = decs(&["0.3333", "0.3333", "0.3334"]);
let max = crate::kernel::MAX_LINE_AMOUNT - dec("0.01");
let parts = allocate_cumulative(max, &shares, &none, 2).unwrap();
assert_eq!(parts.iter().sum::<Decimal>(), max);
// Trailing zeros are not digits: "0.50" and "0.5" split alike.
assert_eq!(
allocate_cumulative(dec("0.01"), &decs(&["0.50", "0.50"]), &decs(&["0", "0"]), 2)
.unwrap(),
decs(&["0.01", "0.00"])
);
}
/// allocate_cumulative scenario 4.
#[test]
fn allocate_cumulative_refuses_unpaired_inputs() {
assert!(matches!(
allocate_cumulative(dec("1"), &decs(&["1"]), &[], 2),
Err(LedgerError::InvalidMembers(_))
));
assert!(matches!(
allocate_cumulative(dec("1"), &decs(&["1"]), &decs(&["0"]), 11),
Err(LedgerError::TooLargeToCompute(_))
));
}
fn period_ledger() -> Ledger {
let mut l = Ledger::new(Currency::USD);
l.add_account("4000", "Revenue", crate::AccountType::Revenue, None, false)
.unwrap();
l.add_account("6000", "Expenses", crate::AccountType::Expense, None, false)
.unwrap();
l.add_account("1000", "Cash", crate::AccountType::Asset, None, false)
.unwrap();
l
}
fn post_sale(l: &mut Ledger, date: NaiveDate, amount: &str) {
let e = l.create_entry(date, "sale");
let rev = l.chart().find_by_code("4000").unwrap().id;
let cash = l.chart().find_by_code("1000").unwrap().id;
l.add_line(
e,
cash,
crate::Side::Debit,
crate::Money::new(amount.parse().unwrap(), Currency::USD),
)
.unwrap();
l.add_line(
e,
rev,
crate::Side::Credit,
crate::Money::new(amount.parse().unwrap(), Currency::USD),
)
.unwrap();
l.post_entry(e).unwrap();
}
#[test]
fn close_zeroes_revenue_and_expense_to_retained_earnings() {
let mut l = period_ledger();
let start = NaiveDate::from_ymd_opt(2026, 10, 1).unwrap();
let end = NaiveDate::from_ymd_opt(2026, 10, 31).unwrap();
let pid = l.create_period("2026-10", start, end).unwrap();
post_sale(
&mut l,
NaiveDate::from_ymd_opt(2026, 10, 15).unwrap(),
"1000.00",
);
// An expense: debit expense, credit cash.
let e = l.create_entry(NaiveDate::from_ymd_opt(2026, 10, 20).unwrap(), "rent");
let exp = l.chart().find_by_code("6000").unwrap().id;
let cash = l.chart().find_by_code("1000").unwrap().id;
let m = crate::Money::new("400.00".parse().unwrap(), Currency::USD);
l.add_line(e, exp, crate::Side::Debit, m).unwrap();
l.add_line(e, cash, crate::Side::Credit, m).unwrap();
l.post_entry(e).unwrap();
l.close_period(pid).unwrap();
// Revenue and expense are zeroed.
let rev = l.chart().find_by_code("4000").unwrap().id;
let exp = l.chart().find_by_code("6000").unwrap().id;
assert_eq!(l.account_balance(rev).unwrap(), rust_decimal::Decimal::ZERO);
assert_eq!(l.account_balance(exp).unwrap(), rust_decimal::Decimal::ZERO);
// Retained earnings holds the net: 1000 - 400 = 600 (credit-normal).
let re = l.chart().find_by_code(RETAINED_EARNINGS_CODE).unwrap().id;
assert_eq!(
l.account_balance(re).unwrap(),
"600".parse::<rust_decimal::Decimal>().unwrap()
);
// Period is closed.
assert_eq!(l.get_period(pid).unwrap().status, PeriodStatus::Closed);
}
#[test]
fn close_rejects_when_already_closed() {
let mut l = period_ledger();
let pid = l
.create_period(
"2026-10",
NaiveDate::from_ymd_opt(2026, 10, 1).unwrap(),
NaiveDate::from_ymd_opt(2026, 10, 31).unwrap(),
)
.unwrap();
l.close_period(pid).unwrap();
assert_eq!(
l.close_period(pid).unwrap_err(),
LedgerError::PeriodAlreadyClosed(pid)
);
}
#[test]
fn close_rejects_with_drafts_in_period() {
let mut l = period_ledger();
let pid = l
.create_period(
"2026-10",
NaiveDate::from_ymd_opt(2026, 10, 1).unwrap(),
NaiveDate::from_ymd_opt(2026, 10, 31).unwrap(),
)
.unwrap();
// A draft dated inside the period blocks the close.
l.create_entry(NaiveDate::from_ymd_opt(2026, 10, 15).unwrap(), "draft");
assert_eq!(
l.close_period(pid).unwrap_err(),
LedgerError::DraftsInPeriod(pid)
);
}
#[test]
fn closed_period_rejects_new_entries() {
let mut l = period_ledger();
let pid = l
.create_period(
"2026-10",
NaiveDate::from_ymd_opt(2026, 10, 1).unwrap(),
NaiveDate::from_ymd_opt(2026, 10, 31).unwrap(),
)
.unwrap();
l.close_period(pid).unwrap();
// create_entry with a date in the closed period fails.
let err = l.try_create_entry(NaiveDate::from_ymd_opt(2026, 10, 15).unwrap(), "late");
assert!(matches!(err, Err(LedgerError::ClosedPeriod(_, _))));
}
#[test]
fn invalid_range_rejected() {
let mut l = period_ledger();
let err = l.create_period(
"bad",
NaiveDate::from_ymd_opt(2026, 10, 31).unwrap(),
NaiveDate::from_ymd_opt(2026, 10, 1).unwrap(),
);
assert!(matches!(err, Err(LedgerError::InvalidPeriodRange { .. })));
}
}