merge: P4b S6, paying on terms (branch S6, 6c4480cd)

The third of the paused branches: Ledger::settle with discounts and the
processor's fee (kernel::settle, Kani settle_is_exact, Lean
settle_posts_a_document_step), books.receive_payment and books.pay_bill
with applications, books.bills_due, the agings' detail and tie-out, the
aging exports and the CPA package's aging CSVs, the handlers in
tools/settlements.rs. Twelve files conflicted with the S4.4 and S5
merges, resolved as plan 8.0.2 says: both routers; both test appends
(the shared match arm in arap_standing.rs spliced into both tests; the
shared `.unwrap()` in store.rs); the scope table at 124; one
invoices-and-bills handbook (S5's send step 1 with S6's steps 2, 3, 5
and procedures) registered once; docs/mcp-interface.md with S4.4's
create docs and S6's payment docs; S5's law renumbered 35 and S6's 36
(S1's is 34); the parity rows and sections of both. S6's wire tests
create their bills as S4.4's contract now requires (confirmed, the
vendor's number, a line account, the vendor's invoice uploaded) and live
in their own module beside S4.4's tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
This commit is contained in:
swaitsandClaude Fable 5.1 committed 2026-10-28 21:26:40 -06:00
commit be58059830
45 files changed
+3949 -520

No files matched your search

