Files
wanderbooks/docs/parties-and-attachments.md
T
swaitsandClaude Opus 5.5 1304b9c0ad
check / check (push) Canceled after 0s
P3: dated 1099 decisions, payment methods and the card exception (defects 23, 12, 18, D-40)
A vendor's 1099 decision is its own dated fact (files NEC or MISC, not
reportable with its reason, or undetermined), applied by each payment's
date. Payment methods are recorded; card and network payments are left
out and listed; a member's check through financing counts; a bank payment
with no method counts (D-40); $2,000 for payments after 2025. One
worksheet for the tool, the viewer and the CPA package.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-21 12:12:26 -06:00

27 KiB
Raw Blame History

Parties and attachments

Design for two features built together (the owner, 2026-10-15): a directory of the parties the company deals with, and attachments that link to any subject. Status: design; built after asset tax basis.

Why together

Both answer "what is this connected to?" A W-9 note, a contract, a bank statement, a photo of a laptop: each is a file about a party, an account, a reconciliation, an asset, a period. One subject model serves both.

Parties

A party is anyone the company deals with. Today's vendors and customers become parties; employees stop being name strings on payroll lines; the Apple, Google, Anthropic, IRS, and Utah that live only in entry descriptions get records.

Model (ledger-core, parties.rs)

Party {
  id: PartyId,                 // its own id space (vendor and customer records keep theirs)
  name: String,                // legal or trade name, as on documents
  display_name: Option<String>,
  roles: BTreeSet<Role>,       // set by hand: contractor, employee, member, payout_platform,
                               // tax_agency, government_agency, financial_institution,
                               // landlord (vendor and
                               // customer follow the records; see all_roles)
  website: Option<String>,
  address: Option<String>,     // business address; no personal PII beyond it
  notes: Option<String>,
  archived: bool,              // no longer dealt with; history stays
  w9: Option<W9Facts>,         // contractor/landlord: the TIN itself is in the vault
  w4: Option<W4Facts>,         // employee: never the SSN
  vendor: Option<VendorId>,    // its vendor record (bills, payments, 1099 form)
  customer: Option<CustomerId>,// its customer record (invoices)
}
W9Facts { received: NaiveDate, entity_type: String, exempt_payee_code: Option<String>,
          fatca_code: Option<String>, tin_last4: String, kept_at: String,
          vault_record: Option<String> /* SHA-256 of the vault record */ }
W4Facts { received: NaiveDate, state: String, kept_at: String }

The 1099 form stays on the vendor record (vendor.1099_set); a party reaches it through vendor.

Rules (to vendor as B-26 with sources):

  • A full TIN or SSN is never in the journal: tin_last4 is exactly four digits, and any nine-digit run in any party field is refused (the engine checks, not the agent). Full TINs live only in the vault (below). Source: IRS truncated-TIN rules (Treas. Reg. §301.6109-4, to vendor) for what may appear; DECISION (the owner, 2026-10-15) for the vault.
  • Roles are a set; each role's facts are only allowed with the role (W-9 facts need contractor or landlord; W-4 facts need employee).
  • Names are unique ignoring case and surrounding space among non-archived parties.
  • A 1099 needs a contractor or landlord role; 1099 totals come from the entries tagged with the party, as today's vendor 1099 report does.
  • Entries: entry.party_set {entry_id, party_id|null} generalizes entry.vendor_set (one counterparty); entry.parties_set names several, each with a share (below).
  • Invoices and bills already name a customer or vendor id: those are party ids after migration.
  • Payroll lines name an employee party id (old runs keep their name strings; new runs must name a party with the employee role).

Journal events

