merge: P4b S7, bad debts (branch S7, 4b4fecb9)

The fourth of the paused branches: Ledger::write_off and the bad_debts
control role, invoice.written_off with its evidence filed in the same
store call, books.write_off_invoice, the write-off on the timeline and
in Needs attention, Lean writeOff_within_bounds, rules.md B-41 and the
bookspec examples. Twenty-eight files conflicted with the S4.4, S5 and
S6 merges; resolved as plan 8.0.2 says, keeping both sides' appends with
their seams restored, and: the scope table at 125; S7's law numbered 37;
B-41 placed before B-42; the handbook's step 4 and recovery procedures
folded into the one invoices-and-bills section; S7's wire tests in their
own module with the confirm S4.4's contract needs; S7's copy of the
payment steps dropped (S6 replaced them with Ledger::settle) and its
write-off journaled through S6's journal_auto_lines; in the old-books
fixture S7's hand-written write-off moved to a second invoice, since
S6's hand-written payment settles invoice 1 in full.

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:47:33 -06:00
commit 335a3eaa7e
47 files changed
+2883 -74

No files matched your search

+3
View File
@@ -184,6 +184,9 @@ groups are:
- `parties` — parties, vendors, customers, roles, W-9s, the TIN vault,
1099s, entry parties;
- `attachments` — receipts, filed documents, and their links;
- `write_offs` — `books.write_off_invoice`, the bad debt (P4b S7; rules.md
B-41), kept apart from `documents` so S4.4's document tools and this one
land in different files;
- `settings` — the handbook, `books.settings` and the recorded FX rates
(the module is `settings_tools`, since `crate::settings` is the live
settings);
+25 -3
View File
@@ -354,7 +354,7 @@ Tool descriptions are at most 700 characters; eight longer ones are listed
in `tool_descriptions_fit_700_characters`, to be shortened in P5.
**Annotations.** Every tool has a `title` and a `readOnlyHint`; every tool
that changes anything also has a `destructiveHint`, true for these 29:
that changes anything also has a `destructiveHint`, true for these 30:
`books.post_entry`, `books.reverse_entry`, `books.set_setting`, `books.retire_convention`, `books.unlink_receipt`,
`books.unlink_attachment`, `books.withdraw_attachment`,
`books.correct_asset`, `books.dispose_asset`, `books.run_depreciation`,
@@ -364,8 +364,8 @@ that changes anything also has a `destructiveHint`, true for these 29:
`books.void_statement`, `books.void_reconciliation`, `books.set_w9`,
`books.set_tin` (both overwrite a vault record), `books.purge_tin`,
`books.create_invoice`, `books.create_bill`, `books.receive_payment`,
`books.pay_bill`, `books.void_document`, `books.run_payroll`,
`books.remit_payroll_taxes`; and an
`books.pay_bill`, `books.void_document`, `books.write_off_invoice`,
`books.run_payroll`, `books.remit_payroll_taxes`; and an
`idempotentHint` on the ten that a repeat with the same arguments leaves
the journal and the vault as they were: `books.verify`,
`books.run_depreciation`, `books.run_schedules`, `books.run_recurring`,
@@ -2142,6 +2142,28 @@ snapshots; see plan 8.2 S4.
recipients, or while the books wait for `documents_v2`
(`format_upgrade_pending`). `sends` is every send on the invoice, in
order, the new one last.
- `books.write_off_invoice { invoice, date, amount?, reason, upload_id |
existing_attachment_id, confirmed? }` → `{ entry_id, document: { ref,
status, paid, written_off, outstanding } }`. Writes off an invoice that
will not be collected (a bad debt, P4b S7; rules.md B-41): posts Dr
`6990` Bad Debt Expense / Cr `1200` Accounts Receivable, the receivable
line tagged with the invoice (`WriteOff`), so the invoice reads
`written_off` and its figures stay derived (B-38). `amount` (a decimal
string, default all it owes) is at most what is owed and may be partial
(§1.166-3(a)). Its evidence of worthlessness (collection
correspondence, a bankruptcy notice: §1.166-2(a)) is filed in the same
call, as `upload_id` (books.begin_upload, purpose `document` or
`receipt`, then `wanderbooks upload`) or as an
`existing_attachment_id` already filed and not linked; it becomes a
receipt on the write-off's entry and a link to the invoice. Refused on
cash-basis books (§1.166-1(e)), above what it owes, before the
invoice's date or in a closed period. Without `confirmed: true` it
answers `entry_id` 0 and the figures the write-off would leave, writing
nothing. A recovery is not a new command (D-49): the same tax year,
reverse the write-off (`books.reverse_entry`, the invoice reopens) and
record the payment; a later year, a plain entry Dr the deposit account /
Cr a "Bad debt recoveries" revenue account, the invoice staying written
off (§111).
- `books.documents { kind, status?, party?, due_by?, as_of? }` → `{ as_of,
kind, documents: [{ ref, party, date, due_date, total, outstanding,
status, overdue_days, last_sent? }], outstanding_total }`. Invoices (`kind`
+10
View File
@@ -163,3 +163,13 @@ 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).
## P4b S7: bad debts (write-offs), 2026-10-27
A/R gained the direct write-off the owner chose (A-5; plan 8.2 S7): an
invoice that will not be collected comes off 1200 with Dr 6990 Bad Debt
Expense, its receivable line tagged `WriteOff` so the receipt's figures
stay derived, and its evidence of worthlessness filed in the same call
(rules.md B-41; IRC §166, §111; Treas. Reg. §1.166-1(e), (f), -2(a),
-3(a)). A same-year recovery is the reversal D-49 records, not a new
movement; bills are never written off. No row above changed status (the
A/R and A/P rows already say `done`), so the floor stands (10 done).
+10 -1
View File
@@ -62,6 +62,9 @@ apply is safe — on startup the applier replays every journal line with
send: a document's record, never a ledger line or a figure; P4b S5)
(plus the A/R, A/P, asset, period, payroll, and class events listed on
`JournalLine`).
`invoice.written_off`, `bill.created`, `receipt.attached`, `receipt.linked`, `receipt.unlinked`,
`document.filed`, `attachment.withdrawn` (plus the A/R, A/P, asset,
period, payroll, and class events listed on `JournalLine`).
- `payload`: the full domain facts (account fields, entry with lines, blob
sha, ...). Everything the applier needs; nothing it must look up elsewhere.
An `invoice.created` or `bill.created` written after the `documents_v2`
@@ -76,7 +79,13 @@ apply is safe — on startup the applier replays every journal line with
entry exists): it is a `receipt.attached` on the invoice's own entry
(`INV-0007.pdf`, `application/pdf`, "Issued invoice"), its SHA-256 the
`blob_sha`, linked to the invoice with `attachment.linked` and found
through that link (plan 8.3.11).
through that link (plan 8.3.11). An `invoice.written_off` (plan 8.3.6,
P4b S7) carries `{invoice_id, entry_id, date, amount, reason}`; replay
tags the entry's receivables line `WriteOff`, and the projection keeps
no written-off figure either: it is the tagged lines' sum (rules.md
B-38, B-41). Its evidence of worthlessness rides beside it, as a
`receipt.attached` on the write-off's entry and an `attachment.linked`
to the invoice, filed in the same call.
- `prev`: hex SHA-256 of the previous line's canonical JSON (keys sorted, no
whitespace). The genesis line uses `"prev": "000...0"` (64 zeros).
+102
View File
@@ -3120,6 +3120,108 @@ 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-41 A debt that will not be collected is written off, and a recovery is income
**bookspec:** `BS-arap-5`
**Status:** CHOICE (the owner, 2026-10-23, plan 8.3.1 A-5: bad debts by
the **direct write-off** method, and 8.3.2: for a small company's
receivables a CPA accepts the direct method and notes it, where GAAP
prefers an allowance under ASC 326-20). AUTHORITY for what the rule
rests on: IRC §166(a) (a debt that becomes worthless is deductible in
that year), Treas. Reg. §1.166-1(e) (a debt never counted as income has
no basis to deduct), §1.166-2(a) (worthlessness on all the pertinent
evidence), §1.166-3(a) (a partial write-off, to the extent charged off),
and IRC §111 (a recovery of an amount deducted is income in the year
received). ASC 405-20-40-1 for a bill's release.
1. **The write-off.** Writing off an invoice posts one balanced entry:
Dr the bad-debt expense role (`bad_debts`, 6990) and Cr receivables
for the amount, the receivable line carrying the invoice with the
movement `WriteOff`, so what the invoice owes falls by the amount from
the write-off's day and what was written off rises by it. The amount
is above zero and at most what the invoice owes, and at most the least
it owes on any day from the write-off's on (so no later day, a payment
after it included, takes the document below nothing: B-38 INVARIANT 2).
A partial write-off is allowed (§1.166-3(a)); the rest stays owed and
aged.
2. **The day.** The write-off's day is in the calendar, in an open period
(B-12), and not before the invoice's. A document's entry is reversed,
never edited (B-8, B-9): a write-off is not a void and a void is not a
write-off.
3. **Cash basis refused.** Books on the cash basis never counted the
invoice as income, so the debt has no basis to deduct (§1.166-1(e)):
the command refuses, changing nothing.
4. **Bills are never written off** (ASC 405-20-40-1): a liability leaves
the books only when paid or when the creditor releases it, which is a
vendor credit (not scheduled). The command takes an invoice.
5. **A recovery is not a new movement** (plan D-49). In the same tax year,
reverse the write-off: the invoice reopens and owes what the write-off
took off, and the payment is recorded as any payment. In a later year,
record a plain entry Dr the deposit account / Cr a "Bad debt
recoveries" revenue account (tax category other income), its memo
naming the invoice: the recovery is income (§111) and the invoice stays
written off. There is no `Movement::Recovery` and no bad-debt-recoveries
role.
6. **Evidence.** The write-off's evidence of worthlessness — collection
correspondence, a bankruptcy or liquidation notice (§1.166-2(a)) — is
filed in the same call: a `receipt.attached` on the write-off's entry,
linked to the invoice. A write-off with no evidence is refused. The tax
judgments (whether the debt is worthless, and in which year) stay with
the CPA: the books flag each write-off made this year for the CPA's
note.
7. **Journal.** `invoice.written_off {invoice_id, entry_id, date, amount,
reason}` is a new event type and needs journal feature
`documents_v2` (B-27, D-52): an older version refuses it rather than
misreading a document as still owed. Replay tags the receivable line
`WriteOff`; the projection keeps no document figure (B-38).
```text
GOAL: write off what will not be collected, as the tax rules require.
SOURCE: IRC §166(a), §111; Treas. Reg. §1.166-1(e), (f), -2(a), -3(a);
ASC 326-20 and ASC 405-20-40-1 noted; CHOICE: the direct
write-off method (plan 8.3.1 A-5).
INVARIANTS:
1. amount <= outstanding on the write-off's day and on every day from
it; Dr bad_debts / Cr receivables [d, WriteOff]; the evidence of
worthlessness filed in the same call.
2. Its reversal re-instates the debt; bills are never written off.
3. Refused on cash-basis books.
4. A refusal leaves the books and the journal unchanged; NEVER panics.
SCENARIOS:
1. Half paid, the rest written off: Written off, with 50 paid / 50
written off.
2. A write-off of 0, a negative amount, or more than is owed: SHOULD
refuse, naming what is owed.
3. Cash-basis books: SHOULD refuse (no basis to deduct).
4. The write-off reversed in the same year and the payment recorded:
Paid; receivables back to zero.
5. A later year's recovery: a plain entry to a bad-debt-recoveries
revenue account; the invoice stays written off.
```
**Enforced:** `ledger_core::Ledger::write_off` (and its replay
`replay_invoice_write_off`), `LedgerError::{WriteOffOnCashBasis,
InvalidWriteOff}`, the `bad_debts` control role (`BAD_DEBTS`, 6990);
`ledger_store::Store::write_off_invoice` (feature `documents_v2`; the
evidence filed and linked in the same call), `WriteOffEvidence`,
`StoreError::WriteOffNeedsEvidence`; `books.write_off_invoice`
(`crates/ledger-mcp/src/tools/write_offs.rs`); the viewer's timeline
(`Movement::WriteOff`); Needs attention's write-off item.
**Checked:** `ledger-core` `tests/spec_documents.rs`
(`half_paid_rest_written_off_is_written_off`,
`write_off_refused_on_cash_basis`, `write_off_above_outstanding_refused`,
`write_off_then_recovery_same_year`); `ledger-store`
`tests/arap_standing.rs` (`write_off_waits_for_documents_v2`,
`write_off_replays_and_projects`, `write_off_without_evidence_is_refused`),
`tests/arap_props.rs` (`status_is_a_function_of_journal_and_date` with
write-offs); over the wire `crates/ledger-cli/tests/documents.rs`
(`write_off_then_recovery`, `write_off_needs_evidence`); bookspec
`bad-debt-direct-write-off`; Lean `Documents.Step.writeOff_unattached`,
`Documents.writeOff_within_bounds`; the viewer
`a_write_off_shows_on_the_timeline`; the item
`a_write_off_is_flagged_for_the_cpa`.
## B-42 A settlement is exact and names its lines
**bookspec:** `BS-arap-6`
+80 -3
View File
@@ -2674,7 +2674,7 @@ it calls what part A proved and tested.
check, so both output schemas are exercised on every run.
- **The tool surface, `crates/ledger-mcp/src/server.rs`'s tests:**
`annotations_complete` pins `books.void_document` destructive and
posting, `books.control_check` read-only, and counts the 29
posting, `books.control_check` read-only, and counts the 30
destructive tools the doc names; `period_tools_describe_the_one_default`
holds `books.control_check` to the one `as_of` sentence. The generic
lints cover the rest without a change of their own:
@@ -2725,11 +2725,11 @@ strikethrough, and the test asserts the labels.
The happy-path journey lists and reads one, so both output schemas are
exercised on every run.
- **The tool surface:** `annotations_complete` pins both read-only
(table, now 122 tools); `period_tools_describe_the_one_default` holds
(table, now 123 tools); `period_tools_describe_the_one_default` holds
both to the `as_of` sentence;
`every_tool_is_documented_and_every_documented_tool_exists` and the
text lints cover the doc; `scopes.rs`'s table and
`mcp_journeys.rs::mcp_end_to_end` count 122.
`mcp_journeys.rs::mcp_end_to_end` count 123.
- **The viewer, `crates/ledger-gui/tests/invoices.rs`:**
`the_list_shows_the_ledgers_figures` (the reversed payment no longer
counts: $580 owed, $500 of it late; "Invoice 1, Harbor Client, 14 days
@@ -3191,3 +3191,80 @@ 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.
## P4b S7: bad debts (write-offs), 2026-10-27
The write-off (plan 8.2 S7; rules.md B-41): an invoice that will not be
collected leaves the receivables as a deduction, with its evidence filed
in the same call.
- **ledger-core, `tests/spec_documents.rs`:**
`half_paid_rest_written_off_is_written_off` (B-38 SCENARIO 7: 50 paid,
50 written off, one balanced entry Dr 6990 / Cr 1200, the receivable
line tagged `WriteOff`, receivables down by exactly the write-off);
`write_off_refused_on_cash_basis` (B-41 INVARIANT 3: refused, the
snapshot unchanged, no draft left); `write_off_above_outstanding_refused`
(the bound at the day, a zero and a negative amount, then the exact
amount owed taken); `write_off_then_recovery_same_year` (D-49: the
reversal re-instates the debt, the payment settles it, the invoice reads
Paid and the write-off entry no longer stands). The first test's book
is also the identity's: `control_check` agrees after the write-off.
- **ledger-store, `tests/arap_standing.rs`:**
`write_off_waits_for_documents_v2` (R-A: refused with
`FormatUpgradePending`, journal and head unchanged, in the old-books
fixture; taken after the upgrade; the evidence filed on the entry and
linked to the invoice; replay equals live);
`write_off_replays_and_projects` (the journal holds one
`invoice.written_off` with its five facts, `receipt.attached` then
`attachment.linked`; 6990 carries the expense, 1200 is zero, the
control check agrees, `verify` passes and a reopened store has the same
books and the same figures); `write_off_without_evidence_is_refused`
(no evidence, and an attachment that isn't there, journal nothing; one
already filed is linked to the entry and the invoice).
`tests/arap_props.rs` gains `Op::WriteOff`: random write-offs (partial
and over-large, refused ones included) run through the store, which is
then reopened and read by a `Snapshot`; `status_is_a_function_of_journal_and_date`
still holds at every date.
- **Over the wire, `crates/ledger-cli/tests/documents.rs`:**
`write_off_then_recovery` (an upload as the evidence, `confirmed`, the
invoice reading written_off with its figures and its timeline, the
receipt on the entry, the reversal and the payment, the control check
agreeing) and `write_off_needs_evidence` (neither or both evidence
arguments refused; an already-filed attachment linked to the entry and
the invoice).
- **The viewer, `crates/ledger-gui/tests/invoices.rs`:**
`a_write_off_shows_on_the_timeline` (the shipped screenshot
`invoice-written-off`: status Written off, still owed $0.00, the
timeline's "Written off" row opening its entry).
`crates/ledger-gui/tests/view_model.rs`: `a_write_off_is_flagged_for_the_cpa`
(one item, "Write-off awaiting the CPA's note", the same through the
server's read of the store and the viewer's snapshot).
- **Lean, `Wanderbooks/Documents.lean`:** `Step.writeOff` (a tagged
control line and an untagged expense line: a `Step.document` in shape),
`Step.writeOff_unattached` (it moves nothing unattached, so
`control_agrees` and `Reachable.control_is_documents` still hold),
`through_append`, `docSum_writeOffLines`, and `writeOff_within_bounds`
(a write-off of at most what is owed leaves nothing owed below zero over
the date-sum). Both theorems are in `Audit.lean`. No kernel function
was added, so no Kani harness.
- **bookspec:** `bad-debt-direct-write-off` (B-41, CHOICE: invoice 100,
two partial write-offs, the balances and the trial balance) and
`arap-hand-control-posting-refused` (B-38: an entry an earlier version
wrote on receivables replays, attempts the control guard now refuses,
and the books still tie out). The example schema gained the `write_off`
event and lets a refused `lines` event say so (an entry posts whole or
not at all); the refusal vocabulary gained `untagged_control_line`,
which belongs to B-38.
**For P12 (mutation targets, none run, D-33):** `Ledger::write_off_steps`'
cash-basis comparison (the `eq_ignore_ascii_case`, the early return), its
order of checks (amount before the day, the day before the room), the
`amount > room` bound and its `room < owed` message arm, the `WriteOff`
tag, `least_owed_from`'s `max` and its `min` over later days (the refactor
`payment_room` now shares), `replay_invoice_write_off`'s movement, and
`doc_state`'s `written_off` arm; `Store::write_off_invoice`'s evidence
arms (the `check_linkable` call, the already-linked-to-the-invoice check,
the blob-before-journal order, the `File`/`Existing` link steps) and the
feature gate; the MCP handler's amount default, the exactly-one evidence
check, and the preview's clone; attention's year comparison and its
`stands_on` filter; the bookspec loader's `may_refuse` arm.
+30 -1
View File
@@ -579,6 +579,35 @@ come from the kernel:
`Documents.settle_posts_a_document_step`,
`Documents.settle_unattached`.
### Bad debts (P4b S7)
A write-off (rules.md B-41) is `Ledger::write_off`: a new command, so a new
Lean step, and no new kernel function (its two lines are one debit and one
credit, and the bounds are the existing date-sum's).
37. **A write-off of at most what is owed leaves nothing owed below
zero.** The step posts the document's control line credited (tagged
with it, so it is the same sum B-38 fixed) and the amount of
bad-debt expense debited (untagged, off the control accounts).
`through_append` reads the write-off's own two lines through the day
separately; `docSum_writeOffLines` gives the control line's −amount;
`writeOff_within_bounds` then follows by arithmetic from the command's
own premise (the amount is at most the date-sum through the day).
`Step.writeOff_unattached` shows the step moves nothing unattached, so
`control_agrees` and `Reachable.control_is_documents` hold for it with
no new proof. A reversal re-instates: the same `reverse_twice` that
covers payments (`Step.reverse` mirrors every tagged line).
- Lean `Documents.Step.writeOff`, `Documents.Step.writeOff_unattached`,
`Documents.through_append`, `Documents.docSum_writeOffLines`,
`Documents.writeOff_within_bounds` (in `Audit.lean`) · tests
`half_paid_rest_written_off_is_written_off`,
`write_off_above_outstanding_refused`,
`write_off_then_recovery_same_year`, and the store's
`write_off_replays_and_projects` (live, replay and snapshot agree).
The model's limits are the documents model's (one `Nat` id space,
books built from empty); the cash-basis refusal is a policy on the
books' settings, not arithmetic, and is tested, not modelled.
## What the proofs found
The engine already had 190+ tests, property tests, mutation testing,
@@ -667,7 +696,7 @@ Be precise about the claims:
`tests/properties.rs` (trial balance, balance sheet, reversals,
immutability) and the golden files.
- **Out of scope:** currency (the ledger is single-currency, enforced
structurally), A/R and A/P documents, payroll, the calendar walk around
structurally), payroll, the calendar walk around
the proration kernel (`calendar::month_parts`, `assets::depreciation_schedule`,
`schedules::daily_plan`: property tests, `tests/properties.rs`, P3), and
persistence (the journal store's hash chain is tested in `ledger-store`).