+5 -2
View File
@@ -167,9 +167,12 @@ each declared with `#[path]`, opening with `use super::*`, and holding a
BooksServer`; `server.rs`'s `tool_router` adds every group's router. The
groups are:
- `documents` — invoices, bills, payments, voids, `books.documents`,
`books.document`, the control check and the agings;
- `documents` — invoices, bills, voids, `books.documents`,
`books.document` and the control check;
- `sent` — `books.mark_sent`, the record that an invoice went out (P4b S5);
- `settlements` — payments that settle one or several documents
(`books.receive_payment`, `books.pay_bill`), `books.bills_due`, and the
agings (`books.ar_aging`, `books.ap_aging`) with their tie-out (P4b S6);
- `entries` — `books.draft_entry`, `books.add_line`, `books.post_entry`,
`books.reverse_entry`, `books.get_entry`, `books.search_entries`, the
bank CSV import;
+52 -19
View File
@@ -1688,7 +1688,11 @@ posting.
`journal`: each entry posted in the period, line by line; `iif`:
every posted entry from the first through `as_of`, never a period),
`tax_summary` (see "Tools — tax categories"), `fixed_asset_register`
(as of a day; see "Tools — fixed assets").
(as of a day; see "Tools — fixed assets"), `ar_aging`, `ap_aging`
(as of a day: columns `number,party,date,due_date,terms,total,paid,
discounted,outstanding,bucket,days_overdue`, one row per open
document, then a tie-out line `TOTAL,,,,,,,,<Σ outstanding>,GL
<account> <balance>,difference 0.00`; B-23, 8.3.10).
- Formats: `pdf` and `xlsx` for `profit_and_loss`,
`balance_sheet`, `cash_flow`, `trial_balance`, `members_equity`,
`tax_summary` and `fixed_asset_register`; `xlsx`, `csv`, `json` for
@@ -2086,21 +2090,37 @@ snapshots; see plan 8.2 S4.
- `warnings` names an expense account it created with no tax category;
`notes` one per finished or abandoned project a line names (none for
on-spec: a cost of on-spec work is an expense like any other).
- `books.receive_payment { document_id, date, amount, cash_account,
instrument?, reference? }` → `{ entry_id, remaining, status }`. Posts
Dr Cash / Cr A/R, the receivable line tagged with the invoice (B-38),
neither line tagged with a project (balance-sheet only: the invoice's
lines carry its projects). `instrument` (id or label) is tagged on the
entry in the same call; `reference` (one line, at most 200 characters:
a charge id, a trace number) goes into its description. `remaining`
and `status` (open, partial, paid, written_off, void, overpaid,
unattached) are the invoice's derived figures after it. The books
record payments; they never collect them (D-45).
- `books.pay_bill { document_id, date, amount, cash_account, instrument?,
reference? }` → `{ entry_id, remaining, status }`. Posts Dr A/P / Cr
Cash, the payable line tagged with the bill, neither line tagged with a
project; `instrument` and `reference` as for `receive_payment` (a card
payment is left out of 1099-NEC, D-40).
- `books.receive_payment { date, deposit_to | cash_account, instrument?,
reference?, invoice | document_id, amount? | applications: [{ invoice,
amount, discount?, discount_reason? }], fee?: { amount, account?,
vendor? }, paid_in_full_marking? }` → `{ entry_id, documents: [{ ref,
number?, outstanding, status }], deposited, remaining?, status?,
warnings? }`. Settles one or several invoices of one customer in one
entry (B-42): Dr the deposit account the cash less the fee, Dr the
payment-processing-fees account the fee, Dr sales allowances the
discounts, Cr A/R each invoice's cash and discount, every receivable
line tagged with its invoice (payment lines in application order, then
the discount lines). The `amount`s are cash only; a discount goes beside
one. A discount at most the invoice's terms allow and dated by their
discount date needs no reason; a larger or later one needs
`discount_reason`. `fee.vendor` (a processor the books know as a vendor)
is named in the entry's description; only cash movements count toward a
1099, so the fee is not a payment to the processor. `deposited` is the
cash less the fee (A-4's gross presentation); `remaining` and `status`
repeat the single document's figures. An overpayment is refused with the
excess and the remedy (a plain entry to 2300 Customer Deposits).
`paid_in_full_marking: true` adds a UCC §3-311 warning and records
nothing else. `document_id` and `cash_account` are the older names of
`invoice`/`bill` and `deposit_to`/`paid_from`. The books record
payments; they never collect them (D-45).
- `books.pay_bill { date, paid_from | cash_account, instrument?,
reference?, bill | document_id, amount? | applications: [{ bill, amount,
discount?, discount_reason? }] }` → as `receive_payment`, per bill and
one vendor (B-42): Dr A/P each bill's cash and discount, Cr the payer
account the cash, Cr purchase discounts the discounts. A discount on a
bill with an asset or prepaid line is refused with its remedy (record
the bill net, or reclassify Dr 6995 / Cr the asset, D-49); a processor's
fee is refused on a bill.
- `books.void_document { kind, id, date, reason }` → `{ entry_id, status }`.
Voids an invoice or bill (`kind` `invoice` or `bill`) that should never
have existed by reversing its own entry with the reason; it keeps its
@@ -2156,9 +2176,22 @@ snapshots; see plan 8.2 S4.
there is no unattached line and nothing needing attention.
`books.verify` checks the same identity and `books.digest` lists its
problems (`control_problems`).
- `books.ar_aging { as_of? }` → `{ as_of, rows: [{ name, current, days_30,
days_60, days_90, days_90_plus, total }] }` (`as_of` omitted: today).
- `books.ap_aging { as_of? }` → same, per vendor (`as_of` omitted: today).
- `books.ar_aging { as_of?, detail? }` → `{ as_of, rows: [{ name,
current, days_30, days_60, days_90, days_90_plus, total }], documents?:
[{ ref, number?, party, date, due_date, terms?, total, paid,
discounted, outstanding, bucket, days_overdue }], control: { account,
balance, documents_total, unattached, difference } }` (`as_of` omitted:
today). `detail: true` adds one row per open invoice; `control` is the
receivables account's tie-out (B-23, B-38 INVARIANT 1), `difference`
always `0.00`.
- `books.ap_aging { as_of?, detail? }` → same, per vendor and bill.
- `books.bills_due { through?, as_of? }` → `{ as_of, through, bills:
[{ ref, vendor, vendor_ref?, due_date, outstanding, discount_by?,
max_discount?, pay_by_discount_date_amount? }], by_date: [{ date,
amount }] }`. Every open bill due on or before `through` (omitted:
today), due date first, with its terms' early-payment discount when it
is still open (`discount_by`, the most off, what to pay to take it);
`by_date` is the running total of what falls due through each date.
## Tools — foreign exchange
+12
View File
@@ -46,6 +46,9 @@ No other statuses.
| Prepaid expense + deferred revenue amortization | Amortization schedules (Xero/QBO add-ons) | partial | hand-verified fixture, to the cent; no golden file yet |
| Automated cross-implementation diff harness (GnuCash first) | — (`just oracle`) | partial | live: `tests/oracle_gnucash.rs` runs 200 random books through GnuCash 5.10 in a pinned container, 15,800 exact comparisons, weekly in CI (docs/oracle.md Layer 5); open: fixture books diffed as `snapshot()` output, and the machine-readable catalog with CI enforcing this floor |
| Invoice sent tracking (who, how, to whom, when, original or reminder, the channel's reference) | Business → Invoices (Xero's "Sent"; QBO's email) | done | P4b S5; records, never sends (A-4); a send changes no figure; `invoice.sent` under `documents_v2` (D-52) |
| Multi-document settlements with early-payment discounts (gross method) | Business → Invoices/Bills; Receive payment | done | P4b S6: one entry, one party, one application per document (`kernel::settle`, B-42); a discount beyond the terms needs a reason; a bill with an asset line is refused (D-49); the bill and its payment tested, replay equal to live |
| A processor's fee on a payment received (gross presentation) | Xero/QBO Stripe & Shopify deposits | done | P4b S6: Dr the deposit the net, Dr processing fees the fee, Cr A/R the gross (A-4); `a_fee_posts_gross_to_receivables_and_net_to_the_bank` |
| Bills due, aging detail and tie-out, aging exports | Business → Bills; Reports → A/R and A/P Aging | done | P4b S6: `books.bills_due`, `detail`, `control`, `ar_aging.csv`/`ap_aging.csv` in `books.export` and the CPA package, each with its `TOTAL` tie-out (`the_aging_csvs_tie_to_the_control_check`) |
Workflow/UI parity (QuickBooks/Xero reference) gets its own catalog when the
egui phase starts; semantics come first.
@@ -151,3 +154,12 @@ tracking also feeds Needs attention: an issued invoice nobody marked sent
(past `invoicing.unsent_days`) and an overdue invoice not reminded within
`invoicing.reminder_days` are one list in the viewer and in `books.start`,
each offering `books.mark_sent` (step 4; U0b's one engine).
### P4b S6 paying on terms (2026-10-27)
S6 adds the three rows above: multi-document settlements with
early-payment discounts (the gross method, A-8), the processor's fee
posted gross (A-4), and the bills-due list with the agings' detail,
tie-out and exports. The pre-S6 fixture's figures still hold (no figure
moves), and every new write needs the journal feature `documents_v2`,
which the owner's books declare at their next upgrade (D-52).
+95
View File
@@ -3119,3 +3119,98 @@ starts from empty books (legacy lines are covered by the identity
coding of `DocRef`), and an account becoming a control account by its
first document, holding lines from before, is outside it (those lines are
listed as unattached).
## B-42 A settlement is exact and names its lines
**bookspec:** `BS-arap-6`
**Status:** CONVENTION (a payment that settles several documents at once is
still one balanced entry, and each control line carries its document; the
subledger tie-out is B-38's); CHOICE (the gross method for an
early-payment discount, A-8; the processor's fee posted gross, A-4; D-49's
refusal to apportion a discount across a bill's asset lines). ASC
606-10-32-5 to 32-9 are the strict treatment of an expected discount;
this company's size makes the gross method immaterial (A-8). Sources are
cited and not yet vendored, so this rule keeps the CONVENTION label (the
Open list in `README.md`).
1. **One party, one kind.** A settlement applies to one or more invoices
of one customer, or one or more bills of one vendor, never a mix, and
never the same document twice. Deposits: Σ cash + Σ discounts is what
it applies to the documents; Σ cash − fee is what reaches the deposit
account (`kernel::settle`). The arithmetic is exact: no epsilon, no
rounding, and a fee above the cash is refused.
2. **Bounds.** Each application's cash + discount is at most what its
document owes on every day from the settlement's date on
(`payment_room`), so no day is overpaid. A payment is never dated
before its document, and never in a closed period.
3. **Discounts.** A discount is at most `kernel::discount_max(total,
percent_bp)` for the document's terms and is dated on or before
`terms.discount_by(date)`; outside that it needs a reason, journaled
with the payment (a vendor honouring a late discount). Terms that
state no discount are the same: any discount needs a reason. A discount
on a bill whose lines include an asset or prepaid account is refused
with D-49's remedy: record the bill net of the discount, or reclassify
Dr 6995 Purchase Discounts / Cr the asset.
4. **The fee.** A processor's fee is a customer payment's only. It posts
gross: Dr the processing-fee expense, Dr the deposit account the net,
Cr accounts receivable the gross. Only cash movements count toward a
1099, so the fee is not a payment to the processor.
5. **Lines and journal.** The entry's rows are the payment lines in
application order, then the discount lines, then the fee line, then
the cash line; a new payment event names the line it tags (`line`) and,
when it took one, its discount and discount line (`discount`,
`discount_line`, `discount_reason`), and the fee's on the first
application's event (`fee`, `fee_line`). Replay tags exactly those
lines; a legacy event with no `line` takes the first untagged control
line for its amount (B-38 item 1). A line replay cannot match stays
untagged and the control check lists it: replay never refuses (B-28).
6. **Exports.** An aging as of a day lists one row per open document with
its number, party, dates, terms, total, paid, discounted, outstanding,
bucket and days overdue, then a tie-out line against its control
account (B-23, B-38 INVARIANT 1). The CPA package and `books.export`
write the same CSV.
```text
GOAL: a settlement is exact and names its lines.
SOURCE: CONVENTION; ASC 606-10-32-5 to 32-9 noted; CHOICE: the gross
method (A-8), the fee's gross presentation (A-4).
INVARIANTS:
1. Σ cash + Σ discounts = Σ applications; deposit = Σ cash − fee
(kernel::settle); one party per settlement; one application per
document.
2. Each application's cash + discount ≤ what the document owes on every
day from the payment's date on.
3. Each new payment event names the line it tags; replay tags that line;
a legacy event without one takes the first untagged control line for
its amount.
4. A discount on a bill with an asset or prepaid line is refused.
SCENARIOS:
1. Two invoices of one customer, 300 and 80, paid 380 by one deposit:
two payment lines, each tagged, one cash line.
2. "2/10 net 30" bill 100, paid 98 within ten days: Dr A/P 98 (payment)
and 2 (discount), Cr purchase discounts 2, Cr bank 98.
3. The same discount after the tenth day without a reason: refused,
naming discount_reason; with one: taken and journaled.
4. An invoice 100 paid through a processor keeping 3: Dr bank 97, Dr
processing fees 3, Cr A/R 100.
5. A discount on a bill whose line is equipment: refused with the
record-net or reclassify remedy (D-49).
```
**Enforced:** `kernel::settle`, `Ledger::{settle, settlement_lines,
check_discount}`, `arap::{Application, Fee, SettleParams, SettlementRow}`,
`Ledger::{replay_invoice_payment_at, replay_bill_payment_at}`,
`Store::settle`, the aging builders (`Ledger::aging_documents`,
`ledger_package::{aging_csv, aging_statement}`). **Checked:** `ledger-core` `tests/spec_documents.rs`
(`bill_on_terms_with_discount`, `late_discount_needs_a_reason`,
`discount_on_an_asset_bill_is_refused_with_the_remedy`,
`a_fee_posts_gross_to_receivables_and_net_to_the_bank`,
`a_settlement_names_its_lines_and_refuses_a_document_twice`),
`tests/kernel_bridge.rs::settle_agrees`; Kani `settle_is_exact`;
`ledger-store` `tests/arap_standing.rs`
(`a_settlement_names_its_lines_and_replays`, `settlement_waits_for_documents_v2`,
`legacy_payment_events_keep_the_positional_rule`), `tests/arap_props.rs`;
`ledger-cli` `tests/documents.rs`; `ledger-package`'s aging tie-out test.
The Lean step is `Documents.Step.document` with every control line tagged
(`Documents.settle_unattached`); no new `Step` constructor.
+77
View File
@@ -3114,3 +3114,80 @@ predicates in `ledger-package::attention`: `invoice.number.is_some()`
`checked_add_days` `unsent_days` comparison (`>`), `overdue_days > 0`, the
`sends.max()` last send, and the `reminder_days` comparison (`>`), plus
each item's target (`Target::Invoice`) and `books.mark_sent` step args.
## P4b S6: paying on terms (2026-10-27)
rules.md B-42; plan 8.2 P4b S6. One payment settles several documents of
one party, with the early-payment discounts their terms allow, the
processor's fee posted gross, a bills-due list, the agings' detail and
tie-out, and the aging exports.
- **Core (`crates/ledger-core/tests/spec_documents.rs`):**
`bill_on_terms_with_discount` (the gross-method entry for a bill on
"2/10 net 30", and a 3% discount refused without a reason, taken with
one), `late_discount_needs_a_reason` (after the discount date, and the
asset-line refusal's unchanged books),
`discount_on_an_asset_bill_is_refused_with_the_remedy` (D-49: the code
and both remedies), `a_fee_posts_gross_to_receivables_and_net_to_the_bank`
(Dr bank 97, Dr 6060 3, Cr A/R 100),
`a_settlement_names_its_lines_and_refuses_a_document_twice` (control
lines in application order; one document twice refused).
`spec_cash_flow.rs::equipment_bill_payment_is_investing_cash` keeps
D-35 through a settlement.
- **Kernel:** `kernel_bridge.rs::settle_agrees`; Kani
`settle_is_exact` (a nonzero discount and a nonzero fee covered).
- **Store (`crates/ledger-store/tests/arap_standing.rs`):**
`a_settlement_names_its_lines_and_replays` (one entry, two invoices, a
discount and a fee; each event's `line`, `discount_line` and `fee_line`
point at the rows the plan posts; replay equals live);
`settlement_waits_for_documents_v2` (refused with the feature remedy and
no journal line, then accepted after the upgrade);
`legacy_payment_events_keep_the_positional_rule` (the old fixture's
events carry no `line`, and replay still tags in order).
`arap_props.rs`'s history gains `Settle` (two documents, a discount and,
on invoices, a fee), so `subledger_equals_control`, the bounds and
replay-equals-live run over settlements.
- **Store's older shapes:** `tests/store.rs::books_written_under_older_rules_still_open`
writes a payment event of S6's shape by hand through `JournalWriter`
and opens the books, the invoice reading paid and discounted.
- **Over the wire (`crates/ledger-cli/tests/documents.rs`):**
`one_payment_settles_two_invoices`, `late_discount_needs_a_reason`,
`a_fee_posts_gross_to_receivables_and_net_to_the_bank`,
`card_paid_bill_is_not_1099_nec`,
`multi_document_payment_counts_once_for_1099_and_party_totals`,
`overpayment_refused_with_the_excess`,
`bills_due_orders_by_due_and_discount` (the list and its running total;
no terms, so no discount fields), `the_aging_export_ties_out` (the CSV
header, row and `TOTAL` tie-out).
- **CPA package (`crates/ledger-package/tests/package.rs`):**
`the_aging_csvs_tie_to_the_control_check`.
- **Viewer (`crates/ledger-gui/tests/invoices.rs`):**
`the_list_shows_the_ledgers_figures` re-renders the Money out list's
screenshot with the new "Discount by" column (blank until S4.3
journals terms).
- **Not yet possible through the MCP (depends on S4.3/S4.4):**
`bill_on_terms_with_discount` and `bills_due_orders_by_due_and_discount`'s
discount half need a journaled `terms` field (S4.3) and
`equipment_bill_payment_is_investing_cash` needs a bill line's account
in `books.create_bill` (S4.4). The core tests named for them stand in
until those land; see the unit's report.
- **Older books (8.0.6):** the pre-S1 fixture
`tests/fixtures/p4-arap-before` is also pre-S6 and holds payments; its
golden figures still hold (`legacy_reversal_now_counts`), and S6 moves
no figure. Its journal has no declared `documents_v2`, so the store test
above proves the refusal and the upgrade.
**For P12 (mutation targets):** `kernel::settle`'s length check, each
negative check, the fee-above-cash test and both checked adds;
`Ledger::settlement_lines`'s kind/party checks, the duplicate-document
check, the per-application `cash + discount` bound, the discount window
(`<= max`, `<= by`, "has a reason"), the asset-bill loop, the fee-on-bill
refusal and the row order (payment lines, discounts, fee, cash);
`check_discount`'s `within` conjunction and its `because` arms;
`replay_*_payment_at`'s line/amount/account/side match and the discount
line's tag; the store's `documents_v2` condition (more than one
application, any discount, a fee) and the `line`/`discount_line`/`fee_line`
arithmetic; `bills_due`'s `through` filter, the discount-window test and
the running-total grouping; `aging_documents`'s outstanding filter and
bucket; `aging_csv`'s tie-out columns and `aging_statement`'s totals; the
viewer's `discount_by` and `days_overdue` flags.
+24 -2
View File
@@ -544,7 +544,7 @@ come from the kernel:
### Sent tracking (P4b S5)
34. **A send changes no figure.** `Ledger::mark_sent` is a command, so it
35. **A send changes no figure.** `Ledger::mark_sent` is a command, so it
is a `Step` in the model (AGENTS.md's formal-methods bar; the unit's
"no Lean change" phrase was overruled by it, and says so in its As
built block). `Documents.lean`'s `Books` gains `sends`, and
@@ -560,6 +560,25 @@ come from the kernel:
`a_send_changes_no_figure`, `mark_sent_on_void_refused`,
`mark_sent_waits_for_documents_v2`
### Settlements (P4b S6)
36. **A settlement is exact.** The applications' total is Σ cash + Σ
discounts and the deposit is Σ cash − fee, with exact arithmetic; a
fee above the cash, a negative amount, or arrays of different lengths
is refused (None), never wrapped or rounded. The core's posting is a
document step: every control line a settlement posts carries its
document, so `Documents.control_agrees` covers it and no new `Step`
constructor is needed.
- Kani `settle_is_exact` (at i32, fixed arrays of three applications,
a nonzero discount and a nonzero fee covered) · bridge
`settle_agrees` · tests `bill_on_terms_with_discount`,
`late_discount_needs_a_reason`,
`discount_on_an_asset_bill_is_refused_with_the_remedy`,
`a_fee_posts_gross_to_receivables_and_net_to_the_bank`,
`a_settlement_names_its_lines_and_refuses_a_document_twice` · Lean
`Documents.settle_posts_a_document_step`,
`Documents.settle_unattached`.
## What the proofs found
The engine already had 190+ tests, property tests, mutation testing,
@@ -757,7 +776,9 @@ headline theorems) and `just prove-slow` 467 s (8 harnesses, the slowest
P4b S4.1's two harnesses were measured on 2026-10-26 (a busy machine);
the rest are the 2026-10-22 run. `discount_max_is_bounded`'s 163 s is
Kani's verification time (it runs in `prove-slow`).
Kani's verification time (it runs in `prove-slow`). P4b S6 adds
`settle_is_exact` to `prove` and two Lean theorems to the audit
(`Documents.settle_posts_a_document_step`, `Documents.settle_unattached`).
Every harness, the law it proves, the recipe that runs it, and its time
in that quiet run (`prove`: each harness's verification time; `prove-slow`:
@@ -824,4 +845,5 @@ each harness's whole run, its compile of about 50 s included):
| `line_amount_is_exact` | 33 | prove | 6 |
| `bounds_check_matches_definition` | 34 | prove | (not in the 2026-10-22 run; P4b S1, 2026-10-24) |
| `bounds_kept_is_the_definition_on_books_in_bounds` | 34 | prove | (as above) |
| `settle_is_exact` | 36 | prove | 1 |
| `kani_symbolic_copy_canary` | (the checker: "Trusting the checker") | prove | 3 |