party.added {id, name, roles}, party.updated {id, change} (one PartyField per event, {field, value}: name, display_name, website, address, notes, archived), party.role_set {id, role, on}, party.w9_set {id, facts|null}, party.w4_set {id, facts|null}, party.tin_set {id, facts|null} (TIN facts: type, last four, vault record, when purged), party.creditor_set {id, account_id, on} (P3: the party is, or stops being, the creditor of a liability the company owes it itself; the journal declares its feature, party_creditors, before the first one), entry.party_set {entry_id, party_id|null}, entry.parties_set {entry_id, parties: [{party_id, share}]} (a share is {"amount": "30.00"}, {"percent": "60"}, or "derived"; [] clears). Replayed and projected like the rest; every event gets viewer journal text (the GUI test enforces it).

Shares: an entry with several parties

DECISION (the owner, 2026-10-15): there is no "joint" party. A joint transaction is with both members and shows on both members' pages, each with their share; apportioning is explicit and exact when it isn't derived. Spec: ledger_core::shares.

  • An entry names a list of PartyShare {party, share}; Share is Amount(d), Percent(p), or Derived. Tagging one party (set_entry_party) is one derived share: all of it. Ledger::entry_party means "the sole party"; entry_parties lists them all.
  • What a share is of (rules.md B-35, P3 defect 14): one signed target, debit-positive as a line on the parties' own account (positive: the company paid or gave; negative: it received), with its direction (paid, received, neither) and what of it was money and what property in kind. A close: its parties' own-account movement, neither. A reversal: its original's target negated, never re-read from its own lines. An entry naming only members that moves equity: what moved their capital. A contribution is received, the property in it in kind (the K-1 worksheet's test), the rest cash; a distribution is paid, the money it paid cash, the property it gave in kind, the rest (declared to distributions payable, or credited to what the members advanced once they are named its creditors) still owed, counted when paid. Money moved: the money (parties::flow), all cash. Otherwise its parties' own-account movement (a bill −, an invoice +), else its total debits: neither, counted in no total. Read from journaled facts only (the member role, the chart, creditor_of), so leaving [[company.members]] never changes a posted entry's reading. (Before P3 a property contribution read +500.00, the sign of a payment, and its reversal the same.)
  • Amount: exactly that, positive, in cents; the amounts must sum to what the entry moved.
  • Percent: more than 0, at most 100, summing to exactly 100. Each part is rounded toward zero to the cent and the leftover cents (fewer than one per party) go to the first party in order.
  • Derived: a sole party gets all of it. Several parties must all be members ([[company.members]] matched by name to a party with the member role; core is handed them as PartyMembers). If the entry has lines on a named member's capital or distributions account, each 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 a payment toward a single vendor: the vendor tag follows the one party with a vendor record, and the 1099 worksheet counts the whole payment toward it, so name a vendor among others only when the whole payment was its (otherwise record its part as its own entry). Whether shares add up depends on the settings (cash accounts, members), so core checks only their form; the MCP server checks they resolve once the entry has balanced lines, and post_entry refuses (party_shares) until they do. Replay never consults settings.
  • Money: parties::activity_with counts each party's part on two bases (B-35 item 4): cash (its money: cash, a card, the members' financing, a loan) and all (adding property contributed or distributed in kind), the viewer labelling the first "cash basis". Money accounts are the cash accounts, the cards and [books] financing_accounts; any other liability is a promise, counted once, when settled (P3 defect 24). A party may be named the creditor of liabilities it is owed itself (creditor_of: a card's issuer, a loan's lender, each member for what the members advanced); a credit to one never counts as paying it. An entry that counts nothing (a bill, an invoice, a close) is never unresolved; an entry whose shares don't resolve counts for no one (listed as unresolved). Reversals carry 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). For a member, basis all over their equity entries is the statement of members' equity; basis cash their contributions is the K-1 worksheet's cash contributions.
  • One number everywhere: books.party, books.parties, and the viewer's party page and directory read money as the server does ([books] cash_accounts, else every asset account but receivables; the cards; [books] financing_accounts) and count the whole calendar year, so a posted entry dated later this year counts in both. The directory's TIN last four is the TIN facts', else the W-9 facts' (Party::tin_last4, P3 defect 11), as everywhere the books show one. 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 split gives amounts.

A member's capital statement

