docs/mcp-interface.md: the connect text and its budgets, the handbook (tool and resources), annotations, failing closed, import_local_file's roots and refusals, the set_w9 path rule and where TIN-bearing files may go. docs/testing.md: the P0 laws and the tests that check them. docs/architecture.md: the journal format gate and the server's fail-closed store lock. Round-2 review (2026-10-16): mcp-interface.md lists all 26 destructive tools (unlink_receipt, unlink_attachment and correct_asset were missing) and openWorldHint false on every tool; the connect text states the parties rule and the exact receipt rule, and how it fits its budget; the lint covers every refusal; the import roots refuse /, the home folder and folders holding the books or a key, the open is a component walk, every missing or outside path gets one refusal, and the TIN-file registry and text scan (and what they can't catch) are described. architecture.md: the frozen envelope, the lenient next-line prev, no repair of newer books. testing.md: the new laws and tests. verification.md and parity.md: P0 notes (no new law to prove, no status moved). The TIN text check, narrowed (2026-10-16; rules.md B-26, mcp-interface.md): text is refused for a TIN the vault holds, in its common renderings, or a stand-alone printed SSN or EIN shape, so bank exports go in; the oracle a vault comparison gives is recorded as a known limit for P7. testing.md: the P0 mutation run of the final diff, per crate, and how each survivor was resolved (killed by a named test, removed by simpler code, or argued equivalent). The probe bound (rules.md B-26, mcp-interface.md): rule (a) answers for at most two candidates a filing, so finding a TIN from its last four takes on the order of 50,000 refused filings; the open limit now names that path, closed by P2's access log and P7's human-only TIN channel. testing.md: the law and its tests; tin_files.rs mutated again, 74 of 74 caught. Review round 3: rules.md B-28 (four-digit years, amounts a line can carry, checked products, bounded recurring runs) and B-26/B-27 updates (BOM-less UTF-16, when set_w9 records); mcp-interface.md matches the code (25 destructive tools, the five idempotent ones, no reverse_entry receipt checklist, strictly above the threshold, no tool text names wanderbooks.toml, dot names and roots inside the books refused, the new settings lists); testing.md lists the new laws and this round's mutation run. Review round 4: rules.md B-28 says the bounds are for new commands only, replay accepts every line an earlier version accepted, and derived figures refuse rather than panic; mcp-interface.md documents plan_error and the nullable totals; testing.md lists the old-books contracts and this round's mutation run (core, store, MCP, config, GUI). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
1147 lines
68 KiB
Markdown
1147 lines
68 KiB
Markdown
# MCP interface spec — `ledger-mcp`
|
||
|
||
## Goal
|
||
|
||
The primary interface to the books is an AI agent driving MCP tools. The
|
||
engine (`ledger-core`) stays pure, `ledger-store` persists, and `ledger-mcp`
|
||
exposes both over the Model Context Protocol — the open standard, so the
|
||
books are drivable by any MCP client (Muse, Claude Code, IDE agents), not
|
||
locked to one assistant.
|
||
|
||
## Crates in this phase
|
||
|
||
| Crate | Role | Spec |
|
||
|---|---|---|
|
||
| `ledger-store` | SQLite persistence, blob metadata, event log | `docs/receipt-store.md` |
|
||
| `ledger-mcp` | MCP server wrapping store + core | this document |
|
||
|
||
Rust, `rmcp` SDK (the Rust MCP SDK). The MCP crate holds no accounting
|
||
logic: it translates tool calls into `Ledger` commands and formats results.
|
||
If a behavior can't be expressed through `ledger-core`'s public API, the
|
||
core is missing something — fix the core, don't work around it in the MCP
|
||
layer.
|
||
|
||
## Company settings
|
||
|
||
`ledger-mcp` reads the books dir's `wanderbooks.toml` (default: next to
|
||
`--data-dir`; override with `--books-toml PATH`): `[company] name` heads
|
||
every report, `[company] fiscal_year_start` ("MM-DD", default "01-01";
|
||
the year's end is derived, never stored) defines fiscal-year-to-date for the
|
||
CPA package and the balance sheet's net income line, `[[company.members]]`
|
||
(`name`, `share` as "50%" or "0.5", `capital_account` code; shares must sum
|
||
to 100%) drives the statement of members' equity, `[books]
|
||
accounting_basis` ("accrual" or "cash", default accrual) labels report
|
||
footers, and `[books] receipt_threshold` sets the receipt policy unless
|
||
`config.json` sets `receipt_policy`. Members may name a
|
||
`distribution_account`, and `files_jointly_with` (spouses filing one joint
|
||
return; the CPA package's README notes their two K-1s go on one Form 1040,
|
||
and k1-members.csv ends with a members' total, not a K-1, that ties to
|
||
Schedule L); single-owner businesses may set `[books] close_into`
|
||
(the equity account a close puts net income in; default Retained Earnings)
|
||
and `close_sweep` (draw, distribution, or dividend accounts closed into it).
|
||
The cash flow statement's account roles are `[books] cash_accounts` (what
|
||
it reconciles to), `financing_accounts` (loans, and money the owners
|
||
advanced, such as a "Due to owners" liability: ASC 230-10-45-14(b)), and
|
||
`investing_accounts` (loans the company makes, investments it buys: ASC
|
||
230-10-20); the tools and the viewer both read them, and a call may pass
|
||
its own (docs/spec/rules.md B-14).
|
||
|
||
The server reloads `wanderbooks.toml` (and `config.json`'s receipt policy)
|
||
whenever either changes, compared by content at the start of each request,
|
||
so an edit takes effect on the next call with no restart. A broken edit
|
||
keeps the last good settings in force and is reported until fixed; the
|
||
data dir and base currency are fixed at startup (`ledger-mcp/src/settings.rs`).
|
||
|
||
- `books.settings {}` → `{ books_toml, books_toml_exists, config_json_present,
|
||
reload_error, loads, company, fiscal_year_start, tax_form, basis,
|
||
receipt_rule, members, cash_accounts, financing_accounts,
|
||
investing_accounts, close_into, close_sweep, problems }`: the settings in
|
||
force and where they come from. `problems` lists settings that disagree
|
||
with the chart (a code not in it, or of the wrong kind), each saying what
|
||
to change; `books.digest` repeats them, with a failed reload, as
|
||
`settings_problems`. The agent edits the file when the human gives a
|
||
company fact, a refusal names a setting, or a problem is listed, then
|
||
checks `books.settings`.
|
||
|
||
The accounts invoices, bills, payroll, and closes post to are not settings:
|
||
they are control roles in the journal (rules.md B-23).
|
||
|
||
- `books.roles {}` → `{ roles: [{ role, purpose, account_type,
|
||
default_code, assigned, account_code?, account_name? }] }`: every control
|
||
role and the account filling it.
|
||
- `books.assign_role { role, account? }` → `{ roles }`: assign a role one
|
||
of the chart's accounts (`account` null: back to the default code).
|
||
Refuses the wrong type, a placeholder, an account already filling
|
||
another role, and moving a balance sheet role while its account holds a
|
||
balance. Journaled as `role.assigned`.
|
||
|
||
## Transports
|
||
|
||
- **stdio** (default): `ledger-mcp --data-dir PATH`. For Claude Code,
|
||
Claude Desktop and other local agents. No auth: the OS user boundary is
|
||
the only boundary.
|
||
- **Streamable HTTP**: `ledger-mcp --data-dir PATH --http 127.0.0.1:8080`.
|
||
Serves MCP at `http://127.0.0.1:8080/mcp` via rmcp's tower integration.
|
||
**There is no authentication on this transport today**: no token and
|
||
no Origin check. The only guard is rmcp's default `Host` check, which
|
||
accepts `localhost`, `127.0.0.1` and `::1`. Any local process that can
|
||
reach the port can read and change the books. Bind it to loopback only;
|
||
binding a LAN, tailnet or public interface is an explicit human
|
||
decision, never a default. Auth, scopes and Origin validation arrive
|
||
with the served books (`plan/served.md`, P2).
|
||
|
||
## Server instructions
|
||
|
||
The MCP `initialize` response carries an `instructions` string, the
|
||
**connect text** (`ledger-mcp/src/handbook.rs`), composed in `get_info`
|
||
from the settings in force when the client connects (the settings are
|
||
re-read first, so a client connecting after an edit is told the new rule).
|
||
It is short on purpose: clients cut long instructions (one cut the old
|
||
text at 4,096 characters), so it is at most **1,400 characters**, and its
|
||
first paragraph, at most **512**, stands alone: the entry point
|
||
(`books.handbook`), the rule that these tools are the only way to the books
|
||
(never local files), decimal-string amounts, and the
|
||
draft → lines → preview → confirmed post flow. The second paragraph adds
|
||
that every entry balances and uses real accounts from `books.chart`; the
|
||
parties rule in force (`[books] parties`: under "require" every entry
|
||
the agent posts names its party or a no-party reason, since closes,
|
||
depreciation and amortization post with none; under "suggest" name it
|
||
where there is one; under "off" nothing); corrections by reversal; the receipt rule in force
|
||
(`ReceiptPolicy::rule`: the exact threshold in the books' base currency,
|
||
never rounded, the accounts that always need a receipt and those exempt,
|
||
and that reversals need none); the exemptions (depreciation and
|
||
amortization, whose schedules are the evidence, and closes, derived from
|
||
the books); that `books.create_invoice`, `books.create_bill`,
|
||
`books.receive_payment`, `books.pay_bill`, `books.run_payroll` and
|
||
`books.remit_payroll_taxes` post at once without a receipt check; TINs
|
||
only into the vault; show the numbers, and after posting check
|
||
`books.digest` and offer it to the human; and the
|
||
one local file the agent may edit, the settings file `books.settings`
|
||
names (until settings move into the journal, P1). When a long rule (many
|
||
accounts, a huge threshold, a long name) would push it past 1,400
|
||
characters, it drops the tracing hint first (the handbook's
|
||
tracing-figures section has it), then the reasons in parentheses, then the
|
||
tracing hint for good and lists fewer account codes (at last only how many
|
||
there are; `books.settings` lists every one, uncut, in
|
||
`receipt_required_accounts` and `receipt_exempt_accounts`), then words
|
||
that only help; never the rule itself. A company with no name yet (the
|
||
settings' default) is "a new company". The company name in it is one line of plain text (control characters and
|
||
line breaks become spaces, Unicode format characters such as bidi
|
||
overrides and zero-width spaces are dropped), cut to 56 characters.
|
||
`instructions_budget` (the longest rule, every parties setting) and
|
||
`the_company_name_is_one_plain_line` check it.
|
||
|
||
The long guidance is the **handbook**, compiled in from
|
||
`ledger-mcp/handbook/*.md`, with the receipt rule in force rendered in:
|
||
|
||
- `books.handbook { section? }` (read-only) → `{ read_first, sections:
|
||
[{ id, title, uri, chars }], section: { id, title, text } | null }`.
|
||
Without `section`, the index (`read_first` says to read `contract`
|
||
first); with a section id, that section's Markdown; with `"all"`, the
|
||
whole handbook in reading order. The input schema lists the ids:
|
||
contract, entries, drafts-and-reversals, evidence-and-receipts,
|
||
attachments, files-in-out, reconciliation, fixed-assets,
|
||
prepaid-and-recurring, revenue-and-payouts, parties-and-tins, 1099,
|
||
cpa-handoff, settings-and-conventions, viewer, rules-that-change,
|
||
control-roles, tracing-figures.
|
||
- The same as resources: `books://handbook` (the index, JSON) and
|
||
`books://handbook/{section}` (Markdown, `text/markdown`), one listed per
|
||
section.
|
||
|
||
No text the agent is given (tool JSON with its schemas, resources, connect
|
||
text, handbook, and every refusal fixed in code: each error variant's
|
||
template in the crates under the server, scanned from source, built error
|
||
texts, and the server's refusal constants) names a helper command, a home
|
||
path, the data dir or its flag, the import-root flag, a file of the books
|
||
(`.jsonl`, the database, `vault.key`, `.gitignore`), git, GitHub, a pull
|
||
request, a repo doc (`docs/`, `.md`), or tells the agent to read the
|
||
books' files; no tool title or description, connect text or handbook
|
||
section names `wanderbooks.toml` (the input and output schemas name it by
|
||
design, for `books.settings`' `books_toml`) (`tool_text_lint`). A rule set by law that the books
|
||
don't know for a year names its official source (the statute or
|
||
publication, with its address) and says to tell the human.
|
||
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 25:
|
||
`books.post_entry`, `books.reverse_entry`, `books.unlink_receipt`,
|
||
`books.unlink_attachment`, `books.withdraw_attachment`,
|
||
`books.correct_asset`, `books.dispose_asset`, `books.run_depreciation`,
|
||
`books.run_schedules`, `books.run_recurring`, `books.renumber_account`,
|
||
`books.assign_role`, `books.close_period`, `books.reconcile`,
|
||
`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.run_payroll`, `books.remit_payroll_taxes`; and an
|
||
`idempotentHint` on the five 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` and
|
||
`books.set_party_roles` (a setter such as `books.update_party`,
|
||
`books.set_fx_rate` or `books.set_tin` journals a line, and `books.set_tin`
|
||
seals a new vault record, every time, so none carries it;
|
||
`idempotent_tools_change_nothing_when_repeated` calls each candidate twice
|
||
to check). Every tool's
|
||
`openWorldHint` is false: the books are a closed world, and no tool
|
||
reaches beyond them. `books.export_cpa_package` writes a file, so it is
|
||
not read-only. No tool carries `requiresUserInteraction` (owner decision
|
||
2026-10-16: no added prompts). `annotations_complete` pins all of this.
|
||
The read-only hint is true in behaviour: `spec_mcp` checks, on every call,
|
||
that a read-only tool leaves the journal, the contents of every file under
|
||
the data dir (the database by every row of every table; SQLite's -wal and
|
||
-shm files, which move pages and hold read marks on their own, aside),
|
||
the vault and the settings file as they were
|
||
(`read_only_tools_change_nothing`).
|
||
|
||
**Failing closed.** Tool bodies run on blocking threads, one at a time on
|
||
the store. If a request panics (an internal error), the journal may hold a
|
||
line the memory doesn't, so the server stops taking requests: every later
|
||
call, on every session sharing the store, is refused with "the books
|
||
server stopped taking requests after an internal error, to protect the
|
||
books: ask the human to restart the books server", never the panic's own
|
||
text. A restart re-verifies the journal
|
||
(`a_request_that_panics_stops_the_server`). So a panic an input can reach
|
||
would be a denial of service, and none can be: the dates a tool takes
|
||
are four-digit years and real days, its amounts at most a journal
|
||
line's, and a recurring run bounded; books an earlier version wrote past
|
||
those bounds still open (replay accepts what that version accepted), so
|
||
every figure the server derives from them (a draft's total, a schedule's
|
||
plan, aging, a party's totals, the asset register) is computed or
|
||
refused ("too large to compute exactly", or a date outside the calendar
|
||
the books keep), never a panic (rules.md B-28). `tests/hostile_args.rs`
|
||
drives every tool with generated hostile arguments and every resource
|
||
with generated URIs, checks after each call that the server still
|
||
serves, restarts it after them to check the books reopen, and drives
|
||
every tool over every book in ledger-store's `tests/fixtures/old-books`.
|
||
|
||
## Tool design principles
|
||
|
||
- **Narrow tools, explicit arguments.** No "do what I mean" tools. The agent
|
||
supplies dates, accounts, sides, amounts; the server validates.
|
||
- **Amounts are decimal strings** in requests and responses. Never floats,
|
||
never locale-formatted numbers.
|
||
- **Every mutating tool echoes the exact resulting state.** The "show the
|
||
numbers" rule from AGENTS.md applies over the wire too.
|
||
- **Preview-before-post.** `post_entry` and `reverse_entry` take
|
||
`confirmed: bool`. Without confirmation they return a preview (full lines
|
||
+ resulting account balances) and post nothing. The agent must present the
|
||
preview to the human before confirming. This encodes the standing
|
||
fact-check rule in the protocol itself.
|
||
- **Errors are typed strings** mirroring `LedgerError` variants, with the
|
||
offending values included (e.g. unbalanced totals).
|
||
|
||
## Tools — books
|
||
|
||
- `books.handbook { section? }`: the guide (see "Server instructions").
|
||
- `books.chart` → `{ accounts: [{ id, code, name, type, parent, placeholder,
|
||
balance }] }`. Balances included: one call answers "what do we have."
|
||
- `books.draft_entry { date: "2026-10-10", description: "...", vendor?,
|
||
party?, parties?, no_party_reason? }` → `{ entry_id, status: "draft" }`.
|
||
`party` tags the counterparty (see "Tools — parties"); given with
|
||
`vendor`, both must name the same vendor record, else nothing is
|
||
drafted. `parties` names several, each with a share (as
|
||
`books.set_entry_party`), in place of `party` and `vendor`.
|
||
- `books.add_line { entry_id, account: "1000" | <id>, side: "debit" | "credit",
|
||
amount: "123.45" }` → `{ ok: true }`. A refused line (unknown account or
|
||
class, bad amount) adds nothing.
|
||
- `books.post_entry { entry_id, confirmed }` → `{ status, entry_id, date,
|
||
description, total, lines (with resulting balances), receipts,
|
||
receipt_required, receipt_missing, split_detected, party_policy,
|
||
party_id?, party?, parties, parties_error?, no_party_reason?,
|
||
party_missing, party_suggestions }`. Without `confirmed: true`, `status`
|
||
is `"preview"` and nothing changes; with it, the receipt policy is
|
||
enforced and `status` is `"posted"`. `parties` lists every party the
|
||
entry names with its share resolved to the cent (`{ party_id, party,
|
||
share: "amount" | "percent" | "derived", amount?, percent?, resolved }`);
|
||
when the shares don't add up to what the entry moved, `parties_error`
|
||
says why and confirming fails with code `party_shares`.
|
||
- `books.reverse_entry { entry_id, date, description, confirmed }` → same
|
||
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, `parties` / `parties_error` as in the `post_entry`
|
||
preview, and `no_party_reason` when it is marked as having no
|
||
counterparty. `total` (its debits) is null, with `total_error` saying
|
||
why, for a draft an earlier version journaled whose lines exact
|
||
arithmetic can't sum; a draft line on an account the chart doesn't have
|
||
(older books hold such drafts) shows an empty code and "(no account N)".
|
||
- `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`
|
||
(the trial balance as of the date, default today).
|
||
- `books.balance_sheet { as_of? }` → totals (`assets`, `liabilities`,
|
||
`equity`, `current_earnings`, `balanced`) plus `asset_accounts`,
|
||
`liability_accounts`, `equity_accounts` (`{code, name, amount}`), as of
|
||
the date (default today).
|
||
- `books.profit_and_loss { start?, end?, class? }` → totals plus
|
||
`revenue_accounts` and `expense_accounts` for the range. `end` defaults to
|
||
today, `start` to the first posted entry. Closing entries are excluded, so
|
||
a closed period still shows what it earned.
|
||
- `books.cash_flow { start?, end?, cash_accounts?, financing_accounts?, investing_accounts? }` (each defaults to `[books]` in wanderbooks.toml; cash is required one way or the other)
|
||
→ indirect-method cash flow statement. `cash_accounts` are the reconciling
|
||
accounts (required); the three sections always sum to their net change.
|
||
Disposal gains/losses move from operating to investing automatically.
|
||
- `books.digest { since: "2026-10-01" }`
|
||
→ `{ entries_posted: [...], totals_by_account: [...], stale_rules: [...] }`.
|
||
The human review surface: "what did the AI do with my books lately." The
|
||
digest is the answer to "trust, but verify." `stale_rules` lists legal
|
||
amounts wanderbooks may no longer know as of today (`{ name,
|
||
known_through, catalog, source, why }`): research the current value or
|
||
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 }`): 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
|
||
|
||
Straight-line **book** depreciation only; tax depreciation stays with the
|
||
CPA. Partial first/final months prorated by days; the final month trues up
|
||
so the lifetime total is exactly (cost − salvage).
|
||
|
||
- `books.register_asset { description, purchase_date, cost, salvage_value?,
|
||
useful_life_months, cost_account, accum_depr_account, expense_account,
|
||
tag?, serial? }` → `{ asset_id, tag }`. Accounts resolved by id or code.
|
||
Without `tag`, the next free `PREFIX-0001` (`[books] asset_tag_prefix`,
|
||
else the company name's initials without LLC/Inc; "FA" when none).
|
||
- `books.label_asset { asset, tag?, serial?, clear_serial? }` → `{ asset_id,
|
||
tag, serial }`: set or change the tag (unique ignoring case, never reused
|
||
by another asset even after a retag or disposal) or the manufacturer's
|
||
serial. `asset` is the id or current tag. Journaled as `asset.tagged`,
|
||
`asset.serial_set`.
|
||
- `books.asset_labels { assets?, skip? }` → `{ file_name, content_base64,
|
||
tags }`: an Avery 5160/8160 label sheet (PDF): company, tag, QR code of
|
||
the tag, description, serial. Default: every active tagged asset; `skip`
|
||
reuses a partly used sheet.
|
||
- `books.asset_schedule { asset_id | tag }`
|
||
→ `{ status, schedule: [{ period, amount, accumulated, book_value }] }`.
|
||
- `books.list_assets {}` → assets with status and current book values.
|
||
- `books.run_depreciation { period: "YYYY-MM", asset_id? }`
|
||
→ 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, 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. `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
|
||
general ledger's balances of the assets' cost and accumulated
|
||
depreciation accounts, with `unposted_depreciation` and
|
||
`unregistered_cost` (zero when register and books agree;
|
||
`ledger_core::fixed_asset_register`).
|
||
- `books.show { asset }` opens an asset in the viewer; `page: "assets"`
|
||
and `report: "fixed_asset_register"` open the register.
|
||
|
||
## Tools — schedules and recurring entries
|
||
|
||
Amortization spreads a prepaid expense (asset → expense) or deferred
|
||
revenue (liability → revenue) evenly over the months it covers, posting on
|
||
each month end, to the cent (the last month absorbs rounding). Like
|
||
depreciation, the schedule is the evidence: its postings are exempt from
|
||
the receipt policy (the original payment carries the receipt). Recurring
|
||
entries never post: they generate drafts, so every real receipt still goes
|
||
through the policy.
|
||
|
||
- `books.create_schedule { kind: "prepaid_expense" | "deferred_revenue",
|
||
description, total, start, months, balance_account, pl_account }` →
|
||
`{ schedule_id, ..., total, remaining, months: [{ date, amount, posted }],
|
||
plan_error }`. Prepaid needs an asset and an expense account; deferred
|
||
revenue a liability and a revenue account. `plan_error` is null, except
|
||
for a schedule an earlier version accepted whose months run past the
|
||
last date the books can compute: then it says why, `remaining` is null
|
||
and `months` empty, and `books.run_schedules` refuses until it is dealt
|
||
with.
|
||
- `books.list_schedules {}` → `{ schedules: [...] }`, same shape.
|
||
- `books.run_schedules { through?, confirmed? }` → `{ status: "preview" |
|
||
"posted", postings: [{ schedule_id, date, description, amount,
|
||
entry_id? }] }`: every unposted month ending on or before `through`
|
||
(default today). Idempotent; closed periods are refused.
|
||
- `books.create_recurring { description, frequency: "weekly" | "monthly" |
|
||
"quarterly" | "yearly", start, end?, lines: [{ account, side, amount }] }`
|
||
→ `{ recurring_id, ..., generated: [] }`. Lines must balance. Monthly and
|
||
longer occurrences are computed from the start date each time, so a
|
||
month-end start stays on month ends (Jan 31, Feb 28, Mar 31).
|
||
- `books.list_recurring {}` → `{ recurring: [...] }`.
|
||
- `books.run_recurring { through? }` → `{ drafts: [{ recurring_id, date,
|
||
entry_id }] }`: one draft per occurrence on or before `through`, once.
|
||
|
||
## Tools — the viewer
|
||
|
||
- `books.show { entry_id | account | report | attachment_id | asset |
|
||
party | reconciliation | search | page, start?, end? }` → `{ shown,
|
||
note }`. Asks the human's viewer (`ledger-gui`) to open an entry, an
|
||
account register or a report (for start/end dates if given), a stored
|
||
file, an asset, a party (by id or name, resolved as in "Tools —
|
||
parties"), a reconciliation (by id: its account's page, with its
|
||
statements and reconciliations; viewer target `{show: "reconciliation",
|
||
id}`), a transactions search, or a page (`parties` among them). Exactly
|
||
one target; entries, accounts, attachments, assets, parties, and
|
||
reconciliations are checked to exist. Never touches the books: the request is a small JSON file in
|
||
the user's runtime directory (`$XDG_RUNTIME_DIR/wanderbooks/viewer-<hash
|
||
of the data dir>.json`), written atomically, watched by the viewer. A
|
||
viewer started within two minutes of a request opens it; older requests
|
||
are ignored.
|
||
|
||
## Tools — integrity
|
||
|
||
- `books.verify { blobs? }` → `{ status: "ok" | "healed", events, head,
|
||
healed?, blobs_verified?, checked }`. Verifies the journal's hash chain,
|
||
replays it against the live ledger, and compares every table of the
|
||
SQLite database row for row against a fresh projection of the journal.
|
||
A drifted database is rebuilt from the journal (the old file kept as
|
||
`wanderbooks.mismatch.<time>`). With `blobs` (default true), re-hashes
|
||
every stored file. The server runs the same check, and heals the same
|
||
way, every time it starts.
|
||
|
||
## Tools — payouts
|
||
|
||
- `books.record_payout { date, description, deposit_account, deposit,
|
||
revenue: [{ account, amount, class? }], fees?: [{ account, amount,
|
||
class? }], adjustments?: [{ account, side, amount, class? }] }` →
|
||
`{ entry_id, status: "draft", gross, fees, adjustments, deposit, lines,
|
||
next }`. Builds the grossed-up entry — Dr deposit (net; Cr when negative),
|
||
Dr fees, Dr/Cr adjustments (a debit reduces the deposit: refunds,
|
||
chargebacks, reserves held, tax withheld), Cr revenue (gross; revenue or
|
||
liability accounts, e.g. deferred revenue or sales tax collected) — and
|
||
refuses unless gross − fees ± adjustments equals the deposit to the cent,
|
||
naming the difference. A draft: attach the payout statement, preview,
|
||
post. Platform-agnostic; format-specific statement parsers can feed it.
|
||
|
||
## Tools — 1099 tracking
|
||
|
||
Payments are counted as cash: credits minus debits to asset accounts on
|
||
entries tagged with the vendor. Card payments move no asset (the card
|
||
processor's 1099-K covers them), so they drop out; reversals of tagged
|
||
entries carry the tag and net out. Thresholds: $600 for payments through
|
||
2025, $2,000 from 2026 (indexed for inflation after 2026); always
|
||
overridable, always for the CPA to confirm. The books keep whether a W-9
|
||
is on file and the TIN's last four; full TINs live only in the TIN vault
|
||
("Tools — the TIN vault").
|
||
|
||
- `books.list_vendors {}` → `{ vendors: [{ id, name, form_1099?,
|
||
tin_on_file, party_id }] }`.
|
||
- `books.set_vendor_1099 { vendor, form_1099: "nec" | "misc" | "none",
|
||
tin_on_file? }` → the vendor. `vendor` is an id or a name.
|
||
- `books.set_entry_vendor { entry_id, vendor? }` → the entry (as
|
||
`books.get_entry`, which now shows `vendor`). Omitting `vendor` clears
|
||
the tag. Metadata only: posted entries may be tagged. `books.draft_entry`
|
||
takes `vendor` too; bills and their payments are tagged automatically.
|
||
`books.set_entry_party` tags the vendor too when the party has a vendor
|
||
record, so tagging by party counts for 1099s the same way. On an entry
|
||
naming several parties the parties are `books.set_entry_party`'s:
|
||
tagging the vendor of a party already named keeps every share as it
|
||
is; tagging a vendor whose party isn't named, or clearing the tag while
|
||
its party is named, is refused (drop or add the party with `parties`
|
||
instead). An entry and its reversal always carry the same tags.
|
||
- `books.vendor_1099 { year, threshold?, include_tins?, save_csv_to? }` →
|
||
`{ year, threshold, vendors: [{ vendor_id, name, form_1099?, tin_on_file,
|
||
paid, reportable, party_id?, tin_last4?, tin?, tin_type?, tin_note? }],
|
||
missing_w9, notes, warning?, saved_csv? }`. For a year whose threshold
|
||
wanderbooks doesn't know (after 2026 until the indexed amount is added)
|
||
it refuses with what to do; pass `threshold` explicitly once the figure
|
||
is confirmed. On an entry naming several parties, `paid` counts only
|
||
the vendor's resolved share of the payment (rules.md B-19); an entry
|
||
whose shares don't resolve counts in full and `notes` names it.
|
||
`tin_last4` comes from the party's W-9 facts. With
|
||
`include_tins: true` (default false) each line carries `tin`, the full
|
||
TIN formatted as the IRS writes it (123-45-6789; 12-3456789 for an
|
||
EIN), and `tin_type`, read from the vault, or `tin_note` saying why not
|
||
(not in the vault, the vault locked, a record whose last four or hash
|
||
disagree with the books); `warning` then says the output holds full
|
||
TINs, for the CPA only. This is the only tool output that ever holds a
|
||
full TIN. `save_csv_to` also writes the worksheet as CSV (TINs only with
|
||
`include_tins`) to a new file outside the books dir, mode 0600.
|
||
|
||
## Tools — parties
|
||
|
||
The directory of everyone the company deals with (`ledger_core::parties`;
|
||
design in `docs/parties-and-attachments.md`): vendors, contractors,
|
||
landlords, customers, payout platforms, tax agencies, banks and card
|
||
issuers, members, employees. Vendor and customer *records* (bills,
|
||
invoices, aging, 1099 marks) stay where they are; each links to exactly one
|
||
party. `books.add_vendor` and `books.add_customer` link their record to the
|
||
party of the same name (ignoring case and surrounding space, not archived)
|
||
that has no such record yet, or make a new party.
|
||
|
||
**References.** A `party` argument is an id or a name: the name ignoring
|
||
case and surrounding space, among parties not archived (an archived party
|
||
goes by id). A number that is one party's id and another party's name is
|
||
refused, never guessed.
|
||
|
||
**Roles:** `vendor`, `contractor`, `customer`, `employee`, `member`,
|
||
`payout_platform`, `tax_agency`, `government_agency` (regulators and
|
||
registries that don't tax: a division of corporations, a licensing board),
|
||
`financial_institution`, `landlord`.
|
||
Vendor and customer follow the records and are never set by hand.
|
||
|
||
**Rules the engine enforces** (each refusal changes nothing, the journal
|
||
byte for byte): no party field holds a full TIN (a run of nine digits,
|
||
single spaces or hyphens allowed between them); a W-9's TIN is its last
|
||
four digits only; W-9 facts need a 1099 role (contractor or landlord), W-4
|
||
facts the employee role; names are unique among parties not archived;
|
||
texts are at most 500 characters, no control characters.
|
||
|
||
- `books.add_party { name, roles? }` → the party (below). Journaled as
|
||
`party.added`.
|
||
- `books.update_party { party, name?, display_name?, website?, address?,
|
||
notes?, archived? }` → the party. Give at least one field; `""` clears
|
||
`display_name`, `website`, `address`, or `notes`. Every field is checked
|
||
(in order, on a rehearsal) before any is written; each is journaled as
|
||
its own `party.updated`, in the order name, display_name, website,
|
||
address, notes, archived. Un-archiving is refused while another party
|
||
has the name.
|
||
- `books.set_party_roles { party, add?, remove? }` → the party. Adds apply
|
||
before removes (so a contractor's W-9 survives a swap to landlord); roles
|
||
it already has, or lacks, are skipped; each change is journaled as
|
||
`party.role_set`. Removing the last 1099 role with W-9 facts on file, or
|
||
employee with W-4 facts, is refused: clear the facts first.
|
||
- `books.set_w9 { party, received_on, entity_type, exempt_payee_code?,
|
||
fatca_code?, tin_last4, kept_at, tin?, tin_type?, w9_file_path? }` or
|
||
`{ party, clear: true }` → the party. Journaled as `party.w9_set`. A
|
||
party with a vendor record also gets the record's `tin_on_file` set to
|
||
match (journaled as `vendor.1099_set`), so `books.vendor_1099`'s
|
||
`missing_w9` follows. `tin`, `tin_type`, and `w9_file_path` put the full
|
||
TIN and the signed form in the TIN vault (below); with them `tin_last4`
|
||
comes from the TIN (a given one must agree) and `kept_at` defaults to
|
||
`"wanderbooks vault"`.
|
||
- `books.set_w4 { party, received_on, state, kept_at }` or `{ party, clear:
|
||
true }` → the party. Journaled as `party.w4_set`.
|
||
- `books.set_entry_party { entry_id, party?, parties?, no_party_reason? }`
|
||
→ the entry (as `books.get_entry`). `party` null or omitted clears the
|
||
tag. A party with a vendor record also tags the entry's vendor; one
|
||
without, or clearing, clears the vendor tag. Metadata only; posted
|
||
entries may be tagged. Tagging an entry that was reversed, or a
|
||
reversal, tags the other too (the whole reversal chain), so each
|
||
party's part of the pair still nets to zero. Journaled as
|
||
`entry.party_set`.
|
||
`no_party_reason` (with no party) marks an entry as having no
|
||
counterparty (`entry.no_party`).
|
||
- **Several parties:** `parties: [{ party, amount? | percent? }]`, in
|
||
place of `party`, names every counterparty with its share; `[]` clears.
|
||
Journaled as `entry.parties_set { entry_id, parties: [{ party_id, share
|
||
}] }`, `share` being `{"amount": "30.00"}`, `{"percent": "60"}`, or
|
||
`"derived"`. Each share, of what the entry moved (its `entry_flow` net;
|
||
for an entry moving no money, like property contributed, its total):
|
||
- `amount`: exactly that, positive, in cents; the amounts must sum to
|
||
what it moved.
|
||
- `percent`: that percent; the percents must sum to exactly 100. Each
|
||
part is rounded down to the cent and the leftover cents go to the
|
||
first party listed.
|
||
- neither (derived): a sole party gets all of it. Members named
|
||
together (parties with the member role named in `[[company.members]]`)
|
||
get what their own capital and distributions lines on the entry say,
|
||
when it has any (these must account for all it moved); else their
|
||
`[[company.members]]` shares, rescaled to the members named, rounded as
|
||
percents are. A derived share for a non-member among several is
|
||
refused.
|
||
Every party gives the same kind of share. Refused, journaling nothing: a
|
||
party named twice, an unknown party, mixed kinds, percents not summing
|
||
to 100, an amount finer than a cent, two parties with vendor records (a
|
||
1099 counts a payment toward one vendor; the one vendor party's record
|
||
is tagged and counts the whole payment), and, once the entry has
|
||
balanced lines, shares that don't resolve. A draft's shares are checked
|
||
against its lines again at `post_entry`. Under `[books] parties =
|
||
"require"`, any party (or the no-party mark) satisfies the check.
|
||
Reversals carry every share. There is no "joint" party: a joint member
|
||
transaction names both members and lets the shares derive.
|
||
- `books.parties { role?, include_archived?, year? }` → `{ year, parties:
|
||
[{ party_id, name, label, roles, vendor_id?, customer_id?, w9_on_file,
|
||
tin_last4?, w4_on_file, archived, paid, received }] }`, by id.
|
||
- `books.party { party, year? }` → `{ party, year, totals, entries,
|
||
invoices, bills, payroll }`: the facts; the calendar year's totals
|
||
(default this year); every entry it is on (drafts included, newest
|
||
first); its invoices and bills (`{ id, date, due_date, total, paid,
|
||
balance, status, entry_id }`); and the payroll lines naming it
|
||
(`{ run_id, date, entry_id, gross_pay, net_pay }`).
|
||
|
||
The party (every mutating party tool returns it): `{ party_id, name,
|
||
label, display_name?, roles, website?, address?, notes?, archived,
|
||
vendor_id?, customer_id?, w9?: { received_on, entity_type,
|
||
exempt_payee_code?, fatca_code?, tin_last4, kept_at, in_vault }, w4?: { received_on,
|
||
state, kept_at } }`. `label` is the display name, else the name; `roles`
|
||
include vendor and customer when it has those records.
|
||
|
||
**Totals** (`books.party` `totals`; `paid` and `received` in
|
||
`books.parties`), from the journal:
|
||
|
||
- *The party's entries:* tagged with the party; tagged with its vendor
|
||
record (bills and their payments); its customer record's invoices and
|
||
the payments received on them; and reversals of any of these. Only
|
||
posted entries dated in the year count.
|
||
- *Money accounts:* the `[books] cash_accounts` (every asset account but
|
||
receivables when none are set), and every liability account except the
|
||
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
|
||
(`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).
|
||
- `amount_1099` (vendor parties only): what `books.vendor_1099` counts as
|
||
paid to its vendor record in the year (asset accounts only, so card
|
||
payments drop out); `form_1099` is the record's mark.
|
||
- `gross_pay` / `net_pay`: payroll lines naming it, in the year.
|
||
|
||
## Tools — the TIN vault
|
||
|
||
Full TINs (SSN, EIN, ITIN) and signed W-9 forms, kept so what the CPA
|
||
gets is complete (every member's TIN for their K-1, every payee's for
|
||
their 1099), never in the books (`ledger-vault`; design in
|
||
`docs/parties-and-attachments.md` "The TIN vault"; B-26). DECISION
|
||
(Stephen, 2026-10-15): the vault holds everyone's TIN, members included,
|
||
and the CPA package carries them.
|
||
|
||
- **Where:** `vault/` in the books dir (the parent of the data dir, beside
|
||
`wanderbooks.toml`; git-ignored), one file per record, each encrypted
|
||
(XChaCha20-Poly1305) and bound to its party. **Key:** the file named by
|
||
`[vault] key_file` in `wanderbooks.toml`, else
|
||
`~/.config/wanderbooks/vault.key` (`$XDG_CONFIG_HOME`), mode 0600,
|
||
created the first time a TIN or form is stored, never when reading. A
|
||
key file inside the books dir is refused, and so is storing into books
|
||
kept in git (a `.git` beside them) whose `.gitignore` doesn't list
|
||
`vault/`. Without the key the vault is
|
||
locked: the books open and verify; the worksheet says the TINs are
|
||
missing.
|
||
- **Never out:** no tool output, error, or log holds more than a TIN's
|
||
last four, except `books.vendor_1099 { include_tins: true }`; the CPA
|
||
package carries them only in its zip, written outside the books dir.
|
||
The tool arguments that take a TIN never echo it, even when refused.
|
||
- **TIN facts:** any party (any role) may have `tin` facts:
|
||
`{ tin_type: "ssn" | "ein" | "itin", last4, vault_record?, purged? }`,
|
||
journaled as `party.tin_set {id, facts|null}` and shown in every party
|
||
output as `tin: { tin_type, last4, in_vault, purged? }`. They never
|
||
disagree with the party's W-9 facts: both on file means the same last
|
||
four (the engine refuses otherwise).
|
||
- `books.set_tin { party, tin, tin_type }` → the party. Validates the TIN
|
||
as `books.set_w9` does, checks the facts on a copy of the ledger, writes
|
||
the TIN to the vault (the same key and git-ignore checks), and journals
|
||
`party.tin_set` with the record's hash. Any role. A party whose W-9
|
||
facts name another last four is refused (a payee's new TIN comes with
|
||
its W-9: `books.set_w9`); one whose W-9 facts agree gets their
|
||
`vault_record` updated too (`party.w9_set`).
|
||
- `books.set_w9 { ..., tin, tin_type, w9_file_path? }` (above): validates
|
||
the TIN against its type (nine digits, hyphens allowed; an ITIN starts
|
||
with 9 and has an IRS group in digits 4-5; an SSN or EIN doesn't start
|
||
with 9), checks the facts on a copy of the ledger, then writes the vault
|
||
and journals the facts with `vault_record`, the SHA-256 of the TIN
|
||
record (of the form's record when only a form is given). With a TIN it
|
||
also journals the party's TIN facts (`party.tin_set`, as
|
||
`books.set_tin`); a TIN with a new last four clears the old TIN facts
|
||
first (`party.tin_set` null), so the two never disagree. The form (PDF,
|
||
PNG, JPEG; 25 MB at most) goes to the vault only, never the attachment
|
||
store. `w9_file_path` is read exactly as `books.import_local_file` reads
|
||
a path (the import roots, the sealed places, no links followed out of
|
||
them), and its type is judged by the file the path really leads to.
|
||
Facts given later without a TIN keep the vault record when their
|
||
last four agree with the vault's TIN, and are refused when they don't
|
||
(pass the new TIN, or purge). Clearing the facts (`clear: true`) is
|
||
refused while the vault holds the party's TIN or form: purge first.
|
||
- `books.get_w9 { party, save_to }` → `{ party_id, path, file_name,
|
||
size_bytes, note }`. Writes the party's form from the vault to `save_to`:
|
||
a new file (an existing one is refused), in a folder that exists,
|
||
outside the books dir and outside the import roots, mode 0600. Returns
|
||
the path, never the contents. Journals nothing. The same holds for
|
||
`books.vendor_1099`'s `save_csv_to` with `include_tins` and
|
||
`books.export_cpa_package`'s `save_to` with TINs: a file with full TINs
|
||
never lands where it could be filed back in. Nor does one go in from
|
||
elsewhere: the server keeps, beside the vault, the SHA-256 of every file
|
||
it read or wrote with full TINs (the W-9 `books.set_w9` reads, recorded
|
||
once the call has passed every check, just before the vault keeps it,
|
||
so a refused call marks no file; each of these outputs; each file inside a CPA package
|
||
that holds a TIN), shared by every session and kept across restarts,
|
||
and every filing tool (`books.import_local_file`, `books.attach_receipt`,
|
||
`books.file_document`) refuses those exact files and any text file
|
||
(CSV, TSV, text, JSON, IIF, XML, HTML, Markdown, by type, name or UTF-8
|
||
or UTF-16 decoding, UTF-16 with a byte-order mark or NUL-interleaved
|
||
without one) holding a full TIN, storing nothing. A text file
|
||
holds one when it has a TIN the vault holds, anywhere, as nine digits,
|
||
3-2-4 joined by hyphens, spaces or dots, or 2-7 joined by a hyphen or a
|
||
space (compared on the server; a vault that can't be opened leaves the
|
||
next test alone), or a stand-alone number printed as an SSN or EIN is
|
||
(123-45-6789, 12-3456789; no digit, and no hyphen or dot followed by a
|
||
digit, beside it). Bank exports pass: routing and account numbers,
|
||
long references and dates next to amounts are not TINs (rules.md B-26).
|
||
No probing: a text with more than two TIN-shaped numbers ending in the
|
||
last four of a TIN the books know is refused before the vault is
|
||
opened, in the same words whether or not one is that TIN, so the vault
|
||
is compared against at most two candidates a filing (rules.md B-26).
|
||
An altered copy of a binary file (a W-9 PDF re-saved or re-scanned),
|
||
and a TIN the vault doesn't hold written another way, are not
|
||
detected: P2 and P7 take the path tools and full TINs out of the
|
||
agent's reach.
|
||
- `books.purge_tin { party, reason, confirmed? }` → `{ status: "preview" |
|
||
"purged", party_id, name, tin_in_vault, w9_file_in_vault, tin_last4?,
|
||
journaled, note }`. Without `confirmed`, a preview that changes nothing.
|
||
Confirmed, deletes the party's vault records (needs no key) and, when the
|
||
party has W-9 facts, journals `party.w9_set` with `vault_record` cleared,
|
||
the last four kept, and `kept_at` saying when and why it was purged;
|
||
when it has TIN facts, `party.tin_set` likewise, with `purged` saying
|
||
when and why. Refused when the reason is blank or there is nothing to
|
||
purge.
|
||
|
||
## Tools — attachments
|
||
|
||
A stored file (a receipt or a document) links to any number of subjects;
|
||
a subject is `{ kind, id? }`: `entry` (id), `account` (code or id),
|
||
`asset` (tag or id), `party` (id or name), `invoice`, `bill`, `period`,
|
||
`payroll_run`, `schedule`, `reconciliation` (ids), or `company` (no id).
|
||
Every subject is checked to exist. In outputs a subject is `{ kind, id?,
|
||
label }` (`id` absent for the company; for an account, its id, with the
|
||
code in `label`). A file linked to an entry is one of the entry's receipts:
|
||
the receipt policy counts it.
|
||
|
||
- `books.link_attachment { attachment_id, subject }` → `{ attachment_id,
|
||
links }`. Journaled as `attachment.linked`. Refused for an unknown or
|
||
withdrawn attachment, a subject that doesn't exist, or one it is linked
|
||
to already (or filed on).
|
||
- `books.unlink_attachment { attachment_id, subject, reason }` → `{
|
||
attachment_id, links }`. Journaled as `attachment.unlinked` with the
|
||
reason; the file stays filed. Refused when the reason is blank, it isn't
|
||
linked there, it is filed on that entry (`books.unlink_receipt`), it is
|
||
a posted entry's last receipt under the receipt policy
|
||
(`receipt_required`), or it is the statement of a reconciliation not
|
||
voided.
|
||
- `books.attachments { subject }` → `{ subject, attachments: [...] }`: the
|
||
files on a subject (an entry's: filed on it and linked to it), not
|
||
withdrawn, each as in `books.list_receipts`.
|
||
- `link_to: [subject]` on `books.attach_receipt` and `books.file_document`
|
||
links the new file in the same call, each link its own
|
||
`attachment.linked`. All or nothing: every subject is checked (exists,
|
||
named once, not the entry it is filed on) before anything is filed.
|
||
|
||
## Tools — receipts & documents
|
||
|
||
- `books.attach_receipt { entry_id?, file_name, mime_type, content_base64,
|
||
description?, link_to? }` → `{ attachment_id, sha256, deduplicated, links
|
||
}`. `entry_id` is optional: a receipt can be filed before its entry
|
||
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; 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
|
||
receipt policy: an entry that requires a receipt cannot be left with none
|
||
(`receipt_required` error, remedy `books.attach_receipt`), so replacing a
|
||
receipt is attach-new, then unlink-old.
|
||
- `books.withdraw_attachment { attachment_id, reason }` → `{ ok }`. Retires
|
||
an unlinked receipt or a filed document: hidden from listings, never
|
||
linkable again. Journaled as `attachment.withdrawn`; the bytes stay in the
|
||
blob store. Nothing in the evidence trail is ever erased — receipts change
|
||
by new events, the same way entries change by reversals.
|
||
- `books.list_receipts { entry_id?, include_withdrawn? }` → metadata list
|
||
(no bytes), each with `withdrawn` / `withdrawn_reason` and `links` (every
|
||
subject it is linked to, its filing entry first). Withdrawn attachments
|
||
are omitted unless `include_withdrawn` is true. `entry_id` lists the
|
||
files filed on the entry and linked to it.
|
||
- `books.get_receipt { attachment_id }` → `{ metadata, description?,
|
||
links, content_base64 }`.
|
||
- `books.file_document { category: "formation" | "legal" | "tax" |
|
||
"accounting" | "statement" | "contract" | "photo" | "warranty" |
|
||
"import_source" | "other", file_name, mime_type, content_base64,
|
||
description?, link_to? }` → `{ attachment_id, sha256, deduplicated, links
|
||
}`. The Drive-folder replacement (today's 01 Formation / 02 Legal / 03
|
||
Tax / 04 Accounting). Never a W-9: it shows the full TIN (`books.set_w9
|
||
w9_file_path`).
|
||
|
||
## Receipt enforcement
|
||
|
||
Prose tells the model to attach receipts; the policy makes it true. The
|
||
engine stays policy-free (it knows nothing of receipts) — enforcement lives
|
||
in the MCP layer, which reads the `attachments` table before confirming a
|
||
posting.
|
||
|
||
- **Policy config** (server config file, not per-call): `require_receipts_above`
|
||
(default `"25.00"` in base currency), plus `receipt_required_accounts`
|
||
(account codes that always require receipts, e.g. meals and travel) and
|
||
`receipt_exempt_accounts` (e.g. automated bank fees). Human-edited and
|
||
versioned with the deployment.
|
||
- **Preview.** Every `post_entry` preview includes the receipt checklist
|
||
(`receipts: [...]`, `receipt_required: bool`, `receipt_missing: bool`,
|
||
`split_detected: [...]`), so the human sees it before confirming. A
|
||
`reverse_entry` preview carries none: a reversal needs no receipt (the
|
||
original carries the evidence).
|
||
- **Hard error.** Confirming a posting that violates the policy fails with a
|
||
structured, machine-readable error (JSON in the tool-error string):
|
||
`{ "code": "receipt_required", "message": "…", "entry_id": 5,
|
||
"debits": "100.00", "policy": "strictly greater than $25.00",
|
||
"remedy_tool": "books.attach_receipt",
|
||
"remedy_hint": "…" }`. Clients match on `code`, not prose. The agent's job
|
||
then is to get the receipt attached (ask the human for it) and re-confirm
|
||
— not to shrink the amount or split the entry to dodge the threshold. The
|
||
tool description says this explicitly; gaming the policy is a correctness
|
||
violation, and the append-only audit log would show it anyway.
|
||
- **Anti-splitting.** Posting an entry triggers a split check: posted
|
||
entries on the same date with the same normalized description and debit
|
||
accounts are grouped, and if the group total exceeds the threshold while
|
||
the entry alone does not, a receipt is required. The preview surfaces
|
||
`split_detected: [...]`, and the error names the group. Same-day,
|
||
same-description, same-accounts is the signature; recurring spending
|
||
spread across days is not flagged.
|
||
- **Below the threshold**, receipts are still expected per the operating
|
||
contract ("a material entry without its source document is incomplete")
|
||
but not hard-required — the preview checklist keeps them visible.
|
||
|
||
## Tools — export
|
||
|
||
- `books.export { format, report, start?, end?, as_of?, cash_accounts?,
|
||
financing_accounts?, investing_accounts? }` → `{ content, content_encoding? }`.
|
||
- Reports: `profit_and_loss`, `balance_sheet`, `cash_flow` (needs
|
||
`cash_accounts`), `trial_balance`, `members_equity` (statement of
|
||
changes in members' equity; members from `wanderbooks.toml`),
|
||
`general_ledger` (by account: beginning balance, postings with running
|
||
balance and split, ending balance), `general_journal` (alias
|
||
`journal`: every entry, line by line, in date order).
|
||
- Formats: `pdf` and `xlsx` (base64) for the five statements; `xlsx`,
|
||
`csv`, `json` for every report; `iif` for the journal (QuickBooks
|
||
Desktop).
|
||
- Dates: period reports take `start` (default: first entry) and `end`
|
||
(default: today, local time); `balance_sheet` and `trial_balance`
|
||
take `as_of`.
|
||
- Presentation (PDF and XLSX alike, QuickBooks-style): centered company,
|
||
title, and period; a column-header row; "$" on subtotals and totals;
|
||
negatives in parentheses with decimal points aligned; a rule above every
|
||
sum and a double rule under statement totals; a footer with the
|
||
accounting basis, preparation date, and journal seq. Multi-member LLCs
|
||
get "Members' equity" with members named; the balance sheet separates
|
||
retained earnings from the fiscal year's net income (loss).
|
||
|
||
## Tools — chart templates
|
||
|
||
- `books.chart_template { entity_type, owners? }` → `{ csv, settings_toml,
|
||
notes }`: a starter chart for a sole proprietorship (incl. single-member
|
||
LLC), partnership (incl. multi-member LLC), S corporation, or C
|
||
corporation — the equity and owner-pay accounts that entity needs (owner's
|
||
capital and draws; per-partner capital and distributions plus guaranteed
|
||
payments; officer compensation and shareholder distributions; dividends
|
||
and income tax expense), common income and expense accounts with tax
|
||
categories, and the `wanderbooks.toml` settings (tax form, members, close
|
||
target). A proposal: review with the human, then `books.import_chart_csv`.
|
||
|
||
## Tools — tax categories
|
||
|
||
- `books.tax_categories {}` → `{ form?, categories: [{ key, label, kind,
|
||
default_deductible_percent, note }] }`: where an account can land on the
|
||
income tax return, filtered to the business's return when known
|
||
(`[company] tax_form`, or derived from `tax_classification`). Stable
|
||
semantic keys (`meals`, `guaranteed_payments`, `commissions_fees`, ...),
|
||
never form line numbers, which change by year.
|
||
- `books.renumber_account { account, code }` → `{ id, previous_code,
|
||
code, config_mentions }`: give an account a new code. Only the code
|
||
changes (entries name accounts by id), so balances and reports don't
|
||
move. Refuses a code in use, or an unassigned control role's default
|
||
code for an account of the wrong type. `config_mentions` lists the
|
||
wanderbooks.toml settings still naming the old code (edit them; the
|
||
server reloads the file). Journaled as `account.renumbered`.
|
||
- `books.set_tax_category { account, category?, deductible_percent? }`:
|
||
map a revenue or expense account to a category (null clears),
|
||
optionally overriding its deductible percent. Journaled as
|
||
`account.tax_set`.
|
||
- `books.export { report: "tax_summary" }`: book vs. tax amounts by
|
||
category, the difference where they disagree (meals 50%, penalties 0%),
|
||
and every P&L account still uncategorized. In the CPA package for the
|
||
fiscal year to date.
|
||
- `books.import_chart_csv` accepts an optional fifth `tax_category` column,
|
||
validated before anything is created.
|
||
|
||
## Tools — import
|
||
|
||
- `books.import_bank_csv { csv_text, bank_account_code, default_offset_code,
|
||
rules?, date_order?, source_attachment_id? }` → creates one draft per
|
||
transaction (QBO upload layout). `rules` maps description substrings to
|
||
account codes. `source_attachment_id` (the export as filed, category
|
||
`import_source`) is checked before any draft and linked to the bank
|
||
account (`source_linked`; false when it was already), never to the
|
||
drafts: a bank export is not a receipt.
|
||
- `books.import_chart_csv { csv_text }` → creates accounts from
|
||
`code,name,type,parent_code` CSV. All-or-nothing.
|
||
- `books.import_local_file { path, category: "receipt" | "document", ... }`
|
||
→ files a file from the server's own disk, read only from the **import
|
||
roots**: each `--import-root DIR` given to `ledger-mcp` (repeatable), or
|
||
by default the downloads folder only (`$XDG_DOWNLOAD_DIR`, else
|
||
`~/Downloads`). No root may be `/`, the home folder or a folder holding
|
||
it, a folder holding the books dir, the data dir (where a symlinked one
|
||
really is), the vault or a vault key, or a folder inside the books dir,
|
||
the data dir or the vault (every file there would be refused): a named
|
||
root like that stops
|
||
the server at startup, saying why; a default one is dropped. The default
|
||
is used only when it is a real folder that isn't the home folder (an
|
||
`XDG_DOWNLOAD_DIR` of `$HOME/` falls back to `~/Downloads`, and to no
|
||
root when that doesn't exist; an import then says the server has no
|
||
import folder). The path must be absolute, with no `.` or `..` name,
|
||
and where it leads (every link followed) a regular file strictly inside
|
||
a root, with one hard link. Refusals never name a path and never probe:
|
||
a path that is missing, a dangling link, outside the roots, or with a
|
||
`.` or `..` name (refused before anything is looked up: `a/b/..`
|
||
resolves only when `b` is a folder) gets one refusal, "no
|
||
such file in the server's import folders (by default your downloads
|
||
folder): save it there first". Inside a root, the books' sealed places
|
||
(the data dir, the vault, the key file in force and the default one,
|
||
where each leads, `~/.config/wanderbooks`, and the key files' folders
|
||
unless that folder is the home folder, a root, or holds one) are refused
|
||
as "belongs to the books or the TIN vault"; anything else in the books
|
||
dir is refused with "save a copy of it in the server's import folders
|
||
first". The file is opened by a walk from `/`, one name at a time, each
|
||
folder with `openat(O_DIRECTORY | O_NOFOLLOW)` and the file with
|
||
`O_NOFOLLOW | O_NONBLOCK`, so a folder on the path swapped for a link
|
||
after the check fails the open and a pipe can't hang the server; no step
|
||
may land on a protected place by device and inode (one renamed into a
|
||
root), and the file opened must be the checked one (device, inode, one
|
||
link). It is filed under the name and type of the file the path leads
|
||
to. Files holding full TINs are refused as above
|
||
(`ledger-mcp/src/local_files.rs`, `tin_files.rs`). Deleted in P2
|
||
(uploads replace it).
|
||
|
||
## Resources (read-only)
|
||
|
||
Hand-rolled on `ServerHandler` (rmcp 3.x has no resource macros):
|
||
`list_resources` advertises, `read_resource` renders.
|
||
|
||
- `books://chart`
|
||
- `books://reports/trial-balance`, `books://reports/balance-sheet`,
|
||
`books://reports/profit-and-loss`
|
||
- `books://digest?since=2026-10-01`; bare (or with an empty `since=`),
|
||
this month so far
|
||
- `books://handbook` (the index) and `books://handbook/{section}` (one per
|
||
section, Markdown)
|
||
- `books://receipts/{id}` — receipt metadata + base64 bytes
|
||
|
||
Resources exist so read-only clients (dashboards, the future read-only GUI,
|
||
auditors) never need tool credentials. Tested end-to-end over stdio.
|
||
|
||
## The agent operating contract
|
||
|
||
Any AI client driving these tools follows this contract (it will also live
|
||
in the server's tool descriptions, so the model reads it at use time):
|
||
|
||
1. **Show the preview before confirming any posting.** No silent posts, ever.
|
||
2. **Echo exact numbers back to the human.** Amounts as written, no rounding
|
||
in prose.
|
||
3. **Attach receipts at record time.** A material entry without its source
|
||
document is an incomplete entry. File first, link later if the entry
|
||
isn't ready. Postings strictly above the receipt policy threshold *cannot*
|
||
post without one — the server refuses with `receipt_required`.
|
||
4. **When unsure of categorization, draft and ask.** A draft is cheap; a
|
||
wrong posting is a reversal. Never guess-post.
|
||
5. **The digest is the receipt for your work.** After a batch of bookkeeping,
|
||
offer the digest; the human reviews, you fix.
|
||
6. **Keep the party directory current.** Every counterparty gets a party;
|
||
every entry is tagged with its party; contractors and landlords get W-9
|
||
facts before they are paid, employees W-4 facts before their first
|
||
payroll, and every payroll line names its employee party. Never a full
|
||
TIN or SSN: the engine refuses nine-digit numbers.
|
||
|
||
## Out of scope for this phase
|
||
|
||
Bank feeds, payment processing, payroll, tax filing, multi-user authN/Z, the
|
||
GUI (it consumes the event log in a later phase). Cash flow reporting is
|
||
R-FUTURE-3 and rides the engine roadmap, not the MCP layer.
|
||
|
||
## Tools — periods
|
||
|
||
- `books.create_period { name, start, end }` → `{ period_id }`.
|
||
Dates are YYYY-MM-DD, inclusive.
|
||
- `books.close_period { period_id, confirmed? }` → without confirmation:
|
||
`{ status: "preview", lines: [...] }`: the exact closing entry (the close
|
||
run on a copy of the ledger), posting nothing; with `confirmed: true`:
|
||
posts it and marks the period closed. The entry zeroes revenue and
|
||
expense balances as of the period end; the net goes to `3999` Retained
|
||
Earnings, or, with `[[company.members]]` in `wanderbooks.toml`, to each
|
||
member's capital account by share. Periods close in order. Closed periods
|
||
reject new entries.
|
||
- `books.list_periods {}` → `{ periods: [{ id, name, start, end, status,
|
||
closing_entry }] }`.
|
||
|
||
## Tools — bank reconciliation
|
||
|
||
- `books.mark_cleared { entry_id, line_index, cleared }` → `{ ok }`.
|
||
Only posted lines can be marked; the flag is workflow metadata, not
|
||
accounting data (posted entries stay immutable).
|
||
- `books.record_statement { attachment_id, account, period_start,
|
||
period_end, opening_balance, closing_balance }` → the statement `{
|
||
attachment_id, file_name, account_code, account_name, period_start,
|
||
period_end, opening_balance, closing_balance, reconciliation_id? }`.
|
||
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
|
||
(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,
|
||
cleared_balance, statement_balance, difference, uncleared_lines,
|
||
cleared_lines, problems, reconciliation? }`, each line `{ entry_id,
|
||
line_index, date, description, side, amount }`. `difference` is cleared
|
||
− statement; zero means reconciled. With `statement_attachment` the
|
||
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 (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: [{
|
||
reconciliation_id, account_code, account_name, statement_date,
|
||
statement_balance, statement_attachment, cleared: [{ entry_id,
|
||
line_index }], voided, voided_reason? }] }`, voided ones included.
|
||
- `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
|
||
|
||
- `books.add_customer { name }` → `{ id, party_id }`.
|
||
- `books.add_vendor { name }` → `{ id, party_id }`. Both link the record to
|
||
a party ("Tools — parties").
|
||
- `books.create_invoice { party_id, date, due_date, lines: [{ description,
|
||
amount }] }` → `{ id, total, entry_id }`. Auto-posts Dr `1200` Accounts
|
||
Receivable / Cr `4000` Revenue.
|
||
- `books.create_bill { party_id, date, due_date, lines }` → `{ id, total,
|
||
entry_id }`. Auto-posts Dr `6999` General Expenses / Cr `2000` Accounts
|
||
Payable.
|
||
- `books.receive_payment { document_id, date, amount, cash_account }` →
|
||
`{ entry_id, remaining, status }`. Posts Dr Cash / Cr A/R.
|
||
- `books.pay_bill { document_id, date, amount, cash_account }` →
|
||
`{ entry_id, remaining, status }`. Posts Dr A/P / Cr Cash.
|
||
- `books.ar_aging { as_of }` → `{ rows: [{ name, current, days_30, days_60,
|
||
days_90, days_90_plus, total }] }`.
|
||
- `books.ap_aging { as_of }` → same, per vendor.
|
||
|
||
## Tools — foreign exchange
|
||
|
||
- `books.add_line` gains optional `foreign_currency`, `foreign_amount`,
|
||
`fx_rate`. When set, the base amount is `foreign_amount * fx_rate`; the
|
||
foreign original is stored on the line for reference. Reports work in
|
||
base currency.
|
||
- `books.set_fx_rate { currency, date, rate }` → `{ ok }`. Records a
|
||
historical rate for reference; lines store their own rate at entry time.
|
||
|
||
## Tools — payroll (accounting only)
|
||
|
||
No tax tables: the caller supplies every number; the engine cuts correct
|
||
entries.
|
||
|
||
- `books.run_payroll { date, cash_account, lines: [{ employee?,
|
||
employee_name?, gross_pay, federal_withholding, state_withholding, ss_employee,
|
||
medicare_employee, ss_employer, medicare_employer, other_deductions }] }`
|
||
→ `{ run_id, entry_id, total_gross, total_net }`. One balanced entry:
|
||
Dr 6100 Salary & Wage Expense (gross), Dr 6110 Payroll Tax Expense
|
||
(employer SS + Medicare), Cr 2110–2150 withholding payables, Cr cash
|
||
(net). Auto-creates the 61xx/21xx accounts. Respects closed periods.
|
||
`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. 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 }] }`.
|
||
|
||
## Tools — classes (divisional tracking)
|
||
|
||
- `books.add_class { name }` → `{ id }`. E.g. "Rondo", "Coach vs Coach".
|
||
- `books.list_classes` → `{ classes: [{ id, name }] }`.
|
||
- `books.add_line` gains optional `class` (name or id): tags the new line.
|
||
Shared costs are split explicitly across lines at entry time.
|
||
- `books.profit_and_loss` gains optional `class` filter.
|
||
- `books.profit_and_loss_by_class { start, end, class? }` → per-class revenue,
|
||
expenses, net income, plus an unclassified row (only that class's row
|
||
when `class` is given; an unknown or ambiguous class is refused). Reversals carry their
|
||
class so divisional P&L nets correctly.
|
||
|
||
## Tools — CPA handoff
|
||
|
||
- `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
|
||
new `.zip` path, or an existing folder it goes in as
|
||
`cpa-package-<as_of>.zip`; outside the books dir, `exports/` included;
|
||
mode 0600) and the output is its path, never its bytes; without
|
||
`save_to` it is refused. A locked vault or a missing TIN never fails the
|
||
package: the row says why ("TIN not in the vault", "the TIN vault is
|
||
locked", a record that disagrees with the books), the README lists it,
|
||
and `tin_gaps` names each member and each reportable payee without one.
|
||
`include_tins: false`: last fours only, returned as `content_base64`
|
||
unless `save_to` is given. **K-1 worksheet** (`k1-members.csv`, with
|
||
`[[company.members]]`): one row per member, in order: `member,
|
||
party_id, share, capital_account, tin, tin_type, tin_note` (without
|
||
TINs: `tin_last4, tin_type`), then `beginning_capital,
|
||
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 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
|
||
member role, as the viewer's capital statement finds it; with none, the
|
||
member's TIN is a gap.
|
||
**1099 worksheet** with TINs adds `party_id, tin_last4, tin, tin_type,
|
||
tin_note`. A zip with the full set, each
|
||
statement as PDF and XLSX: `profit-and-loss`, `balance-sheet`,
|
||
`cash-flow` (reconciled to `cash_accounts`), `members-equity`
|
||
(multi-member LLCs), `trial-balance`; plus `general-ledger.xlsx` and
|
||
`general-journal.xlsx`. Period reports cover the fiscal year to date.
|
||
What the CPA needs; they never log into the system.
|