Commit Graph
4 Commits
Author SHA1 Message Date
swaits 78a2385440 feat(store): journal settlements and replay their lines (P4b S6)
Store::settle now journals a settlement once, line for line, and one
payment event per application, so replay rebuilds the same tags and the
projection sees the same entry. A single legacy-shaped payment is still
journaled byte for byte; more than one document, a discount or a fee
needs documents_v2, and a payment accepted only because an earlier one
was reversed still needs document_standing.

Each event names the control line it tags (line) and, when it took one,
its discount and discount line; the fee is on the first application's
event with its line. Replay tags exactly those lines and leaves a line it
cannot match untagged, which books.control_check lists; a legacy event
without line keeps the first-untagged-control-line rule.

Tests: a_settlement_names_its_lines_and_replays (two invoices, a discount
and a fee; event indices match the plan; replay equals live),
settlement_waits_for_documents_v2 (refused with the feature remedy and no
journal line, accepted after the upgrade), and
legacy_payment_events_keep_the_positional_rule. arap_props gains a
Settle op (two documents, discount, fee), and
books_written_under_older_rules_still_open gains a hand-written payment
event of the new shape through JournalWriter (8.0.6 item 1).
2026-10-27 12:59:40 -06:00
swaits 0e5aebda99 feat(store): journal the document fields and file the issued PDF (P4b S4.3)
S4.1 gave documents a number, terms, line accounts and snapshots, and S4.2
gave parties an email and default terms, but the store journaled none of
them: a document carrying one was refused by a temporary guard, because a
public write that replay loses would break I-3. This slice makes the
journal carry them.

- `invoice.created` and `bill.created` write a number, terms, bill-to and
  seller snapshots, references, memo and a duplicate's reason, and each
  line its account, quantity and unit price, every key only when set. A
  document naming none is journaled byte for byte as every earlier version
  wrote it (`no_new_fields_journals_byte_for_byte`), so the owner's live
  books keep working.
- All of it is behind `documents_v2` (B-27): a write that names one waits
  for the owner's upgrade, refused with `format_upgrade_pending` and
  journaling nothing; a write that names none never waits
  (`documents_v2_waits_for_the_upgrade`).
- `invoicing.first_number` is settable only while no numbered invoice
  exists (A-2, B-40): the series has begun, and a number is never reused or
  renumbered. Refused at `set_setting`, before anything is journaled.
- The issued copy is filed in the issuing call (A-7, B-40 item 3):
  `create_invoice_issued` journals the entry, the invoice, then
  `receipt.attached` on that entry (`INV-0007.pdf`, `application/pdf`,
  "Issued invoice") and `attachment.linked` to the invoice. There is no
  `issued_pdf` field: the attachment id is allocated after the entry exists
  (CD-A6), and the link is how the copy is found and how its SHA-256 is
  checked. Every refusal happens before the first journal line; the PDF's
  bytes are stored before it, so a refused issue leaves an unreferenced,
  deduplicated blob at worst.
- A preview runs the issue on a clone and journals nothing: the posting,
  the total, the number it would take and the due date (`preview_invoice`,
  `preview_bill`). Books that haven't upgraded keep the legacy shape: the
  copy is filed as "Invoice {id}.pdf" and no number is taken (migration
  item 5).
- Schema 16 adds the document columns; a line's new fields ride
  `lines_json` as its project does. `differs_from` already compares whole
  invoices, bills and parties, so it sees every new field.

8.0.6: (1) `books_written_under_older_rules_still_open` hand-writes both
new document shapes through JournalWriter and still opens; (2) a new
fixture `old-books/p4_s43_documents` was written by main d5385a7e (S4.2,
the version before this slice) through the old-books-writer harness, with
its expected.json; this version opens it and reports the same figures;
(3) see the note on R-A step 4 in the PR (the over-the-wire document write
needs S4.4's arguments; S4.2's invoicing-setting entry already covers the
feature's cut-over); (4) `documents_v2_is_its_own_feature` is green; (5) no
figure moves: no golden changed; (6) merge does not need the owner's
go-ahead; the live books refuse the new writes until he gives it.

just check green (no kernel function, Lean theorem or Ledger command, so no
`just prove` change).
2026-10-27 10:37:03 -06:00
swaits 13282cf80e feat(arap): terms, numbers and line accounts in the core (P4b S4.1)
Documents could name only one revenue or general-expenses account per
project, had no number of their own, and took a due date the caller had
to work out. This slice gives the core the pieces the rest of S4 needs,
and journals nothing: an old-shape document still writes byte for byte,
so the owner's books need no upgrade for it.

- Payment terms (B-39): Net n, Net n EOM, due on receipt and a custom
  date, with the early-payment discount and its exact bound
  (kernel::discount_max, Kani + bridge). A due date past the calendar's
  end is refused, never a panic, and a discount is refused with terms
  that have no net days.
- Invoice numbers (B-40, A-2): first_number plus the count of numbered
  invoices, zero-padded to four digits and wider past 9,999, stored at
  issue, kept by a void; legacy invoices are uncounted.
- Line accounts (A-10): a document line may name its account, and the
  entry posts one line per (account, project) part. An invoice line takes
  revenue or a liability holding no role (deferred revenue); a bill line
  an expense or a non-cash asset holding no role. Roles, cash accounts
  and headers are refused.
- A line's amount is exactly its quantity times its unit price
  (kernel::line_amount, Kani + bridge): both or neither, never rounded.
- A vendor's invoice number names one standing bill per vendor, with
  case, spaces and hyphens ignored, unless a duplicate reason says why
  (B-40).
- The record fields S4.3 will journal: number, terms, bill-to and seller
  snapshots, customer reference, memo, the empty sends list S5 fills, and
  the bill's received_on, all optional and defaulted.

The store refuses a line carrying an account, quantity or unit price
before it rehearses or journals anything: those fields are journaled from
S4.3's documents_v2 on, and I-3 will not let a public write replay
without them. `document_line_fields_wait_for_documents_v2` proves the
refusal, the unchanged journal and allocators, and that an ordinary line
journals exactly as before. S4.3 removes this guard in the same change
that journals the fields and adds its byte-for-byte test.

The document commands keep their signatures; create_invoice_with and
create_bill_with take the optional fields for S4.3 and for tests. They
are the existing issue commands with more fields, not a new state
transition, so no Lean Step is added. Kani: line_amount_is_exact runs in
`just prove`; discount_max_is_bounded relates a product to a quotient and
runs in `prove-slow` (163 s), as the proration charge harnesses do.

just check green; just prove green.
2026-10-27 00:02:51 -06:00
swaitsandClaude Opus 5.5 d7fa16e974 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
2026-10-24 09:17:42 -06:00