A member's party page (role member, named in [[company.members]], whose capital account is in the chart) is their capital account statement over the page's period (Books::capital_statement). Every figure is the CPA package's K-1 worksheet row for that member and span (ledger_core::k1::k1_detail, itself the statement of changes in members' equity split into cash and property), so the page and the K-1 never disagree:

  • capital at the start of the period (including their share of earnings not yet closed into capital);
  • add contributions of cash (deposits, and what they paid for the company) and of property (debits to asset accounts that are neither cash nor receivables, at the value booked: fair market value);
  • add their share of the period's net income, closed or not;
  • less distributions;
  • any other change in their capital, when there is one;
  • capital at the end of the period (always opening plus the movements, exactly);
  • their share of profit, loss, and capital; and their part of what the company owes the members jointly, split by the members' shares: the balance of the [books] financing_accounts liabilities (the liabilities members advance), or, only when that is not set, of the liability accounts named "due to" / "owed to" (such as 2100 Due to Shareholders).

Cash and property are told apart by [books] cash_accounts. Without it the statement reads money as the engine does (every asset account but receivables), so every contribution reads as cash; set cash_accounts to see property apart. (The Cash page keeps its own name-based guess.)

Migration

Old journals replay unchanged. vendor.added and customer.added keep their meaning and, on replay as live, link their record to a party: the live party of that name (ignoring case and surrounding space) without such a record yet, else a new party. So a vendor and a customer of the same name share one party; a second vendor of a taken name gets a new party named "Name (2)" (names stay unique). Vendor and customer names obey the party text rules (no full TIN). entry.vendor_set also sets the entry's party, and entry.party_set to a party with a vendor record sets the vendor tag: one counterparty. On an entry naming several parties the vendor tag follows the parties, never the other way: entry.vendor_set for the vendor of a party already named keeps every share; for any other vendor, or clearing the tag while its party is named, it is refused. A vendor record can't link to a party named beside another party with a vendor record (refused: the entry would name two vendors); linking one tags the entries naming the party that have no vendor tag yet.

Tools

books.add_party, books.update_party, books.party_roles, books.set_w9, books.set_w4, books.parties (list, filter by role), books.party (one: facts, roles, totals this year, documents, links), books.set_entry_party. (Merging two parties, books.merge_parties, is not built yet: records of one name already share a party.) books.add_vendor and books.add_customer stay as shortcuts.

Viewer

A Parties page (by role, searchable) and a page per party: facts, roles, year-to-date paid / owed / invoiced / 1099 amount, every entry, invoice, bill, payroll run, documents, and history. Members link to their capital accounts. Global search finds parties. Needs-attention: a contractor paid without W-9 facts; a 1099 party paid over the threshold.

Money with a party (the owner, 2026-10-15: "how much have I spent on Apple versus how much did I get back"): paid out, received in, and net, for a chosen period (this year, last year, any year, all time), from ledger_core::parties::activity_with, labelled cash basis, with "With property contributed or distributed: paid $X, received $Y" under them when anything moved in kind; what it was for (the non-money lines by account); a monthly out-vs-in chart; the 1099 amount for the calendar year. An entry's page says each party's part in words ("received $740.00, in kind"; "paid $1,480.00 (reversal of #12)"). The directory shows the same columns, sortable.

The TIN vault

