Audit fixes: party money, reconciliations, require policy, K-1 property
check / check (push) Canceled after 0s

An independent recompute of the real books found no numeric
disagreements; review found 15 defects, all fixed with spec-first
regression tests:

- k1: a prepaid credited straight to capital is property; a member
  paying a vendor through 2100 is cash. Docs and B-13 match.
- parties: a reversal of a reversal counts on its root's side;
  liability-to-liability payments (member pays the card) count as paid.
- viewer: party money uses the server's cash accounts and full calendar
  year; per-party "What for" scales multi-party entries.
- reconciliations: refuse a withdrawn statement before journaling; new
  books.void_statement; reconciled lines can't be un-cleared; one shared
  reconcilable-accounts rule for digest and viewer.
- CPA package includes receipts linked to entries; a file can't be both
  filed on and linked to one entry.
- parties = "require" covers payroll, tax remittance (agency) and
  disposal (buyer); no-party marks follow reversal chains and clear when
  a vendor is tagged.
- derived member shares are frozen to exact amounts when posted, so a
  settings change never re-splits history.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-15 20:16:03 -06:00
1 parent 4c74169d18
commit 2275948a64
22 files changed
+1802 -343

No files matched your search

+73 -28
View File
@@ -138,8 +138,9 @@ below.
preview/confirm pattern as post.
- `books.get_entry { entry_id }` → full entry with lines, receipts, and
its `vendor`, `party_id`, and `party` (the party's label, when it names
exactly one) tags, and `parties` / `parties_error` as in the
`post_entry` preview.
exactly one) tags, `parties` / `parties_error` as in the `post_entry`
preview, and `no_party_reason` when it is marked as having no
counterparty.
- `books.search_entries { account?, date_from?, date_to?, text?, limit?, offset? }`
→ entries with lines, newest first. `limit` caps at 100.
- `books.trial_balance { as_of? }` → `as_of`, lines, totals, `balanced`
@@ -165,10 +166,13 @@ below.
ask the human, then update it upstream (pull request or issue at
github.com/swaits/wanderbooks). `unreconciled_accounts` lists the cash
and card accounts due for reconciliation (`{ code, name,
reconciled_through?, due_through, next_step }`): every `[books]
cash_accounts` account and every liability with a recorded statement
that has posted activity through the end of last month but no
reconciliation (not voided) through it.
reconciled_through?, due_through, next_step }`): the cash accounts
(`[books] cash_accounts`, else asset accounts named for cash: cash,
bank, checking, savings, money market), the card accounts (liabilities
named "card", before their first statement), and every other liability
with a recorded statement, that have posted activity through the end of
last month but no reconciliation (not voided) through it. The viewer's
Needs attention uses the same rule (`ledger_store::reconcilable`).
## Tools — fixed assets
@@ -197,11 +201,16 @@ so the lifetime total is exactly (cost − salvage).
→ per-asset `{ entry_id, amount, already_posted }`, plus `skipped` for
assets outside their useful life. Idempotent per asset and period.
- `books.dispose_asset { asset_id | tag, date, proceeds, proceeds_account,
gain_loss_account, confirmed?, receipt_attachment_ids? }` → without
gain_loss_account, buyer?, confirmed?, receipt_attachment_ids? }` →
without
confirmation: `{ status: "preview", ... }` and posts nothing; with
`confirmed: true`: auto-posts missing final-period depreciation, then
posts the balanced disposal entry. Subject to receipt policy (bill of
sale); monthly depreciation is exempt as an allocation.
sale); monthly depreciation is exempt as an allocation. `buyer` (a
party) is the disposal entry's party; under `[books] parties =
"require"` a sale with proceeds without one is refused before anything
posts (`party_required`); a retirement with no proceeds and no buyer is
marked as having no counterparty.
- `books.export { report: "fixed_asset_register", format: json | csv,
as_of? }`: every asset registered by the date (tag, serial, cost,
depreciation by schedule, book value, disposal), totals, and the
@@ -447,12 +456,19 @@ include vendor and customer when it has those records.
payables and payroll withholding roles (`books.roles`). A liability
counts as how the company paid or was paid: a card, a member paying for
it, a loan.
- `paid` / `received`: each entry's money lines, credits minus debits; a
positive net was paid to the party, a negative one received from it; a
reversal counts against its original's side. A bill isn't paid until its
payment is; an entry only moving money between money accounts nets to
zero. An entry naming several parties counts this party's resolved
share only (an entry whose shares don't resolve counts for none).
- `paid` / `received`: each entry's money lines, credits minus debits
(`ledger_core::parties::entry_flow`), with a liability paid down
counting once, as paid to its creditor: by cash (a reimbursement to the
members, a card payment) or by another liability (a member paying the
card from their own funds, a loan paying it off); cash raised by a
liability is received from the lender. A positive net was paid to the
party, a negative one received from it; a reversal counts against its
root original's side (reversing the reversal counts on that side
again). A bill isn't paid until its payment is; a transfer between the
company's own cash accounts nets to zero. An entry naming several
parties counts this party's resolved share only (an entry whose shares
don't resolve counts for none). The year is the whole calendar year;
the viewer's party page and directory show these same numbers.
- `invoiced` / `billed`: its invoices' and bills' totals dated in the year.
`owed` / `receivable`: its open bills' and invoices' balances now (every
year).
@@ -565,7 +581,8 @@ the receipt policy counts it.
exists. `link_to` as in "Tools — attachments".
- `books.link_receipt { receipt_id, entry_id }` → link a previously filed
(unlinked, not withdrawn) receipt to its entry. A receipt linked elsewhere
must be unlinked first.
must be unlinked first; one already linked to that entry
(`books.link_attachment`) is refused, as filing it there twice.
- `books.unlink_receipt { attachment_id, reason }` → `{ ok, entry_id,
remaining_receipts }`. Detaches a receipt from its entry; it stays filed,
unlinked. Journaled as `receipt.unlinked` with the reason. Subject to the
@@ -782,10 +799,17 @@ R-FUTURE-3 and rides the engine roadmap, not the MCP layer.
Records what a filed statement says (balances as printed, in the
account's normal direction, cents at most) and links it to the account.
Journaled as `statement.recorded` then `attachment.linked`. Refused for
an unknown or withdrawn attachment, one already recording a statement,
an account that isn't an asset or liability, or a period that runs
backwards.
- `books.statements { account? }` → `{ statements }`, by statement date.
an unknown or withdrawn attachment, one already recording a statement
(not voided), an account that isn't an asset or liability, or a period
that runs backwards.
- `books.statements { account? }` → `{ statements }`, by statement date:
those not voided whose file isn't withdrawn (withdrawing a statement's
file retires it).
- `books.void_statement { attachment_id, reason }` → the statement as it
was recorded. For a statement recorded wrong: journaled as
`statement.voided`; it is no longer listed, backs no reconciliation, and
the same attachment can record it again. Refused while a reconciliation
not voided rests on it.
- `books.reconcile { statement_attachment? | account, statement_date,
statement_balance, confirmed? }` → `{ status: "preview" | "reconciled",
account_code, statement_date, statement_attachment?, book_balance,
@@ -796,8 +820,9 @@ R-FUTURE-3 and rides the engine roadmap, not the MCP layer.
account, date, and balance are the recorded statement's (any given must
agree). Without `confirmed: true` it is a preview that records nothing;
`problems` lists why a confirmation would be refused. Confirmed, it
needs a recorded statement, a difference of exactly zero, and a date
after the account's last reconciliation not voided; it then journals
needs a recorded statement (its file not withdrawn: refused before
anything is journaled), a difference of exactly zero, and a date after
the account's last reconciliation not voided; it then journals
`reconciliation.completed` (the cleared lines, the statement) and links
the statement to it, returning `reconciliation`.
- `books.reconciliations { account? }` → `{ reconciliations: [{
@@ -807,6 +832,10 @@ R-FUTURE-3 and rides the engine roadmap, not the MCP layer.
- `books.void_reconciliation { reconciliation_id, reason }` → the
reconciliation, voided. Journaled as `reconciliation.voided`; the
account can be reconciled through that date again.
- `books.mark_cleared` refuses to un-clear a line a reconciliation not
voided recorded as cleared: void the reconciliation first. Clearing a
line dated on or before a reconciliation (an outstanding check that
clears on a later statement) is allowed.
## Tools — A/R and A/P
@@ -851,9 +880,15 @@ entries.
`employee` names the employee's party (id or name; it must have the
employee role, else nothing is written); new runs should name it, and
`employee_name` then defaults to the party's name. A line needs one or
the other.
- `books.remit_payroll_taxes { date, cash_account }` → `{ entry_id, total }`.
Clears every payroll payable to cash in one entry.
the other. When every line with net pay names its party, the entry
names those parties, each with its net pay, so each employee's
`books.party` counts it; under `[books] parties = "require"` a line
with net pay and no party is refused before anything posts
(`party_required`).
- `books.remit_payroll_taxes { date, cash_account, agency? }` → `{
entry_id, total }`. Clears every payroll payable to cash in one entry,
naming `agency` (the tax agency's party) on it; required under
`[books] parties = "require"` (`party_required`, nothing posted).
- `books.list_payroll_runs` → `{ runs: [{ id, date, employee_count,
total_gross, entry_id }] }`.
@@ -874,6 +909,12 @@ entries.
- `books.export_cpa_package { as_of?, cash_accounts?, financing_accounts?,
investing_accounts?, include_receipts?, include_tins?, save_to? }` →
`{ file_name, content_base64?, saved_to?, tins_included, tin_gaps, note }`.
**Receipts** (`include_receipts`, default true): `receipts/` holds every
current file behind each posted entry of the fiscal year to date, filed
on it or linked to it (`books.link_attachment`; a linked file counts as
the entry's receipt), one copy per entry, each with a row in
`receipts/index.csv`; `documents/<category>/` holds the files behind no
entry, receipts excepted.
**TINs** (`include_tins`, default true; DECISION, Stephen, 2026-10-15):
the K-1 worksheet and the 1099 worksheet carry every member's and
payee's full TIN from the vault, so the zip is written to `save_to` (a
@@ -892,10 +933,14 @@ entries.
contributions_cash, contributions_property, net_income, distributions,
other, ending_capital` for the fiscal year to date: the statement of
members' equity's figures (`ledger_core::k1`), contributions split into
property (what a contribution debits to asset accounts other than cash,
receivables, and prepaid accounts: a prepaid schedule's account, or one
named "prepaid") and cash (the rest, including a service the members
paid ahead). Property in an entry crediting several members is shared
property (what an entry crediting a member's capital debits to asset
accounts other than cash and receivables: equipment, software, or a
prepaid service contract the members owned and handed over straight to
capital, e.g. AppleCare+ contributed with a laptop) and cash (the rest:
deposits, and what the members paid for the company, including a
service they paid a vendor ahead for, booked through Due to
Shareholders and then converted to capital; DECISION, Stephen,
2026-10-15). Property in an entry crediting several members is shared
in proportion to their credits. An entry and its reversal both in the
year cancel: neither is a contribution or a distribution. A member's
party is the party with the member's name (not a display name) and the
+44 -5
View File
@@ -109,6 +109,16 @@ derived. Spec: `ledger_core::shares`.
member gets what their own lines say (and those must account for all
it moved); otherwise each gets their income share, rescaled to the
members named, rounded as percents are.
- **Posted is forever, the split included:** when an entry naming
several parties with derived shares posts (or a posted entry is tagged
so), the server records the exact amounts they resolve to
(`entry.parties_set` with amounts), so a later change to
`[[company.members]]` (a new income split, a member archived) never
re-splits posted history. A draft's derived shares follow the settings
until it posts. An archived member stays a member for their history
(`Company::party_members` falls back to the archived party of that
name). Entries posted with derived shares before this rule keep them
until re-tagged with `books.set_entry_party`.
- **Refused** (journaling nothing): a party named twice, an unknown
party, mixed kinds of share, percents not summing to 100, an amount
finer than a cent, and two parties with vendor records. A 1099 counts
@@ -126,6 +136,14 @@ derived. Spec: `ledger_core::shares`.
every share, so a reversal subtracts each party's part; re-tagging
either one (parties, shares, or vendor) re-tags the other, so the pair
always nets to zero.
A reversal counts on its root original's side, so reversing the
reversal reinstates the payment as paid (never a negative received).
- **One number everywhere:** `books.party`, `books.parties`, and the
viewer's party page and directory read cash as the server does
(`[books] cash_accounts`, else every asset account but receivables)
and count the whole calendar year, so a posted entry dated later this
year counts in both. The viewer shows the year up to the later of today
and its last posted entry.
- **For the agent:** a joint member transaction (a reimbursement on Due
to Shareholders, a contribution or distribution from joint funds)
names both members with no shares and lets them derive; an uneven
@@ -315,17 +333,26 @@ statement_balance, cleared: [(entry_id, line)], attachment_id}`. It
refuses unless the difference is exactly zero and a statement for that
account and date is attached (the receipt rule's twin). A reconciliation
is never edited; a later one supersedes it; `reconciliation.voided
{id, reason}` exists for mistakes. Viewer: per-account reconciliation
{id, reason}` exists for mistakes. While it stands, the lines it
recorded as cleared can't be un-cleared (void it first), and nothing is
journaled when it is refused (a withdrawn statement file included). A
statement recorded wrong is voided (`statement.voided {attachment_id,
reason}`, refused while a reconciliation rests on it) and recorded again
on the same file; withdrawing a statement's file retires its statement. Viewer: per-account reconciliation
history with its statement; Needs-attention: an account with activity not
reconciled through last month's end.
Tools (built; `docs/mcp-interface.md` "Tools — bank reconciliation"):
`books.record_statement`, `books.statements`, `books.reconcile` (preview
by default; `statement_attachment` + `confirmed: true` records it),
`books.reconciliations`, `books.void_reconciliation`; `books.show
reconciliation`; `books.digest` `unreconciled_accounts` (the `[books]
cash_accounts` and liabilities with statements, not reconciled through
last month's end). The statement of a reconciliation not voided can't be
`books.reconciliations`, `books.void_reconciliation`,
`books.void_statement`; `books.show
reconciliation`; `books.digest` `unreconciled_accounts` and the
viewer's Needs attention, one rule (`ledger_store::reconcilable`): the
cash accounts (`[books] cash_accounts`, else assets named for cash), the
card accounts (liabilities named "card", before their first statement),
and other liabilities with statements, not reconciled through last
month's end). The statement of a reconciliation not voided can't be
unlinked from it.
### Import sources
@@ -378,6 +405,18 @@ without forcing one style. `[books] parties = "off" | "suggest" |
- `require`: posting is refused until the entry names a party or is
marked as having none (transfers, depreciation, amortization, closes
are exempt by kind).
The engine's own posting paths name their counterparty or are refused
first, so nothing posts around the rule: `books.run_payroll` names each
line's employee party on the entry (each with their net pay) and, under
`require`, refuses a line without one; `books.remit_payroll_taxes`
takes `agency` (required under `require`); `books.dispose_asset` takes
`buyer` (required under `require` for a sale with proceeds; a
retirement with no proceeds and no buyer is marked as having none).
- **Marks follow the chain:** a reversal carries its original's parties,
shares, vendor tag, and no-party mark; marking or tagging any entry of a
reversal chain marks or tags them all. Tagging a vendor (or any party)
clears a no-party mark, so an entry never names a party and carries the
mark at once.
Also: memorized matches (journaled rules like description contains
"OPENROUTER" → OpenRouter, applied by bank-CSV import and new drafts,
+6 -2
View File
@@ -238,8 +238,12 @@ closed period).
both in a statement's span are neither contributions nor
distributions (`reports::cancelled_in_span`; the K-1 worksheet reads
the same). The K-1 worksheet's contributed property (Item L) is what
the member owned and handed over, with its own basis (§723): never a
prepaid service they paid for the company (cash), and shared among
the member owned and handed over, with its own basis (§723): a prepaid
service contract credited straight to capital is property (a contract
handed over), while a prepaid service they paid a vendor for on the
company's behalf, booked through Due to Shareholders (Dr prepaid / Cr
2100, then Dr 2100 / Cr capital), is cash (DECISION, Stephen,
2026-10-15); and it is shared among
members credited in one entry in proportion to their credits
(`k1` INVARIANTS 3, 4, 6).