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:
1 parent
df38dcccf9
commit
925c3ba523
7 files changed
+362
-12
No files matched your search
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user