feat(arap)!: documents read their figures from standing lines; control accounts
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
refuse hand entries (P4b S1, part A) Plan P4b (D-45), slice S1, the engine half: rules.md B-38. - ledger-core: every receivables or payables line carries its document (Ledger.line_docs: DocRef, Movement, DocTag), written by the document commands in the step that posts it, derived on replay from the existing document events (the role's account as it stood then; payments by the ordinal rule), copied onto a reversal's mirror. Ledger::stands_on; doc_state / invoice_state / bill_state (total, paid, outstanding, status Open/Partial/Paid/WrittenOff/Void, overdue days) as of any day; ar_aging / ap_aging read them, so reversed payments and voids are right. The control guard (add_line, add_line_fx, post_entry, register_recurring) refuses untagged lines on receivables/payables, exempting a reversal's mirror of a legacy line; replay never refuses. Bounds (kernel::running_bounds, bounds_kept) on payments and reversals; a document reversal never dated before its entry; deposit/payer accounts must be cash-like and hold no role. New API: receive_payment_with / pay_bill_with (PaymentDetails: instrument, reference), void_document / check_void_document, control_check, payments_recorded, standing_payments, replay_reverse_entry. Invoice.amount_paid/status remain only as derived caches for the MCP layer until part B. cashflow bill_splits reads payments from the tags. - ledger-store: journal feature document_standing (D-39 gated) for a payment accepted only because an earlier one was reversed; receive_payment_with / pay_bill_with (instrument journaled as entry.instrument_set, reference in the description), void_document; replay uses replay_reverse_entry; schema 15 drops the projection's amount_paid/status. Fixture p4-arap-before + golden figures written by the version before S1, adjudicated in arap_standing.rs. - ledger-gui: party card, cash "coming up" and company owed totals read doc_state as of today (late from the derived state). - Lean Documents.lean (control_agrees and friends); Kani bounds_check_matches_definition, bounds_kept_is_the_definition_on_books_in_bounds; rules B-38, B-18/B-23 amended; bookspec `older = true` for legacy hand lines; tests that hand-post to 1200/2000 post as replay or renumbered. Review fixes (both reviews of part A): - control_role_of guards only an account the role is assigned to or one holding its documents' lines (a chart's own 1200 Equipment or card at 2000 is free until a document posts there); payment accounts check other roles by holds_role (type-checked). The MCP tests that failed on the guard (oracle_mcp, spec_projects) pass unchanged. - standing_payments leaves out reversals (sorted, deduped); replayed tags match the movement's side; payments_recorded counts every payment event (tagged or not, Ledger.payment_events) and refuses overflow. - The record caches settled (paid + discounted + written off) and owed (Invoice/Bill.owed, never serialized): a void never reads as paid. - assign_role: receivables/payables refuse moving onto an account with untagged lines (RoleAccountHasHandLines) and, on their control account holding a balance, say DocumentRoleHoldsBalance (no hand entry named); B-23 reworded. The reversal-mirror exemption only takes a hand line off (even chain depth); putting it back is refused. - DocStatus Overpaid and Unattached; aging leaves out credit balances; ControlCheck.attention lists out-of-bounds and unattached documents. - stands_on counts a chain in order; indexes (doc_index, doc_accounts, order_pos, reversed_by) keep the derived figures linear. - Viewer reads a post-dated document as of its own date; one state per row. bounds_kept's side-crossing documented as intended. - Tests: ten new spec_documents scenarios, stronger proptests, store proptest arap_props (live = replay = snapshot at every date, closed months), fuzz ops void/payments with details, a GUI post-dated test; Lean Step.assign / Step.release with unattached_zero kept; model limits stated in docs/testing.md and B-38. Review fixes (CPA and engineering lenses): only an assigned role's account or one holding document lines is a control account (a chart's own 1200 asset stays free); a role can't move onto untagged lines or off document lines (Lean Step.assign); a void's cache never reads as paid; no Decimal sum panics; per-document indexes keep figures linear; a replayed tag matches its side; the feature gate counts payment events; a payment's reversal is never listed as standing; an overpaid document has its own status; the viewer keeps post-dated documents. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
54 files changed
+5997
-649
No files matched your search
@@ -99,14 +99,20 @@ fn bal(l: &Ledger, id: AccountId) -> Decimal {
|
||||
l.account_balance(id).expect("known account")
|
||||
}
|
||||
|
||||
/// Draft, add lines, post. Panics on any refusal (setup only).
|
||||
/// Draft, add lines, post. Panics on any refusal (setup only). Posted as
|
||||
/// replay posts it: these books hold hand lines on receivables or payables
|
||||
/// (moving a role's balance by hand, B-23's path before documents read
|
||||
/// their lines), which books an earlier version wrote still hold; the
|
||||
/// control guard (rules.md B-38) refuses them as new work.
|
||||
fn post(l: &mut Ledger, date: NaiveDate, lines: &[(AccountId, Side, &str)]) -> EntryId {
|
||||
let id = l.try_create_entry(date, "test entry").unwrap();
|
||||
for (a, s, amt) in lines {
|
||||
l.add_line(id, *a, *s, usd(amt)).unwrap();
|
||||
}
|
||||
l.post_entry(id).unwrap();
|
||||
id
|
||||
ledger_core::parties::replaying(|| {
|
||||
let id = l.try_create_entry(date, "test entry").unwrap();
|
||||
for (a, s, amt) in lines {
|
||||
l.add_line(id, *a, *s, usd(amt)).unwrap();
|
||||
}
|
||||
l.post_entry(id).unwrap();
|
||||
id
|
||||
})
|
||||
}
|
||||
|
||||
/// The (code, side, amount) lines of an entry, sorted.
|
||||
@@ -788,7 +794,7 @@ fn receivables_cannot_move_while_holding_a_balance_but_can_once_moved_out() {
|
||||
let old = acct(&l, "1200").expect("1200 created by the invoice");
|
||||
let new = add(&mut l, "1100", "Trade Receivables", AccountType::Asset);
|
||||
match refuses(&mut l, "receivables", Some(new)) {
|
||||
LedgerError::RoleHoldsBalance {
|
||||
LedgerError::DocumentRoleHoldsBalance {
|
||||
role,
|
||||
code,
|
||||
balance,
|
||||
@@ -798,15 +804,29 @@ fn receivables_cannot_move_while_holding_a_balance_but_can_once_moved_out() {
|
||||
assert_eq!(code, "1200");
|
||||
assert_eq!(balance, dec("120.00"));
|
||||
}
|
||||
other => panic!("expected RoleHoldsBalance, got {other:?}"),
|
||||
other => panic!("expected DocumentRoleHoldsBalance, got {other:?}"),
|
||||
}
|
||||
// Move the balance with an entry, then the role may move.
|
||||
// Moving the balance by hand is refused now (the control guard,
|
||||
// rules.md B-38): until a role's balance moves with its documents
|
||||
// (plan P4b S7), a receivables role holding a balance stays put.
|
||||
let e = l.try_create_entry(d(2026, 3, 2), "move").unwrap();
|
||||
assert!(matches!(
|
||||
l.add_line(e, old, Side::Credit, usd("120.00")),
|
||||
Err(LedgerError::UntaggedControlLine { .. })
|
||||
));
|
||||
// Books an earlier version wrote can hold such a move, and the
|
||||
// assignment after it (replayed: today 1100's hand line refuses it,
|
||||
// RoleAccountHasHandLines): then the documents follow the role.
|
||||
post(
|
||||
&mut l,
|
||||
d(2026, 3, 2),
|
||||
&[(new, Side::Debit, "120.00"), (old, Side::Credit, "120.00")],
|
||||
);
|
||||
l.assign_role("receivables", Some(new)).unwrap();
|
||||
assert!(matches!(
|
||||
refuses(&mut l, "receivables", Some(new)),
|
||||
LedgerError::RoleAccountHasHandLines { .. }
|
||||
));
|
||||
ledger_core::parties::replaying(|| l.assign_role("receivables", Some(new))).unwrap();
|
||||
assert_eq!(l.role_account(&RECEIVABLES), Some(new));
|
||||
// The open invoice is now controlled by 1100: its payment credits it.
|
||||
let p = l
|
||||
@@ -845,6 +865,21 @@ fn every_balance_sheet_role_holds_while_its_account_has_a_balance() {
|
||||
&format!("Target {i}"),
|
||||
c.account_type,
|
||||
);
|
||||
if c.key == RECEIVABLES.key || c.key == PAYABLES.key {
|
||||
// No document posted there: the balance is the chart's own,
|
||||
// so the role moves (B-38); it can't come back to an account
|
||||
// holding hand lines.
|
||||
l.assign_role(c.key, Some(target)).unwrap();
|
||||
assert!(
|
||||
matches!(
|
||||
refuses(&mut l, c.key, Some(*cur)),
|
||||
LedgerError::RoleAccountHasHandLines { .. }
|
||||
),
|
||||
"{}",
|
||||
c.key
|
||||
);
|
||||
continue;
|
||||
}
|
||||
assert!(
|
||||
matches!(
|
||||
refuses(&mut l, c.key, Some(target)),
|
||||
@@ -877,7 +912,7 @@ fn none_is_refused_while_the_assigned_balance_sheet_account_holds_a_balance() {
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
refuses(&mut l, "payables", None),
|
||||
LedgerError::RoleHoldsBalance { .. }
|
||||
LedgerError::DocumentRoleHoldsBalance { .. }
|
||||
));
|
||||
let _ = cash;
|
||||
}
|
||||
@@ -1121,7 +1156,7 @@ fn pool_books() -> (Ledger, Vec<AccountId>) {
|
||||
}
|
||||
|
||||
/// The reasons the spec gives to refuse an assignment, worked from the
|
||||
/// current books (assign_role SCENARIOS 1–5).
|
||||
/// current books (assign_role SCENARIOS 1–6).
|
||||
fn predicted_refusals(
|
||||
l: &Ledger,
|
||||
c: &ControlAccount,
|
||||
@@ -1142,10 +1177,27 @@ fn predicted_refusals(
|
||||
}
|
||||
let now = l.role_account(c);
|
||||
let next = target.map(|a| a.id);
|
||||
// SCENARIO 6: a document role never moves onto hand lines.
|
||||
let documents = c.key == RECEIVABLES.key || c.key == PAYABLES.key;
|
||||
if documents
|
||||
&& let Some(a) = target
|
||||
&& next != now
|
||||
&& l.posted_entries().any(|e| {
|
||||
e.lines()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.any(|(i, x)| x.account == a.id && l.line_doc(e.id(), i).is_none())
|
||||
})
|
||||
{
|
||||
why.push("handlines");
|
||||
}
|
||||
// SCENARIO 4: a document role's balance holds only on its control
|
||||
// account (assigned there, or holding its documents' lines).
|
||||
if balance_sheet(c)
|
||||
&& next != now
|
||||
&& let Some(cur) = now
|
||||
&& !bal(l, cur).is_zero()
|
||||
&& (!documents || l.control_role_of(cur).is_some_and(|r| r.key == c.key))
|
||||
{
|
||||
why.push("balance");
|
||||
}
|
||||
@@ -1156,7 +1208,10 @@ fn reason(e: &LedgerError) -> &'static str {
|
||||
match e {
|
||||
LedgerError::ControlAccountMismatch { .. } => "mismatch",
|
||||
LedgerError::RoleTaken { .. } => "taken",
|
||||
LedgerError::RoleHoldsBalance { .. } => "balance",
|
||||
LedgerError::RoleHoldsBalance { .. } | LedgerError::DocumentRoleHoldsBalance { .. } => {
|
||||
"balance"
|
||||
}
|
||||
LedgerError::RoleAccountHasHandLines { .. } => "handlines",
|
||||
_ => "other",
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user