fix(core,store): child accounts share their parent's type; periods nest; lines checked when added
Three spec decisions from the owner (2026-10-13), each test-first:
- A child account has its parent's type (ParentTypeMismatch): a child
subdivides its parent. Test: a_child_account_has_its_parents_type.
- Periods nest (a month in a quarter in a year) but never partially
overlap (PeriodsOverlap; a refusal uses no id). Test:
periods_nest_but_never_partially_overlap.
- A line's account must exist and take postings when the line is added,
not only at post (UnknownAccount / PlaceholderAccount from add_line
and add_line_fx); posting still re-checks. Two older tests that pinned
the post-time refusal now check both (the post-time check is reached
through the unchecked replay path, as old journals can).
Compatibility: replay reads history through new Ledger::replay_*
commands (add_account, add_line, add_line_fx, create_period) that skip
checks added later, so books written before these rules still open.
The contract test books_written_under_older_rules_still_open writes such
a journal directly and found, on its first run, that the SQLite
cross-check rebuild used the checked commands and would have failed
verification ("sqlite projection can't be read back"); it now replays
too. Documented in docs/spec/README.md; decisions recorded in rules.md
(B-1, B-4, B-12).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
1 parent
4eaac20311
commit
4647611f96
7 files changed
+362
-12
No files matched your search
@@ -123,6 +123,42 @@ pub enum LedgerError {
|
||||
spec: String,
|
||||
},
|
||||
|
||||
/// A child account subdivides its parent, so it must be the same type
|
||||
/// (owner, 2026-10-13).
|
||||
#[error(
|
||||
"account {code} is {found:?}, but its parent {parent_code} is {expected:?}; a child account must be the same type as its parent"
|
||||
)]
|
||||
ParentTypeMismatch {
|
||||
/// The new account's code.
|
||||
code: String,
|
||||
/// The parent's code.
|
||||
parent_code: String,
|
||||
/// The parent's type, which the child must share.
|
||||
expected: crate::AccountType,
|
||||
/// The type the child was given.
|
||||
found: crate::AccountType,
|
||||
},
|
||||
|
||||
/// Periods nest (a month in a quarter in a year) but never partially
|
||||
/// overlap (owner, 2026-10-13).
|
||||
#[error(
|
||||
"period {name} ({start} to {end}) partially overlaps period {other} ({other_start} to {other_end}); periods may nest, but not overlap"
|
||||
)]
|
||||
PeriodsOverlap {
|
||||
/// The new period's name.
|
||||
name: String,
|
||||
/// Its first day.
|
||||
start: chrono::NaiveDate,
|
||||
/// Its last day.
|
||||
end: chrono::NaiveDate,
|
||||
/// The existing period's name.
|
||||
other: String,
|
||||
/// The existing period's first day.
|
||||
other_start: chrono::NaiveDate,
|
||||
/// The existing period's last day.
|
||||
other_end: chrono::NaiveDate,
|
||||
},
|
||||
|
||||
/// The referenced journal entry does not exist.
|
||||
#[error("entry {0} does not exist")]
|
||||
UnknownEntry(u64),
|
||||
|
||||
@@ -492,6 +492,22 @@ impl Ledger {
|
||||
}
|
||||
|
||||
/// Add an account to the chart. Returns its id.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: register an account under a unique code.
|
||||
/// SOURCE: rules.md B-4 (the five elements); R-4; DECISION (owner,
|
||||
/// 2026-10-13): a child has its parent's type.
|
||||
/// SCENARIOS:
|
||||
/// 1. IF the code is empty or taken: SHOULD refuse.
|
||||
/// 2. IF the parent doesn't exist: SHOULD refuse.
|
||||
/// 3. IF the parent is another type:
|
||||
/// - SHOULD refuse with ParentTypeMismatch
|
||||
/// - NEVER add the account
|
||||
/// BECAUSE a child subdivides its parent.
|
||||
/// ```
|
||||
///
|
||||
/// Replay of journals written before the parent rule uses
|
||||
/// [`Self::replay_add_account`].
|
||||
pub fn add_account(
|
||||
&mut self,
|
||||
code: &str,
|
||||
@@ -499,6 +515,30 @@ impl Ledger {
|
||||
account_type: AccountType,
|
||||
parent: Option<AccountId>,
|
||||
placeholder: bool,
|
||||
) -> Result<AccountId, LedgerError> {
|
||||
if let Some(p) = parent.and_then(|p| self.chart.get(p))
|
||||
&& p.account_type != account_type
|
||||
{
|
||||
return Err(LedgerError::ParentTypeMismatch {
|
||||
code: code.to_string(),
|
||||
parent_code: p.code.clone(),
|
||||
expected: p.account_type,
|
||||
found: account_type,
|
||||
});
|
||||
}
|
||||
self.replay_add_account(code, name, account_type, parent, placeholder)
|
||||
}
|
||||
|
||||
/// [`Self::add_account`] without the rules added on 2026-10-13 (a
|
||||
/// child's type), for replaying journals written before them. Never
|
||||
/// for new work.
|
||||
pub fn replay_add_account(
|
||||
&mut self,
|
||||
code: &str,
|
||||
name: &str,
|
||||
account_type: AccountType,
|
||||
parent: Option<AccountId>,
|
||||
placeholder: bool,
|
||||
) -> Result<AccountId, LedgerError> {
|
||||
let id = self.next_account_id;
|
||||
self.next_account_id += 1;
|
||||
@@ -616,6 +656,32 @@ impl Ledger {
|
||||
account: AccountId,
|
||||
side: Side,
|
||||
amount: Money,
|
||||
) -> Result<(), LedgerError> {
|
||||
self.check_line_account(account)?;
|
||||
self.replay_add_line(entry, account, side, amount)
|
||||
}
|
||||
|
||||
/// A line's account must exist and take postings, checked when the
|
||||
/// line is added (owner, 2026-10-13); posting checks again.
|
||||
fn check_line_account(&self, account: AccountId) -> Result<(), LedgerError> {
|
||||
let a = self
|
||||
.chart
|
||||
.get(account)
|
||||
.ok_or(LedgerError::UnknownAccount(account))?;
|
||||
if a.placeholder {
|
||||
return Err(LedgerError::PlaceholderAccount(a.code.clone()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// [`Self::add_line`] without the account check added on 2026-10-13,
|
||||
/// for replaying journals written before it. Never for new work.
|
||||
pub fn replay_add_line(
|
||||
&mut self,
|
||||
entry: EntryId,
|
||||
account: AccountId,
|
||||
side: Side,
|
||||
amount: Money,
|
||||
) -> Result<(), LedgerError> {
|
||||
if amount.currency != self.base_currency {
|
||||
return Err(LedgerError::CurrencyMismatch {
|
||||
@@ -648,6 +714,28 @@ impl Ledger {
|
||||
foreign_currency: Currency,
|
||||
foreign_amount: Decimal,
|
||||
fx_rate: Decimal,
|
||||
) -> Result<(), LedgerError> {
|
||||
self.check_line_account(account)?;
|
||||
self.replay_add_line_fx(
|
||||
entry,
|
||||
account,
|
||||
side,
|
||||
foreign_currency,
|
||||
foreign_amount,
|
||||
fx_rate,
|
||||
)
|
||||
}
|
||||
|
||||
/// [`Self::add_line_fx`] without the account check added on
|
||||
/// 2026-10-13, for replaying journals written before it.
|
||||
pub fn replay_add_line_fx(
|
||||
&mut self,
|
||||
entry: EntryId,
|
||||
account: AccountId,
|
||||
side: Side,
|
||||
foreign_currency: Currency,
|
||||
foreign_amount: Decimal,
|
||||
fx_rate: Decimal,
|
||||
) -> Result<(), LedgerError> {
|
||||
if foreign_amount <= Decimal::ZERO {
|
||||
return Err(LedgerError::InvalidDocumentLineAmount(foreign_amount));
|
||||
@@ -919,11 +1007,55 @@ impl Ledger {
|
||||
|
||||
/// Create an accounting period. The date range must be valid
|
||||
/// (end >= start). Returns the period id.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: name a span of days that can later be closed.
|
||||
/// SOURCE: rules.md B-12 (IRS Pub. 538: tax years, calendar or fiscal);
|
||||
/// DECISION (owner, 2026-10-13): periods nest, never
|
||||
/// partially overlap.
|
||||
/// SCENARIOS:
|
||||
/// 1. IF end is before start: SHOULD refuse.
|
||||
/// 2. IF it lies inside, or around, every period it touches: SHOULD
|
||||
/// accept (a month in a quarter in a year).
|
||||
/// 3. IF it partially overlaps a period:
|
||||
/// - SHOULD refuse with PeriodsOverlap
|
||||
/// - NEVER create it, or use up an id
|
||||
/// BECAUSE a span split across two periods could close twice or
|
||||
/// never.
|
||||
/// ```
|
||||
pub fn create_period(
|
||||
&mut self,
|
||||
name: &str,
|
||||
start: NaiveDate,
|
||||
end: NaiveDate,
|
||||
) -> Result<PeriodId, LedgerError> {
|
||||
periods::validate_period_range(start, end)?;
|
||||
let nested = |a: (NaiveDate, NaiveDate), b: (NaiveDate, NaiveDate)| {
|
||||
(a.0 >= b.0 && a.1 <= b.1) || (b.0 >= a.0 && b.1 <= a.1)
|
||||
};
|
||||
for q in self.list_periods() {
|
||||
let touches = start <= q.end && q.start <= end;
|
||||
if touches && !nested((start, end), (q.start, q.end)) {
|
||||
return Err(LedgerError::PeriodsOverlap {
|
||||
name: name.to_string(),
|
||||
start,
|
||||
end,
|
||||
other: q.name.clone(),
|
||||
other_start: q.start,
|
||||
other_end: q.end,
|
||||
});
|
||||
}
|
||||
}
|
||||
self.replay_create_period(name, start, end)
|
||||
}
|
||||
|
||||
/// [`Self::create_period`] without the nesting rule added on
|
||||
/// 2026-10-13, for replaying journals written before it.
|
||||
pub fn replay_create_period(
|
||||
&mut self,
|
||||
name: &str,
|
||||
start: NaiveDate,
|
||||
end: NaiveDate,
|
||||
) -> Result<PeriodId, LedgerError> {
|
||||
periods::validate_period_range(start, end)?;
|
||||
let id = self.next_period_id;
|
||||
|
||||
@@ -242,12 +242,18 @@ fn fx_overflow_is_an_error() {
|
||||
|
||||
#[test]
|
||||
fn unknown_account_is_rejected() {
|
||||
// Refused when the line is added (since 2026-10-13); a line that got
|
||||
// in anyway (replayed from an older journal) is still refused at post.
|
||||
let c = fixture_company();
|
||||
let mut ledger = c.ledger;
|
||||
let id = ledger.create_entry(date(21), "Ghost account");
|
||||
ledger.add_line(id, c.cash, Side::Debit, usd(100)).unwrap();
|
||||
assert_eq!(
|
||||
ledger.add_line(id, 999_999, Side::Credit, usd(100)),
|
||||
Err(LedgerError::UnknownAccount(999_999))
|
||||
);
|
||||
ledger
|
||||
.add_line(id, 999_999, Side::Credit, usd(100))
|
||||
.replay_add_line(id, 999_999, Side::Credit, usd(100))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
ledger.post_entry(id).unwrap_err(),
|
||||
@@ -263,7 +269,14 @@ fn placeholder_account_is_rejected() {
|
||||
.add_account("100", "Assets header", AccountType::Asset, None, true)
|
||||
.unwrap();
|
||||
let id = ledger.create_entry(date(21), "Post to header");
|
||||
ledger.add_line(id, header, Side::Debit, usd(100)).unwrap();
|
||||
assert_eq!(
|
||||
ledger.add_line(id, header, Side::Debit, usd(100)),
|
||||
Err(LedgerError::PlaceholderAccount("100".to_string()))
|
||||
);
|
||||
// Got in anyway (an older journal): still refused at post.
|
||||
ledger
|
||||
.replay_add_line(id, header, Side::Debit, usd(100))
|
||||
.unwrap();
|
||||
ledger
|
||||
.add_line(id, c.equity, Side::Credit, usd(100))
|
||||
.unwrap();
|
||||
@@ -906,3 +919,100 @@ fn a_close_refuses_a_3999_that_is_not_equity() {
|
||||
assert_untouched(&before, &l);
|
||||
assert!(l.closed_period_containing(date(5)).is_none());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chart, periods, and lines: rules confirmed by owner, 2026-10-13.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A child account subdivides its parent, so it is the same kind of
|
||||
/// account. SOURCE: rules.md B-4; DECISION (owner, 2026-10-13).
|
||||
#[test]
|
||||
fn a_child_account_has_its_parents_type() {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let cash = l
|
||||
.add_account("1000", "Cash", AccountType::Asset, None, true)
|
||||
.unwrap();
|
||||
l.add_account("1010", "Checking", AccountType::Asset, Some(cash), false)
|
||||
.unwrap();
|
||||
let before = l.chart().len();
|
||||
assert_eq!(
|
||||
l.add_account("1020", "Rent", AccountType::Expense, Some(cash), false),
|
||||
Err(LedgerError::ParentTypeMismatch {
|
||||
code: "1020".into(),
|
||||
parent_code: "1000".into(),
|
||||
expected: AccountType::Asset,
|
||||
found: AccountType::Expense,
|
||||
})
|
||||
);
|
||||
assert_eq!(l.chart().len(), before);
|
||||
assert!(l.chart().find_by_code("1020").is_none());
|
||||
}
|
||||
|
||||
/// Periods nest (a month in a quarter in a year) but never partially
|
||||
/// overlap. DECISION (owner, 2026-10-13).
|
||||
#[test]
|
||||
fn periods_nest_but_never_partially_overlap() {
|
||||
let d = |m: u32, day: u32| NaiveDate::from_ymd_opt(2026, m, day).unwrap();
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
l.create_period("2026-01", d(1, 1), d(1, 31)).unwrap();
|
||||
l.create_period("2026-02", d(2, 1), d(2, 28)).unwrap();
|
||||
// A quarter around both months, and a year around everything.
|
||||
l.create_period("Q1 2026", d(1, 1), d(3, 31)).unwrap();
|
||||
l.create_period("2026", d(1, 1), d(12, 31)).unwrap();
|
||||
// A week inside January nests too.
|
||||
l.create_period("week", d(1, 5), d(1, 11)).unwrap();
|
||||
let next = l.next_period_id();
|
||||
// Mid-January to mid-February straddles two months: refused.
|
||||
assert!(matches!(
|
||||
l.create_period("straddle", d(1, 15), d(2, 15)),
|
||||
Err(LedgerError::PeriodsOverlap { .. })
|
||||
));
|
||||
// So is a quarter that starts inside Q1.
|
||||
assert!(matches!(
|
||||
l.create_period("odd quarter", d(2, 1), d(4, 30)),
|
||||
Err(LedgerError::PeriodsOverlap { .. })
|
||||
));
|
||||
assert_eq!(l.next_period_id(), next, "refused means no id burned");
|
||||
// Disjoint is fine.
|
||||
l.create_period(
|
||||
"2027-01",
|
||||
NaiveDate::from_ymd_opt(2027, 1, 1).unwrap(),
|
||||
NaiveDate::from_ymd_opt(2027, 1, 31).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// A line names a real, postable account when it is added, not only when
|
||||
/// the entry posts. DECISION (owner, 2026-10-13); rules.md B-1.
|
||||
#[test]
|
||||
fn a_line_on_an_unknown_or_placeholder_account_is_refused_when_added() {
|
||||
let c = fixture_company();
|
||||
let mut l = c.ledger.clone();
|
||||
let header = l
|
||||
.add_account("1900", "Other assets", AccountType::Asset, None, true)
|
||||
.unwrap();
|
||||
let e = l.create_entry(date(21), "Lines");
|
||||
assert_eq!(
|
||||
l.add_line(e, 999_999, Side::Debit, usd(10)),
|
||||
Err(LedgerError::UnknownAccount(999_999))
|
||||
);
|
||||
assert_eq!(
|
||||
l.add_line(e, header, Side::Debit, usd(10)),
|
||||
Err(LedgerError::PlaceholderAccount("1900".into()))
|
||||
);
|
||||
assert!(matches!(
|
||||
l.add_line_fx(
|
||||
e,
|
||||
header,
|
||||
Side::Debit,
|
||||
Currency::EUR,
|
||||
Decimal::from(10),
|
||||
Decimal::from(2)
|
||||
),
|
||||
Err(LedgerError::PlaceholderAccount(_))
|
||||
));
|
||||
assert!(
|
||||
l.get_entry(e).unwrap().lines().is_empty(),
|
||||
"refused lines aren't added"
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user