Compare commits

..
185 Commits
Author SHA1 Message Date
swaitsandClaude Opus 5.5 2a00f4ef33 fix(gui): recent activity rows clip before the timestamp column; SHOT_HEIGHT for tall screenshots
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-12 18:37:18 -06:00
swaitsandClaude Opus 5.5 54d2775a33 fix(gui): clip long row text instead of drawing over amounts; drafts aren't payments
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-12 17:32:50 -07:00
swaitsandClaude Opus 5.5 01d41c0daf fix: tell the agent the receipt policy the books actually use
The server's instructions hardcoded '$25.00'; they now state the
configured threshold, and a zero threshold reads 'every entry needs at
least one receipt'.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-12 17:30:35 -07:00
swaitsandClaude Opus 5.5 005a96055a site: wanderbooks.ai — one-liner, canonical and social tags, Netlify config and deploy recipes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-12 17:24:10 -07:00
swaitsandClaude Opus 5.5 45784266b5 feat: the CPA package carries the evidence; the site answers 'why not QuickBooks?'
CPA package: every receipt behind the period's entries with an index,
company documents, the journal as IIF, the 1099 worksheet, and a README;
include_receipts=false for a light package. The scaffolded AGENTS.md
covers receipts from email, switching from QuickBooks/Xero/spreadsheets,
and working with the CPA. GUI: a books copy without a server database
reads 'Read-only copy'. Site: example prompts, QuickBooks comparison,
what it does and doesn't do yet, the CPA section, FAQ.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-12 17:14:42 -07:00
swaitsandClaude Opus 5.5 9694d2a89a feat: end-to-end integrity checks with self-healing; website; installer and Windows fixes
Store::verify compares the whole SQLite projection, every table row for
row, against a fresh projection of the journal; verify_and_heal rebuilds a
drifted database (keeping the old file) at every server start and via the
new books.verify tool, which also re-hashes every blob. The GUI checks the
server's database read-only and every stored file, and shows the result.
rebuild() also drops stale WAL/SHM files.

site/: the wanderbooks website (just site, just publish). install.sh:
OS detection (Windows points to WSL2), C compiler and git checks,
WANDERBOOKS_REPO. Windows: no directory fsync off Unix; .exe-aware lookup.
Repo justfile: check, build, install, demo, site.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-12 17:05:05 -07:00
swaitsandClaude Opus 5.5 714867ed58 feat: one-command onboarding — wanderbooks new, install.sh, justfile
wanderbooks new DIR installs the server and GUI if missing, writes the
books dir (AGENTS.md + CLAUDE.md, wanderbooks.toml, justfile, .mcp.json,
.claude/settings.json approving the server), commits, and starts claude
onboarding. install.sh: Rust + cargo install + wanderbooks new in one
line. The tax summary labels its net when accounts are uncategorized.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-12 16:29:29 -07:00
swaitsandClaude Opus 5.5 105fd52694 feat: ledger-gui — a read-only, live desktop view of the books
egui/wgpu app for owners and their accountant: Overview, Cash, Profit,
Transactions (with rendered receipts and journal history), Reports
(statements with interval columns, comparisons, drill-down), Accounts,
Documents, Company, Journal; Ctrl+K palette; back/forward; light/dark.
Follows the journal live (~30 ms from write to screen). --demo opens a
deterministic demo company, also used for screenshot tests.

Engine: ledger-store Snapshot (read-only, incremental, tolerant of a
half-written tail); ledger-core analysis (intervals, balance series,
P&L by interval, cash flows); ledger-config extracted from ledger-mcp
with the full company profile and cash_accounts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-12 16:20:14 -07:00
swaitsandClaude Opus 5.5 b48addd0e0 feat: 1099 tracking — vendor forms, payment tags, year-end worksheet
Vendors carry form_1099 (NEC/MISC) and tin_on_file (never the TIN).
Entries carry the vendor they paid; bills, bill payments, and reversals
are tagged automatically. books.vendor_1099 counts cash paid per vendor
per calendar year (card payments excluded) against $600 through 2025 and
$2,000 from 2026, or an override, and flags missing W-9s. Four new tools
(64), two journal events, schema v7. Docs: finished R-FUTURE work is no
longer listed as future.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-11 21:18:32 -07:00
swaitsandClaude Opus 5.5 d65f9aa840 feat: payout gross-up (books.record_payout)
A platform deposit becomes a draft entry with the deposit, fees,
adjustments, and gross revenue (by class), refused unless gross - fees
+/- adjustments equals the deposit to the cent. The payout statement is
attached as its receipt before posting. 60 tools.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-11 21:12:38 -07:00
swaitsandClaude Opus 5.5 ddb67a2ca2 feat: prepaid/deferred amortization schedules and recurring entries
Amortization schedules spread a prepaid expense or deferred revenue over
the months it covers, to the cent, posting on month ends; runs preview
first, are idempotent, honor closed periods, and are exempt from the
receipt policy like depreciation. Recurring entries generate drafts only,
never posts. Six new tools (59 total); four new journal events, replayed
and checked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-11 21:09:43 -07:00
swaitsandClaude Opus 5.5 dbdc8e671f feat: entity-aware owner equity — chart templates, draws, distributions, dividends
Every business has owners, and how owners put money in and take it out
depends on what the business is. wanderbooks now knows the four common
US shapes:

- books.chart_template {entity_type, owners}: a starter chart for a sole
  proprietorship (owner's capital and draws), partnership or
  multi-member LLC (capital and distributions per partner, guaranteed
  payments), S corporation (stock, APIC, shareholder distributions,
  officer compensation, retained earnings) or C corporation (dividends,
  income tax expense), plus common income and expense accounts with
  tax categories, and the wanderbooks.toml settings that go with it
  (tax form, members with equal-share placeholders that sum to exactly
  100, close target). A proposal for the agent to review with the human.
- Closing: CloseTo is now RetainedEarnings { sweep }, Account { into,
  sweep } (a sole proprietor's capital) or Members([MemberClose { capital,
  share, distributions }]). Sweeps zero owner-pay equity accounts into
  their target at close: draws into owner's capital, dividends or
  distributions into retained earnings, each partner's distributions
  into their own capital. Validation (equity, distinct, shares sum to 1)
  runs in the pre-close preview, before anything is journaled. The
  journal records the target; journals with no target or the old
  "retained_earnings" string still replay.
- Members' equity: a partner's distributions account is part of their
  capital; its activity reports as distributions.
- Config: members' distribution_account; [books] close_into and
  close_sweep for single-owner businesses (rejected alongside members).
  The cash flow names members on their distribution accounts too.

Tests: a sole proprietor (net 2,000 and 500 of draws into capital: 6,500,
the cash); a corporation (dividends 300 into retained earnings: 600); a
partnership with distribution accounts (Bob 100 + 25 - 20 = 105, swept at
close, February begins there); bad sweep accounts; templates round-trip
through the chart parser, unique codes, equity per entity, every tax
category valid for the entity's return; stdio: template -> settings ->
import -> postings -> close with the exact closing lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-11 21:01:31 -07:00
swaitsandClaude Opus 5.5 f858fb4cfe feat: tax categories and the tax summary report
wanderbooks is a general MCP-first accounting application: every
business needs its accounts mapped to where they land on its income tax
return, and its CPA needs book-to-tax differences surfaced, not hidden.

- ledger_core::tax: a catalog of stable categories (gross_receipts,
  commissions_fees, meals, guaranteed_payments, officer_compensation,
  nondeductible, ...) with kind (income, cost of goods, deduction), the
  returns each appears on (Schedule C, 1065, 1120-S, 1120), a default
  deductible percent (meals 50, nondeductible 0), and guidance. Keys,
  not line numbers: form lines move from year to year.
- Accounts carry tax_category and an optional deductible_percent
  override, validated against the account type (and the return, at the
  MCP layer), journaled as account.tax_set, replayed, projected (schema
  v6).
- tax_summary(start, end, form): the P&L grouped by category, book and
  deductible amounts (half away from zero to the cent), contra income
  (refunds) reducing receipts, and every account still uncategorized.
- MCP: books.tax_categories (filtered to the configured return),
  books.set_tax_category, a tax_summary report in every format and in
  the CPA package, chart output with categories, and a fifth
  tax_category column in chart CSV imports, validated before anything is
  created. [company] tax_form, or derived from tax_classification.

Scope stays book basis: no tax depreciation, basis, or filing.

Tests: a hand-computed quarter (50% meals 333.33 -> 166.67, penalties at
0%, refunds as negative receipts, guaranteed payments, one uncategorized
account; book net 5,276.67 vs tax 5,483.33); overrides; category/account
/return validation; form parsing and derivation; CSV parsing; stdio:
filtered catalog, mapping, refusal, summary, all-or-nothing import.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-11 20:53:56 -07:00
swaitsandClaude Opus 5.5 8164abb2b8 fix: period close sweeps only through the period end; partnerships close into members' capital
The close zeroed every revenue and expense account's all-time movement,
so closing a period after later entries were posted swept those too:
close January in March and February's and March's income landed in
January's closing entry. The preview had the same bug and never showed
where the net went. Now:

- close_period_to(id, to) zeroes balances as of the period end; entries
  dated later stay open for their own period. Periods must close in
  order (LedgerError::CloseOutOfOrder): a later close has already swept
  the earlier period.
- CloseTo::Members: for a partnership (multi-member LLC) the net goes to
  each member's capital account by share, to the cent, the last member
  absorbing rounding, which is what K-1 capital accounts track.
  CloseTo::RetainedEarnings (3999) stays the default; close_period is
  shorthand for it. books.close_period picks Members when
  wanderbooks.toml lists members.
- The journal records the close target; replay re-derives the closing
  entry and now compares every line with the journal, not just the id,
  so a change to closing math can never silently rewrite history.
- The tool's preview is the real close run on a clone of the ledger
  (Ledger now derives Clone), so preview and post can't disagree.
- Members' equity: closing entries into a capital account are income,
  not contributions. Balance sheet for a partnership: capital accounts
  (with allocated earnings), then only unallocated earnings —
  "Undistributed earnings, prior years" if any, and this year's unclosed
  net income (loss). periods::allocate is shared by the close and the
  statement.

Tests: January closed after February is posted sweeps exactly Dr 4000
2,500 / Cr 6100 800 / Cr 3999 1,700 (mutation-checked: the old code gives
3,500/120/800/2,580); out-of-order close refused; a partnership close
splits 666.67 into 333.34/333.33 with no 3999, and the members' equity
statement reads correctly before and after; store replay of a members
close (reopen, rebuild, verify); partnership and corporate balance sheet
layouts; stdio close preview equals the posted lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-11 20:39:17 -07:00
swaitsandClaude Opus 5.5 f591264533 docs: more ledger-gui design references
Records three more dashboard references Stephen shared: the QuickBooks
Online dashboard cards, KPI tiles with period-over-period change, and a
cash-flow dashboard (runway, forecast, A/P and A/R aging, burn).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-11 20:31:08 -07:00
swaitsandClaude Opus 5.5 8f623ba810 feat: canonical report set in QuickBooks-style presentation
Every report a CPA expects, laid out the way US small-business books
read (QuickBooks as the reference):

- Exports: books.export gains general_ledger (by account, running
  balances, splits), general_journal (the entry-ordered listing, which
  the CPA package used to mislabel "general ledger"; alias journal),
  members_equity, and trial_balance as of a date, in xlsx/csv/json, and
  PDF for all five statements (P&L, balance sheet, cash flow, trial
  balance, members' equity). books.trial_balance takes as_of.
- CPA package: every statement as PDF and XLSX, plus the general ledger
  and general journal, all for the fiscal year to date.
- Layout (ledger-import::report): multi-column statements (Total;
  Debit/Credit; one column per member plus Total), "$" on subtotals and
  totals, a rule above every sum (none over an empty section), a double
  rule under statement totals, zero as 0.00, negatives in parentheses
  with the closing paren hanging so decimal points align.
- Terms: "Net income (loss)"; for a multi-member LLC, "Members' equity",
  member contributions and distributions named in the cash flow, and the
  balance sheet splitting retained earnings (the Retained Earnings
  account plus earnings not yet closed from earlier years) from the
  fiscal year's net income (loss); the cash flow's "Adjustments to
  reconcile net income (loss) to net cash from operating activities" and
  "Net increase (decrease) in cash".
- PDF: Inter replaces the typst-assets serif fonts (bundled, SIL OFL 1.1,
  license in fonts/; from the official v4.1 release), centered title
  block, column-header row, full-width rules, footer "Accrual basis ·
  Prepared <date> · Journal seq N" with page numbers. Amounts on total
  rows stay regular weight so bold figures can't leave the decimal
  column. typst-assets dependency removed.
- XLSX: centered title block, header row, Excel accounting formats with
  "$" attached on sums, general ledger and general journal workbooks.
- Config: wanderbooks.toml [[company.members]] (name, share "50%" or
  "0.5", capital_account; shares must total 100%) and [books]
  accounting_basis (accrual | cash).
- Core: RETAINED_EARNINGS_CODE names the "3999" that period close uses.
- Fix: "today" is local time, not UTC; in US evenings UTC is tomorrow,
  so reports defaulting to "through today" included the next day and
  footers carried the wrong date.

Tests: layout (currency and rule placement, retained earnings vs. this
year's net income for a member LLC, footer text); exact template data;
deterministic PDFs; config parsing (members, shares, basis); stdio:
trial balance as of, February general ledger (beginning 1,004.00, splits,
ending 1,003.50), members' equity (contributions 1,000.00, net income
3.50, ending 1,003.50), members' equity PDF, the CPA package's ten files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-11 20:29:12 -07:00
swaitsandClaude Opus 5.5 02b3b9c053 feat: general ledger by account, trial balance as of, members' equity
Three reports a CPA expects that the engine could not produce:

- general_ledger(start, end): the canonical general ledger, organized by
  account in code order. Each account has its beginning balance, every
  posting in the period (date, entry, description, debit, credit, running
  balance on the normal side) and its ending balance, which ties to the
  trial balance. Each posting names its split: the one account on the
  opposite side of its entry, or "-Split-" when that side has several,
  so a compound entry (one debit, several credits) reads correctly from
  every account it touches. The entry-ordered listing the exports called a
  "general ledger" is really the general journal.
- trial_balance_as_of(as_of): a trial balance is always as of a date.
- members_equity(start, end, members): the statement of changes in
  members' equity for a multi-member LLC taxed as a partnership. Each
  member's capital roll-forward: beginning (capital plus their share of
  undistributed earnings), contributions, allocated net income (loss),
  distributions, ending. Allocation is by ownership share to the cent,
  the last member absorbing rounding, so totals are exact and the ending
  total equals balance-sheet equity; any equity change the roll-forward
  can't explain shows as "other" instead of disappearing. Shares must be
  positive and sum to exactly 1; capital accounts must be distinct
  equity accounts (LedgerError::InvalidMembers).
- CashFlowLine.account: the account a line is about, so renderers can
  name members instead of account codes.

Tests (every expectation computed by hand): trial balance as of Jan 31;
February general ledger with beginning balances, running balances, a
card that nets to zero, an inactive account still listed, and every
ending balance tied to the trial balance; a compound entry's splits from
all three sides; a two-member partnership where 666.67 and 100.01 split
unevenly (333.34/333.33, 50.01/50.00), with and without closing January,
tying to the balance sheet each month; bad member configuration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-11 20:16:25 -07:00
swaitsandClaude Opus 5.5 f4cb01b3f9 fix: money strings always carry cents
money() printed whatever scale a Decimal happened to have, so an empty
sum came back as "0" while every other amount read "207.68". Amounts now
have at least two places ("0.00", "1000.00"), padded and never rounded:
an exact FX-converted amount with more places keeps them. Negative zero
normalizes to "0.00".

Test: money_always_has_cents_and_never_rounds; three stdio assertions
that pinned the old "0" now expect "0.00".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-11 19:38:01 -07:00
swaitsandClaude Opus 5.5 42e600bbf2 chore: clippy clean across the workspace, all targets
Zero warnings from `cargo clippy --workspace --all-targets`, which is now
a hard rule rather than "clean except pre-existing":

- ChartOfAccounts::add takes the Account it registers instead of eight
  loose parameters (too_many_arguments); Ledger::add_account builds it.
  Same validation, same errors.
- close_period tool: collapse the nested if-let into a let chain.
- iif.rs docs: the IIF layout example shows tabs as `\t` instead of
  literal tab characters (tabs_in_doc_comments).
- Tests: drop an unused binding; assert! instead of assert_eq! against a
  literal bool.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-11 19:24:10 -07:00
swaitsandClaude Opus 5.5 03212265ce feat: Typst-typeset statements in US accounting format
The printpdf statements padded columns with spaces in a proportional font
(amounts didn't line up), had no company name, printed negatives as
"-207.68", and put everything on one page (a long statement was silently
cut off past ~70 lines). Now:

- PDFs are typeset by Typst from one template,
  ledger-import/templates/statement.typ. The laid-out statement goes in as
  JSON data (sys.inputs), so account names are never parsed as markup.
  Fonts are bundled (typst-assets: Libertinus Serif), so output doesn't
  depend on the machine and is byte-for-byte deterministic. Statements
  paginate, with the company/title repeated on later pages and "Page n of
  m" plus "Prepared from journal through seq N" in the footer.
- US accounting presentation, decided once in ledger-import::report and
  applied by both PDF and XLSX: tabular figures; "$" on each section's
  first amount and on every subtotal and total, pinned to the column's
  left edge; negatives in parentheses with the closing paren hanging (its
  width reserved on every row, so decimal points align); zero as a dash;
  a single rule above sums (none over an empty section) and a double rule
  under statement totals; amounts never bold, because bold figures are
  wider and would leave the decimal column. XLSX uses Excel's own
  Accounting formats for the same result.
- Cash flow: "Net change in cash" is a subtotal; only "Cash at end of
  period" takes the double rule.
- Core fix: cash_flow walked the chart in HashMap order, so the order of
  working-capital and financing lines could change between runs. Lines
  now follow account code. Test: five owners, twenty runs, always
  3000/3001/3003/3005/3009; fails without the sort.

New dependencies (ledger-import): typst, typst-layout, typst-pdf,
typst-assets (fonts), typst-as-lib (World/font plumbing), serde_json (the
template's data). Removed: printpdf. Release ledger-mcp grows by the
bundled fonts.

Tests: exact template data for a hand-built P&L (like a golden); same
statement renders to identical bytes; 200 rows of Typst markup
characters render literally and span several pages; currency-sign and
rule placement; amount splitting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-11 19:23:01 -07:00
swaitsandClaude Opus 5.5 b5af21bb0e feat: real dates and per-account detail in every statement export
Every statement export rendered life-of-ledger totals under whatever date
range or as-of label was asked for, and the P&L and balance sheet XLSX
were totals-only. Now:

- books.profit_and_loss / books.balance_sheet and the csv, json, xlsx,
  pdf exports and CPA package all use core's profit_and_loss_for_period
  and balance_sheet_as_of, with a line per account. The tools keep their
  totals and add revenue/expense/asset/liability/equity account lines.
  An omitted start is the first posted entry, never "0001-01-01".
- ledger-import::report lays a statement out once (section, line,
  subtotal, total) for every renderer, with one exact amount formatter:
  thousands separators, negatives in parentheses, no floats. XLSX keeps
  numeric cells with an accounting number format, named sheets, company
  header, totals with borders; the trial balance gains its totals row.
- ledger-mcp reads wanderbooks.toml (next to --data-dir, or
  --books-toml): company name for headers, fiscal_year_start for the CPA
  package's fiscal year to date (end derived; Feb 29 rejected), and
  [books] receipt_threshold, which the server had been ignoring.
  config.json's receipt_policy still wins when set. New dependency: toml
  1.x (already in the lockfile via another crate).
- Store::journal_seq(); every report notes the journal seq it reflects.

Tests: company.rs unit tests (fiscal years incl. a July start, scaffold
parsing, bad settings); report.rs formatting tests; an MCP stdio test with
--books-toml (threshold 5.00 enforced, February-only P&L with account
lines, start defaults to the first entry, as-of balance sheet).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-11 19:11:36 -07:00
swaitsandClaude Opus 5.5 c6faeeb827 feat: period-scoped P&L and as-of balance sheet; closes don't zero periods
The engine only had life-of-ledger P&L and balance sheet totals, so every
export labeled with a date range or as-of date (PDF, XLSX, CPA package)
printed all-time numbers under that label. Core now has:

- profit_and_loss_for_period(start, end, class?) and
  balance_sheet_as_of(as_of), each with a line per account
  (StatementLine: code, name, normal-side amount) and exact totals.
- Ledger::closing_entry_ids(). Period statements exclude closing entries.
  A close (Dr revenue / Cr expense / net to Retained Earnings, dated at
  period end) moves earnings; it doesn't earn or spend. Counting it made a
  closed period's P&L read as break-even, and made the cash flow show zero
  net income plus a fake "owner contribution" to Retained Earnings. The
  as-of balance sheet keeps closing entries: after a close the earnings
  belong in Retained Earnings, not current earnings.

Tests: tests/statements.rs, a two-month fixture with every expected number
computed by hand (Jan NI 1,700; Feb 880; balance sheets on four dates; a
closed January still reporting 1,700 and a cash flow with one financing
line). Mutation-checked: dropping the closing exclusion fails it (0 vs
1,700). Properties: period P&Ls partition a year at any split, and a
range's net income equals the change in current earnings across it, with
A = L + E on both dates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-11 19:04:18 -07:00
swaitsandClaude Opus 5.5 5548902f27 feat: cash flow statement PDF, pdf export format, cash flow in CPA package
The cash flow statement was computed and exportable as csv/json/xlsx but
missing from every PDF path, so the CPA package carried two of the three
financial statements. Now:

- ledger-import: cash_flow_pdf renders the indirect-method statement
  (operating with net income, depreciation add-back, working-capital
  lines; investing; financing; net change reconciled to beginning and
  ending cash) from the computed CashFlowStatement, so the period and
  cash accounts are the caller's.
- books.export gains format "pdf" for profit_and_loss, balance_sheet, and
  cash_flow (base64, like xlsx), plus financing_accounts for cash_flow.
- books.export_cpa_package adds cash-flow.pdf (fiscal year to date, like
  the P&L) and requires cash_accounts: without them the statement has
  nothing to reconcile to.
- One cash_flow_statement helper resolves codes and dates for xlsx, pdf,
  and the package.

Test: MCP stdio test exports all three statement PDFs, checks the
cash_accounts and bad-report refusals, and unzips the CPA package to find
all five files. Known report gaps are logged in plan/STATUS.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-11 18:57:12 -07:00
swaitsandClaude Opus 5.5 e59e8001c6 feat: receipt unlink and attachment withdrawal
Posted entries stay immutable (R-3), but their evidence can now change the
same way the ledger does: by appending events, never by editing or
deleting. New R-13a.

- books.unlink_receipt {attachment_id, reason} journals receipt.unlinked
  and leaves the receipt filed, unlinked. The receipt policy holds: an
  entry that requires a receipt can't lose its last one (structured
  receipt_required error), so a replacement is attach-new, then
  unlink-old. Drafts and reversals are exempt, as at posting.
- books.withdraw_attachment {attachment_id, reason} journals
  attachment.withdrawn for an unlinked receipt or a filed document: hidden
  from list_receipts (unless include_withdrawn), never linkable again. The
  blob stays in the store, hash-verified and readable.
- link_receipt now refuses an already-linked or withdrawn attachment.
  Before, re-linking silently moved a receipt off a posted entry,
  bypassing the policy; evidence now leaves an entry only through a
  recorded unlink. dispose_asset checks its receipt ids before posting
  instead of after.
- Schema v5 adds attachments.withdrawn_at/withdrawn_reason; existing
  stores migrate on open and replay rebuilds the projection.

Tests: store test (errors, reopen, rebuild, verify, journal history) and
an MCP stdio test (policy refusal, replace flow, withdraw, listings,
under-threshold unlink). Dry-run and live use on the Wanderland Labs books:
8 migrated .eml attachments swapped for their PDF renders, trial balance
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-11 18:40:51 -07:00
swaits 3bfea47a59 docs: STATUS entry for wanderbooks init 2026-10-12 01:07:13 +00:00
swaits 52674af74f docs: README quickstart covers wanderbooks init 2026-10-12 01:07:11 +00:00
swaits 296818f73c feat: lockfile for ledger-cli deps 2026-10-12 01:07:07 +00:00
swaits a016af9cd4 feat: add ledger-cli to workspace members 2026-10-12 01:07:05 +00:00
swaits 3aae8cd2a6 test: init scaffold integration tests (5 green) 2026-10-12 01:07:02 +00:00
swaits e96b5672e9 feat: wanderbooks init scaffolds mise.toml + AGENT.md + books layout 2026-10-12 01:07:01 +00:00
swaits 94c874a56f feat: ledger-cli crate (wanderbooks init binary) 2026-10-12 01:06:59 +00:00
swaits 6540bceac1 Payroll accounting, class tracking, CPA handoff package (48 MCP tools) 2026-10-11 15:32:27 +00:00
swaits da4a9cb0ee Payroll accounting, class tracking, CPA handoff package (48 MCP tools) 2026-10-11 15:32:26 +00:00
swaits 623d9462fe Payroll accounting, class tracking, CPA handoff package (48 MCP tools) 2026-10-11 15:32:24 +00:00
swaits 15a9b0961a Payroll accounting, class tracking, CPA handoff package (48 MCP tools) 2026-10-11 15:32:23 +00:00
swaits 48ef705185 Payroll accounting, class tracking, CPA handoff package (48 MCP tools) 2026-10-11 15:32:21 +00:00
swaits 98015b6c58 Payroll accounting, class tracking, CPA handoff package (48 MCP tools) 2026-10-11 15:32:19 +00:00
swaits 09d1537225 Payroll accounting, class tracking, CPA handoff package (48 MCP tools) 2026-10-11 15:32:18 +00:00
swaits 0a90a72962 Payroll accounting, class tracking, CPA handoff package (48 MCP tools) 2026-10-11 15:32:16 +00:00
swaits 22cfc37bbd Payroll accounting, class tracking, CPA handoff package (48 MCP tools) 2026-10-11 15:32:14 +00:00
swaits 0d91d5839f Payroll accounting, class tracking, CPA handoff package (48 MCP tools) 2026-10-11 15:32:13 +00:00
swaits 72f7143aa9 Payroll accounting, class tracking, CPA handoff package (48 MCP tools) 2026-10-11 15:32:11 +00:00
swaits fe50b38616 Payroll accounting, class tracking, CPA handoff package (48 MCP tools) 2026-10-11 15:32:09 +00:00
swaits 4969cb557e Payroll accounting, class tracking, CPA handoff package (48 MCP tools) 2026-10-11 15:32:07 +00:00
swaits bad75ede4d Payroll accounting, class tracking, CPA handoff package (48 MCP tools) 2026-10-11 15:32:05 +00:00
swaits 2dd23df7ae Payroll accounting, class tracking, CPA handoff package (48 MCP tools) 2026-10-11 15:32:03 +00:00
swaits 61b6e48df7 Payroll accounting, class tracking, CPA handoff package (48 MCP tools) 2026-10-11 15:32:02 +00:00
swaits ab67dccf8e Payroll accounting, class tracking, CPA handoff package (48 MCP tools) 2026-10-11 15:32:00 +00:00
swaits 005dcbb803 Payroll accounting, class tracking, CPA handoff package (48 MCP tools) 2026-10-11 15:31:58 +00:00
swaits d50b844476 Payroll accounting, class tracking, CPA handoff package (48 MCP tools) 2026-10-11 15:31:56 +00:00
swaits 3d80a270ca Payroll accounting, class tracking, CPA handoff package (48 MCP tools) 2026-10-11 15:31:55 +00:00
swaits f521d7d8c6 Payroll accounting, class tracking, CPA handoff package (48 MCP tools) 2026-10-11 15:31:53 +00:00
swaits 6fdd8898a9 Payroll accounting, class tracking, CPA handoff package (48 MCP tools) 2026-10-11 15:31:51 +00:00
swaits c7a47ce76f Period close, bank rec, AR/AP, multi-currency (41 MCP tools) 2026-10-11 08:15:41 +00:00
swaits 134c77ae41 Period close, bank rec, AR/AP, multi-currency (41 MCP tools) 2026-10-11 08:15:38 +00:00
swaits d201b71d45 Period close, bank rec, AR/AP, multi-currency (41 MCP tools) 2026-10-11 08:15:36 +00:00
swaits 15c177850b Period close, bank rec, AR/AP, multi-currency (41 MCP tools) 2026-10-11 08:15:34 +00:00
swaits 4a4d03c329 Period close, bank rec, AR/AP, multi-currency (41 MCP tools) 2026-10-11 08:15:32 +00:00
swaits 91368eda18 Period close, bank rec, AR/AP, multi-currency (41 MCP tools) 2026-10-11 08:15:30 +00:00
swaits 6b6e88516d Period close, bank rec, AR/AP, multi-currency (41 MCP tools) 2026-10-11 08:15:28 +00:00
swaits eb30becb95 Period close, bank rec, AR/AP, multi-currency (41 MCP tools) 2026-10-11 08:15:25 +00:00
swaits 71d415d2a8 Period close, bank rec, AR/AP, multi-currency (41 MCP tools) 2026-10-11 08:15:24 +00:00
swaits 727bdc61e6 Period close, bank rec, AR/AP, multi-currency (41 MCP tools) 2026-10-11 08:15:22 +00:00
swaits 747f0c951e Period close, bank rec, AR/AP, multi-currency (41 MCP tools) 2026-10-11 08:15:20 +00:00
swaits 26f15eb867 Period close, bank rec, AR/AP, multi-currency (41 MCP tools) 2026-10-11 08:15:18 +00:00
swaits dc852d5f7e Period close, bank rec, AR/AP, multi-currency (41 MCP tools) 2026-10-11 08:15:16 +00:00
swaits bb0243c78e Docs: refresh for public repo — rewrite README, update roadmap/STATUS/requirements 2026-10-11 07:35:24 +00:00
swaits 78af7e201d Docs: refresh for public repo — rewrite README, update roadmap/STATUS/requirements 2026-10-11 07:35:22 +00:00
swaits 3039625805 Docs: refresh for public repo — rewrite README, update roadmap/STATUS/requirements 2026-10-11 07:35:20 +00:00
swaits 608788bbe4 Docs: refresh for public repo — rewrite README, update roadmap/STATUS/requirements 2026-10-11 07:35:18 +00:00
swaits 5e9388e6ad README: remove 'private while it cooks' — repo is now public 2026-10-11 07:32:59 +00:00
swaits 343a10a1ac Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:02:55 +00:00
swaits a4b6a41ce9 Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:02:53 +00:00
swaits fc9960eb83 Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:02:48 +00:00
swaits 92921d8a72 Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:02:45 +00:00
swaits 96a7727f92 Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:02:41 +00:00
swaits 7a1c3a2531 Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:02:33 +00:00
swaits d6de58bb41 Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:02:29 +00:00
swaits 42088e06d5 Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:02:24 +00:00
swaits df376d760f Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:02:21 +00:00
swaits a27c17c025 Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:02:18 +00:00
swaits fa6173be84 Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:02:11 +00:00
swaits 377756ff94 Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:02:02 +00:00
swaits 90656055cc Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:01:51 +00:00
swaits 870a4d359b Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:01:44 +00:00
swaits 77a51cfd6f Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:01:39 +00:00
swaits 9a2705d055 Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:01:37 +00:00
swaits 7025056125 Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:01:32 +00:00
swaits 0e4ec80cd8 Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:01:30 +00:00
swaits 5baaa12bf7 Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:01:28 +00:00
swaits 6313c50b11 Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:01:26 +00:00
swaits 9ba4917899 Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:01:24 +00:00
swaits 78915fc3bb Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:01:22 +00:00
swaits 9fa4232664 Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:01:20 +00:00
swaits 5225abb9dd Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:01:19 +00:00
swaits ac1604ed3f Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:01:17 +00:00
swaits 792a0dce19 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:26:03 +00:00
swaits 9ff4b5261a asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:26:02 +00:00
swaits 65dd11d26b asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:26:00 +00:00
swaits d703874f87 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:59 +00:00
swaits 2a72f37a9d asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:58 +00:00
swaits 349ccd1ccf asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:56 +00:00
swaits 1c6524ad20 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:55 +00:00
swaits 8177e6aee3 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:54 +00:00
swaits 793c4e8ab6 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:52 +00:00
swaits 6c052141a0 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:51 +00:00
swaits 686306544a asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:50 +00:00
swaits 50c45e5229 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:49 +00:00
swaits d36d718be0 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:48 +00:00
swaits 284e53feb7 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:46 +00:00
swaits 87b5b90baf asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:45 +00:00
swaits 8c115f1b9e asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:44 +00:00
swaits 1674ce70b4 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:42 +00:00
swaits f92bb4c39c asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:40 +00:00
swaits 325eb1003a asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:38 +00:00
swaits 9126e33f77 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:37 +00:00
swaits 0d3a6d18ad asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:36 +00:00
swaits 1d211c8faa asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:34 +00:00
swaits 7098ac08e5 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:33 +00:00
swaits 3790b4e0b6 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:31 +00:00
swaits 5054268474 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:29 +00:00
swaits 95d46c27d5 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:28 +00:00
swaits d1e3a9525b asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:26 +00:00
swaits ad0c99853f asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:25 +00:00
swaits fcdd002fe0 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:23 +00:00
swaits afcbc4bbdf asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:22 +00:00
swaits 41acb73b5b asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:21 +00:00
swaits a7a8a70496 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:19 +00:00
swaits 99b5b5d0b8 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:18 +00:00
swaits 31d3c61e32 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:16 +00:00
swaits 2fcb50c2c4 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:15 +00:00
swaits 008b5d2d52 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:13 +00:00
swaits eb28e87662 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:12 +00:00
swaits a44f14a275 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:10 +00:00
swaits 1f9f441a16 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:08 +00:00
swaits e0214fcc4c asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:07 +00:00
swaits fc5bb190a1 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:06 +00:00
swaits 291451f725 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:04 +00:00
swaits 090be37107 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:03 +00:00
swaits 01d109e80e asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:01 +00:00
swaits 877e6ed503 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:00 +00:00
swaits 7a26fd2fb1 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:24:58 +00:00
swaits fac7d681e4 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:24:57 +00:00
swaits fae0827246 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:24:55 +00:00
swaits 4884fea21a asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:24:53 +00:00
swaits cbaa858380 test push 2026-10-11 02:24:46 +00:00
swaits 7d23f42185 First slice: ledger-store (journal+blobs+sqlite) and ledger-mcp (18 tools, receipt policy) 2026-10-11 01:37:36 +00:00
swaits 9be8398519 First slice: ledger-store (journal+blobs+sqlite) and ledger-mcp (18 tools, receipt policy) 2026-10-11 01:37:34 +00:00
swaits a65d10c78e First slice: ledger-store (journal+blobs+sqlite) and ledger-mcp (18 tools, receipt policy) 2026-10-11 01:37:32 +00:00
swaits 7d41b99707 First slice: ledger-store (journal+blobs+sqlite) and ledger-mcp (18 tools, receipt policy) 2026-10-11 01:37:31 +00:00
swaits 0ff778526a First slice: ledger-store (journal+blobs+sqlite) and ledger-mcp (18 tools, receipt policy) 2026-10-11 01:37:29 +00:00
swaits e5f63b0d48 First slice: ledger-store (journal+blobs+sqlite) and ledger-mcp (18 tools, receipt policy) 2026-10-11 01:37:27 +00:00
swaits 08b53d2e55 First slice: ledger-store (journal+blobs+sqlite) and ledger-mcp (18 tools, receipt policy) 2026-10-11 01:37:24 +00:00
swaits fcf6e5743d First slice: ledger-store (journal+blobs+sqlite) and ledger-mcp (18 tools, receipt policy) 2026-10-11 01:37:23 +00:00
swaits 57c28679d5 First slice: ledger-store (journal+blobs+sqlite) and ledger-mcp (18 tools, receipt policy) 2026-10-11 01:37:21 +00:00
swaits 25df4962ea First slice: ledger-store (journal+blobs+sqlite) and ledger-mcp (18 tools, receipt policy) 2026-10-11 01:37:19 +00:00
swaits 283e2b4a51 First slice: ledger-store (journal+blobs+sqlite) and ledger-mcp (18 tools, receipt policy) 2026-10-11 01:37:17 +00:00
swaits 922e5d3128 First slice: ledger-store (journal+blobs+sqlite) and ledger-mcp (18 tools, receipt policy) 2026-10-11 01:37:15 +00:00
swaits 794b18e6ab First slice: ledger-store (journal+blobs+sqlite) and ledger-mcp (18 tools, receipt policy) 2026-10-11 01:37:13 +00:00
swaits 6979a20414 First slice: ledger-store (journal+blobs+sqlite) and ledger-mcp (18 tools, receipt policy) 2026-10-11 01:37:12 +00:00
swaits 1b1a1791f1 First slice: ledger-store (journal+blobs+sqlite) and ledger-mcp (18 tools, receipt policy) 2026-10-11 01:37:10 +00:00
swaits b23e0a9011 First slice: ledger-store (journal+blobs+sqlite) and ledger-mcp (18 tools, receipt policy) 2026-10-11 01:37:08 +00:00
swaits e72b3542d2 First slice: ledger-store (journal+blobs+sqlite) and ledger-mcp (18 tools, receipt policy) 2026-10-11 01:37:07 +00:00
swaits e313fec903 First slice: ledger-store (journal+blobs+sqlite) and ledger-mcp (18 tools, receipt policy) 2026-10-11 01:37:05 +00:00
swaits 3c33c79a75 First slice: ledger-store (journal+blobs+sqlite) and ledger-mcp (18 tools, receipt policy) 2026-10-11 01:37:03 +00:00
swaits 2c22024abd First slice: ledger-store (journal+blobs+sqlite) and ledger-mcp (18 tools, receipt policy) 2026-10-11 01:37:01 +00:00
swaits 1fa76aeecd First slice: ledger-store (journal+blobs+sqlite) and ledger-mcp (18 tools, receipt policy) 2026-10-11 01:36:59 +00:00
swaits 11397e9c24 Storage: hash-chained JSONL journal as source of truth, SQLite as disposable projection 2026-10-11 01:16:25 +00:00
swaits 440c10534d Storage: hash-chained JSONL journal as source of truth, SQLite as disposable projection 2026-10-11 01:16:23 +00:00
swaits 57feb81698 Storage: hash-chained JSONL journal as source of truth, SQLite as disposable projection 2026-10-11 01:16:21 +00:00
swaits fd72f1969e QBO formats for import/export; server-side receipt enforcement policy in MCP spec 2026-10-11 01:09:52 +00:00
swaits 6df4020c3f QBO formats for import/export; server-side receipt enforcement policy in MCP spec 2026-10-11 01:09:51 +00:00
swaits 3eaa669ab9 QBO formats for import/export; server-side receipt enforcement policy in MCP spec 2026-10-11 01:09:49 +00:00
swaits b866e601e7 QBO formats for import/export; server-side receipt enforcement policy in MCP spec 2026-10-11 01:09:47 +00:00
swaits 47743bea79 QBO formats for import/export; server-side receipt enforcement policy in MCP spec 2026-10-11 01:09:45 +00:00
swaits 7b77a437f1 QBO formats for import/export; server-side receipt enforcement policy in MCP spec 2026-10-11 01:09:43 +00:00
swaits 1b8da0a477 QBO formats for import/export; server-side receipt enforcement policy in MCP spec 2026-10-11 01:09:41 +00:00
swaits 2a1a7512ae QBO formats for import/export; server-side receipt enforcement policy in MCP spec 2026-10-11 01:09:40 +00:00
swaits f8393d961a QBO formats for import/export; server-side receipt enforcement policy in MCP spec 2026-10-11 01:09:38 +00:00
swaits d18f68464f Spec: AI-first MCP interface phase (ledger-mcp, receipt store, capability parity) 2026-10-11 01:07:53 +00:00
swaits 437977f848 Spec: AI-first MCP interface phase (ledger-mcp, receipt store, capability parity) 2026-10-11 01:07:51 +00:00
swaits a3fdc37f1d Spec: AI-first MCP interface phase (ledger-mcp, receipt store, capability parity) 2026-10-11 01:07:49 +00:00
swaits ae61f02eb8 Spec: AI-first MCP interface phase (ledger-mcp, receipt store, capability parity) 2026-10-11 01:07:47 +00:00
swaits 3a9d83ec6c Spec: AI-first MCP interface phase (ledger-mcp, receipt store, capability parity) 2026-10-11 01:07:45 +00:00
swaits da5715e215 Spec: AI-first MCP interface phase (ledger-mcp, receipt store, capability parity) 2026-10-11 01:07:43 +00:00
734 changed files with 12015 additions and 312009 deletions

No files matched your search

-3
View File
@@ -1,3 +0,0 @@
# Tests compare text and checked-in hashes byte for byte: every OS checks out LF (the
# Windows job of check.yml would otherwise get CRLF from autocrlf).
* text=auto eol=lf
-36
View File
@@ -1,36 +0,0 @@
# Every change: format, clippy and rustdoc with zero warnings, every test.
# Tools come from mise.toml, the same as on a laptop.
name: check
on:
push:
pull_request:
# A newer push to the same branch makes an older check pointless: it is
# cancelled, so only the newest reports.
concurrency:
group: check-${{ github.ref }}
cancel-in-progress: true
jobs:
check:
runs-on: ubuntu-24.04
# The runner's disk is small (the first run after P6 landed died in
# the linker: "No space left on device", ld killed by SIGBUS). A fresh
# runner gains nothing from incremental caches, and line tables keep
# file:line in a panic's backtrace at a fraction of full debug info.
env:
CARGO_INCREMENTAL: "0"
CARGO_PROFILE_DEV_DEBUG: line-tables-only
CARGO_PROFILE_TEST_DEBUG: line-tables-only
steps:
- uses: actions/checkout@v4
- name: Room for the build (the image ships toolchains we never use)
run: |
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc \
/opt/hostedtoolcache/CodeQL /usr/local/.ghcup
df -h / | tail -1
- uses: jdx/mise-action@v2
- name: GUI build dependencies
run: sudo apt-get update && sudo apt-get install -y libxkbcommon-dev libgtk-3-dev
- run: just check
- name: What the build used
if: always()
run: df -h / | tail -1
-59
View File
@@ -1,59 +0,0 @@
# The portable builds (plan P11a step 0, D-56): the workspace builds and
# passes its tests on macOS and Windows, not only on Linux. A workflow of
# its own per OS, not a job beside `check` or beside the other OS: GitHub
# serves a job's log only when its whole run has completed, and `check`
# takes over an hour (the Windows suite most of one), so a failure's reason
# was unreadable for that long while it sat in a shared run.
name: portable-macos
on:
push:
pull_request:
workflow_dispatch:
concurrency:
group: portable-macos-${{ github.ref }}
cancel-in-progress: true
jobs:
# The destination is one installation for macOS, Windows and Linux (D-56,
# P11a step 0): the workspace must build and pass its tests on each, not
# only here. `just check` (fmt, clippy, rustdoc, proofs' friends) stays
# the Linux job's; these run the build and the tests. The job tells
# which OS a failure is on; neither is allowed to hide the other.
portable:
name: portable (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
include:
- os: macos-latest
experimental: false
runs-on: ${{ matrix.os }}
continue-on-error: ${{ matrix.experimental }}
# Same disk discipline as above: no incremental caches, line tables only.
env:
CARGO_INCREMENTAL: "0"
CARGO_PROFILE_DEV_DEBUG: line-tables-only
CARGO_PROFILE_TEST_DEBUG: line-tables-only
# macOS: a Unix socket path holds 104 bytes, and the runner's default
# temp folder (/var/folders/...) plus a test home plus
# `Library/Application Support/wanderbooks/run/` is longer than that;
# the server then refuses the root ("too long for its sockets"). The
# tests' temp homes go under /tmp there. Windows ignores TMPDIR.
TMPDIR: /tmp
steps:
- uses: actions/checkout@v4
- name: Rust (the version mise.toml pins)
shell: bash
run: rustup toolchain install 1.99 --profile minimal && rustup default 1.99
- name: Room for the build
shell: bash
run: df -h . | tail -1
- name: The server-only build (no viewer)
run: cargo build -p ledger-cli --no-default-features
- name: Everything compiles
run: cargo build --workspace --all-targets
- name: Tests
run: cargo test --workspace
- name: What the build used
if: always()
shell: bash
run: df -h . | tail -1
-59
View File
@@ -1,59 +0,0 @@
# The portable builds (plan P11a step 0, D-56): the workspace builds and
# passes its tests on macOS and Windows, not only on Linux. A workflow of
# its own per OS, not a job beside `check` or beside the other OS: GitHub
# serves a job's log only when its whole run has completed, and `check`
# takes over an hour (the Windows suite most of one), so a failure's reason
# was unreadable for that long while it sat in a shared run.
name: portable-windows
on:
push:
pull_request:
workflow_dispatch:
concurrency:
group: portable-windows-${{ github.ref }}
cancel-in-progress: true
jobs:
# The destination is one installation for macOS, Windows and Linux (D-56,
# P11a step 0): the workspace must build and pass its tests on each, not
# only here. `just check` (fmt, clippy, rustdoc, proofs' friends) stays
# the Linux job's; these run the build and the tests. The job tells
# which OS a failure is on; neither is allowed to hide the other.
portable:
name: portable (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
include:
- os: windows-latest
experimental: false
runs-on: ${{ matrix.os }}
continue-on-error: ${{ matrix.experimental }}
# Same disk discipline as above: no incremental caches, line tables only.
env:
CARGO_INCREMENTAL: "0"
CARGO_PROFILE_DEV_DEBUG: line-tables-only
CARGO_PROFILE_TEST_DEBUG: line-tables-only
# macOS: a Unix socket path holds 104 bytes, and the runner's default
# temp folder (/var/folders/...) plus a test home plus
# `Library/Application Support/wanderbooks/run/` is longer than that;
# the server then refuses the root ("too long for its sockets"). The
# tests' temp homes go under /tmp there. Windows ignores TMPDIR.
TMPDIR: /tmp
steps:
- uses: actions/checkout@v4
- name: Rust (the version mise.toml pins)
shell: bash
run: rustup toolchain install 1.99 --profile minimal && rustup default 1.99
- name: Room for the build
shell: bash
run: df -h . | tail -1
- name: The server-only build (no viewer)
run: cargo build -p ledger-cli --no-default-features
- name: Everything compiles
run: cargo build --workspace --all-targets
- name: Tests
run: cargo test --workspace
- name: What the build used
if: always()
shell: bash
run: df -h . | tail -1
-123
View File
@@ -1,123 +0,0 @@
# Releases (plan P11a, D-24, D-26): on a version tag, build the Linux
# packages on the oldest system they claim (ubuntu-24.04, glibc 2.39; a
# program linked on a newer one does not start on an older one), attach
# them to the GitHub release, and add them to the apt and dnf repositories
# on the gh-pages branch (GitHub Pages serves them). Unsigned: step 5 of
# the unit (the release key, Q-4) signs the manifest and the indexes. The
# macOS job builds the .dmg UNSIGNED (no Developer ID, no notarization:
# step 5); the Windows job is unit P11c. The release holds no key today.
# To see the artifacts without a tag: Actions > release > Run workflow,
# pick the branch; each job uploads its files as a run artifact.
name: release
on:
push:
tags: ["v*"]
workflow_dispatch:
permissions:
contents: write
jobs:
linux:
runs-on: ubuntu-24.04
# Same disk discipline as check.yml: no incremental caches.
env:
CARGO_INCREMENTAL: "0"
steps:
- uses: actions/checkout@v4
- name: Room for the build (the image ships toolchains we never use)
run: |
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc \
/opt/hostedtoolcache/CodeQL /usr/local/.ghcup
df -h / | tail -1
- uses: jdx/mise-action@v2
with:
# Only what packaging needs, not the proof and fuzz tools.
install_args: "rust just cargo:cargo-packager cargo:cargo-generate-rpm"
- name: GUI build dependencies
run: sudo apt-get update && sudo apt-get install -y libxkbcommon-dev libgtk-3-dev apt-utils createrepo-c
- name: The tag is the program's version
id: version
run: |
own=$(sed -n 's/^version = "\(.*\)"/\1/p' crates/ledger-cli/Cargo.toml | head -n1)
tag="${GITHUB_REF_NAME#v}"
if [ "${GITHUB_REF_TYPE}" = tag ] && [ "$tag" != "$own" ]; then
echo "tag $GITHUB_REF_NAME is not version $own" >&2
exit 1
fi
echo "version=$own" >> "$GITHUB_OUTPUT"
- name: Build the packages
run: just package-linux ${{ steps.version.outputs.version }}
- name: Attach them to the release
if: github.ref_type == 'tag'
env:
GH_TOKEN: ${{ github.token }}
run: |
v=${{ steps.version.outputs.version }}
gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1 || gh release create "$GITHUB_REF_NAME" --title "$GITHUB_REF_NAME" --notes "wanderbooks $v"
gh release upload "$GITHUB_REF_NAME" --clobber target/packages/$v/*.AppImage target/packages/$v/*.deb target/packages/$v/*.rpm target/packages/$v/*.tar.gz
- name: Add them to the apt and dnf repositories (gh-pages)
if: github.ref_type == 'tag'
run: |
v=${{ steps.version.outputs.version }}
git fetch origin gh-pages || true
if git rev-parse --verify origin/gh-pages >/dev/null 2>&1; then
git worktree add repo origin/gh-pages
else
git worktree add --detach repo && git -C repo checkout --orphan gh-pages && git -C repo rm -rfq . || true
fi
mkdir -p repo/apt/pool repo/dnf
cp target/packages/$v/*.deb repo/apt/pool/
cp target/packages/$v/*.rpm repo/dnf/
deploy/packaging/repo-indexes.sh apt repo/apt
deploy/packaging/repo-indexes.sh dnf repo/dnf
cd repo
git config user.name "wanderbooks release"
git config user.email "release@wanderbooks.ai"
git add -A
git commit -m "wanderbooks $v"
git push origin HEAD:gh-pages
- name: Keep them with the run (a dispatch has no release)
uses: actions/upload-artifact@v4
with:
name: linux-packages
path: target/packages/${{ steps.version.outputs.version }}/
macos:
# UNSIGNED: a .dmg that Gatekeeper refuses until step 5 signs and
# notarizes it. Apple silicon (macos-latest); an Intel build is not
# made yet.
runs-on: macos-latest
env:
CARGO_INCREMENTAL: "0"
steps:
- uses: actions/checkout@v4
- uses: jdx/mise-action@v2
with:
install_args: "rust just cargo:cargo-packager"
- name: The tag is the program's version
id: version
run: |
own=$(sed -n 's/^version = "\(.*\)"/\1/p' crates/ledger-cli/Cargo.toml | head -n1)
tag="${GITHUB_REF_NAME#v}"
if [ "${GITHUB_REF_TYPE}" = tag ] && [ "$tag" != "$own" ]; then
echo "tag $GITHUB_REF_NAME is not version $own" >&2
exit 1
fi
echo "version=$own" >> "$GITHUB_OUTPUT"
- name: Build the unsigned .dmg
run: just package-macos ${{ steps.version.outputs.version }}
- name: Attach it to the release
if: github.ref_type == 'tag'
env:
GH_TOKEN: ${{ github.token }}
run: |
v=${{ steps.version.outputs.version }}
gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1 || gh release create "$GITHUB_REF_NAME" --title "$GITHUB_REF_NAME" --notes "wanderbooks $v"
gh release upload "$GITHUB_REF_NAME" --clobber target/packages/$v/*.dmg
- name: Keep it with the run (a dispatch has no release)
uses: actions/upload-artifact@v4
with:
name: macos-dmg-unsigned
path: target/packages/${{ steps.version.outputs.version }}/*.dmg
-48
View File
@@ -1,48 +0,0 @@
# Weekly, and on demand: the slow layers (docs/testing.md): the proofs,
# the GnuCash oracle and the fuzz targets. Mutation testing is not run in
# CI (owner, 2026-10-29, D-59): it is a local run (`just mutants-*`), and
# P12 runs it whole on the owner's desktop.
name: scheduled
on:
schedule:
- cron: "17 6 * * 1"
workflow_dispatch:
jobs:
prove:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: jdx/mise-action@v2
- run: just prove
# The Kani harnesses too slow for prove's 30 minutes on four cores
# (justfile kani_slow; docs/verification.md), on a runner of their own.
prove-slow:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: jdx/mise-action@v2
- run: just prove-slow
oracle:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: jdx/mise-action@v2
- run: just oracle
fuzz:
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
target: [fuzz_journal_line, fuzz_entry_validate, fuzz_op_sequence, fuzz_core_serde, fuzz_store_open, fuzz_store_replay, fuzz_settings_replay, fuzz_import_csv, fuzz_config_parse, fuzz_extract_pdf, fuzz_extract_xlsx]
steps:
- uses: actions/checkout@v4
- uses: jdx/mise-action@v2
- run: just fuzz ${{ matrix.target }} 600
# A crash's input is the finding: keep it (the 2026-10-26 run found one
# in fuzz_store_replay and kept nothing, so it had to be hunted again).
- if: failure()
uses: actions/upload-artifact@v4
with:
name: fuzz-${{ matrix.target }}-artifacts
path: fuzz/artifacts
if-no-files-found: ignore
-111
View File
@@ -1,111 +0,0 @@
# The two-account, self-host deployment (plan 8.2 P9a, D-51): the books
# server runs as one account, an agent as another, and the agent reaches
# the books only through the server's HTTP door with a static bearer
# token. `crates/ledger-cli/tests/two_user.rs` is the check; this file
# makes the deployment it needs and runs it against real accounts. A
# laptop's `cargo test` skips those two tests (WB_TWO_USER unset); this
# job sets it, so a skip here is a failure's cousin: the job only goes
# green when both tests ran.
#
# The token is minted as on a real server: `token create` keeps it in a
# 0600 file under the root's run/ (ledger-host tokens.rs INVARIANT 3).
name: two-user
on:
push:
pull_request:
workflow_dispatch:
# A newer push makes an older run pointless: it is cancelled.
concurrency:
group: two-user-${{ github.ref }}
cancel-in-progress: true
jobs:
two-user:
runs-on: ubuntu-24.04
timeout-minutes: 45
env:
WB_PORT: "17411"
WB: /usr/local/bin/wanderbooks
steps:
- uses: actions/checkout@v4
- uses: jdx/mise-action@v2
- name: Build the server-only executable and the test
run: |
cargo build --release -p ledger-cli --no-default-features
cargo test -p ledger-cli --test two_user --no-run
- name: Two accounts, one data root only one can read
run: |
set -euo pipefail
sudo useradd --system --create-home --home-dir /var/lib/wanderbooks \
--shell /usr/sbin/nologin wanderbooks
sudo install -d -o wanderbooks -g wanderbooks -m 0700 /srv/wanderbooks
sudo install -d -o wanderbooks -g wanderbooks -m 0700 /srv/desktop
sudo install -d -o wanderbooks -g wanderbooks -m 0700 /srv/desktop-home
sudo install -m 0755 target/release/wanderbooks "$WB"
- name: The deployment, the move, and the check
run: |
set -euo pipefail
as_books() { sudo -u wanderbooks -H env HOME=/var/lib/wanderbooks "$@"; }
wait_open() {
for _ in $(seq 1 240); do
grep -q "The books are open" "$1" && return 0
sleep 0.5
done
echo "the server did not open:" >&2
cat "$1" >&2
return 1
}
stop_servers() {
as_books "$WB" close --root /srv/desktop >/dev/null 2>&1 || true
as_books "$WB" close --root /srv/wanderbooks >/dev/null 2>&1 || true
}
trap stop_servers EXIT
# The machine the books come from: books of this version, made by
# adopting the repository's fixture (so the move changes no event).
# Its own home, so the two roots are not "somewhere else" to one
# another (root::setup records one root per account home).
as_books "$WB" setup --root /srv/desktop --home /srv/desktop-home
as_books "$WB" serve --root /srv/desktop > /tmp/desktop.log 2>&1 &
wait_open /tmp/desktop.log
sudo cp -a crates/ledger-cli/tests/fixtures/p2-served /tmp/fixture
# The server account adopts it, so it must own the copy: adoption
# takes the source's lock (root.rs lock_source), which the runner's
# ownership from the checkout refused it (the job's first runs).
sudo chown -R wanderbooks:wanderbooks /tmp/fixture
as_books "$WB" books adopt /tmp/fixture --root /srv/desktop --id acme --yes
as_books "$WB" close --root /srv/desktop
# The backup clone that is the transport (plan D-31): owned by the
# server account, which adopts it (and locks it), and readable by
# the agent account, because the test reads its journal to know
# the head the move must keep.
sudo cp -a /srv/desktop/books/acme /tmp/source
sudo chown -R wanderbooks:wanderbooks /tmp/source
sudo chmod -R a+rX /tmp/source
# The server's machine: the same books, moved, behind `enforced`.
as_books "$WB" setup --root /srv/wanderbooks
as_books "$WB" serve --root /srv/wanderbooks --isolation enforced \
--http "127.0.0.1:$WB_PORT" > /tmp/tenant.log 2>&1 &
wait_open /tmp/tenant.log
as_books "$WB" books adopt /tmp/source --root /srv/wanderbooks --id acme --yes
as_books "$WB" books create tunnel --root /srv/wanderbooks
# Tokens for the agent account (the test), kept in the root's run/.
as_books "$WB" token create --root /srv/wanderbooks --book acme --name ci >/dev/null
TOKEN=$(as_books "$WB" token print --root /srv/wanderbooks --book acme \
--name ci)
as_books "$WB" token create --root /srv/wanderbooks --book tunnel --name ci >/dev/null
WRITE_TOKEN=$(as_books "$WB" token print --root /srv/wanderbooks --book tunnel \
--name ci)
# The check, as the agent account, over the loopback door.
WB_TWO_USER=1 \
WB_TENANT_ROOT=/srv/wanderbooks \
WB_SERVER_URL="http://127.0.0.1:$WB_PORT" \
WB_BOOK=acme \
WB_TOKEN="$TOKEN" \
WB_WRITE_BOOK=tunnel \
WB_WRITE_TOKEN="$WRITE_TOKEN" \
WB_SOURCE_DIR=/tmp/source \
cargo test -p ledger-cli --test two_user -- --nocapture --test-threads=1
-4
View File
@@ -3,7 +3,3 @@
.DS_Store
site/dist/
.netlify/
# cargo-mutants output (just mutants*)
/mutants.out*/
/mutants-*/
# bookspec's own workspace target
+10 -104
View File
@@ -12,13 +12,9 @@ what they haven't demonstrated.
semantics, built to become a full bookkeeping application (engine now, UI
later). The reference points:
- **Spec:** accounting principles, written down as proofs
(`docs/verification.md`). That is the only authority.
- **Cross-check oracles:** GnuCash (open source, open file formats), the
old Google Sheet, QBO/Xero exports. Useful to diff against, *never*
trusted to be right: a disagreement is adjudicated from principles, and
nothing may rest on another program's behavior alone. See
`docs/oracle.md`, "Who is right".
- **Semantic oracle:** GnuCash. It is open source with open file formats
(XML/SQLite), which makes it both the spec and the reference implementation
we diff against. See `docs/oracle.md`.
- **Workflow reference:** QuickBooks Online and Xero — for *what the UI must
do*, never for code. We observe behavior; we write our own implementation.
- **Method reference:** storytold/ArtCraft's app-cloning pipeline
@@ -41,10 +37,6 @@ later). The reference points:
IRS publications for tax-adjacent logic later. GnuCash itself is GPL —
read its docs, never copy its code. This repo is MIT OR Apache-2.0.
Never disassemble or copy from proprietary software.
6. **Accounting laws are proved, not just tested.** Arithmetic that money
flows through lives in `kernel.rs` and carries Kani proofs; the laws
it serves carry Lean proofs. `just prove` stays green. See
"Formal methods" below.
## Verification protocol (read this twice)
@@ -68,76 +60,6 @@ numbers**. The analog of storytold's screenshot-in-the-loop:
- The randomized invariant test (`random_balanced_journals_preserve_invariants`)
is the always-on property oracle. Extend it when you add invariants.
## Formal methods: the bar (read this three times)
Tests check the cases someone thought of; proofs check all of them. The
first formal pass on this engine (2026-10, `docs/verification.md`) paid
for itself several times over, on code that already had 190+ tests,
property tests, mutation testing, and 11.5M fuzz executions:
- **Negative line amounts were accepted by the engine.** Only the MCP
layer refused them. Any other caller could post them.
- **A large enough amount crashed the engine** (a `Decimal` overflow
panic while summing lines) instead of returning an error.
- **Currency conversion could crash** the same way.
- **The verifier itself had a soundness bug.** Kani 0.68 mis-models
`copy_from_slice` of symbolic length (model-checking/kani#4904): it
reported a reversal that doesn't net to zero. We didn't wave it away.
Replaying the counterexample natively, then shrinking it to a 15-line
crate, proved it was the tool, not the books. Now a canary harness
watches for the fix, and covers prove no proof is vacuous.
- **Writing the spec simplified the code.** `credit_line` and
`closing_line` turned out to be the same function. Lean also proved
that no draft can exist in a closed period, the invariant that lets
the store journal a post before applying it.
So the bar, for every agent, every change:
0. **Specs come from authorities, not memory.** Every rule the engine
enforces is in `docs/spec/rules.md` with its source: Pacioli for the
mechanics of double entry, the IRS (vendored verbatim, public domain)
for records, periods, 1099s, capitalization, partnerships, and payroll,
FASB's Codification (cited, never copied) for GAAP. A rule no authority
states is marked CONVENTION or CHOICE (bookspec's word for a
decision), with who decided and when.
Spec blocks in the code (```text GOAL/SOURCE/INVARIANTS/SCENARIOS)
carry a `SOURCE:` line. Never write a spec from what the code happens
to do, or from what you remember; find the document, or ask.
1. **Money arithmetic goes in `kernel.rs`, generic over `Amount`.** If
you write a sum, a sign flip, a split, or a rounding plug anywhere
else, move it into the kernel. The engine calls the kernel. It never
gets a second copy.
2. **Every kernel function gets a Kani harness** in `src/proofs.rs`, on
fixed arrays (no symbolic-length `Vec` growth, no `copy_from_slice`),
with a `kani::cover!` showing the interesting case is reached.
3. **Every new law gets a Lean theorem** in `verify/lean`. Laws about
the books over time are theorems about `Reachable` states. A new
`Ledger` command is a new `Step` constructor, with its preservation
proof. No `sorry`, ever; `Audit.lean` lists every headline theorem.
4. **Every kernel instantiation gets a bridge property** in
`tests/kernel_bridge.rs`, so what Kani proved on `i64` holds on
`Decimal`.
5. **A failing proof is a finding, not an obstacle.** Get the
counterexample, replay it natively, and decide on evidence: bug in
our code, bug in the spec, or bug in the tool. Never weaken an
assertion, raise a bound, or delete a harness to make red go green
without writing down why in `docs/verification.md`.
6. **More, not less.** When you touch an area the proofs don't cover
yet (A/R and A/P documents, payroll, depreciation, FX, persistence),
leave it with more formal coverage than you found it. Proptest,
mutation testing, fuzzing, Kani, and Lean all stack. Use all of them.
`just check` and `just prove` both pass before a batch of work touching
`ledger-core` is pushed: once, at the end of the batch, with everything
they find fixed in one pass. Inside a batch (a merge, a fix, a unit's
commits), the check is proportional to the change: the workspace
compiles and the tests the change touched pass. The owner's rule
(plan D-60, 2026-10-29): the first wave's five branches took twelve
hours to land with a full gate around every merge, and the one gate at
the end found the same five tests. GitHub's `check` job runs `just
check` on every push and is the exhaustive gate in the background.
## Parity tracking
`docs/parity.md` is the parity catalog: every semantic feature we intend to
@@ -156,23 +78,15 @@ clone, with status. Rules:
`post_entry`, `reverse_entry`, `add_account`). Struct fields stay private;
no other mutation path may exist.
- `ledger-core` depends only on `rust_decimal`, `thiserror`, `chrono`,
`serde`, `serde_json`, and `jiff` (the bundled time-zone database for
`company.timezone`: "today" is computed by the caller and passed in;
the core still reads no clock). New dependencies need justification in
the commit message. No I/O, no networking, no time-source in the core.
- Future crates (`ledger-store`, `ledger-import`, `ledger-reader`, `ledger-oracle`, `books-app`)
`serde`, `serde_json`. New dependencies need justification in the commit
message. No I/O, no networking, no time-source in the core.
- Future crates (`ledger-store`, `ledger-import`, `ledger-oracle`, `books-app`)
depend inward toward the core, never the reverse. See `docs/architecture.md`.
## Working here
- `mise install` sets up every tool (pinned in `mise.toml`): Rust,
just, Kani, Lean, cargo-mutants, cargo-fuzz. If a recipe needs a tool,
it goes in `mise.toml`; nothing gets installed by hand.
- Rust stable, edition 2024. `just check` must pass: fmt, clippy with zero
warnings, every test. `just prove` must pass for core changes. Both run
once per batch, before the push, never around every merge or commit
inside one (D-60); the cheap checks (compile, the touched tests, clippy
on the touched crate) run as you go.
- Rust stable, edition 2024. `cargo test` must pass; `cargo clippy` should be
clean; `cargo fmt` before committing.
- Conventional commits: `feat:`, `fix:`, `docs:`, `test:`, `refactor:`.
One logical change per commit. The commit message explains the *accounting*
reasoning, not just the code.
@@ -186,24 +100,16 @@ clone, with status. Rules:
| Read this | When you need |
|---|---|
| `plan/served.md` | The plan every remaining unit is built from: section 8.0 (status, order, recipes), 8.2 (the units), 10 (decisions) |
| `plan/HANDOFF.md` | The prompt for the implementing team, and how to resume the paused worktrees |
| `docs/requirements.md` | What the engine must do (R-1…), what is future work |
| `docs/architecture.md` | Crate layout, command pattern, dependency rules |
| `docs/oracle.md` | How we check correctness: golden files, property tests, fuzzing, cross-checks against other implementations (and who is right) |
| `docs/spec/` | The authorities every rule comes from (`rules.md`), with public-domain sources vendored verbatim |
| `docs/verification.md` | The formal proofs: every accounting law, its Kani harness and Lean theorem, what they found |
| `docs/oracle.md` | How we prove correctness: golden files, property tests, GnuCash diffing |
| `docs/parity.md` | Feature catalog and the non-regression floor |
| `docs/capability-parity.md` | Parity vs the GSheet+GDrive system + the cutover rule |
| `docs/mcp-interface.md` | The MCP tool surface: the primary interface spec |
| `docs/receipt-store.md` | Blob store + `ledger-store` SQLite persistence spec |
| `docs/testing.md` | Every test layer, each unit's test record, and what P12 must mutate |
| `docs/parties-and-attachments.md` | The party directory, roles, the TIN vault, attachments to any subject |
| https://git.swaits.com/swaits/bookspec | The formal accounting specification's own repository: README, examples, checker, runner (P13); pinned in `crates/ledger-core/Cargo.toml` |
| `deploy/README.md` | Running the books on a machine of your own (P9a) |
| `docs/glossary.md` | Debit/credit and friends, for agents new to accounting |
| `plan/STATUS.md` | Where the project stands right now |
| `plan/roadmap.md` | Where the project has been and is going: the installed app, then the hosted service |
| `plan/roadmap.md` | Phase plan: semantics → oracle harness → egui app → workflow parity |
## License
Generated
+26 -1450
View File
File diff suppressed because it is too large. Load diff
+1 -27
View File
@@ -1,6 +1,6 @@
[workspace]
resolver = "2"
members = ["crates/ledger-core", "crates/ledger-store", "crates/ledger-config", "crates/ledger-mcp", "crates/ledger-import", "crates/ledger-reader", "crates/ledger-cli", "crates/ledger-gui", "crates/ledger-vault", "crates/ledger-host", "crates/ledger-package"]
members = ["crates/ledger-core", "crates/ledger-store", "crates/ledger-config", "crates/ledger-mcp", "crates/ledger-import", "crates/ledger-cli", "crates/ledger-gui"]
[workspace.package]
edition = "2024"
@@ -9,29 +9,3 @@ repository = "https://git.swaits.com/swaits/wanderbooks"
[workspace.lints.rust]
missing_docs = "warn"
# `cargo kani` sets cfg(kani) for the proof harnesses.
unexpected_cfgs = { level = "warn", check-cfg = ["cfg(kani)"] }
# Argon2id at the plan's 64 MiB, three passes, takes seconds unoptimized:
# its crates are built optimized in debug and test builds too.
[profile.dev.package.argon2]
opt-level = 3
[profile.dev.package.blake2]
opt-level = 3
# `wanderbooks update` hashes and verifies a whole program (P11a); its
# tests do it over a debug build's hundreds of megabytes.
[profile.dev.package.sha2]
opt-level = 3
[profile.dev.package.minisign]
opt-level = 3
[profile.dev.package.minisign-verify]
opt-level = 3
# The program that ships in the packages (P11a): symbols stripped, so the
# AppImage, .deb and .rpm are not carrying debug tables.
[profile.release]
strip = "symbols"
+27 -87
View File
@@ -9,17 +9,14 @@ through MCP. Self-hosted, no rent.
- **`ledger-core`** — the semantics engine. Exact decimal money, five
account types, balanced journal entries, immutable posted log with
reversing entries, trial balance, P&L, balance sheet, the statement of
cash flows (direct method, with the reconciliation and noncash
schedules), fixed-asset register with straight-line depreciation.
reversing entries, trial balance, P&L, balance sheet, indirect-method
cash flow, fixed-asset register with straight-line depreciation.
- **`ledger-store`** — durable storage. The source of truth is an
append-only, hash-chained JSONL journal; SQLite is a disposable
projection rebuilt from it; receipts live in a content-addressed blob
store. Drafts are durable, corrupt projections self-heal.
- **`ledger-mcp`** — the AI-first interface. 122 MCP tools (2026-10-28), served by the
books server (`wanderbooks server`) on the data root's local sockets and
reached through the agents' bridge (`wanderbooks mcp --book ID`): draft →
preview → post workflow, server-side receipt
- **`ledger-mcp`** — the AI-first interface. 64 MCP tools over stdio or
Streamable HTTP: draft → preview → post workflow, server-side receipt
policy (>$25), anti-splitting detection, asset management, digest for
human review, plus read-only resources for dashboards.
- **`ledger-import`** — QuickBooks interop. Import bank/credit-card CSVs
@@ -27,7 +24,7 @@ through MCP. Self-hosted, no rent.
Desktop) and XLSX reports.
- **`ledger-config`** — the company settings in a books dir's
`wanderbooks.toml`.
- **`ledger-gui`** — the viewer: a read-only, live window on the books for owners and their
- **`ledger-gui`** — the read-only desktop view for owners and their
accountant (egui). Answers first (cash, runway, profit, what needs
attention), then every number drills down to its lines, its entry, the
rendered receipt, and the journal history. Follows the journal live:
@@ -39,39 +36,19 @@ through MCP. Self-hosted, no rent.
This is the storytold way applied to accounting: agents verify against
oracles, never ship what they haven't demonstrated.
- About 2,950 tests across the two workspaces (2026-10-29): invariant tests encoding accounting law, golden-file
report snapshots, property tests (proptest), end-to-end MCP tests through
a real books server.
- **Formal proofs of the accounting laws** (`just prove`,
`docs/verification.md`). Kani model-checks the engine's arithmetic
kernel exhaustively within bounds. Lean 4 proves that, after any
sequence of commands, the trial balance balances, the accounting
equation holds, posted entries are forever, closed periods stay
frozen, and a close moves exactly the net income to equity. The first
pass found three engine bugs that every test, fuzzer, and mutant had
missed: negative amounts accepted, and two crashes on oversized
amounts. It also caught a soundness bug in the model checker itself.
- Mutation testing via cargo-mutants, a local run (paused for development
until the P12 max-testing pass, D-33; not a CI job, D-59); fuzzing via
cargo-fuzz (11.5M+ executions, zero crashes), weekly in CI.
- GnuCash is *a* behavioral cross-check, never the authority and never
a code source (clean room, MIT OR Apache-2.0). Accounting principles,
proved, are the spec. Where any other program disagrees, we work out
from principles who is right.
- 70 workspace tests: invariant tests encoding accounting law, golden-file
report snapshots, property tests (proptest), end-to-end MCP stdio tests.
- Mutation testing via cargo-mutants; fuzzing via cargo-fuzz (11.5M+
executions, zero crashes).
- GnuCash is the behavioral oracle (never a code source — clean room,
MIT OR Apache-2.0).
## Status
Parity is 12 of 12 against the spreadsheet workflow it replaced
(`docs/capability-parity.md`); the books cut over on 2026-10-20 and the
Sheet is frozen as of 2026-10-11. The remaining work, its order and its
hand-off are `plan/served.md` section 8.0; the destination is one
installed app for Mac, Windows and Linux (P11), then the hosted service
the plan describes and does not build. Today the agent path runs on
Linux, macOS and Windows from a source build: tokens live in a file under
the private data root (D-63), the data root is `%LOCALAPPDATA%\wanderbooks`
with an owner-only access list on Windows (P11c, D-62), and the books'
local channels are named pipes there. Windows is not yet packaged: no
installer, no signing (P11a).
Parity is 12 of 12 against the spreadsheet workflow it replaces
(`docs/capability-parity.md`). The parallel-run plan
(`docs/parallel-run.md`) is written; the Google Sheet stays the system of
record until a clean month and explicit cutover approval.
See [`plan/STATUS.md`](plan/STATUS.md) for where things stand and
[`plan/roadmap.md`](plan/roadmap.md) for where it's going.
@@ -79,69 +56,32 @@ See [`plan/STATUS.md`](plan/STATUS.md) for where things stand and
## Quickstart
```bash
cargo test # whole suite
cargo run -p ledger-gui -- --demo # the viewer, on a demo company
cargo test # whole suite
cargo run -p ledger-mcp -- --data-dir /tmp/books # MCP server over stdio
cargo run -p ledger-gui -- --demo # the desktop view, on a demo company
```
New books, one command (installs Rust and wanderbooks if needed, makes the
books' private folder, starts books called `acme` and lets Claude Code reach
them; nothing needs sudo):
books dir with the MCP server registered for it, and starts the agent):
```bash
curl -fsSL https://wanderbooks.ai/install.sh | sh -s -- acme
curl -fsSL https://wanderbooks.ai/install.sh | sh -s -- ~/Books/Acme
```
(Linux and macOS. On Windows build from source: `cargo install --path crates/ledger-cli`, then the three commands below.)
Already installed, three commands: `wanderbooks setup`, `wanderbooks books
create acme`, `wanderbooks connect claude --book acme` (or `connect codex`).
Then talk to the agent; `wanderbooks gui acme` shows the books, and its
Books menu backs them up, restores, starts new books and closes them. With
no terminal (the AppImage, a menu entry), opening the app with no books
shows a first-run page that does the same three steps (the private folder,
an owner passphrase, the first books) and connects an agent; a panel along
the bottom of the window then runs Claude Code or Codex in the books' own
folder, `~/Documents/Wanderbooks/<id>` (optional: any agent can use the
books).
Updating: an AppImage, or a `wanderbooks` you put somewhere yourself, brings
itself to the newest release with `wanderbooks update` (it checks the
download against our release key, keeps the version it replaces, and the
open books restart onto the new one when idle); `wanderbooks rollback` goes
back, unless a book has since moved to a newer journal format. A `.deb`,
`.rpm` or AUR install is updated by its package manager and `update` says
so. The Linux packages are built here with `just package-linux`
(docs/packaging.md); none is published yet.
## Developing
Every tool the recipes use (Rust, just, Kani, Lean, cargo-mutants,
cargo-fuzz, Node for deploys) is pinned in `mise.toml`:
```sh
mise install
just # lists the recipes: check, build, prove, demo, ...
```
Already installed: `wanderbooks new ~/Books/Acme`. In a books dir, `just`
lists the shortcuts (`just gui`, `just agent`, `just backup`).
## Layout
```
crates/ledger-core/ # engine: money, accounts, journal, ledger, reports
crates/ledger-store/ # journal + SQLite projection + blob store
crates/ledger-mcp/ # MCP server: 129 tools, resources, handbook, receipt policy
crates/ledger-mcp/ # MCP server: 64 tools, resources, receipt policy
crates/ledger-import/ # QBO CSV import, IIF/XLSX export
crates/ledger-config/ # wanderbooks.toml company settings
crates/ledger-cli/ # `wanderbooks`: the one executable (every mode)
crates/ledger-host/ # the host, the agents' bridge, the owner's commands
crates/ledger-package/ # the shared read-only view: the CPA package, the invoice PDF, Needs attention
crates/ledger-vault/ # the TIN vault (XChaCha20-Poly1305 under the book's key)
crates/ledger-gui/ # the viewer: read-only, live (egui)
verify/lean/ # the accounting laws as Lean theorems (`just prove`)
# the formal accounting specification lives in its own repository, https://git.swaits.com/swaits/bookspec (pinned dev-dependency, P13)
fuzz/, oracle/ # fuzz targets; the GnuCash differential (`just oracle`)
deploy/ # the self-host recipe and system unit (P9a)
site/ # wanderbooks.ai and install.sh
docs/ # requirements, architecture, oracle, parity, testing, verification, spec, glossary
plan/ # served.md (the plan), STATUS.md, roadmap.md, HANDOFF.md, gui.md
crates/ledger-cli/ # `wanderbooks new`: one-command books dir + agent
crates/ledger-gui/ # read-only live desktop view (egui)
docs/ # requirements, architecture, oracle, parity, glossary
plan/ # STATUS.md, roadmap.md
AGENTS.md # operating manual for agents working here
```
+2 -47
View File
@@ -4,61 +4,16 @@ version = "0.1.0"
edition.workspace = true
license.workspace = true
repository.workspace = true
description = "wanderbooks: the one executable (viewer, books server, host, agents' bridge, owner's commands)."
description = "wanderbooks CLI: one command from nothing to a working books directory."
[[bin]]
name = "wanderbooks"
path = "src/main.rs"
[features]
default = ["gui"]
# The viewer. Off in the server-only build for another machine (plan
# D-22, P9), so a headless machine carries no window libraries.
gui = ["dep:ledger-gui"]
[dependencies]
ledger-host = { path = "../ledger-host" }
ledger-mcp = { path = "../ledger-mcp" }
ledger-store = { path = "../ledger-store" }
ledger-gui = { path = "../ledger-gui", optional = true }
[dev-dependencies]
base64 = "0.22"
chrono = "0.4"
# Every test that drives the books server's tools: the CPA package they
# open, the outputSchemas they check against, the random cases, and the
# database read_only_tools_change_nothing compares by its rows.
jsonschema = { version = "0.58", default-features = false }
libc = "0.2"
proptest = "1"
reqwest = "0.13"
rusqlite = { version = "0.32", features = ["bundled"] }
rust_decimal = "1"
rmcp = { version = "3", features = ["client", "transport-streamable-http-client-reqwest"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
ledger-config = { path = "../ledger-config" }
ledger-core = { path = "../ledger-core" }
ledger-package = { path = "../ledger-package" }
ledger-import = { path = "../ledger-import" }
ledger-vault = { path = "../ledger-vault" }
serde_json = "1"
sha2 = "0.10"
toml = "1"
tempfile = "3"
zip = "9.0.1"
minisign = "0.10.0"
[lints]
workspace = true
# The .rpm (P11a step 3), built by cargo-generate-rpm from the release
# program: `just package-linux`. Paths are relative to the workspace root.
[package.metadata.generate-rpm]
name = "wanderbooks"
summary = "Double-entry bookkeeping your agent keeps and you can read"
license = "MIT OR Apache-2.0"
url = "https://wanderbooks.ai"
assets = [
{ source = "target/release/wanderbooks", dest = "/usr/bin/wanderbooks", mode = "0755" },
{ source = "deploy/packaging/install-method-rpm", dest = "/usr/share/wanderbooks/install-method", mode = "0644" },
{ source = "deploy/packaging/wanderbooks-static.desktop", dest = "/usr/share/applications/wanderbooks.desktop", mode = "0644" },
]
File diff suppressed because it is too large. Load diff
-202
View File
@@ -1,202 +0,0 @@
//! Agent evals (plan P5 step 7): scripted transcripts, no model. Each is a
//! JSON file in `agent_evals/`, played against a real books server through
//! the wire as an agent reaches it. The rule they enforce is the point of
//! the result envelope: **an agent that begins with `books.start` can do
//! its work by calling only what the previous result's `next_steps` named.**
//!
//! A transcript is `{ "setup": [..], "settings": "<toml>", "steps": [..] }`.
//! `setup` calls are made directly (the chart and the books an agent finds
//! when it arrives; they are not the agent's moves). A step is one of:
//!
//! - `{"call": "books.start", "args": {..}}`: only the first step, and
//! only `books.start`.
//! - `{"follow": "books.x", "with": {..}}`: the previous result's
//! `next_steps` must name tool `books.x`; the call is made with that
//! step's `args` and `with` laid over them (what the agent decides: an
//! amount, a date).
//! - `{"follow_prompt": "onboard"}`: the previous result's `next_steps`
//! must name that prompt; it is fetched with `prompts/get`.
//! - `{"expect_next_steps_contains": "books.x"}` and
//! `{"expect": {"/json/pointer": value}}` on the previous result.
//!
//! In `with` and `args`, `{"$upload": {"name", "mime", "text"}}` is the
//! upload_id of that file, uploaded first as an agent does, and
//! `{"$var": "x"}` is a value a setup call saved with `"save": {"x":
//! "/pointer"}`.
//!
//! Expected behavior comes from `docs/mcp-interface.md` ("The result
//! envelope") and plan 4.6.
mod common;
use common::{Books, Wire, answer_of};
use serde_json::{Map, Value, json};
use std::collections::HashMap;
/// A value with `$upload` and `$var` laid in.
fn resolve(w: &mut Wire, vars: &HashMap<String, Value>, v: &Value) -> Value {
match v {
Value::Object(o) if o.len() == 1 && o.contains_key("$upload") => {
let u = &o["$upload"];
let text = u["text"].as_str().expect("an upload has text");
json!(w.upload(
text.as_bytes(),
u["name"].as_str().unwrap_or("file.pdf"),
u["mime"].as_str().unwrap_or("application/pdf"),
u["purpose"].as_str().unwrap_or("receipt"),
))
}
Value::Object(o) if o.len() == 1 && o.contains_key("$var") => vars
.get(o["$var"].as_str().unwrap())
.unwrap_or_else(|| panic!("no saved value {}", o["$var"]))
.clone(),
Value::Object(o) => Value::Object(
o.iter()
.map(|(k, x)| (k.clone(), resolve(w, vars, x)))
.collect(),
),
Value::Array(a) => Value::Array(a.iter().map(|x| resolve(w, vars, x)).collect()),
other => other.clone(),
}
}
/// Play one transcript; returns the tools it called after `books.start`.
fn play(name: &str, script: &Value) -> Vec<String> {
let books = Books::with(&format!("eval-{name}"), "USD", script["settings"].as_str());
let mut w = books.session("agent-evals");
let mut vars: HashMap<String, Value> = HashMap::new();
for s in script["setup"].as_array().into_iter().flatten() {
let args = resolve(&mut w, &vars, &s["args"]);
let tool = s["call"].as_str().unwrap();
let out =
answer_of(&w.call(tool, args)).unwrap_or_else(|e| panic!("{name}: setup {tool}: {e}"));
for (var, pointer) in s["save"].as_object().into_iter().flatten() {
vars.insert(
var.clone(),
out.pointer(pointer.as_str().unwrap())
.unwrap_or_else(|| panic!("{name}: setup {tool} has no {pointer}: {out}"))
.clone(),
);
}
}
let mut last: Option<Value> = None;
let mut called = Vec::new();
for (i, step) in script["steps"]
.as_array()
.expect("steps")
.iter()
.enumerate()
{
let here = format!("{name} step {i}");
if let Some(tool) = step["call"].as_str() {
assert_eq!(
i, 0,
"{here}: only the first step may call without following"
);
assert_eq!(tool, "books.start", "{here}: the first call is books.start");
let args = resolve(&mut w, &vars, &step["args"]);
last =
Some(answer_of(&w.call_raw(tool, args)).unwrap_or_else(|e| panic!("{here}: {e}")));
} else if let Some(want) = step["follow"].as_str() {
let prev = last
.as_ref()
.unwrap_or_else(|| panic!("{here}: follow before start"));
let offered = prev["next_steps"].as_array().cloned().unwrap_or_default();
let named = offered
.iter()
.find(|s| s["tool"] == json!(want))
.unwrap_or_else(|| {
panic!(
"{here}: {want} is not in the previous result's next_steps: {:?}",
offered
.iter()
.map(|s| s["tool"].clone())
.collect::<Vec<_>>()
)
});
let mut args: Map<String, Value> =
named["args"].as_object().cloned().unwrap_or_default();
for (k, v) in step["with"].as_object().into_iter().flatten() {
args.insert(k.clone(), resolve(&mut w, &vars, v));
}
let out = answer_of(&w.call_raw(want, Value::Object(args)))
.unwrap_or_else(|e| panic!("{here}: {want} was refused: {e}"));
called.push(want.to_string());
last = Some(out);
} else if let Some(want) = step["follow_prompt"].as_str() {
let prev = last
.as_ref()
.unwrap_or_else(|| panic!("{here}: follow before start"));
assert!(
prev["next_steps"]
.as_array()
.into_iter()
.flatten()
.any(|s| s["prompt"] == json!(want)),
"{here}: prompt {want} is not in next_steps"
);
let got = w.rpc("prompts/get", json!({"name": want, "arguments": {}}));
let text = got["result"]["messages"][0]["content"]["text"]
.as_str()
.unwrap_or_else(|| panic!("{here}: the prompt did not render: {got}"));
assert!(text.contains("books."), "{here}: {text}");
} else if let Some(tool) = step["expect_next_steps_contains"].as_str() {
let prev = last.as_ref().unwrap();
assert!(
prev["next_steps"]
.as_array()
.into_iter()
.flatten()
.any(|s| s["tool"] == json!(tool)),
"{here}: next_steps lack {tool}: {}",
prev["next_steps"]
);
} else if let Some(expect) = step["expect"].as_object() {
let prev = last.as_ref().unwrap();
for (pointer, want) in expect {
assert_eq!(
prev.pointer(pointer),
Some(want),
"{here}: {pointer} in {prev}"
);
}
} else {
panic!("{here}: not a step: {step}");
}
}
called
}
/// Every transcript follows only what it was told, and ends where it says.
#[test]
fn agent_evals_follow_only_next_steps() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/agent_evals");
let mut files: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.map(|e| e.unwrap().path())
.filter(|p| p.extension().is_some_and(|e| e == "json"))
.collect();
files.sort();
let names: Vec<String> = files
.iter()
.map(|p| p.file_stem().unwrap().to_string_lossy().into_owned())
.collect();
assert_eq!(
names,
[
"draft_awaiting_a_receipt",
"month_end_close",
"onboarding_to_first_post",
"receipt_backed_post",
"reconciliation",
"reversal"
],
"the six evals the plan names"
);
for (name, path) in names.iter().zip(&files) {
let script: Value = serde_json::from_str(&std::fs::read_to_string(path).unwrap())
.unwrap_or_else(|e| panic!("{name}: {e}"));
let called = play(name, &script);
assert!(!called.is_empty(), "{name} calls nothing");
}
}
@@ -1,102 +0,0 @@
{
"description": "A draft that waits for its receipt, and posts when it arrives.",
"settings": "[company]\nname = \"Eval Co\"\nfiscal_year_start = \"01-01\"\n[books]\ncash_accounts = [\"1000\"]\n",
"setup": [
{
"call": "books.add_account",
"args": {
"code": "1000",
"name": "Cash",
"account_type": "asset"
}
},
{
"call": "books.add_account",
"args": {
"code": "3000",
"name": "Capital",
"account_type": "equity"
}
},
{
"call": "books.add_account",
"args": {
"code": "6000",
"name": "Rent",
"account_type": "expense"
}
}
],
"steps": [
{
"call": "books.start",
"args": {}
},
{
"follow": "books.draft_entry",
"with": {
"date": "2026-01-10",
"description": "Landlord, January",
"no_party_reason": "test"
}
},
{
"follow": "books.add_line",
"with": {
"account": "6000",
"side": "debit",
"amount": "40.00"
}
},
{
"follow": "books.add_line",
"with": {
"account": "1000",
"side": "credit",
"amount": "40.00"
}
},
{
"follow": "books.post_entry",
"with": {}
},
{
"follow": "books.set_draft_state",
"with": {
"state": "awaiting_evidence",
"note": "the landlord's receipt"
}
},
{
"expect": {
"/state": "awaiting_evidence"
}
},
{
"follow": "books.attach_receipt",
"with": {
"upload_id": {
"$upload": {
"name": "receipt.pdf",
"mime": "application/pdf",
"text": "%PDF-1.4 a receipt for rent",
"purpose": "receipt"
}
}
}
},
{
"follow": "books.post_entry",
"with": {}
},
{
"follow": "books.post_entry",
"with": {}
},
{
"expect": {
"/status": "posted"
}
}
]
}
@@ -1,83 +0,0 @@
{
"description": "A period that ended is closed: the open period is found, previewed, closed, and the books verified.",
"settings": "[company]\nname = \"Eval Co\"\nfiscal_year_start = \"01-01\"\n[books]\ncash_accounts = [\"1000\"]\n",
"setup": [
{
"call": "books.add_account",
"args": {
"code": "1000",
"name": "Cash",
"account_type": "asset"
}
},
{
"call": "books.add_account",
"args": {
"code": "3000",
"name": "Capital",
"account_type": "equity"
}
},
{
"call": "books.add_account",
"args": {
"code": "6000",
"name": "Rent",
"account_type": "expense"
}
},
{
"call": "books.create_period",
"args": {
"name": "2025-12",
"start": "2025-12-01",
"end": "2025-12-31"
}
}
],
"steps": [
{
"call": "books.start",
"args": {
"focus": "close"
}
},
{
"expect_next_steps_contains": "books.list_periods"
},
{
"follow": "books.list_periods",
"with": {}
},
{
"follow": "books.close_period",
"with": {}
},
{
"expect": {
"/status": "preview"
}
},
{
"follow": "books.close_period",
"with": {}
},
{
"expect": {
"/status": "closed"
}
},
{
"expect_next_steps_contains": "books.verify"
},
{
"follow": "books.verify",
"with": {}
},
{
"expect": {
"/status": "ok"
}
}
]
}
@@ -1,102 +0,0 @@
{
"description": "A new book: onboarding to the first posted entry, following only next_steps.",
"steps": [
{
"call": "books.start",
"args": {
"agent": {
"model": "scripted",
"reasoning_effort": "none",
"harness": "agent_evals"
}
}
},
{
"expect": {
"/state": "needs_onboarding"
}
},
{
"expect_next_steps_contains": "books.set_setting"
},
{
"follow_prompt": "onboard"
},
{
"follow": "books.set_setting",
"with": {
"changes": [
{
"key": "company.name",
"value": "Eval Co"
}
],
"decided_by": "Avery Quill"
}
},
{
"expect_next_steps_contains": "books.add_account"
},
{
"follow": "books.add_account",
"with": {
"code": "1000",
"name": "Cash",
"account_type": "asset"
}
},
{
"follow": "books.add_account",
"with": {
"code": "3000",
"name": "Capital",
"account_type": "equity"
}
},
{
"follow": "books.draft_entry",
"with": {
"date": "2026-01-05",
"description": "Opening capital",
"no_party_reason": "opening balance"
}
},
{
"follow": "books.add_line",
"with": {
"account": "1000",
"side": "debit",
"amount": "10.00"
}
},
{
"follow": "books.add_line",
"with": {
"account": "3000",
"side": "credit",
"amount": "10.00"
}
},
{
"follow": "books.post_entry",
"with": {}
},
{
"expect": {
"/status": "preview"
}
},
{
"follow": "books.post_entry",
"with": {}
},
{
"expect": {
"/status": "posted"
}
},
{
"expect_next_steps_contains": "books.show"
}
]
}
@@ -1,96 +0,0 @@
{
"description": "A post the receipt policy covers: the preview asks for the receipt, the receipt is attached, the entry posts.",
"settings": "[company]\nname = \"Eval Co\"\nfiscal_year_start = \"01-01\"\n[books]\ncash_accounts = [\"1000\"]\n",
"setup": [
{
"call": "books.add_account",
"args": {
"code": "1000",
"name": "Cash",
"account_type": "asset"
}
},
{
"call": "books.add_account",
"args": {
"code": "3000",
"name": "Capital",
"account_type": "equity"
}
},
{
"call": "books.add_account",
"args": {
"code": "6000",
"name": "Rent",
"account_type": "expense"
}
}
],
"steps": [
{
"call": "books.start",
"args": {}
},
{
"expect_next_steps_contains": "books.draft_entry"
},
{
"follow": "books.draft_entry",
"with": {
"date": "2026-01-10",
"description": "January rent",
"no_party_reason": "test"
}
},
{
"follow": "books.add_line",
"with": {
"account": "6000",
"side": "debit",
"amount": "40.00"
}
},
{
"follow": "books.add_line",
"with": {
"account": "1000",
"side": "credit",
"amount": "40.00"
}
},
{
"follow": "books.post_entry",
"with": {}
},
{
"expect_next_steps_contains": "books.attach_receipt"
},
{
"follow": "books.attach_receipt",
"with": {
"upload_id": {
"$upload": {
"name": "receipt.pdf",
"mime": "application/pdf",
"text": "%PDF-1.4 a receipt for rent",
"purpose": "receipt"
}
}
}
},
{
"follow": "books.post_entry",
"with": {}
},
{
"follow": "books.post_entry",
"with": {}
},
{
"expect": {
"/status": "posted"
}
}
]
}
@@ -1,148 +0,0 @@
{
"description": "A cash account reconciled to its statement: record it, mark the lines, reconcile.",
"settings": "[company]\nname = \"Eval Co\"\nfiscal_year_start = \"01-01\"\n[books]\ncash_accounts = [\"1000\"]\n",
"setup": [
{
"call": "books.add_account",
"args": {
"code": "1000",
"name": "Cash",
"account_type": "asset"
}
},
{
"call": "books.add_account",
"args": {
"code": "3000",
"name": "Capital",
"account_type": "equity"
}
},
{
"call": "books.add_account",
"args": {
"code": "6000",
"name": "Rent",
"account_type": "expense"
}
},
{
"call": "books.draft_entry",
"args": {
"date": "2026-01-05",
"description": "Opening capital",
"no_party_reason": "opening balance"
},
"save": {
"entry": "/entry_id"
}
},
{
"call": "books.add_line",
"args": {
"entry_id": {
"$var": "entry"
},
"account": "1000",
"side": "debit",
"amount": "10.00"
}
},
{
"call": "books.add_line",
"args": {
"entry_id": {
"$var": "entry"
},
"account": "3000",
"side": "credit",
"amount": "10.00"
}
},
{
"call": "books.post_entry",
"args": {
"entry_id": {
"$var": "entry"
},
"confirmed": true
}
},
{
"call": "books.file_document",
"args": {
"category": "statement",
"file_name": "jan.pdf",
"mime_type": "application/pdf",
"upload_id": {
"$upload": {
"name": "jan.pdf",
"mime": "application/pdf",
"text": "%PDF-1.4 January statement",
"purpose": "document"
}
}
},
"save": {
"statement": "/attachment_id"
}
}
],
"steps": [
{
"call": "books.start",
"args": {}
},
{
"expect_next_steps_contains": "books.record_statement"
},
{
"follow": "books.record_statement",
"with": {
"attachment_id": {
"$var": "statement"
},
"account": "1000",
"period_start": "2026-01-01",
"period_end": "2026-01-31",
"opening_balance": "0.00",
"closing_balance": "10.00"
}
},
{
"follow": "books.mark_cleared",
"with": {
"entry_id": {
"$var": "entry"
},
"line_index": 0,
"cleared": true
}
},
{
"follow": "books.reconcile",
"with": {
"account": "1000",
"statement_date": "2026-01-31",
"statement_balance": "10.00",
"statement_attachment": {
"$var": "statement"
}
}
},
{
"expect": {
"/status": "preview"
}
},
{
"follow": "books.reconcile",
"with": {}
},
{
"expect": {
"/status": "reconciled"
}
}
]
}
@@ -1,94 +0,0 @@
{
"description": "A posted entry reversed: previewed, confirmed, never edited.",
"settings": "[company]\nname = \"Eval Co\"\nfiscal_year_start = \"01-01\"\n[books]\ncash_accounts = [\"1000\"]\n",
"setup": [
{
"call": "books.add_account",
"args": {
"code": "1000",
"name": "Cash",
"account_type": "asset"
}
},
{
"call": "books.add_account",
"args": {
"code": "3000",
"name": "Capital",
"account_type": "equity"
}
},
{
"call": "books.add_account",
"args": {
"code": "6000",
"name": "Rent",
"account_type": "expense"
}
}
],
"steps": [
{
"call": "books.start",
"args": {}
},
{
"follow": "books.draft_entry",
"with": {
"date": "2026-01-10",
"description": "Coffee beans",
"no_party_reason": "test"
}
},
{
"follow": "books.add_line",
"with": {
"account": "6000",
"side": "debit",
"amount": "10.00"
}
},
{
"follow": "books.add_line",
"with": {
"account": "1000",
"side": "credit",
"amount": "10.00"
}
},
{
"follow": "books.post_entry",
"with": {}
},
{
"follow": "books.post_entry",
"with": {}
},
{
"expect": {
"/status": "posted"
}
},
{
"follow": "books.reverse_entry",
"with": {
"date": "2026-01-11",
"description": "Reverse: entered in error"
}
},
{
"expect": {
"/status": "preview"
}
},
{
"follow": "books.reverse_entry",
"with": {}
},
{
"expect": {
"/status": "posted"
}
}
]
}
-547
View File
@@ -1,547 +0,0 @@
//! The agent surface over the wire (plan P5): `books.start` v2, the
//! journal in words, source corrections, the prompts and the resource
//! templates. Every call goes through a real books server as an agent
//! reaches it (`common.rs`).
//!
//! Expected behavior comes from `docs/mcp-interface.md` ("Tools — the
//! journal in words (P5)", "The result envelope", "Prompts", "Resources")
//! and plan 4.2, 4.4, 4.5.
mod common;
use common::{Books, answer_of};
use serde_json::{Value, json};
use std::path::Path;
const SETTINGS: &str = "[company]\nname = \"Surface Co\"\nfiscal_year_start = \"01-01\"\n\
[books]\ncash_accounts = [\"1000\"]\n";
fn chart(w: &mut common::Wire) {
for (code, name, ty) in [
("1000", "Cash", "asset"),
("3000", "Capital", "equity"),
("6000", "Rent", "expense"),
] {
answer_of(&w.call(
"books.add_account",
json!({"code": code, "name": name, "account_type": ty}),
))
.unwrap();
}
}
/// A posted entry of `amount` rent paid from cash (under the receipt
/// threshold); its id.
fn post(w: &mut common::Wire, date: &str, what: &str, amount: &str) -> u64 {
let id = answer_of(&w.call(
"books.draft_entry",
json!({"date": date, "description": what, "no_party_reason": "test"}),
))
.unwrap()["entry_id"]
.as_u64()
.unwrap();
for (account, side) in [("6000", "debit"), ("1000", "credit")] {
answer_of(&w.call(
"books.add_line",
json!({"entry_id": id, "account": account, "side": side, "amount": amount}),
))
.unwrap();
}
answer_of(&w.call(
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
))
.unwrap();
id
}
/// `books.journal_history` reads the journal in order, in words; filters
/// by subject and type; pages with `more`; refuses a bad subject; and a
/// refusal changes nothing.
#[test]
fn journal_history_traces_what_the_books_did() {
let books = Books::with("p5-history", "USD", Some(SETTINGS));
let mut w = books.session("p5");
chart(&mut w);
let first = post(&mut w, "2026-01-05", "January rent", "10.00");
let second = post(&mut w, "2026-01-06", "Printer paper", "11.00");
let all = answer_of(&w.call("books.journal_history", json!({"limit": 200}))).unwrap();
let lines = all["lines"].as_array().unwrap();
let seqs: Vec<u64> = lines.iter().map(|l| l["seq"].as_u64().unwrap()).collect();
assert!(
seqs.windows(2).all(|p| p[0] < p[1]),
"journal order: {seqs:?}"
);
assert_eq!(all["head_seq"], *seqs.last().unwrap());
assert_eq!(all["more"], false);
let posted = lines
.iter()
.find(|l| l["type"] == "entry.posted" && l["entry_id"] == json!(first))
.unwrap_or_else(|| panic!("no entry.posted for {first}: {all}"));
assert!(
posted["summary"].as_str().unwrap().contains("January rent"),
"{posted}"
);
// A subject keeps the lines about it, and only those.
let about = answer_of(&w.call(
"books.journal_history",
json!({"subject": format!("entry:{second}")}),
))
.unwrap();
let about = about["lines"].as_array().unwrap();
assert!(about.len() >= 3, "created, lines, posted: {about:?}");
assert!(
about.iter().all(|l| l["entry_id"] == json!(second)),
"{about:?}"
);
// A type group, and an exact type.
let group = answer_of(&w.call("books.journal_history", json!({"types": ["entry."]}))).unwrap();
assert!(
group["lines"]
.as_array()
.unwrap()
.iter()
.all(|l| l["type"].as_str().unwrap().starts_with("entry."))
);
let exact =
answer_of(&w.call("books.journal_history", json!({"types": ["entry.posted"]}))).unwrap();
assert_eq!(exact["lines"].as_array().unwrap().len(), 2);
// Paging: two at a time, from where the last page ended.
let page = answer_of(&w.call("books.journal_history", json!({"limit": 2}))).unwrap();
assert_eq!(page["lines"].as_array().unwrap().len(), 2);
assert_eq!(page["more"], true);
let after = page["lines"][1]["seq"].as_u64().unwrap();
let next = answer_of(&w.call(
"books.journal_history",
json!({"after_seq": after, "limit": 2}),
))
.unwrap();
assert_eq!(next["lines"][0]["seq"].as_u64().unwrap(), after + 1);
// A bad subject is refused with the shapes; the journal is unchanged.
let before = books.journal();
let err =
answer_of(&w.call("books.journal_history", json!({"subject": "nonsense"}))).unwrap_err();
assert!(err.contains("entry:12"), "{err}");
assert_eq!(books.journal(), before);
// It is a read: no head in the envelope.
assert!(all.get("head").is_none(), "{all}");
}
/// A source-only correction over the wire: the three tools take `reason`
/// (or `revises` with it), the words stay, the journal says so, and a
/// change of the words is not a correction.
#[test]
fn a_source_only_revision_over_the_wire() {
let books = Books::with("p5-source", "USD", Some(SETTINGS));
let mut w = books.session("p5");
let convention = |by: &str, body: &str| {
json!({"id": "close-checklist", "kind": "close_step", "title": "Close checklist",
"body_md": body, "decided_by": by, "decided_on": "2026-10-20"})
};
answer_of(&w.call(
"books.set_convention",
convention("Owner", "Reconcile, then close."),
))
.unwrap();
let same = answer_of(&w.call(
"books.set_convention",
convention("Pat Smith", "Reconcile, then close."),
))
.unwrap_err();
assert!(
same.contains("already says exactly that") && same.contains("reason"),
"{same}"
);
let mut fixed = convention("Pat Smith", "Reconcile, then close.");
fixed["reason"] = json!("the CPA decided it");
fixed["citation"] = json!("IRS Pub. 538");
let out = answer_of(&w.call("books.set_convention", fixed.clone())).unwrap();
assert_eq!(
out["convention"]["source"]["decided_by"], "Pat Smith",
"{out}"
);
assert_eq!(out["convention"]["revision"], 1, "the words did not change");
let mut other = convention("Pat Smith", "Close first.");
other["reason"] = json!("x");
let err = answer_of(&w.call("books.set_convention", other)).unwrap_err();
assert!(err.contains("more than its source"), "{err}");
// A decision and an open item.
let decision = answer_of(&w.call(
"books.record_decision",
json!({"subject": "Materiality", "decision_md": "Five hundred dollars.",
"decided_by": ["Owner"], "decided_on": "2026-10-20"}),
))
.unwrap()["id"]
.clone();
let fixed_decision = json!({"revises": decision, "reason": "both decided it",
"decided_by": ["Owner", "Pat Smith"], "decided_on": "2026-10-21"});
answer_of(&w.call("books.record_decision", fixed_decision.clone())).unwrap();
let d = answer_of(&w.call("books.decisions", json!({}))).unwrap();
assert_eq!(
d["decisions"][0]["decided_by"],
json!(["Owner", "Pat Smith"]),
"{d}"
);
assert_eq!(d["decisions"][0]["decision_md"], "Five hundred dollars.");
let no_reason = answer_of(&w.call(
"books.record_decision",
json!({"revises": decision, "decided_by": ["Pat Smith"], "decided_on": "2026-10-22"}),
))
.unwrap_err();
assert!(no_reason.contains("reason"), "{no_reason}");
let item = answer_of(&w.call(
"books.raise_item",
json!({"title": "Ask the CPA", "decided_by": "Owner", "decided_on": "2026-10-20"}),
))
.unwrap()["id"]
.clone();
answer_of(&w.call(
"books.raise_item",
json!({"revises": item, "reason": "raised by the CPA", "decided_by": "Pat Smith",
"decided_on": "2026-10-21"}),
))
.unwrap();
let items = answer_of(&w.call("books.open_items", json!({}))).unwrap();
assert_eq!(items["items"][0]["title"], "Ask the CPA");
assert_eq!(
items["items"][0]["raised"]["decided_by"], "Pat Smith",
"{items}"
);
// The journal says what happened, in order.
let h = answer_of(&w.call(
"books.journal_history",
json!({"types": ["convention.", "decision.", "open_item."]}),
))
.unwrap();
let types: Vec<&str> = h["lines"]
.as_array()
.unwrap()
.iter()
.map(|l| l["type"].as_str().unwrap())
.collect();
assert_eq!(
types,
[
"convention.set",
"convention.source_revised",
"decision.recorded",
"decision.source_revised",
"open_item.raised",
"open_item.source_revised"
]
);
// A restart reads the same books.
books.restart();
let mut w = books.session("p5");
let c = answer_of(&w.call("books.conventions", json!({}))).unwrap();
assert_eq!(
c["conventions"][0]["source"]["decided_by"], "Pat Smith",
"{c}"
);
}
/// Prompts: six, listed with their arguments, rendered with the company's
/// name and the arguments; an argument is one token of the right shape.
#[test]
fn the_prompts_list_and_render() {
let books = Books::with("p5-prompts", "USD", Some(SETTINGS));
let mut w = books.wire();
let init = w.initialize("p5");
assert!(
init["result"]["capabilities"]["prompts"].is_object(),
"{init}"
);
let listed = w.rpc("prompts/list", json!({}));
let names: Vec<&str> = listed["result"]["prompts"]
.as_array()
.unwrap()
.iter()
.map(|p| p["name"].as_str().unwrap())
.collect();
assert_eq!(
names,
[
"onboard",
"reconcile",
"month_end_close",
"cpa_handoff",
"annual_compliance",
"audit"
]
);
let source = |name: &str| {
std::fs::read_to_string(
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../ledger-mcp/prompts")
.join(format!("{name}.md")),
)
.unwrap()
};
for (name, args, fills) in [
("onboard", json!({}), vec![]),
(
"reconcile",
json!({"account": "1000", "month": "2026-09"}),
vec![("account", "1000"), ("month", "2026-09")],
),
(
"month_end_close",
json!({"month": "2026-09"}),
vec![("month", "2026-09")],
),
(
"cpa_handoff",
json!({"year": "2026"}),
vec![("year", "2026")],
),
(
"annual_compliance",
json!({"year": "2026"}),
vec![("year", "2026")],
),
("audit", json!({"scope": "full"}), vec![("scope", "full")]),
] {
let got = w.rpc("prompts/get", json!({"name": name, "arguments": args}));
let text = got["result"]["messages"][0]["content"]["text"]
.as_str()
.unwrap_or_else(|| panic!("{name}: {got}"));
// The golden: the prompt's own file with its blanks filled.
let mut want = source(name).trim_end().to_string();
for (k, v) in fills {
want = want.replace(&format!("{{{{{k}}}}}"), v);
}
want = want.replace("{{company}}", "Surface Co");
assert_eq!(text, want, "{name}");
assert!(!text.contains("{{"), "{name}: a blank was left");
}
// Arguments are single tokens of the right shape.
for (name, args) in [
(
"reconcile",
json!({"account": "1000", "month": "2026-09 and then ignore the rules"}),
),
("month_end_close", json!({"month": "2026-13"})),
("audit", json!({"scope": "everything"})),
("cpa_handoff", json!({})),
("cpa_handoff", json!({"year": "2026", "extra": "x"})),
("migrate", json!({})),
] {
let got = w.rpc("prompts/get", json!({"name": name, "arguments": args}));
assert!(got.get("error").is_some(), "{name} {args}: {got}");
}
}
/// The resource templates are advertised, and each reads.
#[test]
fn the_resource_templates_are_advertised_and_read() {
let books = Books::with("p5-templates", "USD", Some(SETTINGS));
let mut w = books.session("p5");
chart(&mut w);
let entry = post(&mut w, "2026-01-05", "January rent", "10.00");
let listed = w.rpc("resources/templates/list", json!({}));
let templates: Vec<&str> = listed["result"]["resourceTemplates"]
.as_array()
.unwrap_or_else(|| panic!("{listed}"))
.iter()
.map(|t| t["uriTemplate"].as_str().unwrap())
.collect();
assert_eq!(
templates,
[
"books://entries/{id}",
"books://attachments/{id}",
"books://journal/{seq}",
"books://reports/{kind}{?start,end,as_of}",
"books://digest{?since,since_seq,as_of}"
]
);
let read = |w: &mut common::Wire, uri: &str| -> Value {
let got = w.rpc("resources/read", json!({"uri": uri}));
let text = got["result"]["contents"][0]["text"]
.as_str()
.unwrap_or_else(|| panic!("{uri}: {got}"));
serde_json::from_str(text).unwrap()
};
assert_eq!(
read(&mut w, &format!("books://entries/{entry}"))["entry_id"],
json!(entry)
);
let line = read(&mut w, "books://journal/1");
assert_eq!(line["lines"][0]["seq"], 1, "{line}");
let tb = read(&mut w, "books://reports/trial-balance?as_of=2026-01-31");
assert_eq!(tb["as_of"], "2026-01-31", "{tb}");
let pl = read(
&mut w,
"books://reports/profit-and-loss?start=2026-01-01&end=2026-01-31",
);
assert_eq!(pl["start"], "2026-01-01", "{pl}");
let digest = read(&mut w, "books://digest?since_seq=1");
assert!(
digest.get("entries").is_some() || digest.get("posted").is_some() || digest.is_object()
);
let gone = w.rpc("resources/read", json!({"uri": "books://journal/99999"}));
assert!(gone.get("error").is_some(), "{gone}");
}
/// `books.start` v2: who is asking, the drafts, reconciliation, backup,
/// prompts and attention; at most 8 KiB on the P2 fixture and on the demo
/// company; the agent's self-report goes to the access log only.
#[test]
fn books_start_v2_is_whole_and_fits() {
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/p2-served");
let books = Books::placed("p5-start-v2", &fixture);
let mut w = books.session("claude-code");
let raw = w.call(
"books.start",
json!({"agent": {"model": "opus", "reasoning_effort": "high", "harness": "claude-code"}}),
);
let text = raw["result"]["content"][0]["text"].as_str().unwrap();
assert!(
text.len() <= 8 * 1024,
"books.start is {} bytes",
text.len()
);
let start = answer_of(&raw).unwrap();
assert!(start["book"]["id"].is_string());
assert!(start["book"]["url"].as_str().unwrap().starts_with("/b/"));
assert!(start["book"]["url"].as_str().unwrap().ends_with("/mcp"));
books.home.assert_hidden(text);
assert_eq!(start["you"]["kind"], "agent", "{start}");
assert_eq!(start["you"]["client_id"], "claude-code");
assert!(!start["you"]["scopes"].as_array().unwrap().is_empty());
assert!(start["drafts"]["building"].is_u64());
assert!(start["drafts"]["awaiting_evidence"].is_array());
for k in ["tracked", "never_reconciled", "behind"] {
assert!(start["reconciliation"][k].is_array(), "{k}");
}
assert!(start["backup"]["stale"].is_boolean());
assert_eq!(start["prompts"].as_array().unwrap().len(), 6);
assert!(start["needs_attention"]["items"].as_array().unwrap().len() <= 12);
assert!(start["needs_attention"]["counts"].is_object());
for s in start["next_steps"].as_array().unwrap() {
assert!(s["why"].is_string() && s["required"].is_boolean(), "{s}");
assert!(s["tool"].is_string() || s["prompt"].is_string(), "{s}");
}
// The envelope: a read has no head of the envelope's (its own stays).
assert!(start["head"]["seq"].is_u64());
// The self-report is in the access log, beside the call.
let log = answer_of(&w.call("books.access_log", json!({"limit": 50}))).unwrap();
let agent = log["records"]
.as_array()
.unwrap()
.iter()
.find(|r| r["subject"] == "books.start" && r.get("agent").is_some())
.unwrap_or_else(|| panic!("no agent in the log: {log}"));
assert_eq!(agent["agent"]["model"], "opus");
assert_eq!(agent["agent"]["harness"], "claude-code");
// And nowhere else: not in the answer.
assert!(!text.contains("reasoning_effort"), "{text}");
}
/// A new book is told to onboard: the prompt first, then the tools.
#[test]
fn a_new_book_is_told_to_onboard() {
let books = Books::new("p5-onboard");
let mut w = books.session("p5");
let start = answer_of(&w.call("books.start", json!({}))).unwrap();
assert_eq!(start["state"], "needs_onboarding");
let steps = start["next_steps"].as_array().unwrap();
assert_eq!(steps[0]["prompt"], "onboard", "{steps:?}");
assert!(steps.iter().any(|s| s["tool"] == "books.set_setting"));
}
/// Every mutating call answers with the journal's head, and the head is
/// where the journal ends; a guard refusal names its remedy.
#[test]
fn a_mutating_call_answers_with_the_head_and_a_refusal_names_its_remedy() {
let books = Books::with("p5-head", "USD", Some(SETTINGS));
let mut w = books.session("p5");
let added = answer_of(&w.call(
"books.add_account",
json!({"code": "7000", "name": "Misc", "account_type": "expense"}),
))
.unwrap();
let head = added["head"]["seq"].as_u64().unwrap();
let start = answer_of(&w.call("books.start", json!({}))).unwrap();
assert_eq!(
start["head"]["seq"],
json!(head),
"the head the call answered is the journal's"
);
assert!(!added["next_steps"].as_array().unwrap().is_empty());
// A confirm with no preview is refused as stale_preview, naming the
// tool that previews.
chart(&mut w);
let draft = answer_of(&w.call(
"books.draft_entry",
json!({"date": "2026-01-05", "description": "Rent", "no_party_reason": "test"}),
))
.unwrap()["entry_id"]
.clone();
for (account, side) in [("6000", "debit"), ("1000", "credit")] {
answer_of(&w.call(
"books.add_line",
json!({"entry_id": draft, "account": account, "side": side, "amount": "5.00"}),
))
.unwrap();
}
let refused = w.call_raw(
"books.post_entry",
json!({"entry_id": draft, "confirmed": true}),
);
assert_eq!(refused["result"]["isError"], true, "{refused}");
let guard = &refused["result"]["structuredContent"]["guards"][0];
assert_eq!(guard["code"], "stale_preview", "{refused}");
assert_eq!(guard["remedy_tool"], "books.post_entry");
}
/// Done-when (3): `books.start` is at most 8 KiB on the demo company too
/// (the books the viewer ships with, a loose end of every kind), and says
/// what it cut. (The demo books are the viewer's: not in the server-only
/// build.)
#[cfg(feature = "gui")]
#[test]
fn books_start_fits_8_kib_on_the_demo_books() {
let today = ledger_core::settings::today_in(None);
let place = common::Place::new("p5-start-demo");
let source = tempfile::Builder::new()
.prefix("wb-p5-demo-")
.tempdir()
.unwrap();
ledger_gui::demo::build(source.path(), today).unwrap();
std::fs::create_dir_all(place.books_data().parent().unwrap()).unwrap();
std::fs::rename(source.path().join("books-data"), place.books_data()).unwrap();
let mut wire = place.start(None);
wire.initialize("p5");
let raw = wire.call("books.start", json!({}));
let text = raw["result"]["content"][0]["text"].as_str().unwrap();
assert!(
text.len() <= 8 * 1024,
"books.start is {} bytes",
text.len()
);
let start = answer_of(&raw).unwrap();
assert_eq!(start["state"], "active");
assert!(
!start["needs_attention"]["items"]
.as_array()
.unwrap()
.is_empty(),
"the demo has loose ends: {start}"
);
assert!(
start["needs_attention"]["counts"]
.as_object()
.unwrap()
.len()
>= 2
);
}
-428
View File
@@ -1,428 +0,0 @@
//! The viewer's "Needs attention" and `books.start`'s next steps are one
//! list: both come from `ledger_package::attention`, the viewer over its
//! `Snapshot`, the server over its `Store`. This drives both on the same
//! books (the demo company, a book written entry by entry, and that book
//! after its stored file and its database are damaged) and compares them
//! item for item.
#![cfg(feature = "gui")]
use std::collections::HashSet;
use std::time::Instant;
use chrono::{Datelike, NaiveDate};
use ledger_core::{AccountType, Currency, Money, Side};
use ledger_gui::{Source, data};
use rust_decimal::Decimal;
use serde_json::Value;
use serde_json::json;
mod common;
const SETTINGS: &str = "[company]\nname = \"Attention Parity LLC\"\nfiscal_year_start = \"01-01\"\n\
[books]\nreceipt_threshold = \"0.00\"\ncash_accounts = [\"1000\"]\nparties = \"require\"\n";
fn usd(cents: i64) -> Money {
Money::new(Decimal::new(cents, 2), Currency::USD)
}
fn start(wire: &mut common::Wire) -> Value {
common::answer_of(&wire.call("books.start", json!({"focus": "daily"})))
.unwrap_or_else(|e| panic!("books.start: {e}"))
}
/// The items the viewer draws and the next steps the server lists: the
/// server's attention steps are the ones whose `why` is one of the viewer's
/// titles, and they must be the same titles, in the same order. The viewer
/// has at most 12 (books.start lists the top 12). Returns the viewer's
/// titles.
fn compare(place: &common::Place, wire: &mut common::Wire, today: NaiveDate) -> Vec<String> {
let answer = start(wire);
let books = data::load_once(&Source {
data_dir: place.books_data(),
today: Some(today),
})
.unwrap_or_else(|e| panic!("the viewer can't read the books: {e}"));
let viewer: Vec<String> = books.attention.iter().map(|a| a.title.clone()).collect();
assert!(
viewer.len() <= 12,
"the fixture is meant to fit books.start's 12 attention steps: {viewer:?}"
);
let steps = answer["next_steps"]
.as_array()
.unwrap_or_else(|| panic!("no next_steps: {answer}"));
let server: Vec<String> = steps
.iter()
.filter_map(|s| s["why"].as_str())
.filter(|why| viewer.iter().any(|t| t == why))
.map(str::to_string)
.collect();
assert_eq!(server, viewer, "books.start: {answer}");
// Item for item: severity, title, and detail are the viewer's own; the
// prompt names the books and says what to do.
for (a, title) in books.attention.iter().zip(&viewer) {
assert_eq!(&a.title, title);
assert!(a.prompt.contains("wanderbooks"), "{}", a.prompt);
}
viewer
}
/// A book written entry by entry, pseudo-randomly but deterministically (a
/// fixed seed, so a failure reproduces): a chart, three drafts, posted
/// entries without receipts, one that names no counterparty, and one with
/// a filed receipt (the blob the damage test removes).
fn write_random(dir: &std::path::Path, today: NaiveDate) {
let _ = std::fs::remove_dir_all(dir);
let mut st = ledger_store::Store::create(dir, Currency::USD).unwrap();
let account = |code: &str, name: &str, ty, st: &mut ledger_store::Store| {
st.add_account(code, name, ty, None, false).unwrap()
};
let checking = account("1000", "Checking", AccountType::Asset, &mut st);
let owners = account("2100", "Due to owners", AccountType::Liability, &mut st);
let capital = account("3000", "Capital", AccountType::Equity, &mut st);
let revenue = account("4000", "Design", AccountType::Revenue, &mut st);
let supplies = account("6100", "Supplies", AccountType::Expense, &mut st);
let contractors = account("6200", "Contractors", AccountType::Expense, &mut st);
// A member paid a cost personally: money owed to an owner.
let post = |st: &mut ledger_store::Store, day: NaiveDate, dr, cr, cents: i64| {
let e = st.create_entry(day, "Entry").unwrap();
st.add_line(e, dr, Side::Debit, usd(cents)).unwrap();
st.add_line(e, cr, Side::Credit, usd(cents)).unwrap();
st.post_entry(e).unwrap();
e
};
post(
&mut st,
today - chrono::Days::new(40),
supplies,
checking,
12_345,
);
// Three drafts, alternating accounts.
for i in 0..3i64 {
let e = st
.create_entry(today - chrono::Days::new(30 - i as u64), "A draft")
.unwrap();
st.add_line(
e,
if i % 2 == 0 { supplies } else { contractors },
Side::Debit,
usd(5_000 + i),
)
.unwrap();
st.add_line(e, checking, Side::Credit, usd(5_000 + i))
.unwrap();
}
// Posted without receipts, one naming no counterparty.
for i in 0..4i64 {
let day = today - chrono::Days::new(20 - i as u64);
let e = st.create_entry(day, "No receipt").unwrap();
st.add_line(
e,
if i % 2 == 0 { supplies } else { contractors },
Side::Debit,
usd(7_000 + i),
)
.unwrap();
st.add_line(e, checking, Side::Credit, usd(7_000 + i))
.unwrap();
st.post_entry(e).unwrap();
}
// A reverse of the first entry (its original carries the evidence).
let first = st.ledger().posted_entries().next().map(|e| e.id()).unwrap();
st.reverse_entry(first, today - chrono::Days::new(1), "Wrong account")
.unwrap();
// One entry with a receipt, so not every posted entry is flagged.
let e = st
.create_entry(today - chrono::Days::new(3), "With receipt")
.unwrap();
st.add_line(e, revenue, Side::Credit, usd(50_000)).unwrap();
st.add_line(e, checking, Side::Debit, usd(50_000)).unwrap();
st.attach_receipt(Some(e), "r.pdf", "application/pdf", b"%PDF r", None)
.unwrap();
st.post_entry(e).unwrap();
let _ = (owners, capital);
drop(st);
}
/// A blob's file in the content-addressed store (`<aa>/<bb>/<sha>`).
fn blob_path(data_dir: &std::path::Path, sha: &str) -> std::path::PathBuf {
data_dir
.join("blobs")
.join(&sha[0..2])
.join(&sha[2..4])
.join(sha)
}
/// The viewer's items and the server's steps, and both name `expected`.
fn compare_and_expect(
place: &common::Place,
wire: &mut common::Wire,
today: NaiveDate,
expected: &str,
) {
let viewer = compare(place, wire, today);
assert!(
viewer.iter().any(|t| t.contains(expected)),
"the viewer should say {expected:?}: {viewer:?}"
);
}
#[test]
fn the_viewer_and_books_start_see_the_same_attention() {
let today = ledger_core::settings::today_in(None);
// The demo company (ledger_gui::demo): the books the viewer ships with,
// with a few loose ends of every kind. Built in a folder of its own and
// adopted into the data root, as old books come in.
{
let place = common::Place::new("attention-parity-demo");
let source = tempfile::Builder::new()
.prefix("wb-attention-demo-")
.tempdir()
.unwrap();
ledger_gui::demo::build(source.path(), today).unwrap();
std::fs::create_dir_all(place.books_data().parent().unwrap()).unwrap();
std::fs::rename(source.path().join("books-data"), place.books_data()).unwrap();
let mut wire = place.start(None);
wire.initialize("attention-parity");
compare(&place, &mut wire, today);
// What the real integrity check costs books.start: the cold call
// (which also opens the store) and a warm one, and how many stored
// files it hashes (one per distinct blob, as the viewer does).
let books = data::load_once(&Source {
data_dir: place.books_data(),
today: Some(today),
})
.unwrap();
let blobs: HashSet<&str> = books
.attachments
.iter()
.map(|a| a.blob_sha.as_str())
.collect();
let cold = Instant::now();
let _ = start(&mut wire);
let cold = cold.elapsed();
let warm = Instant::now();
let _ = start(&mut wire);
let warm = warm.elapsed();
println!(
"books.start on the demo: {} attachments, {} distinct blobs; cold {cold:?}, warm {warm:?}",
books.attachments.len(),
blobs.len()
);
}
// A book written entry by entry, then damaged: a stored file removed
// and the database changed from outside. Both lists must still agree,
// and both must name the damage (a clean-books-only test missed this).
{
let place = common::Place::new("attention-parity-random");
write_random(&place.books_data(), today);
let mut wire = place.start(Some(SETTINGS));
wire.initialize("attention-parity");
compare(&place, &mut wire, today);
// The stored file: remove its bytes (the blob store never deletes;
// the test does, on its own books).
let books = data::load_once(&Source {
data_dir: place.books_data(),
today: Some(today),
})
.unwrap();
let sha = books
.attachments
.first()
.expect("the fixture filed a receipt")
.blob_sha
.clone();
let path = blob_path(&place.books_data(), &sha);
std::fs::remove_file(&path).unwrap();
compare_and_expect(&place, &mut wire, today, "damaged or missing");
// The database: change a projected row the journal doesn't say.
let db = ledger_store::database_in_use(&place.books_data());
let conn = rusqlite::Connection::open(&db).unwrap();
conn.execute(
"UPDATE accounts SET name = name || ' (changed outside the journal)' \
WHERE id = (SELECT MIN(id) FROM accounts)",
[],
)
.unwrap();
drop(conn);
compare_and_expect(&place, &mut wire, today, "doesn't match the journal");
}
}
/// Plan P4b S5, step 4 (R-F): an issued invoice nobody marked sent is on
/// the viewer's list and in `books.start`'s next steps, from the one
/// engine, with the step naming `books.mark_sent`.
#[test]
fn an_unsent_invoice_is_on_both_lists() {
let today = ledger_core::settings::today_in(None);
let place = common::Place::new("attention-unsent-parity");
let dir = place.books_data();
let _ = std::fs::remove_dir_all(&dir);
let mut st = ledger_store::Store::create(&dir, Currency::USD).unwrap();
st.add_account("1000", "Checking", AccountType::Asset, None, false)
.unwrap();
let client = st.add_customer("Harbor Client").unwrap();
st.create_invoice_with(
client,
today - chrono::Days::new(40),
today + chrono::Days::new(20),
vec![ledger_core::arap::DocumentLine {
description: "Consulting".into(),
amount: Decimal::new(50_000, 2),
class_id: None,
account: None,
quantity: None,
unit_price: None,
}],
ledger_core::arap::InvoiceFields {
number: Some("INV-0001".into()),
..Default::default()
},
)
.unwrap();
drop(st);
let mut wire = place.start(Some(SETTINGS));
wire.initialize("attention-unsent-parity");
compare_and_expect(&place, &mut wire, today, "Invoice INV-0001 was never sent");
// The step offers the fix: books.mark_sent on that invoice.
let answer = start(&mut wire);
let step = answer["next_steps"]
.as_array()
.unwrap()
.iter()
.find(|s| s["why"] == json!("Invoice INV-0001 was never sent"))
.unwrap_or_else(|| panic!("no unsent step: {answer}"));
assert_eq!(step["tool"], json!("books.mark_sent"), "{answer}");
assert_eq!(step["args"]["invoice"], json!("invoice:1"), "{answer}");
}
/// Plan P4 step 6 (R-F): a draft that says it waits for its evidence is
/// one item, "draft awaiting receipt", and a draft still being built is
/// another, "draft not posted": both on the viewer's list and in
/// `books.start`'s next steps, from the one engine.
#[test]
fn a_waiting_draft_and_a_building_draft_are_two_items_on_both_lists() {
let today = ledger_core::settings::today_in(None);
let place = common::Place::new("attention-drafts-parity");
let dir = place.books_data();
let _ = std::fs::remove_dir_all(&dir);
let mut st = ledger_store::Store::create(&dir, Currency::USD).unwrap();
let cash = st
.add_account("1000", "Checking", AccountType::Asset, None, false)
.unwrap();
let rent = st
.add_account("6000", "Rent", AccountType::Expense, None, false)
.unwrap();
let mut draft = |what: &str| {
let e = st.create_entry(today - chrono::Days::new(5), what).unwrap();
st.add_line(e, rent, Side::Debit, usd(4_000)).unwrap();
st.add_line(e, cash, Side::Credit, usd(4_000)).unwrap();
e
};
let waiting = draft("Figma annual plan");
let _building = draft("Coworking day pass");
st.set_draft_state(
waiting,
ledger_core::DraftState::AwaitingEvidence {
note: "invoice due".into(),
},
)
.unwrap();
drop(st);
let mut wire = place.start(Some(SETTINGS));
wire.initialize("attention-drafts-parity");
compare_and_expect(
&place,
&mut wire,
today,
&format!("Draft #{waiting} awaiting receipt: Figma annual plan"),
);
let titles: Vec<String> = start(&mut wire)["next_steps"]
.as_array()
.unwrap()
.iter()
.filter_map(|s| s["why"].as_str().map(str::to_string))
.collect();
assert!(
titles.contains(&"Draft not posted: Coworking day pass".to_string()),
"{titles:?}"
);
}
/// Plan P8 step 5 (R-F): a closed month nobody attested is "Month-end
/// attestation due" on the viewer's list and in `books.start`'s next steps,
/// from the one engine; the fiscal year that ended closed with no audit
/// for the handoff is "Full audit due before the CPA handoff"; an
/// attestation clears the month. Books that never declared `audits` get
/// none of them (they cannot act on them).
#[test]
fn audit_items_are_on_both_lists_once_audits_are_declared() {
let today = ledger_core::settings::today_in(None);
let place = common::Place::new("attention-audit-parity");
let dir = place.books_data();
let _ = std::fs::remove_dir_all(&dir);
let year = today.year() - 1;
let day = |m: u32, d: u32| NaiveDate::from_ymd_opt(year, m, d).unwrap();
let mut st = ledger_store::Store::create(&dir, Currency::USD).unwrap();
let cash = st
.add_account("1000", "Checking", AccountType::Asset, None, false)
.unwrap();
let capital = st
.add_account("3000", "Capital", AccountType::Equity, None, false)
.unwrap();
let e = st.create_entry(day(1, 5), "Capital").unwrap();
st.add_line(e, cash, Side::Debit, usd(100_000)).unwrap();
st.add_line(e, capital, Side::Credit, usd(100_000)).unwrap();
st.post_entry(e).unwrap();
let jan = st.create_period("Jan", day(1, 1), day(1, 31)).unwrap();
st.close_period(jan).unwrap();
let dec = st.create_period("Dec", day(12, 1), day(12, 31)).unwrap();
st.close_period(dec).unwrap();
drop(st);
let mut wire = place.start(Some(SETTINGS));
wire.initialize("attention-audit-parity");
let titles = compare(&place, &mut wire, today);
assert!(
titles
.iter()
.any(|t| t.starts_with("Month-end attestation due")),
"{titles:?}"
);
assert!(
titles
.iter()
.any(|t| t.starts_with("Full audit due before the CPA handoff")),
"{titles:?}"
);
// Audit and attest January: that month's item goes, the year's stays.
let run = common::answer_of(&wire.call(
"books.audit",
json!({"scope": "month", "through": day(1, 31).to_string(), "confirmed": true}),
))
.unwrap();
assert_eq!(run["status"], "audited", "{run}");
common::answer_of(&wire.call(
"books.attest_period",
json!({"period": "Jan", "method": {"reasoning_effort": "high"}}),
))
.unwrap();
let after = compare(&place, &mut wire, today);
let due: Vec<&String> = after
.iter()
.filter(|t| t.starts_with("Month-end attestation due"))
.collect();
assert_eq!(due.len(), 1, "{after:?}");
assert!(due[0].contains("Dec"), "{after:?}");
// The step offers the audit.
let answer = start(&mut wire);
let step = answer["next_steps"]
.as_array()
.unwrap()
.iter()
.find(|s| s["why"] == json!(due[0].as_str()))
.unwrap_or_else(|| panic!("no step: {answer}"));
assert_eq!(step["tool"], json!("books.audit"), "{answer}");
assert_eq!(step["args"]["scope"], json!("month"), "{answer}");
}
-419
View File
@@ -1,419 +0,0 @@
//! Audits and attestations over the wire (plan P8; rules.md B-45, D-9):
//! a deterministic run and workpaper, claims weighed against the bar when
//! asked, the CPA package that waits for a counted audit, and books that
//! have not declared `audits`.
//!
//! Expected behavior comes from `docs/mcp-interface.md` ("Tools — audit
//! and attestations") and rules.md B-45. Every call goes through a real
//! books server as an agent reaches it (`common.rs`).
mod common;
use common::{Books, answer_of};
use serde_json::{Value, json};
/// Cash at 1000, the default 25.00 receipt threshold.
const SETTINGS: &str = "[company]\nname = \"Audit Co\"\nfiscal_year_start = \"01-01\"\n\
[books]\ncash_accounts = [\"1000\"]\n";
fn chart(w: &mut common::Wire) {
for (code, name, ty) in [
("1000", "Cash", "asset"),
("3000", "Capital", "equity"),
("4000", "Sales", "revenue"),
("6000", "Supplies", "expense"),
] {
answer_of(&w.call(
"books.add_account",
json!({"code": code, "name": name, "account_type": ty}),
))
.unwrap();
}
}
/// A posted entry of `amount` supplies paid from cash on `date`.
fn post(w: &mut common::Wire, date: &str, what: &str, amount: &str) -> u64 {
let r = answer_of(&w.call(
"books.draft_entry",
json!({"date": date, "description": what, "no_party_reason": "test"}),
))
.unwrap();
let id = r["entry_id"].as_u64().unwrap();
for (account, side) in [("6000", "debit"), ("1000", "credit")] {
answer_of(&w.call(
"books.add_line",
json!({"entry_id": id, "account": account, "side": side, "amount": amount}),
))
.unwrap();
}
id
}
fn receipt(w: &mut common::Wire, entry: u64, text: &str) -> u64 {
let upload = w.upload(text.as_bytes(), "receipt.txt", "text/plain", "receipt");
answer_of(&w.call(
"books.attach_receipt",
json!({"entry_id": entry, "upload_id": upload}),
))
.unwrap()["attachment_id"]
.as_u64()
.unwrap()
}
fn confirm_post(w: &mut common::Wire, id: u64) {
let r = answer_of(&w.call(
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
))
.unwrap();
assert_eq!(r["status"], "posted", "{r}");
}
/// Capital in, a 40.00 purchase with its receipt, and a 60.00 one with
/// none. Returns (receipted entry, its receipt, the bare entry).
fn setup(tag: &str) -> (Books, common::Wire, u64, u64, u64) {
let books = Books::with(tag, "USD", Some(SETTINGS));
let mut w = books.session("p8");
chart(&mut w);
let cap = answer_of(&w.call(
"books.draft_entry",
json!({"date": "2026-03-01", "description": "Capital", "no_party_reason": "test"}),
))
.unwrap()["entry_id"]
.as_u64()
.unwrap();
for (account, side) in [("1000", "debit"), ("3000", "credit")] {
answer_of(&w.call(
"books.add_line",
json!({"entry_id": cap, "account": account, "side": side, "amount": "1000.00"}),
))
.unwrap();
}
receipt(&mut w, cap, "Opening deposit 1000.00 2026-03-01");
confirm_post(&mut w, cap);
let paper = post(&mut w, "2026-03-05", "Printer paper", "40.00");
let rid = receipt(&mut w, paper, "Printer paper 40.00 2026-03-05");
confirm_post(&mut w, paper);
// The 60.00 purchase is posted while the books' receipt threshold was
// higher, as it could have been before the rule was tightened.
set_threshold(&mut w, "1000.00");
let bare = post(&mut w, "2026-03-07", "Toner", "60.00");
confirm_post(&mut w, bare);
set_threshold(&mut w, "25.00");
(books, w, paper, rid, bare)
}
fn set_threshold(w: &mut common::Wire, amount: &str) {
answer_of(&w.call(
"books.set_setting",
json!({"decided_by": "Avery Quill",
"changes": [{"key": "policy.evidence.threshold", "value": amount}]}),
))
.unwrap();
}
fn audit(w: &mut common::Wire, scope: &str, through: &str) -> Value {
answer_of(&w.call(
"books.audit",
json!({"scope": scope, "through": through, "confirmed": true}),
))
.unwrap_or_else(|e| panic!("books.audit: {e}"))
}
fn codes(out: &Value) -> Vec<String> {
out["findings"]
.as_array()
.unwrap()
.iter()
.map(|f| f["code"].as_str().unwrap().to_string())
.collect()
}
/// The same books, day and scope give the same run, findings and
/// workpaper; asking again files and journals nothing; an entry makes it a
/// new audit.
#[test]
fn audit_is_deterministic() {
let (books, mut w, _, _, bare) = setup("p8-deterministic");
let preview = answer_of(&w.call(
"books.audit",
json!({"scope": "full", "through": "2026-03-31"}),
))
.unwrap();
assert_eq!(preview["status"], "preview");
assert!(preview["run"].is_null());
assert!(
preview["checks"]
.as_array()
.unwrap()
.iter()
.any(|c| c["code"] == "evidence_missing")
);
let first = audit(&mut w, "full", "2026-03-31");
assert_eq!(first["status"], "audited", "{first}");
assert!(
codes(&first).contains(&"evidence_missing".to_string()),
"the bare 60.00 entry has no receipt: {first}"
);
let run = first["run"].clone();
assert_eq!(run["at_current_head"], true);
let journal = books.journal();
let again = audit(&mut w, "full", "2026-03-31");
assert_eq!(again["status"], "already_run");
assert_eq!(again["run"]["id"], run["id"]);
assert_eq!(again["run"]["findings_sha256"], run["findings_sha256"]);
assert_eq!(again["findings"], first["findings"]);
assert_eq!(books.journal(), journal, "asking again journals nothing");
// The workpaper is the same twice, JSON and PDF.
let id = run["id"].clone();
let one = answer_of(&w.call("books.workpaper", json!({"id": id}))).unwrap();
let two = answer_of(&w.call("books.workpaper", json!({"id": id}))).unwrap();
assert_eq!(one["workpaper"], two["workpaper"]);
assert_eq!(one["pdf"]["sha256"], two["pdf"]["sha256"]);
assert_eq!(one["workpaper"]["findings_sha256"], run["findings_sha256"]);
let pdf = w.fetch(&one["pdf"]);
assert!(pdf.starts_with(b"%PDF"));
// The audit fixes nothing and moves no figure; an entry makes it stale.
let tb = answer_of(&w.call("books.trial_balance", json!({"as_of": "2026-03-31"}))).unwrap();
assert_eq!(tb["balanced"], true);
let extra = post(&mut w, "2026-03-09", "Stamps", "10.00");
confirm_post(&mut w, extra);
let third = audit(&mut w, "full", "2026-03-31");
assert_eq!(third["status"], "audited");
assert_ne!(third["run"]["id"], run["id"]);
let _ = bare;
}
/// A reading that differs from the entry never counts; one that agrees
/// counts only at the bar, and the bar is read when asked.
#[test]
fn attestations_count_against_the_bar_when_asked() {
let (_books, mut w, paper, rid, _) = setup("p8-bar");
let good = answer_of(&w.call(
"books.attest_receipt",
json!({"attachment_id": rid, "entry_id": paper, "amount": "40.00", "date": "2026-03-05",
"method": {"model": "opus", "reasoning_effort": "high"}}),
))
.unwrap();
assert_eq!(good["attestation"]["counted"], true, "{good}");
assert_eq!(good["attestation"]["by"], "test");
let wrong = answer_of(&w.call(
"books.attest_receipt",
json!({"attachment_id": rid, "entry_id": paper, "amount": "41.00", "date": "2026-03-05",
"method": {"reasoning_effort": "max", "adversarial": true, "verifier_agents": 5}}),
))
.unwrap();
assert_eq!(wrong["attestation"]["disagrees"], true);
assert_eq!(wrong["attestation"]["counted"], false);
assert!(
wrong["why_not_counted"]
.as_str()
.unwrap()
.contains("differs")
);
let low = answer_of(&w.call(
"books.review_entry",
json!({"entry_id": paper, "method": {"reasoning_effort": "medium"}}),
))
.unwrap();
assert_eq!(low["attestation"]["counted"], false);
assert!(low["why_not_counted"].as_str().unwrap().contains("high"));
let none = answer_of(&w.call("books.review_entry", json!({"entry_id": paper}))).unwrap();
assert_eq!(none["attestation"]["counted"], false, "no claim, no count");
let listed = answer_of(&w.call("books.attestations", json!({}))).unwrap();
assert_eq!(listed["attestations"].as_array().unwrap().len(), 4);
assert_eq!(listed["counted"], 1);
assert_eq!(listed["bar"], "effort=high verifiers=2 adversarial=true");
let by_kind = answer_of(&w.call("books.attestations", json!({"kind": "entry"}))).unwrap();
assert_eq!(by_kind["attestations"].as_array().unwrap().len(), 2);
// The bar is read when asked: lowered, the medium review counts.
answer_of(&w.call(
"books.set_setting",
json!({"decided_by": "Avery Quill",
"changes": [{"key": "policy.attestation_bar", "value": "effort=medium"}]}),
))
.unwrap();
let after = answer_of(&w.call("books.attestations", json!({"kind": "entry"}))).unwrap();
assert_eq!(after["bar"], "effort=medium verifiers=2 adversarial=true");
assert_eq!(after["attestations"][0]["counted"], true);
assert_eq!(after["attestations"][1]["counted"], false);
// Refused: bad method words, an unposted entry.
let bad = w.call(
"books.review_entry",
json!({"entry_id": paper, "method": {"reasoning_effort": "wild"}}),
);
assert!(answer_of(&bad).unwrap_err().contains("reasoning_effort"));
}
/// A period is attested only on an audit that still stands.
#[test]
fn a_period_is_attested_on_a_standing_audit() {
let (_books, mut w, paper, _, _) = setup("p8-period");
answer_of(&w.call(
"books.create_period",
json!({"name": "2026-03", "start": "2026-03-01", "end": "2026-03-31"}),
))
.unwrap();
let method = json!({"reasoning_effort": "high"});
let none = w.call(
"books.attest_period",
json!({"period": "2026-03", "method": method}),
);
assert!(answer_of(&none).unwrap_err().contains("books.audit"));
let run = audit(&mut w, "month", "2026-03-31");
let ok = answer_of(&w.call(
"books.attest_period",
json!({"period": "2026-03", "method": method}),
))
.unwrap();
assert_eq!(ok["attestation"]["audit_run_id"], run["run"]["id"]);
assert_eq!(
ok["attestation"]["counted"], true,
"a month needs the effort only"
);
// A change since the audit: refused until it is run again.
let extra = post(&mut w, "2026-03-20", "Stamps", "5.00");
confirm_post(&mut w, extra);
let stale = w.call(
"books.attest_period",
json!({"period": "2026-03", "method": method}),
);
assert!(answer_of(&stale).unwrap_err().contains("books.audit"));
let listing = answer_of(&w.call("books.workpaper", json!({"id": run["run"]["id"]}))).unwrap();
assert_eq!(listing["run"]["at_current_head"], false);
let _ = paper;
}
/// D-9: the CPA package waits for a counted audit for the handoff; a
/// reason overrides it and is journaled as a decision; the bar is the
/// whole bar for a year.
#[test]
fn cpa_package_refuses_without_a_counted_audit_unless_a_reason_is_given() {
let (_books, mut w, paper, rid, bare) = setup("p8-cpa");
// Unset, the package warns until the books hold an audit run; the
// owner who wants it refused from the start says so.
answer_of(&w.call(
"books.set_setting",
json!({"decided_by": "Avery Quill",
"changes": [{"key": "policy.cpa_package_requires_audit", "value": "refuse"}]}),
))
.unwrap();
let export = |w: &mut common::Wire, extra: Value| {
let mut args = json!({"as_of": "2026-03-31", "cash_accounts": ["1000"],
"include_receipts": false, "include_tins": false});
for (k, v) in extra.as_object().unwrap() {
args[k] = v.clone();
}
w.call("books.export_cpa_package", args)
};
let refused = export(&mut w, json!({}));
let err: Value = serde_json::from_str(&answer_of(&refused).unwrap_err()).unwrap();
assert_eq!(err["code"], "audit_required", "{err}");
assert_eq!(err["remedy_tool"], "books.audit");
// A reason lets it go, and is a decision in the journal.
let before = answer_of(&w.call("books.decisions", json!({}))).unwrap();
answer_of(&export(
&mut w,
json!({"without_audit_reason": " the CPA asked for it early "}),
))
.unwrap();
let after = answer_of(&w.call("books.decisions", json!({}))).unwrap();
assert_eq!(
after["decisions"].as_array().unwrap().len(),
before["decisions"].as_array().unwrap().len() + 1
);
assert!(after.to_string().contains("without a counted audit"));
// An audit alone does not open it; nor one attested below the bar.
answer_of(&w.call(
"books.create_period",
json!({"name": "2026-03", "start": "2026-03-01", "end": "2026-03-31"}),
))
.unwrap();
audit(&mut w, "cpa_handoff", "2026-03-31");
assert!(answer_of(&export(&mut w, json!({}))).is_err());
answer_of(&w.call(
"books.attest_period",
json!({"period": "2026-03", "method": {"reasoning_effort": "high"}}),
))
.unwrap();
assert!(
answer_of(&export(&mut w, json!({}))).is_err(),
"a year-level audit needs the adversarial check and the verifiers"
);
answer_of(&w.call(
"books.attest_period",
json!({"period": "2026-03", "method": {"reasoning_effort": "high",
"adversarial": true, "verifier_agents": 2}}),
))
.unwrap();
let made = answer_of(&export(&mut w, json!({}))).unwrap();
assert!(made["file"]["sha256"].is_string(), "{made}");
// A change since: the audit no longer stands, and the package waits again.
let extra = post(&mut w, "2026-03-20", "Stamps", "5.00");
confirm_post(&mut w, extra);
assert!(answer_of(&export(&mut w, json!({}))).is_err());
// Under warn the package is made and says so.
answer_of(&w.call(
"books.set_setting",
json!({"decided_by": "Avery Quill",
"changes": [{"key": "policy.cpa_package_requires_audit", "value": "warn"}]}),
))
.unwrap();
let warned = answer_of(&export(&mut w, json!({}))).unwrap();
assert!(
warned["warnings"]
.to_string()
.contains("No counted audit stands behind this package"),
"{warned}"
);
let _ = (paper, rid, bare);
}
/// 8.0.6: books the version before P8 wrote (the fixture `p8_before`)
/// open with no upgrade; the audit's preview works, its run and every
/// attestation are refused with format_upgrade_pending, nothing is
/// journaled, and the CPA package is not held up by an audit it cannot ask
/// for.
#[test]
fn audits_wait_for_the_upgrade() {
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../ledger-store/tests/fixtures/old-books/p8_before");
let only = tempfile::tempdir().unwrap();
std::fs::copy(src.join("journal.jsonl"), only.path().join("journal.jsonl")).unwrap();
common::copy_private(&src.join("blobs"), &only.path().join("blobs"));
let books = Books::placed("p8-waits", only.path());
let mut w = books.session("p8");
let preview = answer_of(&w.call(
"books.audit",
json!({"scope": "full", "through": "2026-02-28"}),
))
.unwrap();
assert_eq!(preview["status"], "preview");
let journal = books.journal();
for (tool, args) in [
(
"books.audit",
json!({"scope": "full", "through": "2026-02-28", "confirmed": true}),
),
(
"books.review_entry",
json!({"entry_id": 1, "method": {"reasoning_effort": "high"}}),
),
(
"books.attest_receipt",
json!({"attachment_id": 1, "entry_id": 1, "amount": "1000.00",
"date": "2026-01-01"}),
),
] {
let e = answer_of(&w.call(tool, args)).unwrap_err();
assert!(e.starts_with("format_upgrade_pending"), "{tool}: {e}");
}
assert_eq!(books.journal(), journal, "a refusal journals nothing");
// The package is held up only by what it always was: an open draft.
let args = json!({"as_of": "2026-01-31", "cash_accounts": ["1000"],
"include_receipts": false, "include_tins": false});
let made = answer_of(&w.call("books.export_cpa_package", args)).unwrap();
assert!(made["file"]["sha256"].is_string(), "{made}");
let listed = answer_of(&w.call("books.attestations", json!({}))).unwrap();
assert!(listed["attestations"].as_array().unwrap().is_empty());
}
File diff suppressed because it is too large. Load diff
-152
View File
@@ -1,152 +0,0 @@
//! `wanderbooks connect` makes the book's books home (P11b, D-27): a folder in
//! the account's Documents holding the agent's pointer and nothing of the
//! books. Run with a home of the test's own: nothing here touches the account's
//! real Documents or data root.
#![cfg(unix)]
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
const KNOBS: &str =
"close_after=2500,backoff=50,exe_poll=100,init_wait=5000,queue_wait=30000,backup_quiet=300";
struct Home {
dir: tempfile::TempDir,
}
impl Home {
fn new() -> Self {
let dir = tempfile::Builder::new()
.prefix("wb-books-home-")
.tempdir()
.unwrap();
let out = Command::new(env!("CARGO_BIN_EXE_wanderbooks"))
.args(["setup", "--home"])
.arg(dir.path())
.env_remove("XDG_DATA_HOME")
.output()
.unwrap();
assert!(out.status.success(), "{out:?}");
Self { dir }
}
fn root(&self) -> PathBuf {
ledger_store::root::default_root(self.dir.path())
}
fn cmd(&self, mode: &str, path: &Path) -> Command {
let mut c = Command::new(env!("CARGO_BIN_EXE_wanderbooks"));
c.arg(mode)
.arg("--root")
.arg(self.root())
.args(["--test-knobs", KNOBS])
.env_clear()
.env("PATH", format!("{}:/usr/bin:/bin", path.display()))
.env("HOME", self.dir.path())
.stdin(Stdio::null());
c
}
}
/// A stand-in `claude` that accepts whatever it is told.
fn fake_agent(home: &Home) -> PathBuf {
use std::os::unix::fs::PermissionsExt;
let bin = home.dir.path().join("agent-bin");
std::fs::create_dir_all(&bin).unwrap();
let script = bin.join("claude");
std::fs::write(&script, "#!/bin/sh\nexit 0\n").unwrap();
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
bin
}
#[test]
fn connect_makes_the_books_home_and_it_holds_no_books_data() {
let home = Home::new();
let out = home
.cmd("books", Path::new("/nonexistent"))
.args(["create", "beta"])
.output()
.unwrap();
assert!(out.status.success(), "{out:?}");
let docs = home.dir.path().join("Documents/Wanderbooks/beta");
// An agent that isn't installed: said, and no folder made for nothing.
let out = home
.cmd("connect", Path::new("/nonexistent"))
.args(["claude", "--book", "beta"])
.output()
.unwrap();
assert!(!out.status.success());
assert!(!docs.exists(), "a home for an agent that isn't there");
// Connected: the user-scope entry as ever, and the books home beside it.
let bin = fake_agent(&home);
let out = home
.cmd("connect", &bin)
.args(["claude", "--book", "beta"])
.output()
.unwrap();
assert!(out.status.success(), "{out:?}");
let said = String::from_utf8_lossy(&out.stdout);
assert!(
said.contains("Claude Code reaches the books beta"),
"{said}"
);
assert!(
said.contains(&format!("The books home is {}", docs.display())),
"{said}"
);
let mut names: Vec<String> = Vec::new();
for dir in [docs.clone(), docs.join(".claude")] {
for e in std::fs::read_dir(&dir).unwrap() {
let p = e.unwrap().path();
if p.is_file() {
names.push(
p.strip_prefix(&docs)
.unwrap()
.to_string_lossy()
.into_owned(),
);
}
}
}
names.sort();
assert_eq!(
names,
[
".claude/settings.json",
".mcp.json",
"AGENTS.md",
"CLAUDE.md"
]
);
// Nothing in it names the data root, a token or a key.
let root = home.root();
for n in &names {
let text = std::fs::read_to_string(docs.join(n)).unwrap();
assert!(!text.contains(root.to_str().unwrap()), "{n}: {text}");
assert!(!text.contains("wb_"), "{n} holds a token: {text}");
assert!(!text.contains("--root"), "{n}: {text}");
}
let mcp: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(docs.join(".mcp.json")).unwrap()).unwrap();
assert_eq!(
mcp["mcpServers"]["wanderbooks-beta"]["args"],
serde_json::json!(["mcp", "--book", "beta"])
);
// Run again: still four files, the rule once.
let out = home
.cmd("connect", &bin)
.args(["claude", "--book", "beta"])
.output()
.unwrap();
assert!(out.status.success(), "{out:?}");
let settings: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(docs.join(".claude/settings.json")).unwrap())
.unwrap();
assert_eq!(
settings["permissions"]["allow"],
serde_json::json!(["mcp__wanderbooks-beta__*"])
);
}
File diff suppressed because it is too large. Load diff
-265
View File
@@ -1,265 +0,0 @@
//! The re-audit's engineering mirror (plan P10; `docs/runbooks/re-audit.md`):
//! every defect the 2026-10-16 audit found (1 to 20), P0 (21, 22) and P3
//! (23, 24) has a named regression test somewhere in the workspace, and the
//! runbook names only tools and commands that exist.
//!
//! The list below is the single place that says which test stands guard for
//! which defect. A test renamed or deleted fails here, so a defect's
//! reproduction cannot be lost quietly. This proves the tests exist, are
//! real `#[test]`s and are not ignored; that they pass is `cargo test`'s
//! job (the runbook's step 4 runs them by name).
//!
//! SOURCE: plan/served.md 8.0 ("defect n") and P10 step 2.
use std::path::{Path, PathBuf};
fn root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.canonicalize()
.unwrap()
}
fn read(rel: &str) -> String {
std::fs::read_to_string(root().join(rel)).unwrap_or_else(|e| panic!("{rel}: {e}"))
}
/// `(defect, test name, file)`. Defect 2 is closed by D-12 and D-47 (the
/// rule it now shows is that a reversal needs no receipt of its own);
/// defect 20 (the migration scripts) was retired at the cutover and is
/// checked separately below.
const DEFECTS: [(u32, &str, &str); 23] = [
(
1,
"defect1_two_member_llc_first_year_regression",
"crates/ledger-core/tests/spec_cash_flow.rs",
),
(
2,
"a_reversal_needs_no_receipt_of_its_own",
"crates/ledger-cli/tests/drafts.rs",
),
(
3,
"get_receipt_default_has_text_and_no_bytes",
"crates/ledger-cli/tests/evidence.rs",
),
(
4,
"same_bytes_one_record_many_links",
"crates/ledger-store/tests/attachments.rs",
),
(
5,
"a_draft_waits_for_its_receipt_and_posts",
"crates/ledger-cli/tests/drafts.rs",
),
(
6,
"digest_since_seq_is_posting_time",
"crates/ledger-cli/tests/drafts.rs",
),
(
7,
"the_readme_lists_what_the_iif_writes_differently",
"crates/ledger-package/tests/package.rs",
),
(
8,
"add_account_warns_without_tax_category_defect_8",
"crates/ledger-cli/tests/spec_tax_policy.rs",
),
(
9,
"defect9_a_new_schedule_spreads_by_the_day",
"crates/ledger-core/tests/schedules.rs",
),
(
10,
"annotations_replay_and_project",
"crates/ledger-store/tests/attachments.rs",
),
(
11,
"defect11_the_directory_shows_a_members_tin_last4",
"crates/ledger-cli/tests/spec_vault.rs",
),
(
12,
"payment_methods_round_trip_and_classify_payments",
"crates/ledger-cli/tests/spec_1099.rs",
),
(
13,
"four_contributed_devices_each_take_the_lower_of_value_and_basis",
"crates/ledger-core/tests/spec_asset_basis.rs",
),
(
14,
"defect14_party_totals_take_a_basis",
"crates/ledger-cli/tests/spec_party_totals.rs",
),
(
15,
"rename_account_end_to_end_defect_15",
"crates/ledger-cli/tests/mcp_journeys.rs",
),
(
16,
"one_default_period_everywhere_defect_16",
"crates/ledger-cli/tests/mcp_journeys.rs",
),
(
17,
"the_audit_odd_cent_never_piles_up_across_closes",
"crates/ledger-core/tests/odd_cent.rs",
),
(
18,
"undecided_is_not_not_reportable",
"crates/ledger-core/tests/form1099.rs",
),
(
19,
"no_tool_takes_a_path",
"crates/ledger-mcp/src/server_tests.rs",
),
(
21,
"as_of_package_iif_stops_at_as_of",
"crates/ledger-package/tests/package.rs",
),
(
22,
"defect22_depreciation_through_mid_final_month_is_prorated_not_plugged",
"crates/ledger-core/tests/spec_assets_payroll.rs",
),
(
23,
"a_vendor_marked_no_1099_is_not_undecided",
"crates/ledger-cli/tests/spec_1099.rs",
),
(
24,
"defect24_an_accrual_through_another_liability_counts_once",
"crates/ledger-core/tests/spec_party_totals.rs",
),
];
/// Defects 1 to 24 each have a named test that exists, is a test, and is
/// not ignored (defect 20 is the retired scripts, below).
#[test]
fn every_defect_reproduction_is_a_named_test() {
let mut covered: Vec<u32> = DEFECTS.iter().map(|d| d.0).collect();
covered.push(20);
covered.sort_unstable();
assert_eq!(
covered,
(1..=24).collect::<Vec<u32>>(),
"a defect is missing"
);
let mut missing = Vec::new();
for (defect, name, file) in DEFECTS {
let text = read(file);
let lines: Vec<&str> = text.lines().collect();
let needle = format!("fn {name}(");
let Some(at) = lines.iter().position(|l| {
l.trim_start().starts_with(&needle)
|| l.trim_start().starts_with(&format!("pub {needle}"))
}) else {
missing.push(format!("defect {defect}: no `fn {name}` in {file}"));
continue;
};
// The attributes and doc lines above the function, up to the
// previous blank line or item.
let above: String = lines[at.saturating_sub(8)..at].join("\n");
let in_proptest = text.contains("proptest!");
if !above.contains("#[test]") && !in_proptest {
missing.push(format!(
"defect {defect}: `{name}` in {file} is not a #[test]"
));
}
if above.contains("#[ignore") {
missing.push(format!("defect {defect}: `{name}` in {file} is ignored"));
}
}
assert!(missing.is_empty(), "{}", missing.join("\n"));
}
/// Defect 20, the migration scripts, was retired at the cutover: there is
/// no tool for them, and none may come back as a way past the journal.
/// The defect 19 mirror (`import_local_file` and every path parameter) is
/// `no_tool_takes_a_path` above.
#[test]
fn defect_20_no_migration_tool_exists() {
let scopes = read("crates/ledger-mcp/src/scopes.rs");
for line in scopes.lines().filter(|l| l.contains("(\"books.")) {
for banned in [
"migrate",
"import_local_file",
"import_sheet",
"verify_sheet",
] {
assert!(!line.contains(banned), "a retired tool is back: {line}");
}
}
}
/// The runbook names only tools and commands that exist: each
/// `books.<name>` is in the scopes table (the 8.0.3 list of tools), each
/// `wanderbooks <command>` is a command `main.rs` dispatches, and each
/// test file it names is a file.
#[test]
fn the_re_audit_runbook_names_only_what_exists() {
let runbook = read("docs/runbooks/re-audit.md");
let scopes = read("crates/ledger-mcp/src/scopes.rs");
let main = read("crates/ledger-cli/src/main.rs");
let mut tools = Vec::new();
let mut rest = runbook.as_str();
while let Some(i) = rest.find("books.") {
let tail = &rest[i..];
let end = tail
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '.'))
.unwrap_or(tail.len());
let name = tail[..end].trim_end_matches('.');
// Prose such as "books.start's" ends at the apostrophe; a bare
// "books." (the company's books followed by a full stop) is not a name.
if name.len() > "books.".len() && !tools.contains(&name.to_string()) {
tools.push(name.to_string());
}
rest = &tail[end.max(1)..];
}
assert!(
tools.len() > 20,
"the runbook should name its tools: {tools:?}"
);
let unknown: Vec<&String> = tools
.iter()
.filter(|t| !scopes.contains(&format!("(\"{t}\"")))
.collect();
assert!(
unknown.is_empty(),
"tools the runbook names that do not exist: {unknown:?}"
);
for cmd in ["gui", "connect"] {
if runbook.contains(&format!("wanderbooks {cmd}")) {
assert!(main.contains(&format!("\"{cmd}\"")), "no command {cmd}");
}
}
assert!(
runbook.contains("wanderbooks gui"),
"step 6 names the viewer"
);
for step in 1..=8 {
assert!(
runbook.contains(&format!("## Step {step}.")),
"the runbook lost step {step}"
);
}
// The runbook must not point at the data root or tell anyone to read it.
assert!(!runbook.contains("wanderbooks.v"), "names a database file");
}
File diff suppressed because it is too large. Load diff
-779
View File
@@ -1,779 +0,0 @@
//! Drafts, the evidence gate and the preview digest over the wire (plan
//! P4; rules.md B-43, D-47, D-50): a draft waits for its receipt and
//! posts when it arrives, a discard unblocks a close, a report and the CPA
//! package say when drafts are open, a reversal needs no receipt of its
//! own, and every tool with `confirmed` takes the `preview` it returned
//! and refuses a stale one.
//!
//! Expected behavior comes from `docs/mcp-interface.md` ("Tools — drafts",
//! "The preview digest") and rules.md B-43. Every call goes through a real
//! books server as an agent reaches it (`common.rs`).
mod common;
use common::{Books, answer_of};
use serde_json::{Value, json};
/// Cash at 1000, the default 25.00 receipt threshold.
const SETTINGS: &str = "[company]\nname = \"Drafts Co\"\nfiscal_year_start = \"01-01\"\n\
[books]\ncash_accounts = [\"1000\"]\n";
fn chart(w: &mut common::Wire) {
for (code, name, ty) in [
("1000", "Cash", "asset"),
("3000", "Capital", "equity"),
("4000", "Sales", "revenue"),
("6000", "Rent", "expense"),
] {
answer_of(&w.call(
"books.add_account",
json!({"code": code, "name": name, "account_type": ty}),
))
.unwrap();
}
}
/// A draft of `amount` rent paid from cash on `date`.
fn draft(w: &mut common::Wire, date: &str, what: &str, amount: &str) -> u64 {
let r = answer_of(&w.call(
"books.draft_entry",
json!({"date": date, "description": what, "no_party_reason": "test"}),
))
.unwrap();
let id = r["entry_id"].as_u64().unwrap();
for (account, side) in [("6000", "debit"), ("1000", "credit")] {
answer_of(&w.call(
"books.add_line",
json!({"entry_id": id, "account": account, "side": side, "amount": amount}),
))
.unwrap();
}
id
}
fn a_receipt(w: &mut common::Wire, entry: u64) -> u64 {
let bytes = b"%PDF-1.4 a receipt";
let upload = w.upload(bytes, "receipt.pdf", "application/pdf", "receipt");
answer_of(&w.call(
"books.attach_receipt",
json!({"entry_id": entry, "upload_id": upload}),
))
.unwrap()["attachment_id"]
.as_u64()
.unwrap()
}
fn setup(tag: &str) -> (Books, common::Wire) {
let books = Books::with(tag, "USD", Some(SETTINGS));
let mut w = books.session("p4");
chart(&mut w);
(books, w)
}
/// The refusal a call met, parsed when it is structured.
fn refusal(r: &Value) -> Value {
let text = answer_of(r).expect_err("expected a refusal");
serde_json::from_str(&text).unwrap_or(Value::String(text))
}
/// A draft finished but waiting for its receipt says so, is listed as
/// missing its evidence, is refused at the post, and posts the moment the
/// receipt is attached.
#[test]
fn a_draft_waits_for_its_receipt_and_posts() {
let (_books, mut w) = setup("p4-waits");
let id = draft(&mut w, "2026-03-01", "Figma annual plan", "100.00");
let set = answer_of(&w.call(
"books.set_draft_state",
json!({"entry_id": id, "state": "awaiting_evidence", "note": "Figma invoice due"}),
))
.unwrap();
assert_eq!(set["state"], "awaiting_evidence");
let listed = answer_of(&w.call("books.list_drafts", json!({}))).unwrap();
let d = &listed["drafts"][0];
assert_eq!(d["entry_id"], id);
assert_eq!(d["state"], "awaiting_evidence");
assert_eq!(d["note"], "Figma invoice due");
assert_eq!(d["total"], "100.00");
assert_eq!(d["missing_evidence"], true);
let waiting = answer_of(&w.call(
"books.list_drafts",
json!({"state": "awaiting_evidence", "as_of": "2026-03-31"}),
))
.unwrap();
assert_eq!(waiting["drafts"].as_array().unwrap().len(), 1);
let building = answer_of(&w.call("books.list_drafts", json!({"state": "building"}))).unwrap();
assert!(building["drafts"].as_array().unwrap().is_empty());
// The preview names the need; the confirm is refused with the error the
// MCP always gave.
let p = answer_of(&w.call("books.preview_entry", json!({"entry_id": id}))).unwrap();
assert_eq!(p["status"], "preview");
assert_eq!(p["receipt_required"], true);
assert_eq!(p["receipt_missing"], true);
let digest = p["preview"].as_str().unwrap().to_string();
let r = w.call(
"books.post_entry",
json!({"entry_id": id, "confirmed": true, "preview": digest}),
);
let err = refusal(&r);
assert_eq!(err["code"], "receipt_required", "{err}");
// The receipt arrives; the same draft posts.
a_receipt(&mut w, id);
let r = w.call(
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
);
let posted = answer_of(&r).unwrap();
assert_eq!(posted["status"], "posted");
let listed = answer_of(&w.call("books.list_drafts", json!({}))).unwrap();
assert!(listed["drafts"].as_array().unwrap().is_empty());
}
/// B-43: the structured `receipt_required` error is the one the MCP built
/// before the gate moved into the store.
#[test]
fn the_gate_moved_and_the_mcp_error_is_unchanged() {
let (_books, mut w) = setup("p4-error");
let id = draft(&mut w, "2026-03-01", "Rent", "100.00");
let r = w.call(
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
);
let err = refusal(&r);
assert_eq!(err["code"], "receipt_required");
assert_eq!(err["entry_id"], id);
assert_eq!(err["debits"], "100.00");
assert_eq!(
err["policy"],
"receipts required when entry debits are strictly greater than 25.00"
);
assert_eq!(err["remedy_tool"], "books.attach_receipt");
assert!(
err["message"]
.as_str()
.unwrap()
.starts_with(&format!("entry {id} moves 100.00 in debits ("))
);
assert!(
err["remedy_hint"]
.as_str()
.unwrap()
.contains("then retry books.post_entry with confirmed=true")
);
assert!(err.get("split_group").is_none());
}
/// D-47: a reversal posts at once with no receipt of its own; its evidence
/// is its original's.
#[test]
fn a_reversal_needs_no_receipt_of_its_own() {
let (_books, mut w) = setup("p4-reversal");
let id = draft(&mut w, "2026-03-01", "Rent", "100.00");
a_receipt(&mut w, id);
answer_of(&w.call(
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
))
.unwrap();
let r = answer_of(&w.call(
"books.reverse_entry",
json!({"entry_id": id, "date": "2026-03-05", "description": "Undo", "confirmed": true}),
))
.unwrap();
assert_eq!(r["status"], "posted");
let d = answer_of(&w.call("books.digest", json!({"since": "2026-03-01"}))).unwrap();
for e in d["entries"].as_array().unwrap() {
if e["reversal_of"].is_number() {
assert_eq!(e["receipt_required"], false, "{e}");
assert_eq!(e["receipt_missing"], false, "{e}");
}
}
}
/// A period holding an open draft cannot close; discarded (previewed, then
/// confirmed with its digest), it can.
#[test]
fn discard_unblocks_the_close_over_the_wire() {
let (_books, mut w) = setup("p4-discard");
let seed = draft(&mut w, "2026-03-01", "Seed", "10.00");
answer_of(&w.call(
"books.post_entry",
json!({"entry_id": seed, "confirmed": true}),
))
.unwrap();
let per = answer_of(&w.call(
"books.create_period",
json!({"name": "2026-03", "start": "2026-03-01", "end": "2026-03-31"}),
))
.unwrap()["period_id"]
.as_u64()
.unwrap();
let id = draft(&mut w, "2026-03-20", "Entered twice", "40.00");
let r = w.call(
"books.close_period",
json!({"period_id": per, "confirmed": true}),
);
assert!(
answer_of(&r).is_err(),
"a close is refused with a draft open"
);
let p = answer_of(&w.call(
"books.discard_draft",
json!({"entry_id": id, "reason": "entered twice"}),
))
.unwrap();
assert_eq!(p["status"], "preview");
assert_eq!(p["holds_open"], json!(["2026-03"]));
let digest = p["preview"].as_str().unwrap().to_string();
// A reason is the preview's; a different one is a different preview.
let r = w.call_raw(
"books.discard_draft",
json!({"entry_id": id, "reason": "something else", "confirmed": true, "preview": digest}),
);
assert_eq!(refusal(&r)["code"], "stale_preview");
let done = answer_of(&w.call_raw(
"books.discard_draft",
json!({"entry_id": id, "reason": "entered twice", "confirmed": true, "preview": digest}),
))
.unwrap();
assert_eq!(done["status"], "discarded");
assert!(
answer_of(&w.call("books.list_drafts", json!({}))).unwrap()["drafts"]
.as_array()
.unwrap()
.is_empty()
);
let closed = answer_of(&w.call(
"books.close_period",
json!({"period_id": per, "confirmed": true}),
))
.unwrap();
assert_eq!(closed["status"], "closed");
// Only a draft: the posted seed is never discarded.
let r = w.call(
"books.discard_draft",
json!({"entry_id": seed, "reason": "no"}),
);
assert!(answer_of(&r).unwrap_err().contains("posted"));
}
/// A report says what is open as of its end; strict refuses instead.
#[test]
fn report_warns_on_draft_before_as_of() {
let (_books, mut w) = setup("p4-report");
let id = draft(&mut w, "2026-03-10", "Open", "40.00");
let tb = answer_of(&w.call("books.trial_balance", json!({"as_of": "2026-03-31"}))).unwrap();
let open = &tb["as_of_status"]["drafts_open_on_or_before"];
assert_eq!(open[0]["entry_id"], id);
assert_eq!(open[0]["state"], "building");
let early = answer_of(&w.call("books.trial_balance", json!({"as_of": "2026-03-01"}))).unwrap();
assert!(
early["as_of_status"]["drafts_open_on_or_before"]
.as_array()
.unwrap()
.is_empty(),
"a draft dated after the report's end is not open in it"
);
for tool in [
"books.trial_balance",
"books.balance_sheet",
"books.profit_and_loss",
"books.cash_flow",
] {
let args = match tool {
"books.trial_balance" | "books.balance_sheet" => {
json!({"as_of": "2026-03-31", "strict": true})
}
_ => json!({"start": "2026-03-01", "end": "2026-03-31", "strict": true}),
};
let r = w.call(tool, args);
let why = answer_of(&r).expect_err(tool);
assert!(
why.contains("strict") && why.contains("draft"),
"{tool}: {why}"
);
}
// Posted, the report is clean and strict gives it.
a_receipt(&mut w, id);
answer_of(&w.call(
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
))
.unwrap();
let ok = answer_of(&w.call(
"books.trial_balance",
json!({"as_of": "2026-03-31", "strict": true}),
));
// Cash is not reconciled, so strict still names it: the refusal says
// so and not "draft".
if let Err(why) = ok {
assert!(!why.contains("draft"), "{why}");
}
}
/// The CPA package is refused while a draft dated in it is open, unless
/// the human says why; the reason is journaled as a decision.
#[test]
fn cpa_package_refuses_open_draft() {
let (books, mut w) = setup("p4-cpa");
let id = draft(&mut w, "2026-03-10", "Open", "40.00");
let args = json!({"as_of": "2026-03-31", "cash_accounts": ["1000"]});
let err = refusal(&w.call("books.export_cpa_package", args.clone()));
assert_eq!(err["code"], "open_drafts", "{err}");
assert!(err["message"].as_str().unwrap().contains(&format!("#{id}")));
// A draft dated after as_of does not stop it.
let before = books.journal().len();
let mut allowed = args.clone();
allowed["allow_open_drafts"] = json!({"reason": "the CPA wants it before the receipt comes"});
let out = answer_of(&w.call("books.export_cpa_package", allowed)).unwrap();
assert!(out["file"].is_object(), "{out}");
let journal = String::from_utf8(books.journal()).unwrap();
assert!(books.journal().len() > before);
assert!(journal.contains("decision.recorded"));
assert!(journal.contains("the CPA wants it before the receipt comes"));
}
/// D-50: a confirm takes the preview it was shown; without it, or after a
/// changed argument or a write by another session, it is refused with
/// `stale_preview` before anything is written.
#[test]
fn stale_preview_refused() {
let (books, mut w) = setup("p4-stale");
let id = draft(&mut w, "2026-03-01", "Rent", "10.00");
// A draft's preview is the draft's state: a line added since is stale.
let p = answer_of(&w.call("books.preview_entry", json!({"entry_id": id}))).unwrap();
let digest = p["preview"].as_str().unwrap().to_string();
// The same preview from post_entry itself.
let p2 = answer_of(&w.call("books.post_entry", json!({"entry_id": id}))).unwrap();
assert_eq!(p2["preview"], p["preview"]);
// No preview at all.
let r = w.call_raw(
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
);
assert_eq!(refusal(&r)["code"], "stale_preview");
// A line added since the preview.
answer_of(&w.call(
"books.add_line",
json!({"entry_id": id, "account": "6000", "side": "debit", "amount": "1.00"}),
))
.unwrap();
answer_of(&w.call(
"books.add_line",
json!({"entry_id": id, "account": "1000", "side": "credit", "amount": "1.00"}),
))
.unwrap();
let before = books.journal();
let r = w.call_raw(
"books.post_entry",
json!({"entry_id": id, "confirmed": true, "preview": digest}),
);
let err = refusal(&r);
assert_eq!(err["code"], "stale_preview");
assert_eq!(err["remedy_tool"], "books.post_entry");
assert_eq!(books.journal(), before, "a stale confirm writes nothing");
// An operation's preview moves with the journal's head: a post by
// another session in between forces a re-preview.
let other = draft(&mut w, "2026-03-02", "Other", "5.00");
let first = answer_of(&w.call(
"books.reverse_entry",
json!({"entry_id": other, "date": "2026-03-03", "description": "x"}),
));
assert!(first.is_err(), "a draft is not reversible");
answer_of(&w.call(
"books.post_entry",
json!({"entry_id": other, "confirmed": true}),
))
.unwrap();
let p = answer_of(&w.call(
"books.reverse_entry",
json!({"entry_id": other, "date": "2026-03-03", "description": "x"}),
))
.unwrap();
let digest = p["preview"].as_str().unwrap().to_string();
// The same call with another description is another preview...
let r = w.call_raw(
"books.reverse_entry",
json!({"entry_id": other, "date": "2026-03-03", "description": "y",
"confirmed": true, "preview": digest}),
);
assert_eq!(refusal(&r)["code"], "stale_preview");
// ...and so is a write between preview and confirm.
answer_of(&w.call(
"books.add_account",
json!({"code": "6100", "name": "Software", "account_type": "expense"}),
))
.unwrap();
let r = w.call_raw(
"books.reverse_entry",
json!({"entry_id": other, "date": "2026-03-03", "description": "x",
"confirmed": true, "preview": digest}),
);
assert_eq!(refusal(&r)["code"], "stale_preview");
// Re-previewed, it confirms.
let r = w.call(
"books.reverse_entry",
json!({"entry_id": other, "date": "2026-03-03", "description": "x",
"confirmed": true}),
);
assert_eq!(answer_of(&r).unwrap()["status"], "posted");
}
/// Every tool that takes `confirmed` takes `preview` (D-50, from P4 on).
#[test]
fn every_confirm_tool_takes_a_preview() {
let books = Books::new("p4-schema");
let mut w = books.session("p4");
let tools = w.rpc("tools/list", json!({}))["result"]["tools"].clone();
let mut confirm = Vec::new();
for t in tools.as_array().expect("tools") {
let props = &t["inputSchema"]["properties"];
if props.get("confirmed").is_some() {
confirm.push(t["name"].as_str().unwrap().to_string());
assert!(
props.get("preview").is_some(),
"{} takes confirmed but not preview",
t["name"]
);
}
}
for tool in [
"books.post_entry",
"books.reverse_entry",
"books.run_schedules",
"books.dispose_asset",
"books.reconcile",
"books.close_period",
"books.create_invoice",
"books.create_bill",
"books.receive_payment",
"books.pay_bill",
"books.write_off_invoice",
"books.discard_draft",
] {
assert!(confirm.iter().any(|t| t == tool), "{tool} has no confirmed");
}
}
/// Defect 6: an entry dated in the past but posted just now is in the
/// digest asked for by the journal sequence number, and not in the one
/// asked for by date.
#[test]
fn digest_since_seq_is_posting_time() {
let (_books, mut w) = setup("p4-digest");
let early = draft(&mut w, "2026-01-05", "Backdated", "20.00");
let head =
answer_of(&w.call("books.digest", json!({"since": "2026-01-01"}))).unwrap()["head_seq"]
.as_u64()
.unwrap();
answer_of(&w.call(
"books.post_entry",
json!({"entry_id": early, "confirmed": true}),
))
.unwrap();
let by_seq = answer_of(&w.call("books.digest", json!({"since_seq": head}))).unwrap();
let ids: Vec<u64> = by_seq["entries"]
.as_array()
.unwrap()
.iter()
.map(|e| e["entry_id"].as_u64().unwrap())
.collect();
assert_eq!(ids, vec![early]);
assert!(by_seq["head_seq"].as_u64().unwrap() > head);
let by_date = answer_of(&w.call("books.digest", json!({"since": "2026-02-01"}))).unwrap();
assert!(by_date["entries"].as_array().unwrap().is_empty());
// Nothing after the new head.
let none =
answer_of(&w.call("books.digest", json!({"since_seq": by_seq["head_seq"]}))).unwrap();
assert!(none["entries"].as_array().unwrap().is_empty());
// An account touched whose net movement is zero is listed, not lost.
let second = draft(&mut w, "2026-01-06", "Refund", "20.00");
// (reverse the sign on the same accounts: debit cash, credit rent)
answer_of(&w.call(
"books.post_entry",
json!({"entry_id": second, "confirmed": true}),
))
.unwrap();
let rev = answer_of(&w.call(
"books.reverse_entry",
json!({"entry_id": second, "date": "2026-01-07", "description": "Undo",
"confirmed": true}),
))
.unwrap();
assert_eq!(rev["status"], "posted");
let d = answer_of(&w.call("books.digest", json!({"since_seq": head}))).unwrap();
let zero: Vec<&str> = d["zero_net_accounts"]
.as_array()
.unwrap()
.iter()
.filter_map(Value::as_str)
.collect();
assert!(
zero.is_empty(),
"the first entry still moves rent: {zero:?}"
);
let only_pair = answer_of(&w.call(
"books.digest",
json!({"since": "2026-01-06", "as_of": "2026-01-31"}),
))
.unwrap();
let zero: Vec<&str> = only_pair["zero_net_accounts"]
.as_array()
.unwrap()
.iter()
.filter_map(Value::as_str)
.collect();
assert_eq!(zero, vec!["1000 Cash", "6000 Rent"]);
assert_eq!(only_pair["drafts_by_state"], json!({}));
assert_eq!(only_pair["never_reconciled"], json!(["1000"]));
}
/// The viewer and the server ask one function whether an entry lacks its
/// evidence (plan P4 step 2, I-5): on random books under three policies
/// the viewer's missing-evidence set equals the server's, and both equal
/// what the viewer decided before the gate moved (an entry's origin read
/// from the journal's events: a hand entry or a recurring draft is
/// covered; a document's, a payment's, a close, a schedule month's, a
/// depreciation's, a payroll's and a reversal are not).
#[cfg(feature = "gui")]
#[test]
fn gui_and_server_gate_agree() {
use chrono::NaiveDate;
use ledger_core::settings::{Change, Source};
use ledger_core::{AccountType, Currency, Money, Side};
use ledger_gui::data;
use rust_decimal::Decimal;
use std::collections::{BTreeMap, BTreeSet};
let day = |m: u32, d: u32| NaiveDate::from_ymd_opt(2026, m, d).unwrap();
let usd = |c: i64| Money::new(Decimal::new(c, 2), Currency::USD);
for (n, (threshold, required, exempt)) in [
("0.00", vec![], vec![]),
("25.00", vec!["6200"], vec!["6300"]),
("100.00", vec![], vec!["6300", "6100"]),
]
.into_iter()
.enumerate()
{
let root = tempfile::Builder::new()
.prefix(&format!("wb-p4-gate-{n}-"))
.tempdir()
.unwrap();
let dir = root.path().join("book");
std::fs::create_dir_all(&dir).unwrap();
let mut st = ledger_store::Store::create(&dir, Currency::USD).unwrap();
st.change_settings(
&[
Change {
key: "policy.evidence.threshold".into(),
value: Some(json!(threshold)),
},
Change {
key: "policy.evidence.required_accounts".into(),
value: Some(json!(required)),
},
Change {
key: "policy.evidence.exempt_accounts".into(),
value: Some(json!(exempt)),
},
],
&Source {
decided_by: "test".into(),
decided_on: day(1, 1),
citation: None,
note: None,
},
)
.unwrap();
let mut acct =
|code: &str, name: &str, ty| st.add_account(code, name, ty, None, false).unwrap();
let bank = acct("1000", "Bank", AccountType::Asset);
let prepaid = acct("1300", "Prepaid", AccountType::Asset);
let capital = acct("3000", "Capital", AccountType::Equity);
let sales = acct("4000", "Sales", AccountType::Revenue);
let expenses = [
acct("6100", "Supplies", AccountType::Expense),
acct("6200", "Contractors", AccountType::Expense),
acct("6300", "Fees", AccountType::Expense),
];
let seed = st.create_entry(day(1, 1), "Capital").unwrap();
st.add_line(seed, bank, Side::Debit, usd(10_000_000))
.unwrap();
st.add_line(seed, capital, Side::Credit, usd(10_000_000))
.unwrap();
st.post_entry(seed).unwrap();
// A deterministic pseudo-random run of hand entries.
let mut x: u64 = 0x2545F4914F6CDD1D ^ (n as u64);
let mut next = move || {
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
x
};
let mut posted = Vec::new();
for i in 0..36u32 {
let e = st
.create_entry(day(2, 1 + (i % 27)), &format!("Entry {i}"))
.unwrap();
let cents = 100 + (next() % 30_000) as i64;
let ex = expenses[(next() % 3) as usize];
st.add_line(e, ex, Side::Debit, usd(cents)).unwrap();
st.add_line(e, bank, Side::Credit, usd(cents)).unwrap();
if next() % 3 == 0 {
st.attach_receipt(Some(e), "r.pdf", "application/pdf", b"%PDF r", None)
.unwrap();
}
// Some stay drafts.
if next() % 6 != 0 {
st.post_entry(e).unwrap();
posted.push(e);
}
}
for e in posted.iter().take(5) {
st.reverse_entry(*e, day(3, 2), "Reversed").unwrap();
}
let customer = st.add_customer("Fernhill Labs").unwrap();
let inv = st
.create_invoice(
customer,
day(3, 3),
day(4, 2),
vec![ledger_core::arap::DocumentLine {
description: "Design".into(),
amount: Decimal::new(250_000, 2),
class_id: None,
account: None,
quantity: None,
unit_price: None,
}],
)
.unwrap();
st.receive_payment(inv, day(3, 20), Decimal::new(100_000, 2), bank)
.unwrap();
let sched = st
.register_schedule(ledger_core::NewSchedule {
description: "Insurance".into(),
kind: ledger_core::ScheduleKind::PrepaidExpense,
total: usd(120_000),
start: day(1, 1),
months: 12,
balance_account: prepaid,
pl_account: expenses[0],
convention: ledger_core::ScheduleConvention::MonthlyEven,
})
.unwrap();
st.post_schedule_month(sched, day(1, 31)).unwrap();
let _ = sales;
let per = st.create_period("2026-01", day(1, 1), day(1, 31)).unwrap();
let _ = st.close_period(per);
st.register_recurring(ledger_core::NewRecurring {
description: "Figma".into(),
lines: vec![
(expenses[0], Side::Debit, usd(4_000)),
(bank, Side::Credit, usd(4_000)),
],
frequency: ledger_core::Frequency::Monthly,
start: day(5, 10),
end: None,
})
.unwrap();
st.run_recurring(day(5, 31)).unwrap();
// What the server decides: the engine, with the receipts filed.
let server: BTreeSet<u64> = (1..st.ledger().next_entry_id())
.filter(|id| {
let Some(e) = st.ledger().get_entry(*id) else {
return false;
};
ledger_core::evidence::requirement(st.ledger(), e, st.ledger().settings())
.is_required()
&& st.list_attachments(Some(*id)).unwrap().is_empty()
})
.collect();
// What the viewer decided before: origin from the journal's events.
let policy = ledger_core::evidence::Policy::from_settings(st.ledger().settings());
let journal = String::from_utf8(std::fs::read(dir.join("journal.jsonl")).unwrap()).unwrap();
let mut refs: BTreeMap<u64, Vec<String>> = BTreeMap::new();
for line in journal.lines() {
let v: Value = serde_json::from_str(line).unwrap();
let ty = v["type"].as_str().unwrap().to_string();
let p = &v["payload"];
let mut ids = Vec::new();
if ty.starts_with("entry.") {
ids.extend(p["id"].as_u64());
}
ids.extend(p["entry_id"].as_u64());
ids.extend(p["closing_entry"].as_u64());
ids.extend(p["entry"]["id"].as_u64());
for id in ids {
refs.entry(id).or_default().push(ty.clone());
}
}
let closings = st.ledger().closing_entry_ids();
let old: BTreeSet<u64> = (1..st.ledger().next_entry_id())
.filter(|id| {
let Some(e) = st.ledger().get_entry(*id) else {
return false;
};
let has = |t: &str| refs.get(id).is_some_and(|v| v.iter().any(|x| x == t));
let by_hand = !closings.contains(id)
&& !has("entry.reversed")
&& !has("schedule.posted")
&& !has("asset.depreciation_posted")
&& !has("asset.disposed")
&& !has("bill.created")
&& !has("bill.paid")
&& !has("invoice.created")
&& !has("invoice.payment_received")
&& !has("payroll.run")
&& !has("payroll.taxes_remitted");
by_hand
&& e.reversal_of().is_none()
&& st.list_attachments(Some(*id)).unwrap().is_empty()
&& policy.receipt_required(st.ledger(), e)
})
.collect();
drop(st);
let books = data::load_once(&data::Source {
data_dir: dir.clone(),
today: Some(day(6, 1)),
})
.unwrap();
let viewer: BTreeSet<u64> = (1..=books.ledger.next_entry_id())
.filter(|id| books.entry(*id).is_some_and(|e| e.missing_receipt))
.collect();
assert!(!server.is_empty(), "policy {threshold}: nothing to compare");
assert_eq!(viewer, server, "policy {threshold}: viewer vs server");
assert_eq!(server, old, "policy {threshold}: engine vs the old reading");
}
}
/// Books an older version wrote hold a reversal and drafts (the fixture
/// `p4_before`): opened under this version, with no upgrade, the figures
/// are the writer's, the reversal needs no receipt of its own, and the
/// drafts are open and building.
#[test]
fn legacy_reversed_replays_identically() {
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../ledger-store/tests/fixtures/old-books/p4_before");
let only = tempfile::tempdir().unwrap();
std::fs::copy(src.join("journal.jsonl"), only.path().join("journal.jsonl")).unwrap();
let books = Books::placed("p4-legacy", only.path());
let mut w = books.session("p4");
let d = answer_of(&w.call("books.digest", json!({"since": "2026-01-01"}))).unwrap();
let reversals: Vec<&Value> = d["entries"]
.as_array()
.unwrap()
.iter()
.filter(|e| e["reversal_of"].is_number())
.collect();
assert_eq!(reversals.len(), 1);
assert_eq!(reversals[0]["receipt_required"], false);
assert_eq!(reversals[0]["receipt_missing"], false);
let drafts = answer_of(&w.call("books.list_drafts", json!({}))).unwrap();
let open: Vec<&Value> = drafts["drafts"].as_array().unwrap().iter().collect();
assert_eq!(open.len(), 3, "{drafts}");
assert!(open.iter().all(|d| d["state"] == "building"));
assert_eq!(d["drafts_by_state"], json!({"building": 3}));
let tb = answer_of(&w.call("books.trial_balance", json!({"as_of": "2026-12-31"}))).unwrap();
assert_eq!(tb["balanced"], true);
}
-467
View File
@@ -1,467 +0,0 @@
//! The evidence pipeline over the wire (plan P6): a receipt's text and
//! pages, annotations, finding a file, consolidating duplicates, and the
//! backfill command.
//!
//! Expected behavior comes from `docs/mcp-interface.md` ("Tools —
//! evidence", "Tools — receipts & documents") and rules.md B-44. Every
//! call goes through a real books server as an agent reaches it
//! (`common.rs`).
mod common;
use common::{Books, answer_of};
use serde_json::json;
use std::path::{Path, PathBuf};
/// The settings a test's books start with: a high receipt threshold (so a
/// simple posting needs no receipt) and cash at 1000.
const SETTINGS: &str = "[company]\nname = \"Evidence Co\"\nfiscal_year_start = \"01-01\"\n\
[books]\ncash_accounts = [\"1000\"]\nreceipt_threshold = \"1000.00\"\n";
/// A PDF with one page that says the word "Rent" (the extractor's own
/// typesetter, so the text is real and the page renders).
fn a_pdf() -> Vec<u8> {
use ledger_import::report::{ReportMeta, Row, RowKind, statement};
use rust_decimal::Decimal;
let st = statement(
"Receipt",
"March 2026".to_string(),
&["Total"],
vec![Row {
kind: RowKind::Line,
label: "Rent".into(),
amounts: vec![Some(Decimal::new(10000, 2))],
currency: true,
rule_above: false,
}],
);
let meta = ReportMeta {
company: "Evidence Co".into(),
basis: "Accrual".into(),
prepared_on: chrono::NaiveDate::from_ymd_opt(2026, 3, 2).unwrap(),
journal_seq: 1,
};
ledger_import::statement_pdf(&st, &meta).unwrap()
}
/// A draft with two lines, and the accounts made for it.
fn a_draft(w: &mut common::Wire) -> u64 {
for (code, name, ty) in [("1000", "Cash", "asset"), ("6000", "Rent", "expense")] {
let r = w.call(
"books.add_account",
json!({"code": code, "name": name, "account_type": ty}),
);
if let Err(e) = answer_of(&r) {
assert!(e.contains("already"), "{e}");
}
}
let r = w.call(
"books.draft_entry",
json!({"date": "2026-03-01", "description": "March rent"}),
);
let id = answer_of(&r).unwrap()["entry_id"].as_u64().unwrap();
for (account, side, amount) in [("6000", "debit", "100.00"), ("1000", "credit", "100.00")] {
let r = w.call(
"books.add_line",
json!({"entry_id": id, "account": account, "side": side, "amount": amount}),
);
answer_of(&r).unwrap();
}
id
}
/// File `bytes` on `entry` through an upload, as an agent does (D-46).
fn file(w: &mut common::Wire, entry: u64, bytes: &[u8], name: &str, mime: &str) -> u64 {
let upload = w.upload(bytes, name, mime, "receipt");
let r = w.call(
"books.attach_receipt",
json!({"entry_id": entry, "upload_id": upload}),
);
answer_of(&r).unwrap()["attachment_id"].as_u64().unwrap()
}
/// A PNG's width and height, from its IHDR.
fn png_size(bytes: &[u8]) -> (u32, u32) {
assert!(bytes.starts_with(b"\x89PNG\r\n\x1a\n"), "not a PNG");
let w = u32::from_be_bytes(bytes[16..20].try_into().unwrap());
let h = u32::from_be_bytes(bytes[20..24].try_into().unwrap());
(w, h)
}
/// A book folder with two live receipts of one file's bytes, filed by an
/// older version on two entries (plan P6 step 4: legacy duplicates). With
/// `withdraw_first`, the first is already withdrawn, as a duplicate of the
/// second, so the second is its entry's only evidence.
fn legacy_duplicates(only: &str, withdraw_first: bool) -> (tempfile::TempDir, PathBuf) {
use ledger_core::{AccountType, Currency, Money, Side};
let root = tempfile::Builder::new()
.prefix(&format!("wb-p6-{only}-"))
.tempdir()
.unwrap();
let dir = root.path().join("book");
std::fs::create_dir_all(&dir).unwrap();
let mut s = ledger_store::Store::create(&dir, Currency::USD).unwrap();
let cash = s
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let expense = s
.add_account("6000", "Rent", AccountType::Expense, None, false)
.unwrap();
let mut entries = Vec::new();
for (d, what) in [("2026-03-01", "Rent one"), ("2026-03-02", "Rent two")] {
let e = s
.create_entry(
chrono::NaiveDate::parse_from_str(d, "%Y-%m-%d").unwrap(),
what,
)
.unwrap();
let m = Money::new("100.00".parse().unwrap(), Currency::USD);
s.add_line(e, expense, Side::Debit, m).unwrap();
s.add_line(e, cash, Side::Credit, m).unwrap();
s.post_entry(e).unwrap();
entries.push(e);
}
drop(s);
let bytes = b"one receipt, filed twice by an older version";
let (sha, _) = ledger_store::BlobStore::open(&dir.join("blobs"))
.unwrap()
.put(bytes)
.unwrap();
let mut w = ledger_store::JournalWriter::open(&dir.join(ledger_store::JOURNAL_FILE)).unwrap();
for (id, entry, name) in [(1u64, entries[0], "one.txt"), (2, entries[1], "two.txt")] {
w.append(
"receipt.attached",
json!({
"attachment_id": id, "blob_sha": sha, "entry_id": entry,
"category": "receipts", "file_name": name,
"mime_type": "text/plain", "size_bytes": bytes.len() as u64,
"description": null,
}),
)
.unwrap();
}
if withdraw_first {
w.append(
"receipt.unlinked",
json!({"attachment_id": 1, "entry_id": entries[0], "reason": "duplicate"}),
)
.unwrap();
w.append(
"attachment.withdrawn",
json!({"attachment_id": 1, "reason": "duplicate of #2", "duplicate_of": 2}),
)
.unwrap();
}
drop(w);
for f in [
ledger_store::db_file(),
ledger_store::LOCK_FILE.to_string(),
format!("{}-wal", ledger_store::db_file()),
format!("{}-shm", ledger_store::db_file()),
] {
let _ = std::fs::remove_file(dir.join(f));
}
(root, dir)
}
/// Plan P6 step 3: the default answer is what the file is, the first 8 KiB
/// of its text and its pages; never its bytes, and small enough to read.
#[test]
fn get_receipt_default_has_text_and_no_bytes() {
let books = Books::with("p6-meta", "USD", Some(SETTINGS));
let mut w = books.session("evidence");
let entry = a_draft(&mut w);
let pdf = a_pdf();
let id = file(&mut w, entry, &pdf, "receipt.pdf", "application/pdf");
let r = w.call("books.get_receipt", json!({"attachment_id": id}));
let out = answer_of(&r).unwrap();
assert_eq!(out["text_status"], "ok", "{out}");
assert_eq!(out["pages"], 1, "{out}");
let text = out["text"].as_str().unwrap();
assert!(text.contains("Rent"), "{text}");
assert!(text.len() <= 8 * 1024);
assert_eq!(out["text_truncated"], false);
assert!(out.get("file").is_none(), "no file in the default answer");
assert!(
serde_json::to_string(&out).unwrap().len() < 8 * 1024,
"the default answer is under 8 KiB"
);
assert!(!serde_json::to_string(&out).unwrap().contains("base64"));
// view text: the whole text, no file.
let r = w.call(
"books.get_receipt",
json!({"attachment_id": id, "view": "text"}),
);
let out = answer_of(&r).unwrap();
assert_eq!(out["text"].as_str().unwrap(), text);
// The resource reads the same: metadata plus text.
let r = w.rpc(
"resources/read",
json!({"uri": format!("books://attachments/{id}")}),
);
let text = r["result"]["contents"][0]["text"].as_str().unwrap();
assert!(text.contains("\"attachment_id\""), "{text}");
assert!(text.contains("Rent"), "{text}");
}
/// Plan P6 step 3: one page as a PNG, handed over; page numbers start at
/// 1; a page past the last is refused.
#[test]
fn page_image_is_a_png_of_the_page() {
let books = Books::with("p6-page", "USD", Some(SETTINGS));
let mut w = books.session("evidence");
let entry = a_draft(&mut w);
let id = file(&mut w, entry, &a_pdf(), "receipt.pdf", "application/pdf");
let r = w.call(
"books.get_receipt",
json!({"attachment_id": id, "view": "page_image", "page": 1, "max_px": 300}),
);
let out = answer_of(&r).unwrap();
let file = &out["file"];
assert_eq!(file["mime_type"], "image/png");
let png = w.fetch(file);
let (width, height) = png_size(&png);
assert!(width.max(height) <= 300, "{width}x{height}");
assert!(width > 0 && height > 0);
// The page resource gives a PNG of the page too (its size its own:
// a resource takes no max_px).
let r = w.rpc(
"resources/read",
json!({"uri": format!("books://attachments/{id}/page/1")}),
);
let contents = &r["result"]["contents"][0];
assert_eq!(contents["mimeType"], "image/png", "{contents}");
let encoded = contents["blob"].as_str().unwrap();
use base64::Engine;
let bytes = base64::engine::general_purpose::STANDARD
.decode(encoded)
.unwrap();
png_size(&bytes);
// Past the last page: refused.
let r = w.call(
"books.get_receipt",
json!({"attachment_id": id, "view": "page_image", "page": 2}),
);
assert!(answer_of(&r).is_err());
// bytes still hands the file over, unchanged.
let r = w.call(
"books.get_receipt",
json!({"attachment_id": id, "view": "bytes"}),
);
let out = answer_of(&r).unwrap();
assert_eq!(w.fetch(&out["file"]), a_pdf());
}
/// Plan P6 steps 4 and 5: annotations, the engine's numbers, the three
/// ways to find a file, and a settlement recorded.
#[test]
fn annotations_and_searches() {
let books = Books::with("p6-find", "USD", Some(SETTINGS));
let mut w = books.session("evidence");
let entry = a_draft(&mut w);
let text = b"Harbor Design LLC\nInvoice H-1042\nHosting, 42.00, 2026-03-01\n";
let id = file(&mut w, entry, text, "harbor.txt", "text/plain");
let r = w.call(
"books.annotate_attachment",
json!({"attachment_id": id, "fields": {
"receipt_number": 7, "payee": "Harbor Design LLC", "amount": "42.00",
"date": "2026-03-01", "instrument": "Check 1042",
"source": "the owner's email", "reimbursable_to": "Avery Quill"
}, "note": "read on 2026-03-02"}),
);
let out = answer_of(&r).unwrap();
assert_eq!(out["fields"]["receipt_number"], 7);
assert_eq!(out["fields"]["amount"], "42.00");
assert_eq!(out["next_receipt_number"], 8);
// Meta carries the annotation and the number.
let r = w.call("books.get_receipt", json!({"attachment_id": id}));
let out = answer_of(&r).unwrap();
assert_eq!(out["receipt_number"], 7);
assert_eq!(out["annotation"]["payee"], "Harbor Design LLC");
// Found three ways.
for args in [
json!({"receipt_number": 7}),
json!({"sha256": ledger_store::journal::sha256_hex(text)}),
json!({"text": "harbor design"}),
] {
let r = w.call("books.find_attachment", args.clone());
let out = answer_of(&r).unwrap();
assert!(
out["attachments"]
.as_array()
.unwrap()
.iter()
.any(|a| a["attachment_id"] == id),
"{args}: {out}"
);
}
// Exactly one selector.
let r = w.call(
"books.find_attachment",
json!({"sha256": ledger_store::journal::sha256_hex(text), "text": "x"}),
);
assert!(answer_of(&r).is_err());
// A settlement is recorded on the paying entry and read back.
let pay = a_draft(&mut w);
let r = w.call(
"books.set_entry_settles",
json!({"entry_id": pay, "settles": [entry]}),
);
let out = answer_of(&r).unwrap();
assert_eq!(out["settles"], json!([entry]));
let r = w.call(
"books.set_entry_settles",
json!({"entry_id": pay, "settles": [pay]}),
);
assert!(answer_of(&r).is_err(), "an entry settles not itself");
let r = w.call("books.verify", json!({}));
assert_eq!(answer_of(&r).unwrap()["status"], "ok");
}
/// Plan P6 step 4 and rules.md B-44 item 5: consolidation never leaves an
/// entry the receipt policy covers with no evidence.
#[test]
fn consolidation_never_removes_an_entrys_only_evidence() {
let (_root, fixture) = legacy_duplicates("dupes-only", true);
let books = Books::placed("p6-dupes-only", &fixture);
let mut w = books.session("evidence");
let before = books.journal();
let r = w.call("books.consolidate_duplicates", json!({"confirmed": true}));
let why = answer_of(&r).unwrap_err();
assert!(why.contains("receipt_required"), "{why}");
assert_eq!(books.journal(), before, "a refusal journals nothing");
}
/// Plan P6 step 4: the kept file takes every subject the duplicates had;
/// the others are withdrawn as duplicates of it, and nothing is deleted.
#[test]
fn consolidation_links_the_kept_file_to_every_subject() {
let (_root, fixture) = legacy_duplicates("dupes-live", false);
let books = Books::placed("p6-dupes-live", &fixture);
let mut w = books.session("evidence");
let sha = {
let r = w.call("books.get_receipt", json!({"attachment_id": 2}));
answer_of(&r).unwrap()["sha256"]
.as_str()
.unwrap()
.to_string()
};
let r = w.call("books.consolidate_duplicates", json!({}));
let preview = answer_of(&r).unwrap();
assert_eq!(preview["status"], "preview");
assert_eq!(preview["groups"][0]["keep"], 1);
assert_eq!(preview["groups"][0]["withdraw"], json!([2]));
let before = books.journal();
assert_eq!(before, books.journal(), "a preview journals nothing");
let r = w.call("books.consolidate_duplicates", json!({"confirmed": true}));
let out = answer_of(&r).unwrap();
assert_eq!(out["status"], "consolidated", "{out}");
assert_eq!(out["withdrawn"], 1);
// The second entry is still evidenced, by the kept file.
let r = w.call("books.list_receipts", json!({"entry_id": 2}));
let out = answer_of(&r).unwrap();
assert_eq!(out["receipts"].as_array().unwrap().len(), 1, "{out}");
assert_eq!(out["receipts"][0]["attachment_id"], 1);
// The withdrawn one is found, marked, and names what it duplicates.
let r = w.call("books.find_attachment", json!({"sha256": sha}));
let out = answer_of(&r).unwrap();
let found = out["attachments"].as_array().unwrap();
assert_eq!(found.len(), 2, "{out}");
let gone = found.iter().find(|a| a["attachment_id"] == 2).unwrap();
assert_eq!(gone["withdrawn"], true);
let r = w.call("books.verify", json!({}));
assert_eq!(answer_of(&r).unwrap()["status"], "ok");
}
/// Done when (1): every attachment of the `p2-served` fixture gives a
/// default `books.get_receipt` answer under 8 KiB.
#[test]
fn every_p2_served_attachment_has_a_small_default_answer() {
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/p2-served");
let books = Books::placed("p6-p2-served", &fixture);
let mut w = books.session("evidence");
let mut seen = 0;
for id in 1..=64u64 {
let r = w.call("books.get_receipt", json!({"attachment_id": id}));
let Ok(out) = answer_of(&r) else { continue };
seen += 1;
let size = serde_json::to_string(&out).unwrap().len();
assert!(size < 8 * 1024, "attachment {id}: {size} bytes");
assert_eq!(out["text_truncated"], false, "attachment {id}");
assert!(out.get("file").is_none(), "attachment {id}");
}
assert!(seen >= 60, "the fixture's attachments: {seen}");
}
/// Done when (4): the backfill reads an older book's files, one
/// attachment at a time, and a second run journals nothing.
#[test]
fn the_backfill_reads_an_old_fixtures_files_once() {
let fixture = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../ledger-store/tests/fixtures/old-books/p6_evidence");
let books = Books::placed("p6-backfill", &fixture);
// The fixture is from before P6: upgrade it (the owner's step), then
// the backfill can journal extraction records.
books.stop();
{
let mut s = ledger_store::Store::open(&books.dir()).unwrap();
s.upgrade_format().unwrap();
s.verify().unwrap();
}
books.start();
let mut w = books.session("evidence");
// Attachment 1 is a PDF (one page with text), 2 a text file, 3 a
// Markdown document.
for id in 1..=2 {
let before = books.journal();
let r = w.call("books.get_receipt", json!({"attachment_id": id}));
let out = answer_of(&r).unwrap();
assert_eq!(out["text_status"], "not_extracted", "{out}");
let answer = books.home.admin(ledger_mcp::admin::Request::Extract {
id: books.id.clone(),
attachment_id: id,
});
match answer {
ledger_mcp::admin::Response::Extracted { status, .. } => assert_eq!(status, "ok"),
other => panic!("{other:?}"),
}
assert_ne!(books.journal(), before, "the backfill journals a record");
}
// The PDF's text is readable now, and its pages render.
let r = w.call("books.get_receipt", json!({"attachment_id": 1}));
let out = answer_of(&r).unwrap();
assert_eq!(out["text_status"], "ok", "{out}");
assert_eq!(out["pages"], 1, "{out}");
assert!(out["text"].as_str().unwrap().contains("Rent"), "{out}");
// The command itself, on the Markdown document (attachment 3), and
// again: a second run journals nothing. The test's own server is
// stopped first: the command opens the books and closes them as the
// host does.
books.stop();
let run = || {
let mut extract = std::process::Command::new(env!("CARGO_BIN_EXE_wanderbooks"));
extract
.args(["books", "extract", &books.id, "3", "--root"])
.arg(books.home.root());
common::clean_env(&mut extract, books.home.dir.path());
extract.output().unwrap()
};
let before = books.journal();
let out = run();
assert!(out.status.success(), "{out:?}");
assert!(
String::from_utf8_lossy(&out.stdout).contains("Read attachment 3"),
"{out:?}"
);
assert_ne!(books.journal(), before, "the command journals a record");
let after = books.journal();
let out = run();
assert!(out.status.success(), "{out:?}");
assert_eq!(books.journal(), after, "an extract journals once");
books.start();
let mut w = books.session("evidence");
let r = w.call("books.verify", json!({}));
assert_eq!(answer_of(&r).unwrap()["status"], "ok");
let r = w.call("books.get_receipt", json!({"attachment_id": 3}));
assert_eq!(answer_of(&r).unwrap()["text_status"], "ok");
}
@@ -1 +0,0 @@
%PDF-1.4 fictional receipt 46 Software paid by member 9 2025-09-18
@@ -1 +0,0 @@
%PDF-1.4 fictional receipt 39 Design work 8 2025-08-08
@@ -1 +0,0 @@
%PDF-1.4 fictional receipt 42 Bank fee 8 2025-08-23
@@ -1 +0,0 @@
%PDF-1.4 fictional receipt 18 Consulting 4 2025-04-03
@@ -1 +0,0 @@
%PDF-1.4 fictional receipt 27 Bank fee 5 2025-05-23
@@ -1 +0,0 @@
%PDF-1.4 fictional receipt 44 Design work 9 2025-09-08
@@ -1 +0,0 @@
%PDF-1.4 fictional receipt 6 Software paid by member 1 2025-01-18
@@ -1 +0,0 @@
%PDF-1.4 fictional receipt 20 Supplies 4 2025-04-13
@@ -1 +0,0 @@
%PDF-1.4 fictional receipt 36 Software paid by member 7 2025-07-18
@@ -1 +0,0 @@
%PDF-1.4 fictional receipt 50 Supplies 10 2025-10-13
@@ -1 +0,0 @@
%PDF-1.4 fictional receipt 2 Capital in B 2025-01-02
@@ -1 +0,0 @@
%PDF-1.4 fictional receipt 41 Software paid by member 8 2025-08-18
@@ -1 +0,0 @@
%PDF-1.4 fictional receipt 47 Bank fee 9 2025-09-23
@@ -1 +0,0 @@
%PDF-1.4 fictional receipt 61 Software paid by member 12 2025-12-18
@@ -1 +0,0 @@
%PDF-1.4 fictional receipt 60 Supplies 12 2025-12-13
@@ -1 +0,0 @@
%PDF-1.4 fictional receipt 38 Consulting 8 2025-08-03
@@ -1 +0,0 @@
%PDF-1.4 fictional receipt 10 Supplies 2 2025-02-13
@@ -1 +0,0 @@
%PDF-1.4 fictional receipt 31 Software paid by member 6 2025-06-18
@@ -1 +0,0 @@
%PDF-1.4 fictional receipt 52 Bank fee 10 2025-10-23
@@ -1 +0,0 @@
%PDF-1.4 fictional receipt 34 Design work 7 2025-07-08
@@ -1 +0,0 @@
%PDF-1.4 fictional receipt 48 Consulting 10 2025-10-03
@@ -1 +0,0 @@
%PDF-1.4 fictional receipt 64 Insurance year 2025-02-01
@@ -1 +0,0 @@
%PDF-1.4 fictional receipt 9 Design work 2 2025-02-08
@@ -1 +0,0 @@
%PDF-1.4 fictional receipt 32 Bank fee 6 2025-06-23
@@ -1 +0,0 @@
%PDF-1.4 fictional receipt 15 Supplies 3 2025-03-13
@@ -1 +0,0 @@
%PDF-1.4 fictional receipt 14 Design work 3 2025-03-08
@@ -1 +0,0 @@
%PDF-1.4 fictional receipt 51 Software paid by member 10 2025-10-18
@@ -1 +0,0 @@
%PDF-1.4 fictional receipt 21 Software paid by member 4 2025-04-18
@@ -1 +0,0 @@
%PDF-1.4 fictional receipt 43 Consulting 9 2025-09-03
@@ -1 +0,0 @@
%PDF-1.4 fictional receipt 8 Consulting 2 2025-02-03
@@ -1 +0,0 @@
%PDF-1.4 fictional receipt 57 Bank fee 11 2025-11-23
@@ -1 +0,0 @@
%PDF-1.4 fictional receipt 53 Consulting 11 2025-11-03
@@ -1 +0,0 @@
%PDF-1.4 fictional receipt 23 Consulting 5 2025-05-03
@@ -1 +0,0 @@
%PDF-1.4 fictional receipt 17 Bank fee 3 2025-03-23
@@ -1 +0,0 @@
%PDF-1.4 fictional receipt 19 Design work 4 2025-04-08
@@ -1 +0,0 @@
%PDF-1.4 fictional receipt 7 Bank fee 1 2025-01-23
@@ -1 +0,0 @@
%PDF-1.4 fictional receipt 45 Supplies 9 2025-09-13
@@ -1 +0,0 @@
%PDF-1.4 fictional receipt 58 Consulting 12 2025-12-03
@@ -1 +0,0 @@
%PDF-1.4 fictional receipt 30 Supplies 6 2025-06-13
@@ -1 +0,0 @@
%PDF-1.4 fictional receipt 56 Software paid by member 11 2025-11-18
@@ -1 +0,0 @@
%PDF-1.4 fictional receipt 63 Laptop 2025-03-01
@@ -1 +0,0 @@
%PDF-1.4 fictional receipt 62 Bank fee 12 2025-12-23
@@ -1 +0,0 @@
%PDF-1.4 fictional receipt 12 Bank fee 2 2025-02-23
@@ -1 +0,0 @@
%PDF-1.4 fictional receipt 25 Supplies 5 2025-05-13
@@ -1 +0,0 @@
%PDF-1.4 fictional receipt 4 Design work 1 2025-01-08
@@ -1 +0,0 @@
%PDF-1.4 fictional receipt 49 Design work 10 2025-10-08
@@ -1 +0,0 @@
%PDF-1.4 fictional receipt 22 Bank fee 4 2025-04-23
@@ -1 +0,0 @@
%PDF-1.4 fictional receipt 3 Consulting 1 2025-01-03
@@ -1 +0,0 @@
%PDF-1.4 fictional receipt 5 Supplies 1 2025-01-13
@@ -1 +0,0 @@
%PDF-1.4 fictional receipt 28 Consulting 6 2025-06-03
@@ -1 +0,0 @@
%PDF-1.4 fictional receipt 11 Software paid by member 2 2025-02-18
@@ -1 +0,0 @@
%PDF-1.4 fictional receipt 1 Capital in A 2025-01-02
@@ -1 +0,0 @@
%PDF-1.4 fictional receipt 37 Bank fee 7 2025-07-23
@@ -1 +0,0 @@
%PDF-1.4 fictional receipt 33 Consulting 7 2025-07-03
@@ -1 +0,0 @@
%PDF-1.4 fictional receipt 29 Design work 6 2025-06-08
@@ -1 +0,0 @@
%PDF-1.4 fictional receipt 13 Consulting 3 2025-03-03
@@ -1 +0,0 @@
%PDF-1.4 fictional receipt 35 Supplies 7 2025-07-13
@@ -1 +0,0 @@
%PDF-1.4 fictional receipt 26 Software paid by member 5 2025-05-18
@@ -1 +0,0 @@
%PDF-1.4 fictional receipt 54 Design work 11 2025-11-08
@@ -1 +0,0 @@
%PDF-1.4 fictional receipt 16 Software paid by member 3 2025-03-18
@@ -1 +0,0 @@
%PDF-1.4 fictional receipt 55 Supplies 11 2025-11-13
@@ -1 +0,0 @@
%PDF-1.4 fictional receipt 24 Design work 5 2025-05-08
@@ -1 +0,0 @@
%PDF-1.4 fictional receipt 59 Design work 12 2025-12-08
@@ -1 +0,0 @@
%PDF-1.4 fictional receipt 40 Supplies 8 2025-08-13
-470
View File
@@ -1,470 +0,0 @@
{"payload":{"base_currency":"USD"},"prev":"0000000000000000000000000000000000000000000000000000000000000000","seq":1,"ts":"2026-10-21T21:28:50Z","type":"store.created"}
{"payload":{"features":[],"format":2},"prev":"6f671350b51dcf342c70a96d74fe2e7a51201bf076f8fdf9f56e91ae713b1b93","seq":2,"ts":"2026-10-21T21:28:50Z","type":"journal.format_set"}
{"payload":{"changes":[{"key":"company.name","value":"Fernhill Labs LLC"},{"key":"company.entity_type","value":"multi_member_llc"},{"key":"company.fiscal_year_start","value":"01-01"}],"source":{"decided_by":"Avery Quill","decided_on":"2026-10-21"}},"prev":"0553dd22c23902d6585f2bbaed018c059a9aea58df7406d404bf4137d5ecede1","seq":3,"ts":"2026-10-21T21:28:50Z","type":"settings.set"}
{"payload":{"account_type":"Asset","code":"1000","currency":"USD","id":1,"name":"Bank","parent":null,"placeholder":false},"prev":"108d387a4869358c663077990d942b4ffb5644635236c93a8ad344a298a50227","seq":4,"ts":"2026-10-21T21:28:50Z","type":"account.added"}
{"payload":{"account_type":"Asset","code":"1300","currency":"USD","id":2,"name":"Prepaid","parent":null,"placeholder":false},"prev":"44a88cabb5d1cf52f7d20efd8faf2ffaac5b2bd65a54988899e27e05cc4deecf","seq":5,"ts":"2026-10-21T21:28:50Z","type":"account.added"}
{"payload":{"account_type":"Asset","code":"1500","currency":"USD","id":3,"name":"Equipment","parent":null,"placeholder":false},"prev":"e3a5983c198273610292fd01475cf9b94597fad49941dc143ff78c156226cc36","seq":6,"ts":"2026-10-21T21:28:50Z","type":"account.added"}
{"payload":{"account_type":"Asset","code":"1510","currency":"USD","id":4,"name":"Accumulated depreciation","parent":null,"placeholder":false},"prev":"7776e448d58efadfdb096e404dd3eec87310b250b25fc97630679346f6b4172f","seq":7,"ts":"2026-10-21T21:28:50Z","type":"account.added"}
{"payload":{"account_type":"Asset","code":"1200","currency":"USD","id":5,"name":"Accounts receivable","parent":null,"placeholder":false},"prev":"58ca33a2fb8230622c6df46c491accbbe82e52d8be43a49a96f599dee1c9460d","seq":8,"ts":"2026-10-21T21:28:50Z","type":"account.added"}
{"payload":{"account_type":"Liability","code":"2000","currency":"USD","id":6,"name":"Accounts payable","parent":null,"placeholder":false},"prev":"630e419ad2afbcb3171177430d8701e4931a571ac10f0d5f635c4f37e8bbd965","seq":9,"ts":"2026-10-21T21:28:50Z","type":"account.added"}
{"payload":{"account_type":"Liability","code":"2050","currency":"USD","id":7,"name":"Company card","parent":null,"placeholder":false},"prev":"dca477176282fc4b6f9adbf3ad5450198c24647eec5ece472127faa0d256f012","seq":10,"ts":"2026-10-21T21:28:50Z","type":"account.added"}
{"payload":{"account_type":"Liability","code":"2100","currency":"USD","id":8,"name":"Due to members","parent":null,"placeholder":false},"prev":"d635bc76135ad344c1b6151ecff322849af89e0ea159124704af70dfdeaadff2","seq":11,"ts":"2026-10-21T21:28:50Z","type":"account.added"}
{"payload":{"account_type":"Equity","code":"3000","currency":"USD","id":9,"name":"Capital A","parent":null,"placeholder":false},"prev":"ebe9968feff05860f2da8a2e3cd4bc07d7d3f0c3922f037e238fcedfc10f00d7","seq":12,"ts":"2026-10-21T21:28:50Z","type":"account.added"}
{"payload":{"account_type":"Equity","code":"3001","currency":"USD","id":10,"name":"Capital B","parent":null,"placeholder":false},"prev":"3ae241ef0c699d888c8c50f5bcf6b141ff10963cb4ed3880b6b02eec5268cbdc","seq":13,"ts":"2026-10-21T21:28:50Z","type":"account.added"}
{"payload":{"account_type":"Equity","code":"3900","currency":"USD","id":11,"name":"Retained earnings","parent":null,"placeholder":false},"prev":"6d13b74f8e9ef3b15f1ea75751fa8070ec0f83f073fc091be0c154a019f030c6","seq":14,"ts":"2026-10-21T21:28:50Z","type":"account.added"}
{"payload":{"account_type":"Revenue","code":"4000","currency":"USD","id":12,"name":"Consulting revenue","parent":null,"placeholder":false},"prev":"a0db3c9413975175f75fe1c37e6831c4758d331f52fb9378863180162c5ca8ad","seq":15,"ts":"2026-10-21T21:28:50Z","type":"account.added"}
{"payload":{"account_type":"Expense","code":"6000","currency":"USD","id":13,"name":"Supplies","parent":null,"placeholder":false},"prev":"78a4ebd277a2f9e3ea76414a0bf8f7406cc3e30dbfa48754366752f940b15075","seq":16,"ts":"2026-10-21T21:28:50Z","type":"account.added"}
{"payload":{"account_type":"Expense","code":"6100","currency":"USD","id":14,"name":"Software","parent":null,"placeholder":false},"prev":"5b6f921b6731bddc951abd2912231e2282111b9ad2e1ddb12e108846cd396d44","seq":17,"ts":"2026-10-21T21:28:50Z","type":"account.added"}
{"payload":{"account_type":"Expense","code":"6200","currency":"USD","id":15,"name":"Depreciation","parent":null,"placeholder":false},"prev":"41b76a17cf130608e358df070a5917f86eeb3c229ba02a7e06322355a3bfaaea","seq":18,"ts":"2026-10-21T21:28:50Z","type":"account.added"}
{"payload":{"account_type":"Expense","code":"6300","currency":"USD","id":16,"name":"Insurance","parent":null,"placeholder":false},"prev":"2d13667fdebc469d0d59bee761e650325d7a402186dbb84890168adf8d741f7d","seq":19,"ts":"2026-10-21T21:28:50Z","type":"account.added"}
{"payload":{"account_type":"Expense","code":"6400","currency":"USD","id":17,"name":"Contract labor","parent":null,"placeholder":false},"prev":"819cfd940a1b72d4e4a6e3c9283579cc8c06cf5ff4339a28f88f2c16b24fe88f","seq":20,"ts":"2026-10-21T21:28:50Z","type":"account.added"}
{"payload":{"changes":[{"key":"company.members","value":[{"capital_account":"3000","name":"Avery Quill","share":"0.5"},{"capital_account":"3001","name":"Rowan Pike","share":"0.5"}]},{"key":"books.cash_accounts","value":["1000"]},{"key":"books.financing_accounts","value":["2100"]},{"key":"backup.git_remote","value":"file:///home/swaits/.cache/wanderbooks/p3-rev-data-e2e/p2home/remote.git"},{"key":"backup.folder","value":"/home/swaits/.cache/wanderbooks/p3-rev-data-e2e/p2home/usb"}],"source":{"decided_by":"Avery Quill","decided_on":"2026-10-21"}},"prev":"92acb98bfa80e1e722c6158bbe6e9ed4c8c2fc31ad1adb14b9f61d304043ca6d","seq":21,"ts":"2026-10-21T21:28:50Z","type":"settings.set"}
{"payload":{"id":1,"name":"Avery Quill","roles":["member"]},"prev":"a182ca8fabcebddef6818143575d3749d0e8fc036baf6e312bd0a849f78436cf","seq":22,"ts":"2026-10-21T21:28:50Z","type":"party.added"}
{"payload":{"id":2,"name":"Rowan Pike","roles":["member"]},"prev":"fa48b3d8f4372ee6ae38661251659ce4b4374f54fa51329e974a09ba16815448","seq":23,"ts":"2026-10-21T21:28:50Z","type":"party.added"}
{"payload":{"id":1,"name":"Harbor Design LLC"},"prev":"596325a7614846296dd6d073baf2f543107b665c35328447459012c74855c461","seq":24,"ts":"2026-10-21T21:28:50Z","type":"vendor.added"}
{"payload":{"id":2,"name":"Bigco Supply"},"prev":"c424a9910d582b5f0359b5b6705e7d96dc5bcb1914ced459a4276f27d0e36f01","seq":25,"ts":"2026-10-21T21:28:50Z","type":"vendor.added"}
{"payload":{"id":1,"name":"Cedar Client Co"},"prev":"46c8d1500320b40b9666a5a42d931822f05cf054165d410c5cf5732250bed40f","seq":26,"ts":"2026-10-21T21:28:51Z","type":"customer.added"}
{"payload":{"facts":{"last4":"6780","purged":null,"tin_type":"ein","vault_record":"69788eb06e09d55ca50e3ec73dd403baa06fc5818ce36d34547aa8de3563df43"},"id":3},"prev":"80907e1ddb276655db6cdea50222daa46b589455b4a4d5394bc53d62dc48fcd0","seq":27,"ts":"2026-10-21T21:28:51Z","type":"party.tin_set"}
{"payload":{"facts":{"last4":"3456","purged":null,"tin_type":"ssn","vault_record":"18a2ba28ac3346b30598fb86d645d75b756bc43116cf0c5a78b3ffbd168e537a"},"id":1},"prev":"6be2d55137906b67c54034f40be01ad0d7736c7057ff4b4285a2f0494e920a63","seq":28,"ts":"2026-10-21T21:28:51Z","type":"party.tin_set"}
{"payload":{"form_1099":"nec","tin_on_file":true,"vendor_id":1},"prev":"1607945e13e8fa9b142a7a089d5679a544384d5e44e051042690bac65fdc4f7f","seq":29,"ts":"2026-10-21T21:28:51Z","type":"vendor.1099_set"}
{"payload":{"form_1099":null,"tin_on_file":false,"vendor_id":2},"prev":"8d815aad8481d577c28fd177765464119020cd3223240c3d8a9449eca05763e8","seq":30,"ts":"2026-10-21T21:28:51Z","type":"vendor.1099_set"}
{"payload":{"date":"2025-01-02","description":"Capital in A","id":1},"prev":"e21b02f65b2d01347d2280a81d5c684947acf795a902bc6329c63b48fb636468","seq":31,"ts":"2026-10-21T21:28:51Z","type":"entry.created"}
{"payload":{"entry_id":1,"party_id":1},"prev":"4ada99e48cccb19f70a89f57cc2fe28c9d8a38a7b7c204b2c64c8511304e4dc0","seq":32,"ts":"2026-10-21T21:28:51Z","type":"entry.party_set"}
{"payload":{"account":1,"amount":"5000.00","currency":"USD","id":1,"side":"debit"},"prev":"89bd45cc0937030d7d7cfb81d816d341b000bfb020d7bfda50b8b84f89536675","seq":33,"ts":"2026-10-21T21:28:51Z","type":"entry.line_added"}
{"payload":{"account":9,"amount":"5000.00","currency":"USD","id":1,"side":"credit"},"prev":"2dd8bcd4901d1c419d862293a52c42a42c4e77d415df5f623db0d4b4a0d4e484","seq":34,"ts":"2026-10-21T21:28:51Z","type":"entry.line_added"}
{"payload":{"attachment_id":1,"blob_sha":"aeb484e66061b3e49eb809f0ddf792afdee2da15cb69885b91ae8c12a4a1e59b","category":"receipts","description":null,"entry_id":1,"file_name":"r1.pdf","mime_type":"application/pdf","size_bytes":53},"prev":"441c5859752bfa9639bef24b417d2b43a7c99d5a755e4820fd987664e3b93910","seq":35,"ts":"2026-10-21T21:28:51Z","type":"receipt.attached"}
{"payload":{"date":"2025-01-02","description":"Capital in A","id":1,"lines":[{"account":1,"amount":"5000.00","currency":"USD","side":"debit"},{"account":9,"amount":"5000.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"dc86cc21e1409de4aa1fe4b966d96e7de7dbb065eaad7852d6782d02d2f8310c","seq":36,"ts":"2026-10-21T21:28:51Z","type":"entry.posted"}
{"payload":{"date":"2025-01-02","description":"Capital in B","id":2},"prev":"45e788b2c6173364749d188be75732039813e67ea27ce66d2203cb10906e1832","seq":37,"ts":"2026-10-21T21:28:51Z","type":"entry.created"}
{"payload":{"entry_id":2,"party_id":2},"prev":"78bf42bcd02c0fa6e5f028baa69f13b3490be06db837734b944a2c63f3acf4c9","seq":38,"ts":"2026-10-21T21:28:51Z","type":"entry.party_set"}
{"payload":{"account":1,"amount":"5000.00","currency":"USD","id":2,"side":"debit"},"prev":"5d56e32e035b1305e9aa7bcef55f2fcaf6958f25869c99b958d1f8e82a4f7520","seq":39,"ts":"2026-10-21T21:28:51Z","type":"entry.line_added"}
{"payload":{"account":10,"amount":"5000.00","currency":"USD","id":2,"side":"credit"},"prev":"8d722949f628f0e20e87aaa8b024cbf9987e773b4f7113a37a0ba17d4386d6d1","seq":40,"ts":"2026-10-21T21:28:51Z","type":"entry.line_added"}
{"payload":{"attachment_id":2,"blob_sha":"23191deb4999b3f1726e6d382a6c0d020a3eb9ffb2e4f06e6c4208120a2b1dc7","category":"receipts","description":null,"entry_id":2,"file_name":"r2.pdf","mime_type":"application/pdf","size_bytes":53},"prev":"7298f2f2b297b9025d4203a7a07dde44fb99ae473d5d7f0fbab1967a585a133b","seq":41,"ts":"2026-10-21T21:28:51Z","type":"receipt.attached"}
{"payload":{"date":"2025-01-02","description":"Capital in B","id":2,"lines":[{"account":1,"amount":"5000.00","currency":"USD","side":"debit"},{"account":10,"amount":"5000.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"f1b98f8e16280ce39da64198a583957caa67585c96db16a30d5dc697c1549457","seq":42,"ts":"2026-10-21T21:28:51Z","type":"entry.posted"}
{"payload":{"date":"2025-01-03","description":"Consulting 1","id":3},"prev":"6816748843a8a19a8327aa5756c751706dee3f93e41a5c13816e5642072f8896","seq":43,"ts":"2026-10-21T21:28:51Z","type":"entry.created"}
{"payload":{"entry_id":3,"party_id":5},"prev":"2e9fb36852fca6384a4eed6289e66659453943c0d7c2988d3cdae83abb3352ce","seq":44,"ts":"2026-10-21T21:28:51Z","type":"entry.party_set"}
{"payload":{"account":1,"amount":"425.45","currency":"USD","id":3,"side":"debit"},"prev":"a55a24cf00fcfd9097f868cfaa432a4b1d274ffc4c777d8b460e01a71a714aac","seq":45,"ts":"2026-10-21T21:28:51Z","type":"entry.line_added"}
{"payload":{"account":12,"amount":"425.45","currency":"USD","id":3,"side":"credit"},"prev":"d5fe8b6df89d5df23335d2292cac76e3200d3e96020a3be40cb819ce71565e95","seq":46,"ts":"2026-10-21T21:28:51Z","type":"entry.line_added"}
{"payload":{"attachment_id":3,"blob_sha":"a1fd86e4db54256d3cbcc42c003499e77bea22309d3ca22936c9a3119f951eb8","category":"receipts","description":null,"entry_id":3,"file_name":"r3.pdf","mime_type":"application/pdf","size_bytes":53},"prev":"b0f2ab24fac7904b1c1cd1f98d8043fe94f2300259476149fabdfe728a31f8b3","seq":47,"ts":"2026-10-21T21:28:51Z","type":"receipt.attached"}
{"payload":{"date":"2025-01-03","description":"Consulting 1","id":3,"lines":[{"account":1,"amount":"425.45","currency":"USD","side":"debit"},{"account":12,"amount":"425.45","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"9299ff9a6d7d14512b050e9e37bbf6fd0880c8bd4211f0f1c2579236e9a585b0","seq":48,"ts":"2026-10-21T21:28:51Z","type":"entry.posted"}
{"payload":{"date":"2025-01-08","description":"Design work 1","id":4},"prev":"dabd38384f06af507b8b6440966c7591ec779586137a07d0af17dc49288bf3a1","seq":49,"ts":"2026-10-21T21:28:51Z","type":"entry.created"}
{"payload":{"entry_id":4,"party_id":3},"prev":"741fa40bc0e48646e50d6223d5051bf77d472834f462464f57e8560365a21904","seq":50,"ts":"2026-10-21T21:28:51Z","type":"entry.party_set"}
{"payload":{"account":17,"amount":"198.72","currency":"USD","id":4,"side":"debit"},"prev":"3d1fc1b51c92da1e773b9c45f83102ca73b79bdb29b39346ff298e50da7f83cf","seq":51,"ts":"2026-10-21T21:28:51Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"198.72","currency":"USD","id":4,"side":"credit"},"prev":"a5e24ac9d61aa1c8f077b1dc2afa4d54d2193035c61d6a5afb909ee0c3d09e84","seq":52,"ts":"2026-10-21T21:28:51Z","type":"entry.line_added"}
{"payload":{"attachment_id":4,"blob_sha":"967db8ecef54a42031f76d14ef0493b998d08c941bf8a739edf1237f903d4649","category":"receipts","description":null,"entry_id":4,"file_name":"r4.pdf","mime_type":"application/pdf","size_bytes":54},"prev":"5dd3bf41b20fa2d306e946df90d519ec2e187dbc22d3f007206fee08fce75c94","seq":53,"ts":"2026-10-21T21:28:51Z","type":"receipt.attached"}
{"payload":{"date":"2025-01-08","description":"Design work 1","id":4,"lines":[{"account":17,"amount":"198.72","currency":"USD","side":"debit"},{"account":1,"amount":"198.72","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"052111cbc65ecee4c26cd5b628101afbe96dc7cdcfd8e6401515f4f5435cf242","seq":54,"ts":"2026-10-21T21:28:51Z","type":"entry.posted"}
{"payload":{"date":"2025-01-13","description":"Supplies 1","id":5},"prev":"edd15c9fc1945f0b8cce5cd7a595433830fd1eef2788b6b9b0f09e7e2e0b9555","seq":55,"ts":"2026-10-21T21:28:52Z","type":"entry.created"}
{"payload":{"entry_id":5,"party_id":4},"prev":"314ccb3da1e29adfde995b4aa48ba4de31ec4bdb5bb905abaa4307017865b6f7","seq":56,"ts":"2026-10-21T21:28:52Z","type":"entry.party_set"}
{"payload":{"account":13,"amount":"518.50","currency":"USD","id":5,"side":"debit"},"prev":"329b80dad0d3ddfbf70b48903836835e85b908ec9374fd0843f04b2216dddfdb","seq":57,"ts":"2026-10-21T21:28:52Z","type":"entry.line_added"}
{"payload":{"account":7,"amount":"518.50","currency":"USD","id":5,"side":"credit"},"prev":"693c0aa1c33cb0c1bfec75eca43bf0ce7fe078110dc8387b9e8daee72246c698","seq":58,"ts":"2026-10-21T21:28:52Z","type":"entry.line_added"}
{"payload":{"attachment_id":5,"blob_sha":"a38f2f89b4c09962c99eca9b842a2382f2bda3726357af210626543cc52f1e7c","category":"receipts","description":null,"entry_id":5,"file_name":"r5.pdf","mime_type":"application/pdf","size_bytes":51},"prev":"794a8c3d0292339a0b8cacd6fa57e14dad135466963c2ed994341edac2f4dccd","seq":59,"ts":"2026-10-21T21:28:52Z","type":"receipt.attached"}
{"payload":{"date":"2025-01-13","description":"Supplies 1","id":5,"lines":[{"account":13,"amount":"518.50","currency":"USD","side":"debit"},{"account":7,"amount":"518.50","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"b66e18803ac268c5f2ddd31f076ec3198a4d29375619a8b40a2d7b0edb5992d3","seq":60,"ts":"2026-10-21T21:28:52Z","type":"entry.posted"}
{"payload":{"date":"2025-01-18","description":"Software paid by member 1","id":6},"prev":"d52c34b2f167deb3492e8a7d2e2b50ed8a592e47c5b7a8b1f7cf41d263b15b10","seq":61,"ts":"2026-10-21T21:28:52Z","type":"entry.created"}
{"payload":{"entry_id":6,"party_id":1},"prev":"12dd3a4a341d12ec13b2735e91081655de1d76c45b350cbdb1b25e0743aab0e8","seq":62,"ts":"2026-10-21T21:28:52Z","type":"entry.party_set"}
{"payload":{"account":14,"amount":"854.19","currency":"USD","id":6,"side":"debit"},"prev":"58a015bb07e9ce1d353eef832298587ffe4d0ef5cfd70b80b25888137ae4e5f4","seq":63,"ts":"2026-10-21T21:28:52Z","type":"entry.line_added"}
{"payload":{"account":8,"amount":"854.19","currency":"USD","id":6,"side":"credit"},"prev":"e58d9f1c7e5b0533a7a82455b0cb7a8d60d7eb1ce87c9b03ecf1695517d7c63f","seq":64,"ts":"2026-10-21T21:28:52Z","type":"entry.line_added"}
{"payload":{"attachment_id":6,"blob_sha":"17ecf1539551d918be6e6ef9f3decc93c3e093a4985b1153f08d2c18da545b7b","category":"receipts","description":null,"entry_id":6,"file_name":"r6.pdf","mime_type":"application/pdf","size_bytes":66},"prev":"21934f4937d4bf5ccdaca3c967d8ae75d9f03379a9afbe158ffa7f7d12a0a4e0","seq":65,"ts":"2026-10-21T21:28:52Z","type":"receipt.attached"}
{"payload":{"date":"2025-01-18","description":"Software paid by member 1","id":6,"lines":[{"account":14,"amount":"854.19","currency":"USD","side":"debit"},{"account":8,"amount":"854.19","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"51a777f27ddcf09ad862b622bd53e185907af1338799a4d06cb412b3845c8deb","seq":66,"ts":"2026-10-21T21:28:52Z","type":"entry.posted"}
{"payload":{"date":"2025-01-23","description":"Bank fee 1","id":7},"prev":"197bde02ee7ed2a3a12a378984865819548cdb778b6a33c57dac74552f5522c5","seq":67,"ts":"2026-10-21T21:28:52Z","type":"entry.created"}
{"payload":{"entry_id":7,"reason":"bank fee"},"prev":"e50d6c5f0b1c2c497dc2f37dd889365e9fc2d7b0a00a35a89c66c9d39b332c3e","seq":68,"ts":"2026-10-21T21:28:52Z","type":"entry.no_party"}
{"payload":{"account":13,"amount":"12.00","currency":"USD","id":7,"side":"debit"},"prev":"c95c400f054caae9bd95457abb2f534d6ec693e8a826dddc3136d4b25ba37851","seq":69,"ts":"2026-10-21T21:28:52Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"12.00","currency":"USD","id":7,"side":"credit"},"prev":"9e6be2d5eea5fbc795e421c3e4f35275ee5d1e8a68d9d18e99eb1492ffac343f","seq":70,"ts":"2026-10-21T21:28:52Z","type":"entry.line_added"}
{"payload":{"attachment_id":7,"blob_sha":"7d85565514a409a2d8bbacdfc2c68a2260ad0e311328cd0bda91800600098ed4","category":"receipts","description":null,"entry_id":7,"file_name":"r7.pdf","mime_type":"application/pdf","size_bytes":51},"prev":"ce4ff33af6791a860889ff147bb7725b3e256925f421a9d98d29cd105771dc61","seq":71,"ts":"2026-10-21T21:28:52Z","type":"receipt.attached"}
{"payload":{"date":"2025-01-23","description":"Bank fee 1","id":7,"lines":[{"account":13,"amount":"12.00","currency":"USD","side":"debit"},{"account":1,"amount":"12.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"4416f011c27bcc9dafb26d68f4a989df6973c49b86aa396277c990994a29aa19","seq":72,"ts":"2026-10-21T21:28:52Z","type":"entry.posted"}
{"payload":{"date":"2025-02-03","description":"Consulting 2","id":8},"prev":"86f329829a794b9337f0a756d6dcc8fab39ba4ae96fc3d30201693a39ba050c4","seq":73,"ts":"2026-10-21T21:28:52Z","type":"entry.created"}
{"payload":{"entry_id":8,"party_id":5},"prev":"e1ce616977d845dab6738b3753469116c3f773a510389b84fff4f7879a223283","seq":74,"ts":"2026-10-21T21:28:52Z","type":"entry.party_set"}
{"payload":{"account":1,"amount":"95.94","currency":"USD","id":8,"side":"debit"},"prev":"8e54df62ea15c54668a59912517daf3655c0d095ddc8ef7d64b2fd13dcef9892","seq":75,"ts":"2026-10-21T21:28:52Z","type":"entry.line_added"}
{"payload":{"account":12,"amount":"95.94","currency":"USD","id":8,"side":"credit"},"prev":"245ec39fe29eb8be7ca72c83a0dc45f88bb58dbc0a75a7aa2e8cf0de973697ed","seq":76,"ts":"2026-10-21T21:28:52Z","type":"entry.line_added"}
{"payload":{"attachment_id":8,"blob_sha":"57d707ac4c8f4987ad0de50f710d25d20729d276e5bb57963191e07c29c59b7a","category":"receipts","description":null,"entry_id":8,"file_name":"r8.pdf","mime_type":"application/pdf","size_bytes":53},"prev":"a4d90d26d8c94e3d6e8c4861e8b78ad02aca3305fbd1ea4ff3ccef611f9851a4","seq":77,"ts":"2026-10-21T21:28:52Z","type":"receipt.attached"}
{"payload":{"date":"2025-02-03","description":"Consulting 2","id":8,"lines":[{"account":1,"amount":"95.94","currency":"USD","side":"debit"},{"account":12,"amount":"95.94","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"aa51bf5a3c0b3b357a3c9b27619f538c5efe9cf1415c402279b23f4dc930133e","seq":78,"ts":"2026-10-21T21:28:52Z","type":"entry.posted"}
{"payload":{"date":"2025-02-08","description":"Design work 2","id":9},"prev":"515e776c06c84b658bd6775a915942c25c35d5b5dbc85085647f17bcb8bfe14f","seq":79,"ts":"2026-10-21T21:28:52Z","type":"entry.created"}
{"payload":{"entry_id":9,"party_id":3},"prev":"01e212769b1b8a6ff8a60bb62f2315b651a888d095cc91b7e9328743901dc71f","seq":80,"ts":"2026-10-21T21:28:52Z","type":"entry.party_set"}
{"payload":{"account":17,"amount":"703.39","currency":"USD","id":9,"side":"debit"},"prev":"7ba725f7b5db6995c5388ffe70ade057eb195cbefa6f849a7dd0b2657ed2ab26","seq":81,"ts":"2026-10-21T21:28:52Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"703.39","currency":"USD","id":9,"side":"credit"},"prev":"99228fcdcfc3dfccfdf6692ca4474c05ecd9b479ad6dbf1dab58c7b8b58e1b35","seq":82,"ts":"2026-10-21T21:28:52Z","type":"entry.line_added"}
{"payload":{"attachment_id":9,"blob_sha":"44ecd22cf54e2cfe9331fb015aa011affd23aad6788939c86a85de028a34df14","category":"receipts","description":null,"entry_id":9,"file_name":"r9.pdf","mime_type":"application/pdf","size_bytes":54},"prev":"6d944d78bb57b1556b6b8e07b5f7f2a3286e408efad2ff116b28f1e48514fe29","seq":83,"ts":"2026-10-21T21:28:52Z","type":"receipt.attached"}
{"payload":{"date":"2025-02-08","description":"Design work 2","id":9,"lines":[{"account":17,"amount":"703.39","currency":"USD","side":"debit"},{"account":1,"amount":"703.39","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"8ecdbd55959498b2ac506e96ce3cd31f0c4bd715bf78decc97fb300b6bc3d9fb","seq":84,"ts":"2026-10-21T21:28:52Z","type":"entry.posted"}
{"payload":{"date":"2025-02-13","description":"Supplies 2","id":10},"prev":"eb4c97113391df5c0a12a8ad1b745b7bb40b528c87dae70b235aa873034d46b6","seq":85,"ts":"2026-10-21T21:28:52Z","type":"entry.created"}
{"payload":{"entry_id":10,"party_id":4},"prev":"0ff6a2037bfc1949862708059d124a80c1fd8863a7822593ecd51bcc8ff0ec10","seq":86,"ts":"2026-10-21T21:28:52Z","type":"entry.party_set"}
{"payload":{"account":13,"amount":"124.37","currency":"USD","id":10,"side":"debit"},"prev":"4f596d265ad21904214fe768fa6d813b5b91684bb1f57e21e68a7a2aac4ea597","seq":87,"ts":"2026-10-21T21:28:52Z","type":"entry.line_added"}
{"payload":{"account":7,"amount":"124.37","currency":"USD","id":10,"side":"credit"},"prev":"c21eb19cd2ac7da02f3e563c2e43470cf6243d0526bac2c31051990f6a1a7cc7","seq":88,"ts":"2026-10-21T21:28:52Z","type":"entry.line_added"}
{"payload":{"attachment_id":10,"blob_sha":"36ffc77eeb9f51878977e69d554148bec710772fcdac8a85291d1cc6d85fc1a5","category":"receipts","description":null,"entry_id":10,"file_name":"r10.pdf","mime_type":"application/pdf","size_bytes":52},"prev":"45bedecd2fd71a2d2f6e28c04ebe9b07bd69e90a19551f15ef589229c23c92e5","seq":89,"ts":"2026-10-21T21:28:52Z","type":"receipt.attached"}
{"payload":{"date":"2025-02-13","description":"Supplies 2","id":10,"lines":[{"account":13,"amount":"124.37","currency":"USD","side":"debit"},{"account":7,"amount":"124.37","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"c73b6e6219916b20250550d10023467b2608ed6a871294e5ffd19f81589f6638","seq":90,"ts":"2026-10-21T21:28:52Z","type":"entry.posted"}
{"payload":{"date":"2025-02-18","description":"Software paid by member 2","id":11},"prev":"3bcea4d211bccb6c3f965b63ed9926e0a2ff8e03e524671e5d16bccbf8c7f2b2","seq":91,"ts":"2026-10-21T21:28:53Z","type":"entry.created"}
{"payload":{"entry_id":11,"party_id":1},"prev":"593b7a10014a3aae4db791dc88f01059de5dba30e0c3d8d81d8981919426fad0","seq":92,"ts":"2026-10-21T21:28:53Z","type":"entry.party_set"}
{"payload":{"account":14,"amount":"480.31","currency":"USD","id":11,"side":"debit"},"prev":"0d0c93ffcf4737d6b2aed0f6ac106b168731acf3ac9c8dfe1ebe58a07b500ee4","seq":93,"ts":"2026-10-21T21:28:53Z","type":"entry.line_added"}
{"payload":{"account":8,"amount":"480.31","currency":"USD","id":11,"side":"credit"},"prev":"c62930589fd64363d19044508091db6f1a7a31bc2d6229392d293175c6dd43b6","seq":94,"ts":"2026-10-21T21:28:53Z","type":"entry.line_added"}
{"payload":{"attachment_id":11,"blob_sha":"adb51e4f7d428927236d250e88465a36f56a842d7878a62222fe2bd29f4d73fd","category":"receipts","description":null,"entry_id":11,"file_name":"r11.pdf","mime_type":"application/pdf","size_bytes":67},"prev":"9e909f641d42f42854e4d2569d10aca5210336d4a266416fab450c2b9ad2d328","seq":95,"ts":"2026-10-21T21:28:53Z","type":"receipt.attached"}
{"payload":{"date":"2025-02-18","description":"Software paid by member 2","id":11,"lines":[{"account":14,"amount":"480.31","currency":"USD","side":"debit"},{"account":8,"amount":"480.31","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"e3ea21147baef2177949040be10c4a4a2ce12f82c1e1f1c9e7ed4a0fae93c655","seq":96,"ts":"2026-10-21T21:28:53Z","type":"entry.posted"}
{"payload":{"date":"2025-02-23","description":"Bank fee 2","id":12},"prev":"1f1994c6bb186155ff6c1e452247eff373ce903fcb04bd0a2c670160c5a639f7","seq":97,"ts":"2026-10-21T21:28:53Z","type":"entry.created"}
{"payload":{"entry_id":12,"reason":"bank fee"},"prev":"5b275e93057427c467c4790c51432a25180f6b02ec28db4fd3ea516bbccff9d8","seq":98,"ts":"2026-10-21T21:28:53Z","type":"entry.no_party"}
{"payload":{"account":13,"amount":"12.00","currency":"USD","id":12,"side":"debit"},"prev":"3b0e4b89a80cc27b5d471c5db255b01ea8941f335d47d0355361f4162828c806","seq":99,"ts":"2026-10-21T21:28:53Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"12.00","currency":"USD","id":12,"side":"credit"},"prev":"4847abf2d56ce8584c9f9b9ff581d8fdbe76b4f7931ac4f03a1a059c224c187d","seq":100,"ts":"2026-10-21T21:28:53Z","type":"entry.line_added"}
{"payload":{"attachment_id":12,"blob_sha":"8dc13ceaede8f28843e0cce29d7361079db7db662dddcad5363206973983a5ac","category":"receipts","description":null,"entry_id":12,"file_name":"r12.pdf","mime_type":"application/pdf","size_bytes":52},"prev":"8fbd785163460476a7f249b8a186388ace63db4126ce98b251fcb85f8446b6c6","seq":101,"ts":"2026-10-21T21:28:53Z","type":"receipt.attached"}
{"payload":{"date":"2025-02-23","description":"Bank fee 2","id":12,"lines":[{"account":13,"amount":"12.00","currency":"USD","side":"debit"},{"account":1,"amount":"12.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"741e73614fc19a61c148919eadaac97feccfe63e10dd033a37e1a4278749dff2","seq":102,"ts":"2026-10-21T21:28:53Z","type":"entry.posted"}
{"payload":{"date":"2025-03-03","description":"Consulting 3","id":13},"prev":"bcded4e6928cb1d98cfecad9418c88868173a31715d9c7258fd1ac43f47eeb50","seq":103,"ts":"2026-10-21T21:28:53Z","type":"entry.created"}
{"payload":{"entry_id":13,"party_id":5},"prev":"4ae7dbfad307311ffd842c853b5c38e490f1cf90337feadf063a1f8a11094d01","seq":104,"ts":"2026-10-21T21:28:53Z","type":"entry.party_set"}
{"payload":{"account":1,"amount":"77.02","currency":"USD","id":13,"side":"debit"},"prev":"4a5a7d56822cefdfa32825eeea6649f8d5fe417bd6af61bf25572c3321c4dc8a","seq":105,"ts":"2026-10-21T21:28:53Z","type":"entry.line_added"}
{"payload":{"account":12,"amount":"77.02","currency":"USD","id":13,"side":"credit"},"prev":"f480211077e50b34ed8d410b94a2a896e3aa41e5e976ac23d026956c925f5846","seq":106,"ts":"2026-10-21T21:28:53Z","type":"entry.line_added"}
{"payload":{"attachment_id":13,"blob_sha":"bdd80cf4f88b7072fe957a747868541c636fd110390c977807cbc27ece7fc3f5","category":"receipts","description":null,"entry_id":13,"file_name":"r13.pdf","mime_type":"application/pdf","size_bytes":54},"prev":"37e9bdee99d55fbca6ee1ed3a5c7b5930a07f63726b25e01e4f5d3f8f6332d39","seq":107,"ts":"2026-10-21T21:28:53Z","type":"receipt.attached"}
{"payload":{"date":"2025-03-03","description":"Consulting 3","id":13,"lines":[{"account":1,"amount":"77.02","currency":"USD","side":"debit"},{"account":12,"amount":"77.02","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"75b43dc62ba4de561993dac61fa66b6cb36f25db4ee76f9c44b37186b20799dc","seq":108,"ts":"2026-10-21T21:28:53Z","type":"entry.posted"}
{"payload":{"date":"2025-03-08","description":"Design work 3","id":14},"prev":"6787e29c68214763e273f8c51a03f6eb7e9e8325beab276dcf057a5d2e1c5257","seq":109,"ts":"2026-10-21T21:28:53Z","type":"entry.created"}
{"payload":{"entry_id":14,"party_id":3},"prev":"fd970f288cd034209f01fe016b5c14d6fdb63059b7db0fcf90a064d1274f0b41","seq":110,"ts":"2026-10-21T21:28:53Z","type":"entry.party_set"}
{"payload":{"account":17,"amount":"666.10","currency":"USD","id":14,"side":"debit"},"prev":"361e3e012906190157032b7f1e266f4ffbb3a44c3220dccbd42bca8fc8acf43e","seq":111,"ts":"2026-10-21T21:28:53Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"666.10","currency":"USD","id":14,"side":"credit"},"prev":"31d5b169b81aa9feb3df1c3b9f736601e1c82de28be4c54723fc0d50cb3f68d8","seq":112,"ts":"2026-10-21T21:28:53Z","type":"entry.line_added"}
{"payload":{"attachment_id":14,"blob_sha":"53d0a291ee3782d571157ef0c84f5862c9fe3ea33964edc3c4bd6d5a966d91e5","category":"receipts","description":null,"entry_id":14,"file_name":"r14.pdf","mime_type":"application/pdf","size_bytes":55},"prev":"48d9582f794a39423947fe63c4bc1f954413f8aa0d8b0a20d7e7ca1a7945dd2c","seq":113,"ts":"2026-10-21T21:28:53Z","type":"receipt.attached"}
{"payload":{"date":"2025-03-08","description":"Design work 3","id":14,"lines":[{"account":17,"amount":"666.10","currency":"USD","side":"debit"},{"account":1,"amount":"666.10","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"65233a70ad796f6819b5f3b142db86baee2f534df196c647ed9d64524815e651","seq":114,"ts":"2026-10-21T21:28:53Z","type":"entry.posted"}
{"payload":{"date":"2025-03-13","description":"Supplies 3","id":15},"prev":"fef2b0e474a659625d01ae2eb4546bd2e320331e47a5493c07a453f7e7f2e60f","seq":115,"ts":"2026-10-21T21:28:53Z","type":"entry.created"}
{"payload":{"entry_id":15,"party_id":4},"prev":"d232fa4453f161809c08271b5f2da9ef6abe5a88658309e6eb874630cd8d815d","seq":116,"ts":"2026-10-21T21:28:53Z","type":"entry.party_set"}
{"payload":{"account":13,"amount":"282.40","currency":"USD","id":15,"side":"debit"},"prev":"329b0488d4a9da17b537ca08c02848943e31cd33634cb15ae2ded821ca026936","seq":117,"ts":"2026-10-21T21:28:53Z","type":"entry.line_added"}
{"payload":{"account":7,"amount":"282.40","currency":"USD","id":15,"side":"credit"},"prev":"c7951f1954727979a4b4f22a21b3582c889324a17257a16e30dc5ca51bf1fb2f","seq":118,"ts":"2026-10-21T21:28:53Z","type":"entry.line_added"}
{"payload":{"attachment_id":15,"blob_sha":"50362975c484841d345e5b431ff1de0c91a38a3a609a10b446e92214b12b89c7","category":"receipts","description":null,"entry_id":15,"file_name":"r15.pdf","mime_type":"application/pdf","size_bytes":52},"prev":"3c47ea24e1c379899e0959dedbea581010b89520066efda366a503c42b9ea429","seq":119,"ts":"2026-10-21T21:28:53Z","type":"receipt.attached"}
{"payload":{"date":"2025-03-13","description":"Supplies 3","id":15,"lines":[{"account":13,"amount":"282.40","currency":"USD","side":"debit"},{"account":7,"amount":"282.40","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"5be04d392a8bb37c8f46eaf40b9475596780b133c089fbf2c55611aa514e881b","seq":120,"ts":"2026-10-21T21:28:53Z","type":"entry.posted"}
{"payload":{"date":"2025-03-18","description":"Software paid by member 3","id":16},"prev":"e144d0ead638ccef5dbb3515ce9fcf44847ff1572b51f4e0333db8d6ed9b742b","seq":121,"ts":"2026-10-21T21:28:53Z","type":"entry.created"}
{"payload":{"entry_id":16,"party_id":1},"prev":"13fc8211de63981e31fe6636cb8cbfd133c330f63233fc72378425677c1463fb","seq":122,"ts":"2026-10-21T21:28:53Z","type":"entry.party_set"}
{"payload":{"account":14,"amount":"50.14","currency":"USD","id":16,"side":"debit"},"prev":"e1a1ded0ba49a9db6cde6792e1b714366a00671549adf4324a4598b1d74a5c55","seq":123,"ts":"2026-10-21T21:28:53Z","type":"entry.line_added"}
{"payload":{"account":8,"amount":"50.14","currency":"USD","id":16,"side":"credit"},"prev":"c6e25c989a0b701eb022c2ba61ca3fb87889035ec3a41504f1f0c05202a4e882","seq":124,"ts":"2026-10-21T21:28:53Z","type":"entry.line_added"}
{"payload":{"attachment_id":16,"blob_sha":"d362d36ef90e3eaa91a0c8dda829b78c6af7bbaf6d780a4c6ccb2b5fc3847677","category":"receipts","description":null,"entry_id":16,"file_name":"r16.pdf","mime_type":"application/pdf","size_bytes":67},"prev":"8d896806acde9e0d97d7858b61e300f6952744ddb6d35605ed64dac5254e140a","seq":125,"ts":"2026-10-21T21:28:54Z","type":"receipt.attached"}
{"payload":{"date":"2025-03-18","description":"Software paid by member 3","id":16,"lines":[{"account":14,"amount":"50.14","currency":"USD","side":"debit"},{"account":8,"amount":"50.14","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"3e65cb179dde4a39b10434b8c68b4de5afe0e31de2427367b37b72472d852e61","seq":126,"ts":"2026-10-21T21:28:54Z","type":"entry.posted"}
{"payload":{"date":"2025-03-23","description":"Bank fee 3","id":17},"prev":"01d5f1f87c958891490e89de23951f85d0e60ad048d9057be9c843ddf2a8624d","seq":127,"ts":"2026-10-21T21:28:54Z","type":"entry.created"}
{"payload":{"entry_id":17,"reason":"bank fee"},"prev":"4d765897ebb1b5caa54ccc9da98db76071dba2d990a3ad508d3d35c10e4842de","seq":128,"ts":"2026-10-21T21:28:54Z","type":"entry.no_party"}
{"payload":{"account":13,"amount":"12.00","currency":"USD","id":17,"side":"debit"},"prev":"d900fa39a22dc0af4519b5b24d271a874058d348a19ab91edc7c5b978bd34774","seq":129,"ts":"2026-10-21T21:28:54Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"12.00","currency":"USD","id":17,"side":"credit"},"prev":"54c9aa19896f483d6611034b2fabd50368127f65b3f5b14fa7b859d5afad739f","seq":130,"ts":"2026-10-21T21:28:54Z","type":"entry.line_added"}
{"payload":{"attachment_id":17,"blob_sha":"697a5b6d8f74f341972c78207e471c30d07ced60ef8a00827be79dadd9e8948b","category":"receipts","description":null,"entry_id":17,"file_name":"r17.pdf","mime_type":"application/pdf","size_bytes":52},"prev":"898692b09d5bcce584af16f0e0dc2897481796bc7a607558c3e71da57fdef6a6","seq":131,"ts":"2026-10-21T21:28:54Z","type":"receipt.attached"}
{"payload":{"date":"2025-03-23","description":"Bank fee 3","id":17,"lines":[{"account":13,"amount":"12.00","currency":"USD","side":"debit"},{"account":1,"amount":"12.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"117202b822b0090e7c45baabdcbf9cace96d6d64760776fcef1d71e815b2c8bc","seq":132,"ts":"2026-10-21T21:28:54Z","type":"entry.posted"}
{"payload":{"date":"2025-04-03","description":"Consulting 4","id":18},"prev":"98b756a3cf8dc05c724889a67a011f587dd0f528547fb085ee40d23fe92c9b83","seq":133,"ts":"2026-10-21T21:28:54Z","type":"entry.created"}
{"payload":{"entry_id":18,"party_id":5},"prev":"b460b4e56a6c09995d0765b0f728acd5252808587aaab153af0980ed9eb29bdd","seq":134,"ts":"2026-10-21T21:28:54Z","type":"entry.party_set"}
{"payload":{"account":1,"amount":"569.38","currency":"USD","id":18,"side":"debit"},"prev":"f25e935d9606f16026377498d191ee8fb6fc84e06e7b1acbf308b46d4465ddf0","seq":135,"ts":"2026-10-21T21:28:54Z","type":"entry.line_added"}
{"payload":{"account":12,"amount":"569.38","currency":"USD","id":18,"side":"credit"},"prev":"405ea8804c75f2a21bce3abe2c5ae46c74a833c04c32df66fa05976586757e7e","seq":136,"ts":"2026-10-21T21:28:54Z","type":"entry.line_added"}
{"payload":{"attachment_id":18,"blob_sha":"135feedb9b3f3931a9af46640e942d497cd2c7eedd46cc49c31116107d63e1af","category":"receipts","description":null,"entry_id":18,"file_name":"r18.pdf","mime_type":"application/pdf","size_bytes":54},"prev":"10aa42e5c28659af0088f3f2bdfb76f9cf16f7f96697409c43ee35e79e0eb31e","seq":137,"ts":"2026-10-21T21:28:54Z","type":"receipt.attached"}
{"payload":{"date":"2025-04-03","description":"Consulting 4","id":18,"lines":[{"account":1,"amount":"569.38","currency":"USD","side":"debit"},{"account":12,"amount":"569.38","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"9cd8f1fc5f278e051552cac6a067bfdea4462c2a16998c87d4ee9fb229f5a9b1","seq":138,"ts":"2026-10-21T21:28:54Z","type":"entry.posted"}
{"payload":{"date":"2025-04-08","description":"Design work 4","id":19},"prev":"81b61a711735d100665af0f90f66427fa58b12c2609a4366c92eb595aa54bfe7","seq":139,"ts":"2026-10-21T21:28:54Z","type":"entry.created"}
{"payload":{"entry_id":19,"party_id":3},"prev":"0b06a1d6e8db2b3d1541c41653104843e37a5e9c9403869f850b3b23ce4ff318","seq":140,"ts":"2026-10-21T21:28:54Z","type":"entry.party_set"}
{"payload":{"account":17,"amount":"549.10","currency":"USD","id":19,"side":"debit"},"prev":"de1602a62459badaca7c86d11a80fb8fb077efbb4c0e0a96aa5f57fa4d46e69b","seq":141,"ts":"2026-10-21T21:28:54Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"549.10","currency":"USD","id":19,"side":"credit"},"prev":"c4fd94a05cec6453bda42d40960b8ab1648faa17a73f85298aff2d02f9d674cd","seq":142,"ts":"2026-10-21T21:28:54Z","type":"entry.line_added"}
{"payload":{"attachment_id":19,"blob_sha":"7c621c63ab0b77d026b903714442e0cb16d786386abc15873937a05304dad04a","category":"receipts","description":null,"entry_id":19,"file_name":"r19.pdf","mime_type":"application/pdf","size_bytes":55},"prev":"78d0f1a293a59f415181156b936e46d37a2d9d445f5692eb308b615990fbcb27","seq":143,"ts":"2026-10-21T21:28:54Z","type":"receipt.attached"}
{"payload":{"date":"2025-04-08","description":"Design work 4","id":19,"lines":[{"account":17,"amount":"549.10","currency":"USD","side":"debit"},{"account":1,"amount":"549.10","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"155d95bc1fe3bb903e700a0ee45a1a78be072e67df5a3a83ecbda62413526b54","seq":144,"ts":"2026-10-21T21:28:54Z","type":"entry.posted"}
{"payload":{"date":"2025-04-13","description":"Supplies 4","id":20},"prev":"bebb19d5b7bb2ff595ec01c3fd236202e0317d9173b54cef1d3539ec6efbae54","seq":145,"ts":"2026-10-21T21:28:54Z","type":"entry.created"}
{"payload":{"entry_id":20,"party_id":4},"prev":"c5a504b1fab56ae93e77357100fe56af642434409293f5bacaa281e19befc344","seq":146,"ts":"2026-10-21T21:28:54Z","type":"entry.party_set"}
{"payload":{"account":13,"amount":"92.56","currency":"USD","id":20,"side":"debit"},"prev":"542ee36d9463ffa01d276fcc5479b656a8617393d1c61f53fecf78ccb2632da8","seq":147,"ts":"2026-10-21T21:28:54Z","type":"entry.line_added"}
{"payload":{"account":7,"amount":"92.56","currency":"USD","id":20,"side":"credit"},"prev":"b03b1f3cb6cb871a4da589887226b65415677c75b92ba0d0e8aace71bb0a7940","seq":148,"ts":"2026-10-21T21:28:54Z","type":"entry.line_added"}
{"payload":{"attachment_id":20,"blob_sha":"1ccdb1535622cb9fd70388cead476435d5a0bf48cb0f5486569d072b79702e3a","category":"receipts","description":null,"entry_id":20,"file_name":"r20.pdf","mime_type":"application/pdf","size_bytes":52},"prev":"b0561a03b062d4b99c583d3bde744eccb66df3b8bbfff0ffd00a8151a92563ca","seq":149,"ts":"2026-10-21T21:28:54Z","type":"receipt.attached"}
{"payload":{"date":"2025-04-13","description":"Supplies 4","id":20,"lines":[{"account":13,"amount":"92.56","currency":"USD","side":"debit"},{"account":7,"amount":"92.56","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"17df72a2ff4d8ad8e7b53ebcae37547075dd6e867974a331b90de12af50d9678","seq":150,"ts":"2026-10-21T21:28:54Z","type":"entry.posted"}
{"payload":{"date":"2025-04-18","description":"Software paid by member 4","id":21},"prev":"aa98755989b66c1168da2ebf9ef2f667bf17dbfb090b0feb228abd47ada4145d","seq":151,"ts":"2026-10-21T21:28:54Z","type":"entry.created"}
{"payload":{"entry_id":21,"party_id":1},"prev":"08c220fb3aa840582e44d05113bcf17ef393044a1ee9a13d8bd1628efc543454","seq":152,"ts":"2026-10-21T21:28:54Z","type":"entry.party_set"}
{"payload":{"account":14,"amount":"316.44","currency":"USD","id":21,"side":"debit"},"prev":"b7b2e135f947b02ce7b13bd5397fe0ad6ee6c5953365384b3c975024f0a35c1e","seq":153,"ts":"2026-10-21T21:28:54Z","type":"entry.line_added"}
{"payload":{"account":8,"amount":"316.44","currency":"USD","id":21,"side":"credit"},"prev":"5b78e258d4718d45fb43f8295d70ee1b5e29f97a1404e53287355ddbbdf63596","seq":154,"ts":"2026-10-21T21:28:54Z","type":"entry.line_added"}
{"payload":{"attachment_id":21,"blob_sha":"5484fb84ff7718440c626ab6f5ea214c193f00a84079b51d811df2cdf35af58c","category":"receipts","description":null,"entry_id":21,"file_name":"r21.pdf","mime_type":"application/pdf","size_bytes":67},"prev":"615867565e9b02e72a0e8d5180e5d43a0c5a4c0fdd8e8d1f4d98535c14c92f3e","seq":155,"ts":"2026-10-21T21:28:54Z","type":"receipt.attached"}
{"payload":{"date":"2025-04-18","description":"Software paid by member 4","id":21,"lines":[{"account":14,"amount":"316.44","currency":"USD","side":"debit"},{"account":8,"amount":"316.44","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"61daf7a1e6e75e7f3e1d4adb1b69e07997ab36225fd1add8f7a238af7adfc8d3","seq":156,"ts":"2026-10-21T21:28:54Z","type":"entry.posted"}
{"payload":{"date":"2025-04-23","description":"Bank fee 4","id":22},"prev":"210ff2e8a7172d687a06e850e0421355f0f142eae1844e3b841c19a83866e492","seq":157,"ts":"2026-10-21T21:28:54Z","type":"entry.created"}
{"payload":{"entry_id":22,"reason":"bank fee"},"prev":"c31826899ed4c4775b47f6d0fcb0877caa7d8e52eac8dc1e82c5dff809789713","seq":158,"ts":"2026-10-21T21:28:54Z","type":"entry.no_party"}
{"payload":{"account":13,"amount":"12.00","currency":"USD","id":22,"side":"debit"},"prev":"e1a92cfbcbee238dc149050bb0116a091bd9a49730eef6b32991b4c462012752","seq":159,"ts":"2026-10-21T21:28:55Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"12.00","currency":"USD","id":22,"side":"credit"},"prev":"eaf21d71f261786cb6aa58a92577bc8c282bb889255593560d29670947e6d5a3","seq":160,"ts":"2026-10-21T21:28:55Z","type":"entry.line_added"}
{"payload":{"attachment_id":22,"blob_sha":"a1cfdbd7df3bd87d040f3f88ba9fd852d57c9e308a8fd8884b5872f5ab65d955","category":"receipts","description":null,"entry_id":22,"file_name":"r22.pdf","mime_type":"application/pdf","size_bytes":52},"prev":"df36dae34a74a00382ef386d1275e9cfc125dd24d0e8ebccf23628ccf9063b5d","seq":161,"ts":"2026-10-21T21:28:55Z","type":"receipt.attached"}
{"payload":{"date":"2025-04-23","description":"Bank fee 4","id":22,"lines":[{"account":13,"amount":"12.00","currency":"USD","side":"debit"},{"account":1,"amount":"12.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"4aea0b2d4309babbd6be12d741a242b394b86b74b67c0cc124f148318710c3ff","seq":162,"ts":"2026-10-21T21:28:55Z","type":"entry.posted"}
{"payload":{"date":"2025-05-03","description":"Consulting 5","id":23},"prev":"6ef10f9bad3e4e57d67e5af36eeeca16cd685409a31578ab3e145472c91efbe9","seq":163,"ts":"2026-10-21T21:28:55Z","type":"entry.created"}
{"payload":{"entry_id":23,"party_id":5},"prev":"0185d788c816eac9251c2011ab63ea1314e2053a29f914540e84e0c1f9a5b51e","seq":164,"ts":"2026-10-21T21:28:55Z","type":"entry.party_set"}
{"payload":{"account":1,"amount":"723.26","currency":"USD","id":23,"side":"debit"},"prev":"2bed4193fe6f8e6a79b6f3a26d257520c203d25d21f88700b2b795eb38cf2640","seq":165,"ts":"2026-10-21T21:28:55Z","type":"entry.line_added"}
{"payload":{"account":12,"amount":"723.26","currency":"USD","id":23,"side":"credit"},"prev":"f1d76570adf1ca7b0cd93e478ae8394b7639e545295b0a459bd1cf1426877245","seq":166,"ts":"2026-10-21T21:28:55Z","type":"entry.line_added"}
{"payload":{"attachment_id":23,"blob_sha":"603f71cd317e709741b1c58e20690f48592dfc1e13c91b169d5207e8b3db45ca","category":"receipts","description":null,"entry_id":23,"file_name":"r23.pdf","mime_type":"application/pdf","size_bytes":54},"prev":"9584359be3c76055e35579fe3cd88b8d0c0b5ad93deda34f8ea15bda937739b7","seq":167,"ts":"2026-10-21T21:28:55Z","type":"receipt.attached"}
{"payload":{"date":"2025-05-03","description":"Consulting 5","id":23,"lines":[{"account":1,"amount":"723.26","currency":"USD","side":"debit"},{"account":12,"amount":"723.26","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"d6ad64035427d8ab4243493aa7c39c1207f62a8c815fcd98eb609a58cab5f7eb","seq":168,"ts":"2026-10-21T21:28:55Z","type":"entry.posted"}
{"payload":{"date":"2025-05-08","description":"Design work 5","id":24},"prev":"04d667574c83c71c34d17f508bf5d6f64ac549b7c03968cf645c03e0ea007236","seq":169,"ts":"2026-10-21T21:28:55Z","type":"entry.created"}
{"payload":{"entry_id":24,"party_id":3},"prev":"582fa7556c44d7d006428010ebdd06f6717fa3e4349e9d1da43286093fc2d804","seq":170,"ts":"2026-10-21T21:28:55Z","type":"entry.party_set"}
{"payload":{"account":17,"amount":"557.42","currency":"USD","id":24,"side":"debit"},"prev":"4d7f750fe1197f5dc45cf3b36f0a8b5a8bbaefd59e0e70964e850468f15c806d","seq":171,"ts":"2026-10-21T21:28:55Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"557.42","currency":"USD","id":24,"side":"credit"},"prev":"b349beaeafe3b3a159d9e430afdf9b5c16245922ece79168e7374f4d0ac3c226","seq":172,"ts":"2026-10-21T21:28:55Z","type":"entry.line_added"}
{"payload":{"attachment_id":24,"blob_sha":"e2150d5a433a641134c1c1e927a05dea2fddf44775f2b9dd2c23366e79d38389","category":"receipts","description":null,"entry_id":24,"file_name":"r24.pdf","mime_type":"application/pdf","size_bytes":55},"prev":"40139e3503971af4374cf0b4b56c707d6525ac946e1ef2b1f35322f5b6b34850","seq":173,"ts":"2026-10-21T21:28:55Z","type":"receipt.attached"}
{"payload":{"date":"2025-05-08","description":"Design work 5","id":24,"lines":[{"account":17,"amount":"557.42","currency":"USD","side":"debit"},{"account":1,"amount":"557.42","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"b1966191ebf5b382aad792634f4c2c5daaaf5f84a739ac3c3507153c9031756c","seq":174,"ts":"2026-10-21T21:28:55Z","type":"entry.posted"}
{"payload":{"date":"2025-05-13","description":"Supplies 5","id":25},"prev":"1225dc7926558c39b65926da7e4e9cea942713a29451d670aa458c4411cdadf2","seq":175,"ts":"2026-10-21T21:28:55Z","type":"entry.created"}
{"payload":{"entry_id":25,"party_id":4},"prev":"0387af139832d42580c0d9d61990c7a0ed8f2c6e260bfb10b061882bb210885e","seq":176,"ts":"2026-10-21T21:28:55Z","type":"entry.party_set"}
{"payload":{"account":13,"amount":"78.47","currency":"USD","id":25,"side":"debit"},"prev":"7217b65bae27528c5586423029b77c7d17bf039d993011221b46b91f6bad4adf","seq":177,"ts":"2026-10-21T21:28:55Z","type":"entry.line_added"}
{"payload":{"account":7,"amount":"78.47","currency":"USD","id":25,"side":"credit"},"prev":"2ba1a67b7064dafbc8b921da91fe35e1dda7093eb9dc94ca1ebac7dbcf904c8f","seq":178,"ts":"2026-10-21T21:28:55Z","type":"entry.line_added"}
{"payload":{"attachment_id":25,"blob_sha":"930a53802a91886ad895106907b55e56fad62a32d8933ecf10fa6ffda0ca4d99","category":"receipts","description":null,"entry_id":25,"file_name":"r25.pdf","mime_type":"application/pdf","size_bytes":52},"prev":"341c0fe14ddff9e61aaff5b254088fdcdc22fe7b9569e8269c9b82e704d0c4fb","seq":179,"ts":"2026-10-21T21:28:55Z","type":"receipt.attached"}
{"payload":{"date":"2025-05-13","description":"Supplies 5","id":25,"lines":[{"account":13,"amount":"78.47","currency":"USD","side":"debit"},{"account":7,"amount":"78.47","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"b3b056c6c81e328b556a0e5f701d03cca27b0b535a3d6e5fa713c49038e17f35","seq":180,"ts":"2026-10-21T21:28:55Z","type":"entry.posted"}
{"payload":{"date":"2025-05-18","description":"Software paid by member 5","id":26},"prev":"589d6238947cb71db0fc7ca67fe191bd21815d6f2f73321866966cdcdc25cd5f","seq":181,"ts":"2026-10-21T21:28:55Z","type":"entry.created"}
{"payload":{"entry_id":26,"party_id":1},"prev":"e189d25bbbe2f1584aad41d5745394d64375193da201844a3a249d5107678894","seq":182,"ts":"2026-10-21T21:28:55Z","type":"entry.party_set"}
{"payload":{"account":14,"amount":"742.15","currency":"USD","id":26,"side":"debit"},"prev":"5b631f9b461464590322911961ef79fed887a1d13f711698ccf8346641ad6caa","seq":183,"ts":"2026-10-21T21:28:55Z","type":"entry.line_added"}
{"payload":{"account":8,"amount":"742.15","currency":"USD","id":26,"side":"credit"},"prev":"bb62bc83d88c4d51892ac46bea4973a9551a5eba47146590d4c1f64b93094001","seq":184,"ts":"2026-10-21T21:28:55Z","type":"entry.line_added"}
{"payload":{"attachment_id":26,"blob_sha":"c5ec7ae0d321249ce2b1ca7ada6605a45e4afd8a09e8e3321b09427cf46fa876","category":"receipts","description":null,"entry_id":26,"file_name":"r26.pdf","mime_type":"application/pdf","size_bytes":67},"prev":"55ef1c2620ed5e12ed6ebc4924d601d0b225a8597d33641a9b9cae40878008c2","seq":185,"ts":"2026-10-21T21:28:55Z","type":"receipt.attached"}
{"payload":{"date":"2025-05-18","description":"Software paid by member 5","id":26,"lines":[{"account":14,"amount":"742.15","currency":"USD","side":"debit"},{"account":8,"amount":"742.15","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"1561153fc0243c3fd1a7c4827e594f6989329425d8af48e8b400383b8ce8bfbf","seq":186,"ts":"2026-10-21T21:28:55Z","type":"entry.posted"}
{"payload":{"date":"2025-05-23","description":"Bank fee 5","id":27},"prev":"56423388ab6c87b857dda42ae3c223be85fd9f252ed6ab3c8e6e1263bf1de933","seq":187,"ts":"2026-10-21T21:28:55Z","type":"entry.created"}
{"payload":{"entry_id":27,"reason":"bank fee"},"prev":"191c29d74cfba04703fcce75bfe2bca04f2b55ec53fd59f648f0870167e27309","seq":188,"ts":"2026-10-21T21:28:55Z","type":"entry.no_party"}
{"payload":{"account":13,"amount":"12.00","currency":"USD","id":27,"side":"debit"},"prev":"b98709e04a040c95b35edda750ac20a2cbf12bbd1671750037dafc00d66a3b13","seq":189,"ts":"2026-10-21T21:28:55Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"12.00","currency":"USD","id":27,"side":"credit"},"prev":"4409fdddbe25988a80846a91a889333ff58f3cabeb93013380c89e0043bf0ad4","seq":190,"ts":"2026-10-21T21:28:55Z","type":"entry.line_added"}
{"payload":{"attachment_id":27,"blob_sha":"154a2bd5bd5bf103a7a5dbb129fcf44a3a6c2c4a3214cc0bb6a17311c82d8061","category":"receipts","description":null,"entry_id":27,"file_name":"r27.pdf","mime_type":"application/pdf","size_bytes":52},"prev":"328edf40f496826dca364f529faadb44b22a1630307911a0d019be7870988e10","seq":191,"ts":"2026-10-21T21:28:56Z","type":"receipt.attached"}
{"payload":{"date":"2025-05-23","description":"Bank fee 5","id":27,"lines":[{"account":13,"amount":"12.00","currency":"USD","side":"debit"},{"account":1,"amount":"12.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"d80aa5223319bac0ada01e8e5f33d0277139a8d500539cf31131e2292390b787","seq":192,"ts":"2026-10-21T21:28:56Z","type":"entry.posted"}
{"payload":{"date":"2025-06-03","description":"Consulting 6","id":28},"prev":"b69840e0dca6e2119aa5009e125e962377b01649f8f3e283374d7073ef23e799","seq":193,"ts":"2026-10-21T21:28:56Z","type":"entry.created"}
{"payload":{"entry_id":28,"party_id":5},"prev":"1bdea236f3725c76feaf17f990e44450b488b4406d8c3ba327a3e8b284c7bb45","seq":194,"ts":"2026-10-21T21:28:56Z","type":"entry.party_set"}
{"payload":{"account":1,"amount":"293.60","currency":"USD","id":28,"side":"debit"},"prev":"24f568bccbdea731f1d3d65a3b0a7b1ee71b3328d5ed98990460d4be384658fa","seq":195,"ts":"2026-10-21T21:28:56Z","type":"entry.line_added"}
{"payload":{"account":12,"amount":"293.60","currency":"USD","id":28,"side":"credit"},"prev":"9d884d36fd35dcd0d94c02f7f9800c3132ad0c5754d77b89f3c184c442b405eb","seq":196,"ts":"2026-10-21T21:28:56Z","type":"entry.line_added"}
{"payload":{"attachment_id":28,"blob_sha":"a8234e922d52bb6f585639bd99714794d6a62a089faedb24711f7f7b8ff24091","category":"receipts","description":null,"entry_id":28,"file_name":"r28.pdf","mime_type":"application/pdf","size_bytes":54},"prev":"615f1b0c850ea082b2caf71aade572e0d98e8fa7c181077ec0c493685af02129","seq":197,"ts":"2026-10-21T21:28:56Z","type":"receipt.attached"}
{"payload":{"date":"2025-06-03","description":"Consulting 6","id":28,"lines":[{"account":1,"amount":"293.60","currency":"USD","side":"debit"},{"account":12,"amount":"293.60","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"cdcc49409546f1fb63a7847dd8862e8ffc00eec2439b2700378cb67f1d935d36","seq":198,"ts":"2026-10-21T21:28:56Z","type":"entry.posted"}
{"payload":{"date":"2025-06-08","description":"Design work 6","id":29},"prev":"a45ca1c0c8ca330f2de5a0bfc150d3cc9a1f0dd4bd434347c23393638a2ec71f","seq":199,"ts":"2026-10-21T21:28:56Z","type":"entry.created"}
{"payload":{"entry_id":29,"party_id":3},"prev":"a23eb273a2b7a8423bd7fda205a7801d3b259ff9290fe4199d58b9a70bc3cd11","seq":200,"ts":"2026-10-21T21:28:56Z","type":"entry.party_set"}
{"payload":{"account":17,"amount":"827.57","currency":"USD","id":29,"side":"debit"},"prev":"86a6323eec09275c1a2cafa1e570f479198b41d7d7d0fbb9901c43954718e070","seq":201,"ts":"2026-10-21T21:28:56Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"827.57","currency":"USD","id":29,"side":"credit"},"prev":"9cab5dc278d32771d1dfec9f002b4a8213cd2dfa341f6348cbc4b3db4d563af2","seq":202,"ts":"2026-10-21T21:28:56Z","type":"entry.line_added"}
{"payload":{"attachment_id":29,"blob_sha":"bd7936f79f1b7e603feaa91d6e66a165bb5959bc91519f884f60dd63012b53eb","category":"receipts","description":null,"entry_id":29,"file_name":"r29.pdf","mime_type":"application/pdf","size_bytes":55},"prev":"7f05ec8fd278f0c25556041310ca0ee32b6cf1d6d9101f4e336c1dae0bef6484","seq":203,"ts":"2026-10-21T21:28:56Z","type":"receipt.attached"}
{"payload":{"date":"2025-06-08","description":"Design work 6","id":29,"lines":[{"account":17,"amount":"827.57","currency":"USD","side":"debit"},{"account":1,"amount":"827.57","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"a2a196019c9ca93a5ee9e6b7394d7d909cd213e2728cf4d18015f5d65cea2d54","seq":204,"ts":"2026-10-21T21:28:56Z","type":"entry.posted"}
{"payload":{"date":"2025-06-13","description":"Supplies 6","id":30},"prev":"b709f836b0dff6856f22fda0dea4990df172f62a354e25123609bd0174be8d66","seq":205,"ts":"2026-10-21T21:28:56Z","type":"entry.created"}
{"payload":{"entry_id":30,"party_id":4},"prev":"97f1b47f77f8c1197de24bb989fa8bb7e9083641fac69fba471b6545ab2e9687","seq":206,"ts":"2026-10-21T21:28:56Z","type":"entry.party_set"}
{"payload":{"account":13,"amount":"823.38","currency":"USD","id":30,"side":"debit"},"prev":"6b625c1035f8054a7c2754a992fb590385b0c6ebe41518e16c207cc1322d63c8","seq":207,"ts":"2026-10-21T21:28:56Z","type":"entry.line_added"}
{"payload":{"account":7,"amount":"823.38","currency":"USD","id":30,"side":"credit"},"prev":"83fcd89112f49407b564c85dc788120a63b8abd8df9822abdd35199aed997a9e","seq":208,"ts":"2026-10-21T21:28:56Z","type":"entry.line_added"}
{"payload":{"attachment_id":30,"blob_sha":"80776469bdd56d9957eaef7735f9d603ef7d4b69a51fa500aa4f0b70d24fd95e","category":"receipts","description":null,"entry_id":30,"file_name":"r30.pdf","mime_type":"application/pdf","size_bytes":52},"prev":"b585d23d0d3f25c96fc81432c71382825debd976de9862fb19541204abdf83a7","seq":209,"ts":"2026-10-21T21:28:56Z","type":"receipt.attached"}
{"payload":{"date":"2025-06-13","description":"Supplies 6","id":30,"lines":[{"account":13,"amount":"823.38","currency":"USD","side":"debit"},{"account":7,"amount":"823.38","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"4fa950b384f9c5307170c95c335c272362859e427eaf29402fead4f447fc2002","seq":210,"ts":"2026-10-21T21:28:56Z","type":"entry.posted"}
{"payload":{"date":"2025-06-18","description":"Software paid by member 6","id":31},"prev":"af902bae6a319f9efca635c501b5c005c68cf58c69ef6341e9db5a7f25a6c651","seq":211,"ts":"2026-10-21T21:28:56Z","type":"entry.created"}
{"payload":{"entry_id":31,"party_id":1},"prev":"17bd5f87e9abb49150365543faaf258265fa368fd005e551f16465575f9b38ba","seq":212,"ts":"2026-10-21T21:28:56Z","type":"entry.party_set"}
{"payload":{"account":14,"amount":"765.14","currency":"USD","id":31,"side":"debit"},"prev":"830f09569349f9ae18605bf6f977294e4580d28d1511954347b4df606068d0e4","seq":213,"ts":"2026-10-21T21:28:56Z","type":"entry.line_added"}
{"payload":{"account":8,"amount":"765.14","currency":"USD","id":31,"side":"credit"},"prev":"4d92bcf5c31923002854dc00bdccce819209ac05144d7f3cbe5a1f951788ec17","seq":214,"ts":"2026-10-21T21:28:56Z","type":"entry.line_added"}
{"payload":{"attachment_id":31,"blob_sha":"372029955692c864bd5f8fc93da8a3512bcfa45099626c289eca75de84478f0c","category":"receipts","description":null,"entry_id":31,"file_name":"r31.pdf","mime_type":"application/pdf","size_bytes":67},"prev":"22323b6e007830ba4fa0e701318ab0fb1a40764022fda8067b9076843fa938b6","seq":215,"ts":"2026-10-21T21:28:56Z","type":"receipt.attached"}
{"payload":{"date":"2025-06-18","description":"Software paid by member 6","id":31,"lines":[{"account":14,"amount":"765.14","currency":"USD","side":"debit"},{"account":8,"amount":"765.14","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"07b9323c35fa23cd4e7a009ea458f095b71924fc00dfe24a9b0e6e7f5f163665","seq":216,"ts":"2026-10-21T21:28:56Z","type":"entry.posted"}
{"payload":{"date":"2025-06-23","description":"Bank fee 6","id":32},"prev":"070e5f13698f701be19ab710494ab4cedf78e46f94b6d8f3fad55a90405f0590","seq":217,"ts":"2026-10-21T21:28:56Z","type":"entry.created"}
{"payload":{"entry_id":32,"reason":"bank fee"},"prev":"09be96a0740db022c5bf2a3c466597811c18cc5ea62b09a2fe81ede6bf832a16","seq":218,"ts":"2026-10-21T21:28:56Z","type":"entry.no_party"}
{"payload":{"account":13,"amount":"12.00","currency":"USD","id":32,"side":"debit"},"prev":"1e79025529dca4144f1ca4a9b1a2466aca3ce101ece1a6ff57cbbee0d6c2d766","seq":219,"ts":"2026-10-21T21:28:57Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"12.00","currency":"USD","id":32,"side":"credit"},"prev":"64c01758d90db85b0d33d743d2955b66e00041e8babda6a49139f47f0bf71828","seq":220,"ts":"2026-10-21T21:28:57Z","type":"entry.line_added"}
{"payload":{"attachment_id":32,"blob_sha":"4e2d180772eae2ddf0596a5444f301e052f93f7266845eecf454bc31f053d36f","category":"receipts","description":null,"entry_id":32,"file_name":"r32.pdf","mime_type":"application/pdf","size_bytes":52},"prev":"3a6f46683d9c4e507d0fd4268c4be69f372f779b43b8ab6ffb42ae79fc28869c","seq":221,"ts":"2026-10-21T21:28:57Z","type":"receipt.attached"}
{"payload":{"date":"2025-06-23","description":"Bank fee 6","id":32,"lines":[{"account":13,"amount":"12.00","currency":"USD","side":"debit"},{"account":1,"amount":"12.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"6c5b6d6badc42ae5dd9d5045c2ba54fac1e353bc99a87a6c3ff2186e369fadc9","seq":222,"ts":"2026-10-21T21:28:57Z","type":"entry.posted"}
{"payload":{"date":"2025-07-03","description":"Consulting 7","id":33},"prev":"f759dd462a9cb7019463699c98db1902a07c4caa63648830a693217df128dacf","seq":223,"ts":"2026-10-21T21:28:57Z","type":"entry.created"}
{"payload":{"entry_id":33,"party_id":5},"prev":"0c980e96236d23fa01a2a67fab09b71cca3068ccafef1c19c6e6287bd2dc65a1","seq":224,"ts":"2026-10-21T21:28:57Z","type":"entry.party_set"}
{"payload":{"account":1,"amount":"757.42","currency":"USD","id":33,"side":"debit"},"prev":"0d721835811bdfa94d879f95cbd8ff64ad5c6b8c3c315a26918605d1a2833b30","seq":225,"ts":"2026-10-21T21:28:57Z","type":"entry.line_added"}
{"payload":{"account":12,"amount":"757.42","currency":"USD","id":33,"side":"credit"},"prev":"68c5f21aef69766ec2494c0a6f5bfa670541ca15160d63613b3b37d93de96111","seq":226,"ts":"2026-10-21T21:28:57Z","type":"entry.line_added"}
{"payload":{"attachment_id":33,"blob_sha":"b05c9555f4e49468970319698e33cab20430bd03a877183870c44f037aebd740","category":"receipts","description":null,"entry_id":33,"file_name":"r33.pdf","mime_type":"application/pdf","size_bytes":54},"prev":"173360449b8d5b426a30c584b46b4ec9c86a86caae4ec52bdef7569657698d41","seq":227,"ts":"2026-10-21T21:28:57Z","type":"receipt.attached"}
{"payload":{"date":"2025-07-03","description":"Consulting 7","id":33,"lines":[{"account":1,"amount":"757.42","currency":"USD","side":"debit"},{"account":12,"amount":"757.42","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"8b92ffbf33a62e49a33d458ed9c40a051fa1fb7deb6db790588f7f1fef5e4c04","seq":228,"ts":"2026-10-21T21:28:57Z","type":"entry.posted"}
{"payload":{"date":"2025-07-08","description":"Design work 7","id":34},"prev":"9591c74c137256cf21719fa68c52044f8af84880de48c0630770cb410cb7cab0","seq":229,"ts":"2026-10-21T21:28:57Z","type":"entry.created"}
{"payload":{"entry_id":34,"party_id":3},"prev":"248bbe7571c71f650ccf9e71419e0375cf1aab0b9a526f7726e247109b7ebe8c","seq":230,"ts":"2026-10-21T21:28:57Z","type":"entry.party_set"}
{"payload":{"account":17,"amount":"768.48","currency":"USD","id":34,"side":"debit"},"prev":"713137aed828f3a99c9a4e47084102d60ec3cce05a5b7583bfbe2d0cc3c45861","seq":231,"ts":"2026-10-21T21:28:57Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"768.48","currency":"USD","id":34,"side":"credit"},"prev":"dce879af18b9a6eaed664dc7fdbb4e72aced9e82993d8505b3ed0c31a14fe40a","seq":232,"ts":"2026-10-21T21:28:57Z","type":"entry.line_added"}
{"payload":{"attachment_id":34,"blob_sha":"3acdde86472882410cc59ff61474bff5e8272eb4411654d5d57965b34d65673d","category":"receipts","description":null,"entry_id":34,"file_name":"r34.pdf","mime_type":"application/pdf","size_bytes":55},"prev":"11c5349b0772892ed01eb3952297621029f63bc5766a5b5aaa56910cff860818","seq":233,"ts":"2026-10-21T21:28:57Z","type":"receipt.attached"}
{"payload":{"date":"2025-07-08","description":"Design work 7","id":34,"lines":[{"account":17,"amount":"768.48","currency":"USD","side":"debit"},{"account":1,"amount":"768.48","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"e3aba3df02941358e8c2b4d3f1175d60d930b35d2538f164e95fe182abff6a0d","seq":234,"ts":"2026-10-21T21:28:57Z","type":"entry.posted"}
{"payload":{"date":"2025-07-13","description":"Supplies 7","id":35},"prev":"eabdfe6299076017215304052f4942730cd9dce056bd341c1901798d8c062107","seq":235,"ts":"2026-10-21T21:28:57Z","type":"entry.created"}
{"payload":{"entry_id":35,"party_id":4},"prev":"1293dd57ef8c2dec6095abed25e7477a9d8ab03ffc0e784559cb4ac82c765cad","seq":236,"ts":"2026-10-21T21:28:57Z","type":"entry.party_set"}
{"payload":{"account":13,"amount":"520.93","currency":"USD","id":35,"side":"debit"},"prev":"6c85e1110a15121c335512824b0c90d3323aa81031d0ad067d981c5233b2e6be","seq":237,"ts":"2026-10-21T21:28:57Z","type":"entry.line_added"}
{"payload":{"account":7,"amount":"520.93","currency":"USD","id":35,"side":"credit"},"prev":"ee3b23302c580618337aed8a4298786f6fc1b81b53f0b2b4000fa921fea7fb8b","seq":238,"ts":"2026-10-21T21:28:57Z","type":"entry.line_added"}
{"payload":{"attachment_id":35,"blob_sha":"c090474697214b4507cdbb20ee2ba05be74d257a9d0f7ce06e0c16cf6d071dc3","category":"receipts","description":null,"entry_id":35,"file_name":"r35.pdf","mime_type":"application/pdf","size_bytes":52},"prev":"9b10307ae7dd15778ad0d930f1e238728d3b9596fafa2131d0ef11b4a958c6d0","seq":239,"ts":"2026-10-21T21:28:57Z","type":"receipt.attached"}
{"payload":{"date":"2025-07-13","description":"Supplies 7","id":35,"lines":[{"account":13,"amount":"520.93","currency":"USD","side":"debit"},{"account":7,"amount":"520.93","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"c965d22e48395c8a99933fe3e80292fe503d41f37b708340400425eb6f6d2f42","seq":240,"ts":"2026-10-21T21:28:57Z","type":"entry.posted"}
{"payload":{"date":"2025-07-18","description":"Software paid by member 7","id":36},"prev":"cf4466aae68140545f41cc3baf77c3163ee7640b620d2624e77f7f76b73c7610","seq":241,"ts":"2026-10-21T21:28:57Z","type":"entry.created"}
{"payload":{"entry_id":36,"party_id":1},"prev":"945b101940418f2f4eb207ca138ea3f83d2aa9c01cd569037058948e7b697153","seq":242,"ts":"2026-10-21T21:28:57Z","type":"entry.party_set"}
{"payload":{"account":14,"amount":"65.99","currency":"USD","id":36,"side":"debit"},"prev":"95c9efa06f94ed2770c960850c4dfabb63a7de6244346cf883c08bc2bdf36132","seq":243,"ts":"2026-10-21T21:28:57Z","type":"entry.line_added"}
{"payload":{"account":8,"amount":"65.99","currency":"USD","id":36,"side":"credit"},"prev":"01426ffdbc7e438ce7a8952fff163c5551f40b070b200140ab9c0f78f78fbdfd","seq":244,"ts":"2026-10-21T21:28:57Z","type":"entry.line_added"}
{"payload":{"attachment_id":36,"blob_sha":"210959e4644efeb3e0dec3b5da30d3882c2f6bf1dd05d36b81b3f9ab1d7cf8d5","category":"receipts","description":null,"entry_id":36,"file_name":"r36.pdf","mime_type":"application/pdf","size_bytes":67},"prev":"1ba7cb63e6d6f1f1b6b31e48ae575fc85b58a3a4f70f8f92c054ad40a8e34f48","seq":245,"ts":"2026-10-21T21:28:57Z","type":"receipt.attached"}
{"payload":{"date":"2025-07-18","description":"Software paid by member 7","id":36,"lines":[{"account":14,"amount":"65.99","currency":"USD","side":"debit"},{"account":8,"amount":"65.99","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"1183a1094dac472704a51c0252b676b6763ee599348228a61766f4d062e625e5","seq":246,"ts":"2026-10-21T21:28:57Z","type":"entry.posted"}
{"payload":{"date":"2025-07-23","description":"Bank fee 7","id":37},"prev":"cd136c62a69f1b7cf15e5bb6038726abf06b9562118a38a0a34a8a0511ea6c9f","seq":247,"ts":"2026-10-21T21:28:57Z","type":"entry.created"}
{"payload":{"entry_id":37,"reason":"bank fee"},"prev":"f592fc409d09e4d968191cbdfff9385d0e6290f0a27b3cc058b30f8e71d1e793","seq":248,"ts":"2026-10-21T21:28:57Z","type":"entry.no_party"}
{"payload":{"account":13,"amount":"12.00","currency":"USD","id":37,"side":"debit"},"prev":"d0ddd4d22bd0478793c92fc15c9b4dff85a4bf937099c3198919d045617ba8db","seq":249,"ts":"2026-10-21T21:28:57Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"12.00","currency":"USD","id":37,"side":"credit"},"prev":"b8526b55e451046fc8121ae3b975406e84268dd9fec5ebf2d77fec8977463a15","seq":250,"ts":"2026-10-21T21:28:58Z","type":"entry.line_added"}
{"payload":{"attachment_id":37,"blob_sha":"af8c085ab8779b9ae9fcb3989f60a55719db4d91dbdf5c5e60f3ac39c6efd05b","category":"receipts","description":null,"entry_id":37,"file_name":"r37.pdf","mime_type":"application/pdf","size_bytes":52},"prev":"3978dac72a1bf49c6c488d99ae9e2480ca1ff0dd5d0493b657c936ba2f467e5f","seq":251,"ts":"2026-10-21T21:28:58Z","type":"receipt.attached"}
{"payload":{"date":"2025-07-23","description":"Bank fee 7","id":37,"lines":[{"account":13,"amount":"12.00","currency":"USD","side":"debit"},{"account":1,"amount":"12.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"4edb283721c38215f970a20de821fc6afd5e503c5fc5687c521f7d24f66dc016","seq":252,"ts":"2026-10-21T21:28:58Z","type":"entry.posted"}
{"payload":{"date":"2025-08-03","description":"Consulting 8","id":38},"prev":"b0a4871b1c83e596c62bdb9495bb00f48f2b4c7afcf55b324d0629aed0c7f81d","seq":253,"ts":"2026-10-21T21:28:58Z","type":"entry.created"}
{"payload":{"entry_id":38,"party_id":5},"prev":"fdd9ea92046c54ed28685e1ca916ab5059298e9c0c68e7c0ee2d7e9491ab2c67","seq":254,"ts":"2026-10-21T21:28:58Z","type":"entry.party_set"}
{"payload":{"account":1,"amount":"62.05","currency":"USD","id":38,"side":"debit"},"prev":"5a21e496268f99275bbd57a3b1c031768d4734b28b3688485cb62fe4bb452f1c","seq":255,"ts":"2026-10-21T21:28:58Z","type":"entry.line_added"}
{"payload":{"account":12,"amount":"62.05","currency":"USD","id":38,"side":"credit"},"prev":"6ac8d4eb51cb07c0ef399001f7075a3833f8efc20c8287c48ddda31e1be49b5d","seq":256,"ts":"2026-10-21T21:28:58Z","type":"entry.line_added"}
{"payload":{"attachment_id":38,"blob_sha":"350c8d74e3937e0d444fae5eef32a5c1e10fb32fa5aced6d07405ab36aeae8a7","category":"receipts","description":null,"entry_id":38,"file_name":"r38.pdf","mime_type":"application/pdf","size_bytes":54},"prev":"e1b62c3f89d8fced181f6f6ff01135f2aace75318a2e9f95bb91609cd9c516e2","seq":257,"ts":"2026-10-21T21:28:58Z","type":"receipt.attached"}
{"payload":{"date":"2025-08-03","description":"Consulting 8","id":38,"lines":[{"account":1,"amount":"62.05","currency":"USD","side":"debit"},{"account":12,"amount":"62.05","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"2a17b77d74e6376166edf3dbdfc145da3fa4b25cbb202bf3f3244ca8bc1b1836","seq":258,"ts":"2026-10-21T21:28:58Z","type":"entry.posted"}
{"payload":{"date":"2025-08-08","description":"Design work 8","id":39},"prev":"3fa32a40fc5211fa6c9e2fff209b74841b8b85f75901e4e23be7902777022f21","seq":259,"ts":"2026-10-21T21:28:58Z","type":"entry.created"}
{"payload":{"entry_id":39,"party_id":3},"prev":"ce87e0b80d60bd26ba2c22f542bd6e1ff212ab868e445b12aa158b0213d54d17","seq":260,"ts":"2026-10-21T21:28:58Z","type":"entry.party_set"}
{"payload":{"account":17,"amount":"730.63","currency":"USD","id":39,"side":"debit"},"prev":"87b250f95f256c3ada0bcdffc0a18e8d0334839735b198031591c9f0b2140112","seq":261,"ts":"2026-10-21T21:28:58Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"730.63","currency":"USD","id":39,"side":"credit"},"prev":"f63dd64c97b5195f8b14ea8ac55418e66f3609ed931cd64f318012431a42c716","seq":262,"ts":"2026-10-21T21:28:58Z","type":"entry.line_added"}
{"payload":{"attachment_id":39,"blob_sha":"090a938b8299df0c5b93304381d24a65b7feb45a6ed923c5fe10c10d7118e336","category":"receipts","description":null,"entry_id":39,"file_name":"r39.pdf","mime_type":"application/pdf","size_bytes":55},"prev":"86f9aaa2f67bfd2dde1f76b83d5c618aadaca66e781e0a6ccceb08b557e94821","seq":263,"ts":"2026-10-21T21:28:58Z","type":"receipt.attached"}
{"payload":{"date":"2025-08-08","description":"Design work 8","id":39,"lines":[{"account":17,"amount":"730.63","currency":"USD","side":"debit"},{"account":1,"amount":"730.63","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"7dbc46d17cedd252e198846fb543182ef4d8a8195c96239e396afb4e669043ca","seq":264,"ts":"2026-10-21T21:28:58Z","type":"entry.posted"}
{"payload":{"date":"2025-08-13","description":"Supplies 8","id":40},"prev":"63f3ad6ce777ce928bbd9eae7689c5e677bb2d37b0fb75dd2d0b382c7212a220","seq":265,"ts":"2026-10-21T21:28:58Z","type":"entry.created"}
{"payload":{"entry_id":40,"party_id":4},"prev":"10425e29f230394a89838ce3c41229d8f3e567766c13276740ea9e0855ae11a4","seq":266,"ts":"2026-10-21T21:28:58Z","type":"entry.party_set"}
{"payload":{"account":13,"amount":"175.55","currency":"USD","id":40,"side":"debit"},"prev":"988a8fbd3403db7add3364ded562d4864774c19b7ded2cc8eb1ed429478e33d6","seq":267,"ts":"2026-10-21T21:28:58Z","type":"entry.line_added"}
{"payload":{"account":7,"amount":"175.55","currency":"USD","id":40,"side":"credit"},"prev":"c8d9dbf34eb4052c69c4f2bfb5310e9d3517d14a4b39144d094ecf9c361cc22d","seq":268,"ts":"2026-10-21T21:28:58Z","type":"entry.line_added"}
{"payload":{"attachment_id":40,"blob_sha":"f52f29e9a5eb89e4f0851f39f7ee9c25ffb63e7973bc4dfaf45c3359fe923f1e","category":"receipts","description":null,"entry_id":40,"file_name":"r40.pdf","mime_type":"application/pdf","size_bytes":52},"prev":"cf459ec8aadf61e86f470d4c975401aedd639ecf7bfc4ab72f982c2905d1d37a","seq":269,"ts":"2026-10-21T21:28:58Z","type":"receipt.attached"}
{"payload":{"date":"2025-08-13","description":"Supplies 8","id":40,"lines":[{"account":13,"amount":"175.55","currency":"USD","side":"debit"},{"account":7,"amount":"175.55","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"df91e170c107eb3ac37ceb43079954efb97d2b36b8fda8d9953e0592b9e6fc7e","seq":270,"ts":"2026-10-21T21:28:58Z","type":"entry.posted"}
{"payload":{"date":"2025-08-18","description":"Software paid by member 8","id":41},"prev":"28d8955805ea47de1856adcab5ea51f354e06a781e92407f4e7fd2dadc012655","seq":271,"ts":"2026-10-21T21:28:58Z","type":"entry.created"}
{"payload":{"entry_id":41,"party_id":1},"prev":"2ec6d9a3319a17a11e52838665efcfdde6050dcc73ef822718a70c41834c75ee","seq":272,"ts":"2026-10-21T21:28:58Z","type":"entry.party_set"}
{"payload":{"account":14,"amount":"380.59","currency":"USD","id":41,"side":"debit"},"prev":"86cf71ad7ca19f8873609ee21fc01d7189cb41c62b773d612d22c7261cc3f483","seq":273,"ts":"2026-10-21T21:28:58Z","type":"entry.line_added"}
{"payload":{"account":8,"amount":"380.59","currency":"USD","id":41,"side":"credit"},"prev":"01a14c8f086622be0bff9f8af0d50a540acf7b916b5d2428c20ec5f400e9b5c6","seq":274,"ts":"2026-10-21T21:28:58Z","type":"entry.line_added"}
{"payload":{"attachment_id":41,"blob_sha":"266487055317ad4a9ec8504c20da90ea14fd7b101894b0c001c7106ead417c20","category":"receipts","description":null,"entry_id":41,"file_name":"r41.pdf","mime_type":"application/pdf","size_bytes":67},"prev":"290b112f536d6d414aafd2ba484f337f3112e29f394696bbcf15b34b75ae0c9c","seq":275,"ts":"2026-10-21T21:28:58Z","type":"receipt.attached"}
{"payload":{"date":"2025-08-18","description":"Software paid by member 8","id":41,"lines":[{"account":14,"amount":"380.59","currency":"USD","side":"debit"},{"account":8,"amount":"380.59","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"5724ec3db36f52fd2076c0c15cf6207753056f9896cb822c410cbf1cff967731","seq":276,"ts":"2026-10-21T21:28:58Z","type":"entry.posted"}
{"payload":{"date":"2025-08-23","description":"Bank fee 8","id":42},"prev":"1f4cde58fd842e88ce9747b62094cd58db56f4e7106fc18b1d8696026367f3e6","seq":277,"ts":"2026-10-21T21:28:58Z","type":"entry.created"}
{"payload":{"entry_id":42,"reason":"bank fee"},"prev":"13d7dfd5202e9f27bd46870c7f342690e09a322d3669fa14b82c5ff65ac9787d","seq":278,"ts":"2026-10-21T21:28:58Z","type":"entry.no_party"}
{"payload":{"account":13,"amount":"12.00","currency":"USD","id":42,"side":"debit"},"prev":"1fddaed992fda0912cf718f8a9dcae48100a5daf9ed1cac2bbdac95ff75ee5db","seq":279,"ts":"2026-10-21T21:28:59Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"12.00","currency":"USD","id":42,"side":"credit"},"prev":"2a815bee24fa4ad299249b6d18bfa4263a46236e4cf95c62e4b0013cb0bed4e0","seq":280,"ts":"2026-10-21T21:28:59Z","type":"entry.line_added"}
{"payload":{"attachment_id":42,"blob_sha":"09d567fff5b3478f1c5e388f0b173dfb2f45eb553528c95b6723d5f13a6b0dba","category":"receipts","description":null,"entry_id":42,"file_name":"r42.pdf","mime_type":"application/pdf","size_bytes":52},"prev":"40ad0df5d16cc355e28c6dba42ae850632367993a0bd6a356a01b1db4cab2d01","seq":281,"ts":"2026-10-21T21:28:59Z","type":"receipt.attached"}
{"payload":{"date":"2025-08-23","description":"Bank fee 8","id":42,"lines":[{"account":13,"amount":"12.00","currency":"USD","side":"debit"},{"account":1,"amount":"12.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"1c0a55791cafa9e099b572f2b7e6abfc455dd77f89df4bdda91b62a97a682453","seq":282,"ts":"2026-10-21T21:28:59Z","type":"entry.posted"}
{"payload":{"date":"2025-09-03","description":"Consulting 9","id":43},"prev":"b5acb73ab3af2bf2efa177dcda6010d7fbe66626a86dc3589e601b4c5a82a91d","seq":283,"ts":"2026-10-21T21:28:59Z","type":"entry.created"}
{"payload":{"entry_id":43,"party_id":5},"prev":"d0a8d7e0443823abbc31649fa177d7f284b25884dcb11bad39a7a505b5405130","seq":284,"ts":"2026-10-21T21:28:59Z","type":"entry.party_set"}
{"payload":{"account":1,"amount":"190.07","currency":"USD","id":43,"side":"debit"},"prev":"a896cb4223177131ab30faf764086948804bc85af2b0ca7ae052f77d5b28d138","seq":285,"ts":"2026-10-21T21:28:59Z","type":"entry.line_added"}
{"payload":{"account":12,"amount":"190.07","currency":"USD","id":43,"side":"credit"},"prev":"36db1572ca560eee9633eff1d343258e96247f9fb05589c031aead4c0008c0de","seq":286,"ts":"2026-10-21T21:28:59Z","type":"entry.line_added"}
{"payload":{"attachment_id":43,"blob_sha":"549225ddc356bd1a217220cc6681b76dba4ea9eff24a5f34ca6e832a1e7dfc97","category":"receipts","description":null,"entry_id":43,"file_name":"r43.pdf","mime_type":"application/pdf","size_bytes":54},"prev":"0fb8e62c8d504fb5e6f4f849f8d6816c49be78cf68e77d8c3a9d921425fa7d59","seq":287,"ts":"2026-10-21T21:28:59Z","type":"receipt.attached"}
{"payload":{"date":"2025-09-03","description":"Consulting 9","id":43,"lines":[{"account":1,"amount":"190.07","currency":"USD","side":"debit"},{"account":12,"amount":"190.07","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"33eba839f1657919df5693dee0d20385a562e96f25f66046ab591910f71e5802","seq":288,"ts":"2026-10-21T21:28:59Z","type":"entry.posted"}
{"payload":{"date":"2025-09-08","description":"Design work 9","id":44},"prev":"0185772912d7905d64b60989068f41fd2b61a2f19542d6d8663704e61e0b314c","seq":289,"ts":"2026-10-21T21:28:59Z","type":"entry.created"}
{"payload":{"entry_id":44,"party_id":3},"prev":"c81d8a7e54878101c1d36e4a120485b555f0b7d9476145dea2cef53b52831fc6","seq":290,"ts":"2026-10-21T21:28:59Z","type":"entry.party_set"}
{"payload":{"account":17,"amount":"709.68","currency":"USD","id":44,"side":"debit"},"prev":"30b4d9e23c114c65583c94594fd0f59763f95cf13c7c6baffbe4eb955646ef57","seq":291,"ts":"2026-10-21T21:28:59Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"709.68","currency":"USD","id":44,"side":"credit"},"prev":"d56cf6fde598e32ffeddc8a792895884703a69e3e27a7bd2dac5c8624bccdd76","seq":292,"ts":"2026-10-21T21:28:59Z","type":"entry.line_added"}
{"payload":{"attachment_id":44,"blob_sha":"160144f9771ebf02e5e5c914137f3ae642dcbf75b8f44443296f32fab855b62a","category":"receipts","description":null,"entry_id":44,"file_name":"r44.pdf","mime_type":"application/pdf","size_bytes":55},"prev":"84bb3fcb531b4d64803c05e7e16043e0f7ac5a4b95a3b90a8909edc3b2e004f4","seq":293,"ts":"2026-10-21T21:28:59Z","type":"receipt.attached"}
{"payload":{"date":"2025-09-08","description":"Design work 9","id":44,"lines":[{"account":17,"amount":"709.68","currency":"USD","side":"debit"},{"account":1,"amount":"709.68","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"c9142d9f7a6dabb1e117100951ea1186914766cd2f21cb59c06285fd7f2b9462","seq":294,"ts":"2026-10-21T21:28:59Z","type":"entry.posted"}
{"payload":{"date":"2025-09-13","description":"Supplies 9","id":45},"prev":"8e62d11efb2f897c1886a72cea2a10024d19bcefa948f39d801ed746c554c45b","seq":295,"ts":"2026-10-21T21:28:59Z","type":"entry.created"}
{"payload":{"entry_id":45,"party_id":4},"prev":"9fe86a91236d52e50d70e5dffc7ccc10989700ee85999f8181a1ed45fb598da9","seq":296,"ts":"2026-10-21T21:28:59Z","type":"entry.party_set"}
{"payload":{"account":13,"amount":"155.39","currency":"USD","id":45,"side":"debit"},"prev":"534bbb9f5c0790c0e4f332b9857aa54563b5fc8dbba582763997cb2e373999f3","seq":297,"ts":"2026-10-21T21:28:59Z","type":"entry.line_added"}
{"payload":{"account":7,"amount":"155.39","currency":"USD","id":45,"side":"credit"},"prev":"7116dfa0caa53e9eb5e5199afb7d693f1cfb9ae715d0b694d08118ab7f22ed14","seq":298,"ts":"2026-10-21T21:28:59Z","type":"entry.line_added"}
{"payload":{"attachment_id":45,"blob_sha":"7fcbd4774d24625ed2dd3195dc8b298a41e19dc56c6e5a3fdafb3862a1198eeb","category":"receipts","description":null,"entry_id":45,"file_name":"r45.pdf","mime_type":"application/pdf","size_bytes":52},"prev":"c39dcd970e9da7d21aa1924cbd662d973c60cf9497af73ecb5379d6b1d17ae05","seq":299,"ts":"2026-10-21T21:28:59Z","type":"receipt.attached"}
{"payload":{"date":"2025-09-13","description":"Supplies 9","id":45,"lines":[{"account":13,"amount":"155.39","currency":"USD","side":"debit"},{"account":7,"amount":"155.39","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"07dad89fbefd0e89de4920a26cb2da5d61f88365d09ed327f6a753b12c7fd952","seq":300,"ts":"2026-10-21T21:28:59Z","type":"entry.posted"}
{"payload":{"date":"2025-09-18","description":"Software paid by member 9","id":46},"prev":"002822633eb6996ee1ed69849ac2e2027b238e39c8c38fbfe2e1b54c8ca97a3f","seq":301,"ts":"2026-10-21T21:28:59Z","type":"entry.created"}
{"payload":{"entry_id":46,"party_id":1},"prev":"a954c30417563df040b718dab32da9f78978a8154fafdbade90fa33e51fb08b9","seq":302,"ts":"2026-10-21T21:28:59Z","type":"entry.party_set"}
{"payload":{"account":14,"amount":"749.30","currency":"USD","id":46,"side":"debit"},"prev":"40bc1d6f6745146f9adfa43ba29d203f70c6fe065809922f462288544a62c7d1","seq":303,"ts":"2026-10-21T21:28:59Z","type":"entry.line_added"}
{"payload":{"account":8,"amount":"749.30","currency":"USD","id":46,"side":"credit"},"prev":"a43d18d01a5373d97c8b8d39dd7d8e7cccaa27d21bab78d04ff360da6278ba86","seq":304,"ts":"2026-10-21T21:28:59Z","type":"entry.line_added"}
{"payload":{"attachment_id":46,"blob_sha":"0126c8f9f243e03c0cb71f8d47ba27730f9a0503e25abdbd43f94e3a44479e6b","category":"receipts","description":null,"entry_id":46,"file_name":"r46.pdf","mime_type":"application/pdf","size_bytes":67},"prev":"2eefc744c374f495f10c6b59738903fcdd77c96f0bc2440fa71c43e7072047b6","seq":305,"ts":"2026-10-21T21:28:59Z","type":"receipt.attached"}
{"payload":{"date":"2025-09-18","description":"Software paid by member 9","id":46,"lines":[{"account":14,"amount":"749.30","currency":"USD","side":"debit"},{"account":8,"amount":"749.30","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"1030dd48e2298995eafd77d4864b87e492ea842cfe6294018e6bb7a2889f6ab5","seq":306,"ts":"2026-10-21T21:28:59Z","type":"entry.posted"}
{"payload":{"date":"2025-09-23","description":"Bank fee 9","id":47},"prev":"0cc00d0afccbc3cbc47fa69f46c35f70b495d88fe093da4ecc02f9146f585c99","seq":307,"ts":"2026-10-21T21:28:59Z","type":"entry.created"}
{"payload":{"entry_id":47,"reason":"bank fee"},"prev":"e38279f0f1ac2d1ba9347ac3dadeed99198a1c8f53603875c140ac79155ebfaa","seq":308,"ts":"2026-10-21T21:28:59Z","type":"entry.no_party"}
{"payload":{"account":13,"amount":"12.00","currency":"USD","id":47,"side":"debit"},"prev":"e7f860c5ed73429179d942ed735f3620a5c67a251fd884dcdc81ad2268b98c71","seq":309,"ts":"2026-10-21T21:28:59Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"12.00","currency":"USD","id":47,"side":"credit"},"prev":"e02d2ff27e7ca3599be224bfefcf1e0df5b98c0308526953a73603f66f199eb7","seq":310,"ts":"2026-10-21T21:28:59Z","type":"entry.line_added"}
{"payload":{"attachment_id":47,"blob_sha":"288625ee69e0c00d34b1defbf7e02b95768dfdcaa3df3fefd684a56a3b2c3d4a","category":"receipts","description":null,"entry_id":47,"file_name":"r47.pdf","mime_type":"application/pdf","size_bytes":52},"prev":"e5f958df210efc615129fe33643298b61bc436a92660d61a707215175ab87169","seq":311,"ts":"2026-10-21T21:28:59Z","type":"receipt.attached"}
{"payload":{"date":"2025-09-23","description":"Bank fee 9","id":47,"lines":[{"account":13,"amount":"12.00","currency":"USD","side":"debit"},{"account":1,"amount":"12.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"7777a10915275157ba3f997c41a0e0015607caab9e153489f85f62c4a1af1add","seq":312,"ts":"2026-10-21T21:29:00Z","type":"entry.posted"}
{"payload":{"date":"2025-10-03","description":"Consulting 10","id":48},"prev":"ce7052dbc841adff64afdb9078936d72dd1dd90db17519c517498630f8399f4c","seq":313,"ts":"2026-10-21T21:29:00Z","type":"entry.created"}
{"payload":{"entry_id":48,"party_id":5},"prev":"3af46e02bc03d2bc4b0eb68c4beeeef2dbf155b2ecc9740123ccd44c3466ae19","seq":314,"ts":"2026-10-21T21:29:00Z","type":"entry.party_set"}
{"payload":{"account":1,"amount":"735.34","currency":"USD","id":48,"side":"debit"},"prev":"2224b0c15c2fa780a17b51d03afae3f7b71bb26f82744d0ffeb90313781b8d34","seq":315,"ts":"2026-10-21T21:29:00Z","type":"entry.line_added"}
{"payload":{"account":12,"amount":"735.34","currency":"USD","id":48,"side":"credit"},"prev":"71bc9269902f739f8d1e6b82c12d319831d0d79a7da56242c3847856b3911e28","seq":316,"ts":"2026-10-21T21:29:00Z","type":"entry.line_added"}
{"payload":{"attachment_id":48,"blob_sha":"3ff73e7967f4f72c974941bce53012f75a995b229982457def7312c6547df6f9","category":"receipts","description":null,"entry_id":48,"file_name":"r48.pdf","mime_type":"application/pdf","size_bytes":55},"prev":"6c290b24a3ec2d89183d05ee66db17cd4c9f7640671f7312300d6a7834f96269","seq":317,"ts":"2026-10-21T21:29:00Z","type":"receipt.attached"}
{"payload":{"date":"2025-10-03","description":"Consulting 10","id":48,"lines":[{"account":1,"amount":"735.34","currency":"USD","side":"debit"},{"account":12,"amount":"735.34","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"7d22a280a8ddaedaf9f32f223b27134c70797f259ba9844bdffdd99f10e25df1","seq":318,"ts":"2026-10-21T21:29:00Z","type":"entry.posted"}
{"payload":{"date":"2025-10-08","description":"Design work 10","id":49},"prev":"2957de627eb301a870f9c7247e2e4a76499615f25395b6a9f39912b66f3727c4","seq":319,"ts":"2026-10-21T21:29:00Z","type":"entry.created"}
{"payload":{"entry_id":49,"party_id":3},"prev":"1b7bbe892805e43738683ffbb05f37970dc4b0a9c7696b2fe0c12924b8ea8d4c","seq":320,"ts":"2026-10-21T21:29:00Z","type":"entry.party_set"}
{"payload":{"account":17,"amount":"894.91","currency":"USD","id":49,"side":"debit"},"prev":"7385e04ea8e892fb45c846291e5b095766f7545e6ee17de349d565ff0fec9178","seq":321,"ts":"2026-10-21T21:29:00Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"894.91","currency":"USD","id":49,"side":"credit"},"prev":"7936ae3480dee70dea7c6f4266074a57cd05f0ff174ab39619a50db805762b1b","seq":322,"ts":"2026-10-21T21:29:00Z","type":"entry.line_added"}
{"payload":{"attachment_id":49,"blob_sha":"98ad30c16a2f0b8f462702570194a2ca2ccd95703535299753e773d8ba737d09","category":"receipts","description":null,"entry_id":49,"file_name":"r49.pdf","mime_type":"application/pdf","size_bytes":56},"prev":"2efea58df2f347f7f38c6a4beb5177aa72c5d41d6f32183ca35b8af99736fb1f","seq":323,"ts":"2026-10-21T21:29:00Z","type":"receipt.attached"}
{"payload":{"date":"2025-10-08","description":"Design work 10","id":49,"lines":[{"account":17,"amount":"894.91","currency":"USD","side":"debit"},{"account":1,"amount":"894.91","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"8706e7f9660800873bd26f0ffd4ff13d49e891fadf8da3961a4ca6f1a1e1de88","seq":324,"ts":"2026-10-21T21:29:00Z","type":"entry.posted"}
{"payload":{"date":"2025-10-13","description":"Supplies 10","id":50},"prev":"a83eb84e3fa5a51a8238dc9ad9e60c35dae9b74d8a291f997a3a18f4ae1a1cce","seq":325,"ts":"2026-10-21T21:29:00Z","type":"entry.created"}
{"payload":{"entry_id":50,"party_id":4},"prev":"1a26a3c924bcd3620a6df36922496ef395ec9cb245ac79fc396c9884a7740e86","seq":326,"ts":"2026-10-21T21:29:00Z","type":"entry.party_set"}
{"payload":{"account":13,"amount":"237.88","currency":"USD","id":50,"side":"debit"},"prev":"80e6be396f1610f675b29c2f92363a0e7d70807cabd4206f88571e3965fb82bf","seq":327,"ts":"2026-10-21T21:29:00Z","type":"entry.line_added"}
{"payload":{"account":7,"amount":"237.88","currency":"USD","id":50,"side":"credit"},"prev":"fd43c46e495a652eb8a6a26f02343b14856d784b40ac52a673efd9fc99085c1a","seq":328,"ts":"2026-10-21T21:29:00Z","type":"entry.line_added"}
{"payload":{"attachment_id":50,"blob_sha":"218116eca79b67dbe85d3d813d3b0b8373a2eef6dde93ccb201458a74bfdafb1","category":"receipts","description":null,"entry_id":50,"file_name":"r50.pdf","mime_type":"application/pdf","size_bytes":53},"prev":"a72dec373ee67c4db566214f887c4c973ef4e03b217e96c5dd1b958f6d3519bf","seq":329,"ts":"2026-10-21T21:29:00Z","type":"receipt.attached"}
{"payload":{"date":"2025-10-13","description":"Supplies 10","id":50,"lines":[{"account":13,"amount":"237.88","currency":"USD","side":"debit"},{"account":7,"amount":"237.88","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"75aca5b510317e463c70c556001f7a89f9ced1a3887a70f544014057115bf939","seq":330,"ts":"2026-10-21T21:29:00Z","type":"entry.posted"}
{"payload":{"date":"2025-10-18","description":"Software paid by member 10","id":51},"prev":"eed78f69673c4d35ff80e0c782097c6865fc19823aef6898416d84405c050b16","seq":331,"ts":"2026-10-21T21:29:00Z","type":"entry.created"}
{"payload":{"entry_id":51,"party_id":1},"prev":"7e053ae25b809a194c8160018d66e31575d647adc60a1ed9b874bbcfd0ef3146","seq":332,"ts":"2026-10-21T21:29:00Z","type":"entry.party_set"}
{"payload":{"account":14,"amount":"136.07","currency":"USD","id":51,"side":"debit"},"prev":"b24fe59527a0287a9ed30fd6f8bfa239d847d7336838da4cc2accb34999d0650","seq":333,"ts":"2026-10-21T21:29:00Z","type":"entry.line_added"}
{"payload":{"account":8,"amount":"136.07","currency":"USD","id":51,"side":"credit"},"prev":"30b459d0cd9e01a392ed4b97bdec83eb59538928a4d757264e9423facefea8c3","seq":334,"ts":"2026-10-21T21:29:00Z","type":"entry.line_added"}
{"payload":{"attachment_id":51,"blob_sha":"53f7de0945923a52b4fbcd56659f6dd4d3a02830ba0adcfbe81df0f1b7bd8ff2","category":"receipts","description":null,"entry_id":51,"file_name":"r51.pdf","mime_type":"application/pdf","size_bytes":68},"prev":"f30844a58b3d32afe3c18c4ad003cff39cf607e715c0e285e89436a4b864cb15","seq":335,"ts":"2026-10-21T21:29:00Z","type":"receipt.attached"}
{"payload":{"date":"2025-10-18","description":"Software paid by member 10","id":51,"lines":[{"account":14,"amount":"136.07","currency":"USD","side":"debit"},{"account":8,"amount":"136.07","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"9a14e758062a1c678e3b0d56a3fd14338fe10e303665365d8c060ae5d6b4a97b","seq":336,"ts":"2026-10-21T21:29:00Z","type":"entry.posted"}
{"payload":{"date":"2025-10-23","description":"Bank fee 10","id":52},"prev":"189502c8c423a7264d9071c8b32ccfb1b1bda2c1176969d00a36dff1bb828dd0","seq":337,"ts":"2026-10-21T21:29:00Z","type":"entry.created"}
{"payload":{"entry_id":52,"reason":"bank fee"},"prev":"0e2d33f282a68cbbd94dcfbb854d82a66c99089618dcf6f6d0b3f34bb2ce1b9a","seq":338,"ts":"2026-10-21T21:29:00Z","type":"entry.no_party"}
{"payload":{"account":13,"amount":"12.00","currency":"USD","id":52,"side":"debit"},"prev":"507b44679c2bf90e6d2a946a5ea757cbc259b37adae922324d79139126e0a5a9","seq":339,"ts":"2026-10-21T21:29:00Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"12.00","currency":"USD","id":52,"side":"credit"},"prev":"5a2c0b9f8e4eeb5dc3471c36b8eaca3dd64de3e495f01c03c99a4b9fcca99462","seq":340,"ts":"2026-10-21T21:29:00Z","type":"entry.line_added"}
{"payload":{"attachment_id":52,"blob_sha":"37fa15b60bca1e11c46715e3b270a60573ab89be57f4908623a73572ee0325cb","category":"receipts","description":null,"entry_id":52,"file_name":"r52.pdf","mime_type":"application/pdf","size_bytes":53},"prev":"1f672e76d179887d18ed8c28b1a78d57e1392528911d24615fbdda9f6b2c2cd5","seq":341,"ts":"2026-10-21T21:29:00Z","type":"receipt.attached"}
{"payload":{"date":"2025-10-23","description":"Bank fee 10","id":52,"lines":[{"account":13,"amount":"12.00","currency":"USD","side":"debit"},{"account":1,"amount":"12.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"545c33cbafa6fc1486f05c3aafec0948e5052be6037850cad5d5aa94323f635e","seq":342,"ts":"2026-10-21T21:29:00Z","type":"entry.posted"}
{"payload":{"date":"2025-11-03","description":"Consulting 11","id":53},"prev":"f3052b35a608797568c42f3ab2b8238f661718bd6e60275b15a0a99a7198828c","seq":343,"ts":"2026-10-21T21:29:00Z","type":"entry.created"}
{"payload":{"entry_id":53,"party_id":5},"prev":"2e7a295cc168ed4fae9d5b6e338403c574804b8298f2cb14d74638f216cef928","seq":344,"ts":"2026-10-21T21:29:00Z","type":"entry.party_set"}
{"payload":{"account":1,"amount":"749.68","currency":"USD","id":53,"side":"debit"},"prev":"ef7b3258ac6edc69a5bc0a6ac8885d475cfb9285d6bd774ba6e706ca3b804645","seq":345,"ts":"2026-10-21T21:29:00Z","type":"entry.line_added"}
{"payload":{"account":12,"amount":"749.68","currency":"USD","id":53,"side":"credit"},"prev":"5e38b5b521a8edb5200f0dcb42b7c6eb4d1446ce023b2d205f6dcacb65fe1e28","seq":346,"ts":"2026-10-21T21:29:00Z","type":"entry.line_added"}
{"payload":{"attachment_id":53,"blob_sha":"5ba9b95dcfaad7cd77f590a2dbc8d14ddeb8f9694ec5f393da4b4802fc7462dd","category":"receipts","description":null,"entry_id":53,"file_name":"r53.pdf","mime_type":"application/pdf","size_bytes":55},"prev":"8f9427d783c6683a07b7b29cb951475610ce8f829d63f89f4683b3424967c636","seq":347,"ts":"2026-10-21T21:29:00Z","type":"receipt.attached"}
{"payload":{"date":"2025-11-03","description":"Consulting 11","id":53,"lines":[{"account":1,"amount":"749.68","currency":"USD","side":"debit"},{"account":12,"amount":"749.68","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"4b1d07090bc763bae6c8d4778d86d1b636e3da123b1497f773786fef3bd0a039","seq":348,"ts":"2026-10-21T21:29:00Z","type":"entry.posted"}
{"payload":{"date":"2025-11-08","description":"Design work 11","id":54},"prev":"c7eccb78beb92636d00e47362c9760656ee5587ba8318fd5ab73027187aa43a2","seq":349,"ts":"2026-10-21T21:29:01Z","type":"entry.created"}
{"payload":{"entry_id":54,"party_id":3},"prev":"2a6c05d2e9362aeedadde55e878bf45407f1daf46cf6f786c8d0b6c293a54072","seq":350,"ts":"2026-10-21T21:29:01Z","type":"entry.party_set"}
{"payload":{"account":17,"amount":"838.43","currency":"USD","id":54,"side":"debit"},"prev":"f56fc8fc42dbd05b165a4588689ae1c2b85c3e344aa628a486b0a2f0ea236726","seq":351,"ts":"2026-10-21T21:29:01Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"838.43","currency":"USD","id":54,"side":"credit"},"prev":"7d5e712f56cc3c476fdc5c7f99b08ab4301254acd288ac85d8c58ab5213cfa3f","seq":352,"ts":"2026-10-21T21:29:01Z","type":"entry.line_added"}
{"payload":{"attachment_id":54,"blob_sha":"cf63d0935c457f42cf6610cdec14c1072de13b8e0dbd63e83518821e8fd4341b","category":"receipts","description":null,"entry_id":54,"file_name":"r54.pdf","mime_type":"application/pdf","size_bytes":56},"prev":"5cbc46b789656498728ade76bf7291fc277a22d07b7c1932bb4abea0b7608ff0","seq":353,"ts":"2026-10-21T21:29:01Z","type":"receipt.attached"}
{"payload":{"date":"2025-11-08","description":"Design work 11","id":54,"lines":[{"account":17,"amount":"838.43","currency":"USD","side":"debit"},{"account":1,"amount":"838.43","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"2041d4dfcad47aa29439edffeb9a1e72f15baef31ce8ca64289985fbbdbc7737","seq":354,"ts":"2026-10-21T21:29:01Z","type":"entry.posted"}
{"payload":{"date":"2025-11-13","description":"Supplies 11","id":55},"prev":"bf3119d06d07a984345850e0f7ab0b59737fac22b13573149fcf5dc05e1b644a","seq":355,"ts":"2026-10-21T21:29:01Z","type":"entry.created"}
{"payload":{"entry_id":55,"party_id":4},"prev":"bc57f2095a4cf1e6a9ac606552f42d065dd7b13e7d0798cacf6b228143ed08e5","seq":356,"ts":"2026-10-21T21:29:01Z","type":"entry.party_set"}
{"payload":{"account":13,"amount":"247.24","currency":"USD","id":55,"side":"debit"},"prev":"3a6e73647d7b10b82b2e6ef071eb9ff4e11b4b92ec8aaaca997840518655c43b","seq":357,"ts":"2026-10-21T21:29:01Z","type":"entry.line_added"}
{"payload":{"account":7,"amount":"247.24","currency":"USD","id":55,"side":"credit"},"prev":"753b14754cb738bc6a744497d02a57291fcaa9a4d6257ca387f7b79c5e080781","seq":358,"ts":"2026-10-21T21:29:01Z","type":"entry.line_added"}
{"payload":{"attachment_id":55,"blob_sha":"dfdd55e9d11c066afa47ae3e93b7ec3aa7b77a41bb21ffd7cfd17a260eb4f915","category":"receipts","description":null,"entry_id":55,"file_name":"r55.pdf","mime_type":"application/pdf","size_bytes":53},"prev":"b7a4ea5387942f62b0121f43ac2c6415b2a38020612d1642f0ae843b580604fd","seq":359,"ts":"2026-10-21T21:29:01Z","type":"receipt.attached"}
{"payload":{"date":"2025-11-13","description":"Supplies 11","id":55,"lines":[{"account":13,"amount":"247.24","currency":"USD","side":"debit"},{"account":7,"amount":"247.24","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"14a2c5a6a2b1834df2790e7a12b649f9b421e52084a4a500daea236f66bca068","seq":360,"ts":"2026-10-21T21:29:01Z","type":"entry.posted"}
{"payload":{"date":"2025-11-18","description":"Software paid by member 11","id":56},"prev":"2a619bea70d624bac05f2429919e6d5e20192f5615a49b634f3910f89dc41ce9","seq":361,"ts":"2026-10-21T21:29:01Z","type":"entry.created"}
{"payload":{"entry_id":56,"party_id":1},"prev":"3d05fd3fad0d19264976f97f1c59c6ef5dfb1545bfd54f110a229e649c74ae87","seq":362,"ts":"2026-10-21T21:29:01Z","type":"entry.party_set"}
{"payload":{"account":14,"amount":"489.10","currency":"USD","id":56,"side":"debit"},"prev":"2402181c094548ca28bbb23300cfa632ee0494cfae468ee1bceb4f2176c213a5","seq":363,"ts":"2026-10-21T21:29:01Z","type":"entry.line_added"}
{"payload":{"account":8,"amount":"489.10","currency":"USD","id":56,"side":"credit"},"prev":"51fb21d99531ac22e80984309cebd23e3a014d1966e2f3632e7706579489567f","seq":364,"ts":"2026-10-21T21:29:01Z","type":"entry.line_added"}
{"payload":{"attachment_id":56,"blob_sha":"82c5c2c9cb7af39e0859bcc399f0df627105673fac9a02f3172af63fae493cc1","category":"receipts","description":null,"entry_id":56,"file_name":"r56.pdf","mime_type":"application/pdf","size_bytes":68},"prev":"0a953433470f13fba6e489fa10abd60e7b6483b57b4461133ab4ad093a908843","seq":365,"ts":"2026-10-21T21:29:01Z","type":"receipt.attached"}
{"payload":{"date":"2025-11-18","description":"Software paid by member 11","id":56,"lines":[{"account":14,"amount":"489.10","currency":"USD","side":"debit"},{"account":8,"amount":"489.10","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"40f6bfba12a8454fc2f3b3d90c1d3c40c2be881baebe0366b592c397e4fb46d1","seq":366,"ts":"2026-10-21T21:29:01Z","type":"entry.posted"}
{"payload":{"date":"2025-11-23","description":"Bank fee 11","id":57},"prev":"68b996e35b3e97a95059bef317cb2d828353eef17c71c5fa7fa7b6816f4454e1","seq":367,"ts":"2026-10-21T21:29:01Z","type":"entry.created"}
{"payload":{"entry_id":57,"reason":"bank fee"},"prev":"8b6b7a8298429f51a2fb2a292243f31da54d5afa44a1a2106ef0e1824d20668c","seq":368,"ts":"2026-10-21T21:29:01Z","type":"entry.no_party"}
{"payload":{"account":13,"amount":"12.00","currency":"USD","id":57,"side":"debit"},"prev":"03cbc8ea6f20210066385046c3153caf4f339147b28075dd404769e23674e7a0","seq":369,"ts":"2026-10-21T21:29:01Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"12.00","currency":"USD","id":57,"side":"credit"},"prev":"093db4b5c2dda279d565e1a52e412ff58b87cce5901c5df39be7787fad8efec6","seq":370,"ts":"2026-10-21T21:29:01Z","type":"entry.line_added"}
{"payload":{"attachment_id":57,"blob_sha":"58132a2be4c24afc7e9fa6824021b731c1e7ab947950b330efaac5c23838a65f","category":"receipts","description":null,"entry_id":57,"file_name":"r57.pdf","mime_type":"application/pdf","size_bytes":53},"prev":"5e69ad8e782af70a8916c65cb7044e1cdc9ab8b38a7999dcefc8142bf8c7ad69","seq":371,"ts":"2026-10-21T21:29:01Z","type":"receipt.attached"}
{"payload":{"date":"2025-11-23","description":"Bank fee 11","id":57,"lines":[{"account":13,"amount":"12.00","currency":"USD","side":"debit"},{"account":1,"amount":"12.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"ccd47b8a432670e86f576d0894c0288e8abf773811901cdecefb590acd90086b","seq":372,"ts":"2026-10-21T21:29:01Z","type":"entry.posted"}
{"payload":{"date":"2025-12-03","description":"Consulting 12","id":58},"prev":"5a869fd53ac45a8a6db2b2fddefa199472ca491de5225cc9a9651840075c82e4","seq":373,"ts":"2026-10-21T21:29:01Z","type":"entry.created"}
{"payload":{"entry_id":58,"party_id":5},"prev":"d4f4e8c092e759349398e17701cc3a8f1c6df7a386970db45145c429b12a6e77","seq":374,"ts":"2026-10-21T21:29:01Z","type":"entry.party_set"}
{"payload":{"account":1,"amount":"718.93","currency":"USD","id":58,"side":"debit"},"prev":"7c4bb51ac57db7a1d5bb1c8bbc1c9849ba657b5d90208269e0c80f6224886e86","seq":375,"ts":"2026-10-21T21:29:01Z","type":"entry.line_added"}
{"payload":{"account":12,"amount":"718.93","currency":"USD","id":58,"side":"credit"},"prev":"3bd4f070afd2f5e608553123084c96a9927f78d30ed480c3c93c10a255c98fab","seq":376,"ts":"2026-10-21T21:29:01Z","type":"entry.line_added"}
{"payload":{"attachment_id":58,"blob_sha":"80107280d3e5d99e971770a018db9285c9e1e46498eab9dd77535cec612e7708","category":"receipts","description":null,"entry_id":58,"file_name":"r58.pdf","mime_type":"application/pdf","size_bytes":55},"prev":"4ce4bb82870b6e9a1679b10621d158cb35e69ad39b63032809fb1a0cbddce7fb","seq":377,"ts":"2026-10-21T21:29:01Z","type":"receipt.attached"}
{"payload":{"date":"2025-12-03","description":"Consulting 12","id":58,"lines":[{"account":1,"amount":"718.93","currency":"USD","side":"debit"},{"account":12,"amount":"718.93","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"9ec10febf57a82f705df03a25e7610527e6d4e84c1654a26cd984527ea969df0","seq":378,"ts":"2026-10-21T21:29:01Z","type":"entry.posted"}
{"payload":{"date":"2025-12-08","description":"Design work 12","id":59},"prev":"1322577f2db48ce75ae7a874ec48681ed33b709c7a68d8652294f22a82943f4a","seq":379,"ts":"2026-10-21T21:29:01Z","type":"entry.created"}
{"payload":{"entry_id":59,"party_id":3},"prev":"a7715accec23f324d8c6ea7fa92f9400d2d6792d5f319912da7b55916ad5d79c","seq":380,"ts":"2026-10-21T21:29:01Z","type":"entry.party_set"}
{"payload":{"account":17,"amount":"83.29","currency":"USD","id":59,"side":"debit"},"prev":"b92370edeb9fbbc30c3dac971d0514ffd6492c88d7105b22e530fa9bda25354c","seq":381,"ts":"2026-10-21T21:29:01Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"83.29","currency":"USD","id":59,"side":"credit"},"prev":"c815de80d2a481c8e5b38881fe8bb947b6c9f439c05e08eb6987653ef8da5933","seq":382,"ts":"2026-10-21T21:29:02Z","type":"entry.line_added"}
{"payload":{"attachment_id":59,"blob_sha":"e554361bcf2115a5a55b5ccb80f32fe860710dd679f917f99686d1171e49964d","category":"receipts","description":null,"entry_id":59,"file_name":"r59.pdf","mime_type":"application/pdf","size_bytes":56},"prev":"793ed5800359e366d9500a921eb1eb6de1b03fa1112cd8ce32bc2cb0af267e81","seq":383,"ts":"2026-10-21T21:29:02Z","type":"receipt.attached"}
{"payload":{"date":"2025-12-08","description":"Design work 12","id":59,"lines":[{"account":17,"amount":"83.29","currency":"USD","side":"debit"},{"account":1,"amount":"83.29","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"0d759e13534b33038fbd4592af647111c9dc82a81be2106c803e64213a871e92","seq":384,"ts":"2026-10-21T21:29:02Z","type":"entry.posted"}
{"payload":{"date":"2025-12-13","description":"Supplies 12","id":60},"prev":"73580cfcac242f8c5266594daf1263def1f1a5813885071dd26ea85ed474e1da","seq":385,"ts":"2026-10-21T21:29:02Z","type":"entry.created"}
{"payload":{"entry_id":60,"party_id":4},"prev":"818998c07fbfb3e620d6a8c007a2933dae383d4b242767542431dca70f2adb2b","seq":386,"ts":"2026-10-21T21:29:02Z","type":"entry.party_set"}
{"payload":{"account":13,"amount":"740.72","currency":"USD","id":60,"side":"debit"},"prev":"ca2b88a667f101307223e3d3a682e344ebbe0670c3e2e7bccba356b504e19724","seq":387,"ts":"2026-10-21T21:29:02Z","type":"entry.line_added"}
{"payload":{"account":7,"amount":"740.72","currency":"USD","id":60,"side":"credit"},"prev":"c9758d36322d2439ad0842dd94c7a69ba2f2cc7f4f2788f6ec13a1f352d02511","seq":388,"ts":"2026-10-21T21:29:02Z","type":"entry.line_added"}
{"payload":{"attachment_id":60,"blob_sha":"348514bbc9e4a658e2681f0981d359d8f8494de6b17459fc4d730503b5a19024","category":"receipts","description":null,"entry_id":60,"file_name":"r60.pdf","mime_type":"application/pdf","size_bytes":53},"prev":"d9a8c135c5412fa3a9a9e87ad3714497788153b8df424d82463f91f7a160c3c1","seq":389,"ts":"2026-10-21T21:29:02Z","type":"receipt.attached"}
{"payload":{"date":"2025-12-13","description":"Supplies 12","id":60,"lines":[{"account":13,"amount":"740.72","currency":"USD","side":"debit"},{"account":7,"amount":"740.72","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"975afeb93d9c8ae6b21c0b79082e9b3094b0df96ba4ab2965bb6fba929f6f099","seq":390,"ts":"2026-10-21T21:29:02Z","type":"entry.posted"}
{"payload":{"date":"2025-12-18","description":"Software paid by member 12","id":61},"prev":"e939e60e9d522abf781d651ff470cf4236af80d0d497177ac2907f2107a6f66f","seq":391,"ts":"2026-10-21T21:29:02Z","type":"entry.created"}
{"payload":{"entry_id":61,"party_id":1},"prev":"ca11d669a78b359190d893df4d57375c941549fd9ba3b0796deef9dfaa0f5b35","seq":392,"ts":"2026-10-21T21:29:02Z","type":"entry.party_set"}
{"payload":{"account":14,"amount":"79.12","currency":"USD","id":61,"side":"debit"},"prev":"e1ef9eb8784d6a8159a2b06eb1e3b19ac8d9c4e5f65741f1d05ebffa91edb9f5","seq":393,"ts":"2026-10-21T21:29:02Z","type":"entry.line_added"}
{"payload":{"account":8,"amount":"79.12","currency":"USD","id":61,"side":"credit"},"prev":"91d0bf2878f1b44bef06a569984a5a9e79445606da929eb6cf0ddc178562a107","seq":394,"ts":"2026-10-21T21:29:02Z","type":"entry.line_added"}
{"payload":{"attachment_id":61,"blob_sha":"3339b8401edf750b6b34d0d90ec5b7a35fe8b6e26dba68d15b9c65f60d11e251","category":"receipts","description":null,"entry_id":61,"file_name":"r61.pdf","mime_type":"application/pdf","size_bytes":68},"prev":"04399b473dc1425a688aaf3159cbd88cd78fca54a7f8db5b99c9e215da838aa7","seq":395,"ts":"2026-10-21T21:29:02Z","type":"receipt.attached"}
{"payload":{"date":"2025-12-18","description":"Software paid by member 12","id":61,"lines":[{"account":14,"amount":"79.12","currency":"USD","side":"debit"},{"account":8,"amount":"79.12","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"0b280a4a27211eacf8d5c7e25b795fdf959ec3b8b69fde307fad7fb0ccc925a9","seq":396,"ts":"2026-10-21T21:29:02Z","type":"entry.posted"}
{"payload":{"date":"2025-12-23","description":"Bank fee 12","id":62},"prev":"27f88d75fc1e88cd66d408e5fa89a9d5b818e4baaa71ff6ab154650f393c851b","seq":397,"ts":"2026-10-21T21:29:02Z","type":"entry.created"}
{"payload":{"entry_id":62,"reason":"bank fee"},"prev":"f76c2e6adeb67d7760aadedb534445d632030c9bd12292dac5de5b2b2828ae2a","seq":398,"ts":"2026-10-21T21:29:02Z","type":"entry.no_party"}
{"payload":{"account":13,"amount":"12.00","currency":"USD","id":62,"side":"debit"},"prev":"c2f347917a69a154836e7ddf4dec3fe2ab1e15cf2a8f8225c36ebc969ce65328","seq":399,"ts":"2026-10-21T21:29:02Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"12.00","currency":"USD","id":62,"side":"credit"},"prev":"2601edb1df9aaf7fcf821ffea0a5aeecb064cc66cb246817e32d9daf265775c9","seq":400,"ts":"2026-10-21T21:29:02Z","type":"entry.line_added"}
{"payload":{"attachment_id":62,"blob_sha":"8d95aa1b0d183a31141f7093bc4ef2dcf0b165d42625efe2c50fac9bba78bd7f","category":"receipts","description":null,"entry_id":62,"file_name":"r62.pdf","mime_type":"application/pdf","size_bytes":53},"prev":"c21f7b405b5f50ef6c104c456e7ab42439f4ab617bae4323cc5e7cfb733ad667","seq":401,"ts":"2026-10-21T21:29:02Z","type":"receipt.attached"}
{"payload":{"date":"2025-12-23","description":"Bank fee 12","id":62,"lines":[{"account":13,"amount":"12.00","currency":"USD","side":"debit"},{"account":1,"amount":"12.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"97b789402ca050d2816977e1866958a0206c2682e97cd84c4dab37cffa0fad02","seq":402,"ts":"2026-10-21T21:29:02Z","type":"entry.posted"}
{"payload":{"date":"2025-03-01","description":"Laptop","id":63},"prev":"dd3373bb32e5656fa6c7b14e46fbcd0af332c111c79fdae8c6a33585d2203f2f","seq":403,"ts":"2026-10-21T21:29:02Z","type":"entry.created"}
{"payload":{"entry_id":63,"party_id":4},"prev":"ed9d7324d7524ed6c45eb23fc8efbd43984855d7710d75a9aedb7cbf8b325fc4","seq":404,"ts":"2026-10-21T21:29:02Z","type":"entry.party_set"}
{"payload":{"account":3,"amount":"2400.00","currency":"USD","id":63,"side":"debit"},"prev":"65c34e5cbb2233e08a10fc5fa93334dcfd1cd2d1c6431f004d4b7a7b982a15c6","seq":405,"ts":"2026-10-21T21:29:02Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"2400.00","currency":"USD","id":63,"side":"credit"},"prev":"478214a7e48572598610da9801e4e4bd07d99b8fa0ac230474828c15b195ec47","seq":406,"ts":"2026-10-21T21:29:02Z","type":"entry.line_added"}
{"payload":{"attachment_id":63,"blob_sha":"8676902d877e2c45200f351eb1671f62e598df60d83bb2f3da74f60d85a76d5f","category":"receipts","description":null,"entry_id":63,"file_name":"r63.pdf","mime_type":"application/pdf","size_bytes":48},"prev":"77bda531c990996596b82a1155feefa838a7b39b9b218ec5fc289508c7cc0587","seq":407,"ts":"2026-10-21T21:29:02Z","type":"receipt.attached"}
{"payload":{"date":"2025-03-01","description":"Laptop","id":63,"lines":[{"account":3,"amount":"2400.00","currency":"USD","side":"debit"},{"account":1,"amount":"2400.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"276fde3703b75331f11e24df3022332cc285f72e27020cc8e1e13528f80c988e","seq":408,"ts":"2026-10-21T21:29:02Z","type":"entry.posted"}
{"payload":{"accum_depr_account":4,"cost":{"amount":"2400.00","currency":"USD"},"cost_account":3,"description":"Laptop","expense_account":15,"id":1,"method":"straight_line","purchase_date":"2025-03-01","salvage_value":{"amount":"0","currency":"USD"},"useful_life_months":36},"prev":"c69cdd6ff196ad5da8f6d7b3242681868f9a1ba4fd42dffb14922ba9c8ce6d2a","seq":409,"ts":"2026-10-21T21:29:02Z","type":"asset.registered"}
{"payload":{"asset_id":1,"previous_tag":null,"tag":"FL-0001"},"prev":"3cfb8d20209d19fc8b1718379037da8f3eca780c182a006fe0a000ac4ceb87b0","seq":410,"ts":"2026-10-21T21:29:02Z","type":"asset.tagged"}
{"payload":{"asset_id":1,"entry_id":63},"prev":"569bf2e5019bf47db07a22fd34725d254544f5d2faa1286884f46caaa98bd54a","seq":411,"ts":"2026-10-21T21:29:02Z","type":"asset.acquisition_linked"}
{"payload":{"date":"2025-03-31","description":"Depreciation — Laptop — 2025-03","id":64},"prev":"22809023c78b048f64008e4e40402ea222a6d5a257112db2f224ca26d3ee41e7","seq":412,"ts":"2026-10-21T21:29:02Z","type":"entry.created"}
{"payload":{"account":15,"amount":"66.67","currency":"USD","id":64,"side":"debit"},"prev":"863171f7c7793cebbb32a192056eaff8b28f4dfc79c99a8367f92d6eec49378f","seq":413,"ts":"2026-10-21T21:29:02Z","type":"entry.line_added"}
{"payload":{"account":4,"amount":"66.67","currency":"USD","id":64,"side":"credit"},"prev":"2bee4b7f54dc66c67eb2655fd936adfb57f8d05555cf77cb6b48989d4255821f","seq":414,"ts":"2026-10-21T21:29:02Z","type":"entry.line_added"}
{"payload":{"date":"2025-03-31","description":"Depreciation — Laptop — 2025-03","id":64,"lines":[{"account":15,"amount":"66.67","currency":"USD","side":"debit"},{"account":4,"amount":"66.67","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"48ded01bfc4b8265a49204c4fb8372f99481ba5f3d656dbe498f3d332e47c448","seq":415,"ts":"2026-10-21T21:29:02Z","type":"entry.posted"}
{"payload":{"amount":"66.67","asset_id":1,"entry_id":64,"period":"2025-03"},"prev":"c30add1e4ead7b0dcb3948bb83b5244a30b9c733a92ec4732999532b5c9eae59","seq":416,"ts":"2026-10-21T21:29:02Z","type":"asset.depreciation_posted"}
{"payload":{"date":"2025-04-30","description":"Depreciation — Laptop — 2025-04","id":65},"prev":"40653fd1e994a584030625fe577665c5db4b2c8ea70772b2ad6ac7dc154ea25d","seq":417,"ts":"2026-10-21T21:29:02Z","type":"entry.created"}
{"payload":{"account":15,"amount":"66.66","currency":"USD","id":65,"side":"debit"},"prev":"67bc4b24d3cc20a221c1bd14d02c4cf5b7dc19333d187541e94dd29e72a5d6e5","seq":418,"ts":"2026-10-21T21:29:02Z","type":"entry.line_added"}
{"payload":{"account":4,"amount":"66.66","currency":"USD","id":65,"side":"credit"},"prev":"3e94b0205bc23dd2f530689d6aca732d68235e20f46ae8416fb03bb729306811","seq":419,"ts":"2026-10-21T21:29:02Z","type":"entry.line_added"}
{"payload":{"date":"2025-04-30","description":"Depreciation — Laptop — 2025-04","id":65,"lines":[{"account":15,"amount":"66.66","currency":"USD","side":"debit"},{"account":4,"amount":"66.66","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"b4b04853c7e85f6f7bab1b542e904ac054d6778e9159a47d0b2b921990f3e8ab","seq":420,"ts":"2026-10-21T21:29:02Z","type":"entry.posted"}
{"payload":{"amount":"66.66","asset_id":1,"entry_id":65,"period":"2025-04"},"prev":"981a7a51a034c228969d13ab2eab20613a2bd57e01b28b75f9ed17bbb0c4076c","seq":421,"ts":"2026-10-21T21:29:02Z","type":"asset.depreciation_posted"}
{"payload":{"date":"2025-05-31","description":"Depreciation — Laptop — 2025-05","id":66},"prev":"41caf1b729a253a90ca48b694c895a8ad078dfd27ec5e75bb362aa1c79ec402f","seq":422,"ts":"2026-10-21T21:29:03Z","type":"entry.created"}
{"payload":{"account":15,"amount":"66.67","currency":"USD","id":66,"side":"debit"},"prev":"69d804f402662361cf9ba7135420e11f2ebaa014824df99ceaa1afbdc384d620","seq":423,"ts":"2026-10-21T21:29:03Z","type":"entry.line_added"}
{"payload":{"account":4,"amount":"66.67","currency":"USD","id":66,"side":"credit"},"prev":"d3019602e2c0e2cf92840eb6c4b9ba77132b79b071cc88e5e928a5525d5a7f0a","seq":424,"ts":"2026-10-21T21:29:03Z","type":"entry.line_added"}
{"payload":{"date":"2025-05-31","description":"Depreciation — Laptop — 2025-05","id":66,"lines":[{"account":15,"amount":"66.67","currency":"USD","side":"debit"},{"account":4,"amount":"66.67","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"f1c7cabf8106ace4486696906ca27948644fea2149c5607191ef8b6426ecaaf9","seq":425,"ts":"2026-10-21T21:29:03Z","type":"entry.posted"}
{"payload":{"amount":"66.67","asset_id":1,"entry_id":66,"period":"2025-05"},"prev":"291bfae8f7cdf9132827647ab388af8708a400945bd33bfd98518f43717f6ae2","seq":426,"ts":"2026-10-21T21:29:03Z","type":"asset.depreciation_posted"}
{"payload":{"date":"2025-06-30","description":"Depreciation — Laptop — 2025-06","id":67},"prev":"1e5895685f8c6248412f13ec2af34949a24b4493efb3e322db370ca7c76f0207","seq":427,"ts":"2026-10-21T21:29:03Z","type":"entry.created"}
{"payload":{"account":15,"amount":"66.67","currency":"USD","id":67,"side":"debit"},"prev":"94c19beb788960587d6c44f9c7c7294bde4813e015d9444803e3409671371456","seq":428,"ts":"2026-10-21T21:29:03Z","type":"entry.line_added"}
{"payload":{"account":4,"amount":"66.67","currency":"USD","id":67,"side":"credit"},"prev":"1e5159ac81c1951152d1d871d112658e7c239aa6ec50affc82291136151057d0","seq":429,"ts":"2026-10-21T21:29:03Z","type":"entry.line_added"}
{"payload":{"date":"2025-06-30","description":"Depreciation — Laptop — 2025-06","id":67,"lines":[{"account":15,"amount":"66.67","currency":"USD","side":"debit"},{"account":4,"amount":"66.67","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"04efed2f4e6761c9588b9316d64122f3672e1fed213b1a2f3f1f2868c4b60444","seq":430,"ts":"2026-10-21T21:29:03Z","type":"entry.posted"}
{"payload":{"amount":"66.67","asset_id":1,"entry_id":67,"period":"2025-06"},"prev":"5584eb6e1aeeb38029e118670e8379df574a4a499acd38e7158c8b5b45594092","seq":431,"ts":"2026-10-21T21:29:03Z","type":"asset.depreciation_posted"}
{"payload":{"date":"2025-07-31","description":"Depreciation — Laptop — 2025-07","id":68},"prev":"d738713444f0c465415e5248388ce05b4bd114e6f0fa6570cdc66cc70e7c2ea2","seq":432,"ts":"2026-10-21T21:29:03Z","type":"entry.created"}
{"payload":{"account":15,"amount":"66.66","currency":"USD","id":68,"side":"debit"},"prev":"8760646dd6d3d731d83b7944c8aa1865ff0aa0c57f5b08daf649b6acb9f68232","seq":433,"ts":"2026-10-21T21:29:03Z","type":"entry.line_added"}
{"payload":{"account":4,"amount":"66.66","currency":"USD","id":68,"side":"credit"},"prev":"f75caf0d430852a76f6225a72baab3cc1639d87d6e64ae13f728685a7b19ccca","seq":434,"ts":"2026-10-21T21:29:03Z","type":"entry.line_added"}
{"payload":{"date":"2025-07-31","description":"Depreciation — Laptop — 2025-07","id":68,"lines":[{"account":15,"amount":"66.66","currency":"USD","side":"debit"},{"account":4,"amount":"66.66","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"e2d21284301eb08363234118bda170f78a5bc6712355398132b57f61ebc90ccf","seq":435,"ts":"2026-10-21T21:29:03Z","type":"entry.posted"}
{"payload":{"amount":"66.66","asset_id":1,"entry_id":68,"period":"2025-07"},"prev":"dda1ba8941f6686fc22bafec2e0daa0f9d62a5b5be9ed100670713526d4548c4","seq":436,"ts":"2026-10-21T21:29:03Z","type":"asset.depreciation_posted"}
{"payload":{"date":"2025-08-31","description":"Depreciation — Laptop — 2025-08","id":69},"prev":"4ab1cdd0071cb84e114c5bc461bbafe0969bb845bbb05f070db067dffeb9e371","seq":437,"ts":"2026-10-21T21:29:03Z","type":"entry.created"}
{"payload":{"account":15,"amount":"66.67","currency":"USD","id":69,"side":"debit"},"prev":"95bad6ff059f689293534f0f19cc7b57284f0125fa65e6c1e76e8341f5bd66f4","seq":438,"ts":"2026-10-21T21:29:03Z","type":"entry.line_added"}
{"payload":{"account":4,"amount":"66.67","currency":"USD","id":69,"side":"credit"},"prev":"257ffe0e663df221be536ab21c9429b5ad663f8e3140b256575f4f1ac14f3795","seq":439,"ts":"2026-10-21T21:29:03Z","type":"entry.line_added"}
{"payload":{"date":"2025-08-31","description":"Depreciation — Laptop — 2025-08","id":69,"lines":[{"account":15,"amount":"66.67","currency":"USD","side":"debit"},{"account":4,"amount":"66.67","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"52e67b69798e6e42e4d721c942fc9c661cad3c9dd21e88cebd0968312708e796","seq":440,"ts":"2026-10-21T21:29:03Z","type":"entry.posted"}
{"payload":{"amount":"66.67","asset_id":1,"entry_id":69,"period":"2025-08"},"prev":"3d7052193c152126591fdc07e445276ea04114b10f1db38c18029d34c6a1a457","seq":441,"ts":"2026-10-21T21:29:03Z","type":"asset.depreciation_posted"}
{"payload":{"date":"2025-09-30","description":"Depreciation — Laptop — 2025-09","id":70},"prev":"df0d66ff32bdb0c201e38864413b65321da625ddedc49f131345e8a17d7dc6af","seq":442,"ts":"2026-10-21T21:29:03Z","type":"entry.created"}
{"payload":{"account":15,"amount":"66.67","currency":"USD","id":70,"side":"debit"},"prev":"353f3e42cc1f1e5c191d9f344cb26c06456fc8c601f4ae86e0420122afb805ca","seq":443,"ts":"2026-10-21T21:29:03Z","type":"entry.line_added"}
{"payload":{"account":4,"amount":"66.67","currency":"USD","id":70,"side":"credit"},"prev":"545d4a21746f97d8460679b2d01fecf161df220d4fbcefcce23786be972b2f01","seq":444,"ts":"2026-10-21T21:29:03Z","type":"entry.line_added"}
{"payload":{"date":"2025-09-30","description":"Depreciation — Laptop — 2025-09","id":70,"lines":[{"account":15,"amount":"66.67","currency":"USD","side":"debit"},{"account":4,"amount":"66.67","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"5515388f429b3bb9234967689c10701f688ffbcacb9eb42e507a56b910a5eca4","seq":445,"ts":"2026-10-21T21:29:03Z","type":"entry.posted"}
{"payload":{"amount":"66.67","asset_id":1,"entry_id":70,"period":"2025-09"},"prev":"134600a07a7f9be26ef29d9b3609d6103c22dd5a5acefadd121c5769bec1ab82","seq":446,"ts":"2026-10-21T21:29:03Z","type":"asset.depreciation_posted"}
{"payload":{"date":"2025-10-31","description":"Depreciation — Laptop — 2025-10","id":71},"prev":"9a088a890a8286cadc7d0df46ef42e343eb5ded8c739fee6ae77bb4eb11cefce","seq":447,"ts":"2026-10-21T21:29:03Z","type":"entry.created"}
{"payload":{"account":15,"amount":"66.66","currency":"USD","id":71,"side":"debit"},"prev":"cdb6b65327e40050fa39cd7d8c32c9e499129fc06a512b945bb94124b34fa548","seq":448,"ts":"2026-10-21T21:29:03Z","type":"entry.line_added"}
{"payload":{"account":4,"amount":"66.66","currency":"USD","id":71,"side":"credit"},"prev":"1b763138e97fbb88aac5d9ecf34d2c6426530408b4b8986b797442ce82b88f61","seq":449,"ts":"2026-10-21T21:29:03Z","type":"entry.line_added"}
{"payload":{"date":"2025-10-31","description":"Depreciation — Laptop — 2025-10","id":71,"lines":[{"account":15,"amount":"66.66","currency":"USD","side":"debit"},{"account":4,"amount":"66.66","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"8f55d708971978b7818f921861f031617adaa6d794f4a369c0137c6be77e6c1d","seq":450,"ts":"2026-10-21T21:29:03Z","type":"entry.posted"}
{"payload":{"amount":"66.66","asset_id":1,"entry_id":71,"period":"2025-10"},"prev":"27bfba00c4431cefff8e551102f87feaddbc00e4ac4ec8081171b0f357a8cb4c","seq":451,"ts":"2026-10-21T21:29:03Z","type":"asset.depreciation_posted"}
{"payload":{"date":"2025-11-30","description":"Depreciation — Laptop — 2025-11","id":72},"prev":"5f05265a9f7207106edef9d8e570d4e63b9f748e6fd382b66b7cb4fd5f026611","seq":452,"ts":"2026-10-21T21:29:03Z","type":"entry.created"}
{"payload":{"account":15,"amount":"66.67","currency":"USD","id":72,"side":"debit"},"prev":"c5ba941fc07e87c4698b2de0ecf3cc982dc3446084fe99332502a182e006692a","seq":453,"ts":"2026-10-21T21:29:03Z","type":"entry.line_added"}
{"payload":{"account":4,"amount":"66.67","currency":"USD","id":72,"side":"credit"},"prev":"a77601eb122a34e9d816a28d91efd9c6546f1ab56bad24fca07be761fa51b6b5","seq":454,"ts":"2026-10-21T21:29:03Z","type":"entry.line_added"}
{"payload":{"date":"2025-11-30","description":"Depreciation — Laptop — 2025-11","id":72,"lines":[{"account":15,"amount":"66.67","currency":"USD","side":"debit"},{"account":4,"amount":"66.67","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"d630324206e2658b2855228bded486e09580cda4901906a9cb37ac1c3827694d","seq":455,"ts":"2026-10-21T21:29:03Z","type":"entry.posted"}
{"payload":{"amount":"66.67","asset_id":1,"entry_id":72,"period":"2025-11"},"prev":"1b5195c8a8d25c63523207169bf4e1bcc31b9b1e381ccac28ac910cd45ec0bd4","seq":456,"ts":"2026-10-21T21:29:03Z","type":"asset.depreciation_posted"}
{"payload":{"date":"2025-12-31","description":"Depreciation — Laptop — 2025-12","id":73},"prev":"5b1227aee10a524c14b49637a7423676999a21a95f161164dc6cc4890d172624","seq":457,"ts":"2026-10-21T21:29:03Z","type":"entry.created"}
{"payload":{"account":15,"amount":"66.67","currency":"USD","id":73,"side":"debit"},"prev":"e21de75b07cc38719cd1eb40177a876faf4b84dfe9791606dc50315750a7cbdf","seq":458,"ts":"2026-10-21T21:29:03Z","type":"entry.line_added"}
{"payload":{"account":4,"amount":"66.67","currency":"USD","id":73,"side":"credit"},"prev":"b8a2f7d8cd30e888fa68f883989190bf6635a4f7f4c90840b6a8a7bb0ef3bda8","seq":459,"ts":"2026-10-21T21:29:03Z","type":"entry.line_added"}
{"payload":{"date":"2025-12-31","description":"Depreciation — Laptop — 2025-12","id":73,"lines":[{"account":15,"amount":"66.67","currency":"USD","side":"debit"},{"account":4,"amount":"66.67","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"474e2016cd9deff5c0fcd864ba32f4abce897c35050378958f2eed8342e15e06","seq":460,"ts":"2026-10-21T21:29:03Z","type":"entry.posted"}
{"payload":{"amount":"66.67","asset_id":1,"entry_id":73,"period":"2025-12"},"prev":"4496340b38dcfe3c449c5a7d5ce15e98f8052f6130405dd209a54fc3e155d04d","seq":461,"ts":"2026-10-21T21:29:03Z","type":"asset.depreciation_posted"}
{"payload":{"date":"2025-02-01","description":"Insurance year","id":74},"prev":"8a3ce2e06e292d10ea2648daf59f15b845ff4954f5b071e7d0d034ee31919b9f","seq":462,"ts":"2026-10-21T21:29:03Z","type":"entry.created"}
{"payload":{"entry_id":74,"party_id":4},"prev":"8b3a320f0a1931010797ebae04ea284d7afc902376e5528a1e42cab2bf6dfbf5","seq":463,"ts":"2026-10-21T21:29:03Z","type":"entry.party_set"}
{"payload":{"account":2,"amount":"1200.00","currency":"USD","id":74,"side":"debit"},"prev":"bfb05eb96932c13c0bf9f6088f96e5456753d8abe274282d0c5b71bf72c3d45d","seq":464,"ts":"2026-10-21T21:29:03Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"1200.00","currency":"USD","id":74,"side":"credit"},"prev":"0e46904b35239d223507dd3249a48e72abf8c2b72787e5a71577285dc7a3fd13","seq":465,"ts":"2026-10-21T21:29:03Z","type":"entry.line_added"}
{"payload":{"attachment_id":64,"blob_sha":"407b7b6f86ec969d83c5370e7a2f639522bbdb7bceafb7cb3dad36a384c2f4ba","category":"receipts","description":null,"entry_id":74,"file_name":"r64.pdf","mime_type":"application/pdf","size_bytes":56},"prev":"fa55ae4bf5fbf7d8e65d0bfa4466c6ba6675e0e33cf08c52e0536c4e734e359c","seq":466,"ts":"2026-10-21T21:29:03Z","type":"receipt.attached"}
{"payload":{"date":"2025-02-01","description":"Insurance year","id":74,"lines":[{"account":2,"amount":"1200.00","currency":"USD","side":"debit"},{"account":1,"amount":"1200.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"39cc2c369dec884abebbaf2afa842e530c0a7d2f9c928cfa658e3ba2dc6877fe","seq":467,"ts":"2026-10-21T21:29:03Z","type":"entry.posted"}
{"payload":{"balance_account":2,"description":"Insurance","id":1,"kind":"prepaid_expense","months":12,"pl_account":16,"start":"2025-02-01","total":{"amount":"1200.00","currency":"USD"}},"prev":"7576a892214455b4f54a9ba4d3a54b31d6e72f95b3c2d57b197bdd86d54de240","seq":468,"ts":"2026-10-21T21:29:03Z","type":"schedule.registered"}
{"payload":{"end":"2025-03-31","id":1,"name":"2025-Q1","start":"2025-01-01"},"prev":"ff0fc0c6c7bc3537bd06b5a1e73d325af2e183e54a75a441e20a740db0bb5cbd","seq":469,"ts":"2026-10-21T21:29:03Z","type":"period.created"}
{"payload":{"close_to":{"members":[{"account":9,"distributions":null,"share":"0.5"},{"account":10,"distributions":null,"share":"0.5"}],"rounding":"cumulative"},"closing_entry":75,"entry":{"date":"2025-03-31","description":"Close period 2025-Q1","id":75,"lines":[{"account":12,"amount":"598.41","currency":"USD","side":"debit"},{"account":13,"amount":"961.27","currency":"USD","side":"credit"},{"account":14,"amount":"1384.64","currency":"USD","side":"credit"},{"account":15,"amount":"66.67","currency":"USD","side":"credit"},{"account":17,"amount":"1568.21","currency":"USD","side":"credit"},{"account":9,"amount":"1691.19","currency":"USD","side":"debit"},{"account":10,"amount":"1691.19","currency":"USD","side":"debit"}]},"id":1},"prev":"7c2011737f719566576cb5834961a755e285f77373c976213bf976e3f1ca4622","seq":470,"ts":"2026-10-21T21:29:03Z","type":"period.closed"}
@@ -1 +0,0 @@
bebe2eda9edb31285dbc4d6ae7a3761d88e260af13a929ba8f1b97baf3d83178
@@ -1 +0,0 @@
WBV14\�8�>F+��(�򦣲��PC7A��7��Y��maM��l��M6/K_�rt�p���K����Uz<��6f�l��!g�
Binary file not shown.
Binary file not shown.
Loaded 100 of 734 files, more files were not shown because too many files have changed in this diff. Show more