fix(core,store): child accounts share their parent's type; periods nest; lines checked when added

Three spec decisions from Stephen (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:
swaitsandClaude Opus 5.5 committed 2026-10-13 14:16:51 -06:00
1 parent df38dcccf9
commit 925c3ba523
7 files changed
+362 -12

No files matched your search

+132
View File
@@ -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 (Stephen,
/// 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 (Stephen, 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 (Stephen, 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;