feat(arap)!: documents read their figures from standing lines; control accounts
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:
swaitsandClaude Opus 5.5 committed 2026-10-24 09:17:42 -06:00
1 parent 7741e96eb3
commit d7fa16e974
54 files changed
+5997 -649

No files matched your search

+151 -77
View File
@@ -3079,9 +3079,9 @@ impl Store {
Ok(live)
}
/// Record a customer payment against an invoice. The payment entry (Dr
/// cash, Cr A/R) is journaled through the standard entry flow first,
/// then `invoice.payment_received`.
/// Record a customer payment against an invoice
/// ([`Self::receive_payment_with`], with no payment method or
/// reference).
pub fn receive_payment(
&mut self,
invoice_id: InvoiceId,
@@ -3089,15 +3089,56 @@ impl Store {
amount: Decimal,
cash_account: AccountId,
) -> Result<EntryId, StoreError> {
self.rehearse(|l| l.receive_payment(invoice_id, date, amount, cash_account))?;
self.receive_payment_with(
invoice_id,
date,
amount,
cash_account,
&ledger_core::PaymentDetails::default(),
)
}
/// Record a customer payment against an invoice, with its payment
/// method and reference ([`Ledger::receive_payment_with`]). The
/// payment entry (Dr the deposit account, Cr A/R) is journaled through
/// the standard entry flow first (the reference in its description),
/// then `invoice.payment_received`, then the payment method as
/// `entry.instrument_set` (feature `payment_instruments`).
///
/// ```text
/// GOAL: a payment is journaled once and replays to the same figures,
/// in any version that can open the books.
/// SOURCE: docs/spec/rules.md B-27, B-38; plan/served.md P4b.7
/// (document_standing).
/// SCENARIOS:
/// 1. IF the engine refuses it (overpaid, before the invoice, a
/// deposit account that isn't cash): SHOULD refuse, journaling
/// nothing.
/// 2. IF it is accepted only because a payment on the invoice was
/// reversed (an older version, which counts every payment event,
/// would see it paid above its total): SHOULD need the journal
/// feature document_standing, refused (FormatUpgradePending) in
/// books that haven't declared it, journaling nothing.
/// 3. IF it names a payment method: SHOULD need payment_instruments.
/// ```
pub fn receive_payment_with(
&mut self,
invoice_id: InvoiceId,
date: NaiveDate,
amount: Decimal,
cash_account: AccountId,
details: &ledger_core::PaymentDetails,
) -> Result<EntryId, StoreError> {
self.rehearse(|l| l.receive_payment_with(invoice_id, date, amount, cash_account, details))?;
self.approval_gate(
|l| l.receive_payment(invoice_id, date, amount, cash_account),
|l| l.receive_payment_with(invoice_id, date, amount, cash_account, details),
false,
)?;
// The rehearsal above is the whole check: hand-written mirrors of
// the engine's refusals drift from it (and were never reached).
self.ensure_payment_features(ledger_core::DocRef::Invoice(invoice_id), amount, details)?;
let ar = self.ensure_control_account(&ledger_core::RECEIVABLES)?;
let description = format!("Payment on invoice {invoice_id}");
let description = details.description(&format!("Payment on invoice {invoice_id}"));
let (entry_id, mut journaled) =
self.journal_auto_entry(date, &description, cash_account, ar, amount)?;
journaled.push(self.append_journal(
@@ -3110,9 +3151,15 @@ impl Store {
"entry_id": entry_id,
}),
)?);
let live = self
.ledger
.receive_payment(invoice_id, date, amount, cash_account)?;
if details.instrument.is_some() {
journaled.push(self.append_journal(
"entry.instrument_set",
crate::form1099::entry_instrument_set(entry_id, details.instrument),
)?);
}
let live =
self.ledger
.receive_payment_with(invoice_id, date, amount, cash_account, details)?;
debug_assert_eq!(live, entry_id, "id allocator diverged from journal");
for line in &journaled {
apply_line_to_sqlite(&self.conn, line)?;
@@ -3120,8 +3167,8 @@ impl Store {
Ok(live)
}
/// Pay a vendor bill. The payment entry (Dr A/P, Cr cash) is journaled
/// through the standard entry flow first, then `bill.paid`.
/// Pay a vendor bill ([`Self::pay_bill_with`], with no payment method
/// or reference).
pub fn pay_bill(
&mut self,
bill_id: BillId,
@@ -3129,10 +3176,36 @@ impl Store {
amount: Decimal,
cash_account: AccountId,
) -> Result<EntryId, StoreError> {
self.rehearse(|l| l.pay_bill(bill_id, date, amount, cash_account))?;
self.approval_gate(|l| l.pay_bill(bill_id, date, amount, cash_account), false)?;
self.pay_bill_with(
bill_id,
date,
amount,
cash_account,
&ledger_core::PaymentDetails::default(),
)
}
/// Pay a vendor bill, with its payment method and reference
/// ([`Ledger::pay_bill_with`]). The payment entry (Dr A/P, Cr the
/// payer account) is journaled through the standard entry flow first,
/// then `bill.paid`, then the payment method as `entry.instrument_set`;
/// as [`Self::receive_payment_with`] (its SCENARIOS too).
pub fn pay_bill_with(
&mut self,
bill_id: BillId,
date: NaiveDate,
amount: Decimal,
cash_account: AccountId,
details: &ledger_core::PaymentDetails,
) -> Result<EntryId, StoreError> {
self.rehearse(|l| l.pay_bill_with(bill_id, date, amount, cash_account, details))?;
self.approval_gate(
|l| l.pay_bill_with(bill_id, date, amount, cash_account, details),
false,
)?;
self.ensure_payment_features(ledger_core::DocRef::Bill(bill_id), amount, details)?;
let ap = self.ensure_control_account(&ledger_core::PAYABLES)?;
let description = format!("Payment on bill {bill_id}");
let description = details.description(&format!("Payment on bill {bill_id}"));
let (entry_id, mut journaled) =
self.journal_auto_entry(date, &description, ap, cash_account, amount)?;
journaled.push(self.append_journal(
@@ -3145,7 +3218,15 @@ impl Store {
"entry_id": entry_id,
}),
)?);
let live = self.ledger.pay_bill(bill_id, date, amount, cash_account)?;
if details.instrument.is_some() {
journaled.push(self.append_journal(
"entry.instrument_set",
crate::form1099::entry_instrument_set(entry_id, details.instrument),
)?);
}
let live = self
.ledger
.pay_bill_with(bill_id, date, amount, cash_account, details)?;
debug_assert_eq!(live, entry_id, "id allocator diverged from journal");
for line in &journaled {
apply_line_to_sqlite(&self.conn, line)?;
@@ -3153,6 +3234,51 @@ impl Store {
Ok(live)
}
/// The journal features a payment of `amount` on `doc` needs
/// ([`Self::receive_payment_with`] SCENARIOS 2 and 3): document_standing
/// when more than an older version thinks is still owed (the
/// document's total less every payment event it recorded, reversed
/// or not, its line tagged or not: `Ledger::payments_recorded`),
/// payment_instruments when it names a payment method.
fn ensure_payment_features(
&self,
doc: ledger_core::DocRef,
amount: Decimal,
details: &ledger_core::PaymentDetails,
) -> Result<(), StoreError> {
let total = match doc {
ledger_core::DocRef::Invoice(id) => self.ledger.get_invoice(id).map(|i| i.total()),
ledger_core::DocRef::Bill(id) => self.ledger.get_bill(id).map(|b| b.total()),
};
if let Some(total) = total {
let older_owed = total?.checked_sub(self.ledger.payments_recorded(doc)?);
if older_owed.is_none_or(|owed| amount > owed) {
self.ensure_feature(crate::format::DOCUMENT_STANDING)?;
}
}
if details.instrument.is_some() {
self.ensure_feature(crate::format::PAYMENT_INSTRUMENTS)?;
}
Ok(())
}
/// Void an invoice or bill: reverse its own entry, described "Void
/// invoice 7: reason" ([`Ledger::void_document`]), journaled as the
/// reversal it is (`entry.reversed`: a void writes no event of its
/// own, plan P4b.7). Refused, journaling nothing, as
/// [`Ledger::check_void_document`] and [`Self::check_reverse_entry`]
/// refuse it (a payment standing on it, void already, a date before
/// it). Returns the reversal's entry.
pub fn void_document(
&mut self,
doc: ledger_core::DocRef,
date: NaiveDate,
reason: &str,
) -> Result<EntryId, StoreError> {
let (target, description) = self.ledger.check_void_document(doc, date, reason)?;
self.reverse_entry(target, date, &description)
}
// ------------------------------------------------------------------
// Foreign exchange
// ------------------------------------------------------------------
@@ -5137,7 +5263,7 @@ impl Store {
Some(orig) => {
// Reversals allocate the next id; replayed in id order the
// allocator is in sync, so the id must match the row.
let got = ledger.reverse_entry(orig as u64, date, &description)?;
let got = ledger.replay_reverse_entry(orig as u64, date, &description)?;
if got != id as u64 {
return Err(StoreError::ReplayMismatch(format!(
"reversal id: sqlite says {id}, replay got {got}"
@@ -5809,7 +5935,9 @@ fn replay_line(
"entry.reversed" => {
let want = get_u64(p, "id")?;
let orig = get_u64(p, "reverses")?;
let got = ledger.reverse_entry(
// As journaled: a reversal later rules would refuse (a void
// under a payment that stands) replays (rules.md B-28).
let got = ledger.replay_reverse_entry(
orig,
parse_date(get_str(p, "date")?)?,
get_str(p, "description")?,
@@ -6385,54 +6513,11 @@ fn replay_line(
Ok(())
}
/// Apply a payment to an invoice or bill row in the SQLite projection:
/// bump `amount_paid` and re-derive the status. Amounts are TEXT in
/// SQLite, so the math happens here in Rust with exact Decimals.
/// `table` is `invoices` or `bills`; an internal constant, never user input.
fn apply_document_payment(
conn: &Connection,
table: &str,
id: u64,
amount: Decimal,
) -> Result<(), StoreError> {
let (paid_str, lines_json): (String, String) = conn.query_row(
&format!("SELECT amount_paid, lines_json FROM {table} WHERE id = ?"),
params![id as i64],
|r| Ok((r.get(0)?, r.get(1)?)),
)?;
let paid: Decimal = paid_str
.parse()
.map_err(|_| StoreError::JournalCorrupt(format!("bad amount_paid {paid_str}")))?;
let too_large = || StoreError::JournalCorrupt(format!("{table} {id}: amounts too large"));
let new_paid = paid.checked_add(amount).ok_or_else(too_large)?;
let lines: Vec<Value> = serde_json::from_str(&lines_json)
.map_err(|_| StoreError::JournalCorrupt("bad lines_json".into()))?;
let mut total = Decimal::ZERO;
for l in &lines {
let amount = get_str(l, "amount")?
.parse::<Decimal>()
.map_err(|_| StoreError::JournalCorrupt("bad line amount".into()))?;
total = total.checked_add(amount).ok_or_else(too_large)?;
}
let status = if new_paid.is_zero() {
"open"
} else if new_paid >= total {
"paid"
} else {
"partial"
};
conn.execute(
&format!("UPDATE {table} SET amount_paid = ?, status = ? WHERE id = ?"),
params![new_paid.to_string(), status, id as i64],
)?;
Ok(())
}
/// Apply one journal line to the SQLite projection: INSERTs plus the events
/// mirror, then advance `last_applied_seq`. The applier is a pure function
/// of the journal line: INSERTs for new facts, targeted UPDATEs/DELETEs
/// for lifecycle changes (receipt linking, disposal, period close,
/// cleared flags, document payments, fx rate updates).
/// cleared flags, fx rate updates).
/// The close target recorded in a `period.closed` line. Absent or
/// `"retained_earnings"`: Retained Earnings with no sweeps (every journal
/// written before targets existed).
@@ -7219,15 +7304,13 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr
StoreError::JournalCorrupt("invoice.created without lines".into())
})?;
conn.execute(
"INSERT INTO invoices(id, customer_id, date, due_date, lines_json, amount_paid, status, entry_id) VALUES (?,?,?,?,?,?,?,?)",
"INSERT INTO invoices(id, customer_id, date, due_date, lines_json, entry_id) VALUES (?,?,?,?,?,?)",
params![
get_u64(p, "id")? as i64,
get_u64(p, "customer_id")? as i64,
get_str(p, "date")?,
get_str(p, "due_date")?,
lines.to_string(),
"0",
"open",
get_u64(p, "entry_id")? as i64,
],
)?;
@@ -7237,15 +7320,13 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr
.get("lines")
.ok_or_else(|| StoreError::JournalCorrupt("bill.created without lines".into()))?;
conn.execute(
"INSERT INTO bills(id, vendor_id, date, due_date, lines_json, amount_paid, status, entry_id) VALUES (?,?,?,?,?,?,?,?)",
"INSERT INTO bills(id, vendor_id, date, due_date, lines_json, entry_id) VALUES (?,?,?,?,?,?)",
params![
get_u64(p, "id")? as i64,
get_u64(p, "vendor_id")? as i64,
get_str(p, "date")?,
get_str(p, "due_date")?,
lines.to_string(),
"0",
"open",
get_u64(p, "entry_id")? as i64,
],
)?;
@@ -7257,17 +7338,10 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr
],
)?;
}
"invoice.payment_received" => {
let amount: Decimal = get_str(p, "amount")?
.parse()
.map_err(|_| StoreError::JournalCorrupt("bad amount".into()))?;
apply_document_payment(conn, "invoices", get_u64(p, "invoice_id")?, amount)?;
}
// A payment moves no document row: what a document is paid is read
// from the ledger lines tagged with it (schema v15, rules.md B-38).
"invoice.payment_received" => {}
"bill.paid" => {
let amount: Decimal = get_str(p, "amount")?
.parse()
.map_err(|_| StoreError::JournalCorrupt("bad amount".into()))?;
apply_document_payment(conn, "bills", get_u64(p, "bill_id")?, amount)?;
conn.execute(
"INSERT OR REPLACE INTO entry_vendors(entry_id, vendor_id) \
SELECT ?, vendor_id FROM bills WHERE id = ?",