Decision (the owner, 2026-10-15): wanderbooks holds full TINs, so what it hands the CPA is complete, but never in the journal. The vault holds everyone's, members included, and the CPA package carries them: "In the vault, that's the WHOLE reason we built it. And it needs to be in the damn CPA package."

  • Where: vault/ in the books dir, listed in .gitignore. Never in the hash-chained journal, the SQLite projection, git, an export other than the 1099 worksheet and the CPA package (handed to the agent as a file to download, never saved in the books), or anything the viewer draws. Nothing outside the vault holds more than the last four digits.
  • Who: any party, whatever its roles: every member (their K-1 needs it) and every 1099 payee. The books keep the party's TIN facts (type, last four, the vault record's hash; party.tin_set), which never disagree with its W-9 facts' last four. The viewer shows "TIN on file ••••1234 (SSN)" on the party's page, never more; clicking it copies the full number (right-click: digits only), read from the vault at that moment with the key in force, so people can paste it into a form (the owner, 2026-10-16: "people still need to actually access these things"). The viewer never creates a key: without one it says the vault is locked.
  • What: per party, the full TIN, its type (SSN, EIN, ITIN), and the W-9 file itself, each encrypted at rest (XChaCha20-Poly1305, one random nonce per record; the file format carries a version and the party id as associated data, so a record can't be moved to another party).
  • Key: a random 256-bit key in a key file outside the books dir (default ~/.config/wanderbooks/vault.key, mode 0600; [vault] key_file in wanderbooks.toml overrides it), created on first use. No OS keyring or other system service (the owner, 2026-10-15: no external dependencies), so the vault works anywhere wanderbooks runs. Recovery is a copy of that file in the human's password manager; without it the vault can't be read (the books still open and verify).
  • Journal: party.w9_set records that a W-9 was received, its date, entity type, codes, and last four, plus a hash of the vault record, so the books prove a W-9 existed without holding it.
  • Purge: books.purge_tin {party, reason} deletes the vault record (after the retention period, or on request) and journals that it was purged; the journal's facts stay.
  • Use: books.vendor_1099 with include_tins: true and books.export_cpa_package (its K-1 worksheet, k1-members.csv, and its 1099 worksheet; include_tins defaults to true there) decrypt them. The package is handed over as a file (an export the asking token fetches with wanderbooks download, or inline over stdio); no tool takes a path. The MCP server never returns a TIN in any other tool, log, or error. Tests check that no other output contains a nine-digit run.
  • Books without a vault (another machine, books adopted without theirs; a restored backup brings its vault and key, D-31) still open and verify; the 1099 worksheet and the CPA package then say which TINs are missing (tin_gaps), and the package is still built.
  • Tools (built; docs/mcp-interface.md "Tools — the TIN vault"): books.set_w9 takes tin + tin_type and the signed form as w9_upload_id (books.begin_upload) or w9_content_base64 with w9_sha256 (the form goes to the vault, never the attachment store) and journals the last four with vault_record; facts that disagree with the vault's TIN are refused, and clearing facts with a vault record needs a purge first. With a TIN it also records the party's TIN facts. books.set_tin {party, tin, tin_type} stores any party's TIN (a member's SSN, say) the same way and journals party.tin_set; a party whose W-9 facts name another last four is refused (a payee's new TIN comes with its W-9). books.get_w9 {party} hands the form over the same way. books.purge_tin {party, reason, confirmed?} previews, then deletes the records (no key needed) and journals the purge as party.w9_set with vault_record cleared and kept_at saying when and why, and party.tin_set with vault_record cleared and purged saying when and why. books.vendor_1099 {include_tins: true} is the one tool output with full TINs (with a warning; csv: true also hands the worksheet over as a CSV file); books.export_cpa_package carries them in its zip, handed over the same way. A key file inside the books dir is refused. tests/spec_vault.rs scans every tool response, the server's log, and every file in the books dir outside vault/ for the TINs in any formatting.

Attachments

Subjects

An attachment links to any number of subjects:

Subject = Entry(id) | Account(id) | Reconciliation(id) | Asset(id) | Party(id)
        | Invoice(id) | Bill(id) | Period(id) | PayrollRun(id) | Schedule(id)
        | Company

attachment.linked {attachment_id, subject} and attachment.unlinked {attachment_id, subject, reason} replace the single entry_id link (old receipt.attached with an entry and receipt.linked replay as links to that entry). A receipt is an attachment linked to an entry; the receipt policy counts links to the entry, as now. A file stored once can be linked to many subjects (no more refiling).

Tools (built; docs/mcp-interface.md "Tools — attachments"): books.link_attachment, books.unlink_attachment (refused for a posted entry's last receipt under the receipt policy, and for a standing reconciliation's statement), books.attachments {subject}, link_to on books.attach_receipt and books.file_document (all or nothing), and links on every listed attachment. books.file_document takes the kinds below as its category (receipt is books.attach_receipt's).

Kinds

receipt, statement, contract, formation, legal, tax, accounting, photo, warranty, import_source, other. A statement carries {account_id, period_start, period_end, opening_balance, closing_balance}, checked against the account's currency.

Reconciliations

books.reconcile with confirmed: true records a reconciliation: reconciliation.completed {id, account_id, statement_date, 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. 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.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 (ledger_core::parties::card_accounts: the business-card payment methods' liabilities, else the liabilities named "card", not "gift", 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

Built: file the export (books.file_document category import_source) and pass source_attachment_id to books.import_bank_csv; it is linked to the bank account imported into. Not to the drafts it creates: a file linked to an entry counts as its receipt, and a bank export is not one (DECISION, 2026-10-15, building the MCP tools). Recording the row each draft came from is not built yet.

Projection

SQLite: attachment_links(attachment_id, subject_kind, subject_id), statements(attachment_id, account_id, period_start, period_end, opening, closing), reconciliations(...), reconciliation_lines(...); the old attachments.entry_id column is kept and filled for entry links during the migration so older readers still work.

Testing

Spec blocks on every command; spec-blind suites per crate; the refusal-leaves-books-unchanged property; replay and migration from v10 books; the GUI's every-event-has-text test; mutation on the new modules.

After this: the completeness engine

Decided (the owner, 2026-10-15), built after parties and attachments: one set of completeness rules in the core (asset: tag, serial, tax basis, acquisition link, label; entry: receipt, 1099 vendor tag; account: reconciled through last month; month: depreciation, amortization, close; party: W-9 before paying a contractor; settings: no problems; and the rules this design adds), next_steps on every tool response naming the exact call that completes each, books.todo as the one punch list behind the digest, the viewer's Needs attention, and the session-start hook, and enforcement where a rule is a rule (tax basis at registration). The standard: do it now, completely.

Counterparties

Decided (the owner, 2026-10-15): the books should be easy to make complete without forcing one style. [books] parties = "off" | "suggest" | "require", default "suggest":

  • off: never mention counterparties.
  • suggest: a draft or preview without a party names the likely one (existing parties matched against the description, the receipt's text, the vendor), or offers to create one; next_steps carries the exact call. Nothing is refused.
  • 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, reviewable and removable); a backfill punch list grouping untagged entries by likely party for one confirmation per group; 1099 nudges (a party given contractor or landlord, or a payment that looks like services to an individual, asks for the role and the W-9).

After that: updates (check and seamless upgrade)

Decided (the owner, 2026-10-15), after the completeness engine. New versions fix accounting, tax, and legal rules, so books running an old version may be wrong; staying current is part of keeping correct books.

  • Check: the MCP server checks for a newer release when it starts and once a day while running (and the viewer and just session-start ask it). Releases carry notes and a severity: a fix to an accounting, tax, or legal rule (a 1099 threshold, a depreciation rule, a refusal that was wrong) is marked so, and says which books it may affect. [updates] check = "daily" | "never" in wanderbooks.toml; the check contacts only the release host and sends nothing about the books.
  • Surface: the update is a completeness item (books.todo), in books.digest, a banner in the viewer, and the session-start hook; the MCP instructions tell the agent to raise it with the human at once, with the release notes, and to treat a rules fix as urgent.
  • Upgrade: one step (wanderbooks update, or the agent offers it): download the release, verify its signature against a key pinned in the installed build, then rehearse: open the books read-only with the new build and run the full verify (replay, projection, migrations) before switching. Swap binaries atomically, keep the previous build for rollback, restart the viewer, and have the MCP server exit cleanly so the client reconnects to the new one. Never downgrade; never touch the journal except through the new build's ordinary migrations.
  • After: the agent re-runs whatever the fix affects (for example, books.vendor_1099 for the year, or the register) and tells the human what changed in their books, if anything.