First-pass bookkeeping agent for small online shops
Agent that matches payment payouts to orders, categorises bank lines, proposes explained ledger entries and drafts a monthly VAT summary.
Built 1 time, downloaded 0 times.
Install from your terminal
Run this in your project folder. It signs you in, shows what changes and asks before it overwrites a file.
npx claude-lab add first-pass-bookkeeping-agent-for-small-online-shops-b9dc3eCLAUDE.md# Bookkeeping agent
## Purpose
First-pass bookkeeping for small online shops. The agent ingests one
payment provider (Stripe as the first adapter, behind a `PaymentProvider`
interface) and CSV bank exports. It matches payouts to orders, categorises
expenses against a chart of accounts, and produces a monthly summary
(revenue, refunds, fees, top expense categories, VAT owed).
Product spec: @SPEC.md
Build phases and /goal prompts: @PLAN.md
Review rules (read by /code-review): @REVIEW.md
## Hard rules
1. Never write to accounting records.
The only output is `ProposedEntry` objects with status `proposed`.
`approved` is set only by a human action in the review queue.
No code path may call an accounting API write.
Grep for one in every review.
2. Every decision is explained.
Every `ProposedEntry` and every match carries a `Decision` with:
- `reason`: plain English an accountant can read
- `evidence`: source row ids and file + line
- `method`: `rule:<id>` | `llm:<model>` | `human`
- `confidence`: 0-1
- `inputsHash`
A missing reason is a bug.
3. Money is integer minor units.
Use `amountMinor: bigint` (or a number of pence/cents) plus an
ISO 4217 currency. Never use floats.
VAT is computed with explicit rounding per line, using the mode set
in the shop config.
4. Uncertain or large items go to review.
Anything below `review.minConfidence` (default 0.85), and any amount
over `review.maxAutoAmountMinor`, goes to the review queue with a
`flagReason`.
## Pipeline order
ingest -> normalise -> deterministic match -> rule-based categorise
-> LLM categorise only the leftovers -> propose -> summarise
- Rules run first. The LLM never matches money.
- The LLM only sees lines no rule could categorise.
## Cost rules
- Haiku 5.5 for bulk categorisation, in batches of up to 50 lines.
- Sonnet 5.5 only for flagged or ambiguous lines.
- Use prompt caching on the chart of accounts and the system prompt.
- Log every API call to `CostLedger` with shopId and tokens.
- Target: per-shop monthly cost under `COST_BUDGET_USD`
(placeholder, set in SPEC.md).
- Never call Opus or Fable from product code.
## Commands
- `pnpm install`
- `pnpm test`: vitest, no network
- `pnpm test:golden`: fixture end-to-end runs
- `pnpm typecheck`: tsc --noEmit
- `pnpm lint`
- `pnpm eval:cost`: replays the fixture month with recorded LLM
responses and prints cost per shop
- `pnpm dev:run --shop fixtures/shops/demo-uk --month 2026-09`
- `pnpm eval:live`: live Claude calls, needs `ANTHROPIC_API_KEY`.
Only when the user asks.
## Tests and the LLM
- Tests never hit the real Claude API.
- Use the recorded-response client in `src/llm/replayClient.ts`.
- Live calls run only in `pnpm eval:live`.
## Data rules
- Real shop data lives only in `data/raw/`.
It is gitignored and blocked from Claude by settings.
- Fixtures in `fixtures/` are synthetic, or anonymised with
`pnpm fixtures:anonymise`.
- Never paste real IBANs, names or emails anywhere:
code, tests, prompts, commits or chat.
## Layout
- `src/ingest/`: provider and bank adapters
- `src/domain/`: types, money, VAT
- `src/match/`: deterministic matching rules
- `src/categorise/`: rules, merchant cache, LLM leftovers
- `src/propose/`: double-entry `ProposedEntry` builder
- `src/summary/`: monthly summary JSON + Markdown
- `src/llm/`: Claude client wrapper, replay client, `CostLedger`
- `fixtures/`: synthetic and anonymised shop data
- `evals/`: recordings and cost snapshots
## Gotchas
- Stripe payouts net out fees and refunds.
Reconcile gross - fees - refunds = payout.
- Bank CSVs differ in date format, sign convention and encoding.
Handle a BOM and `;` delimiters.
- Payouts can split across several bank lines, or several payouts
can combine into one bank line.
- Timezone: bucket by the shop's timezone, not UTC.
## Workflow
1. Explore the relevant code first.
2. Plan the change.
3. Code it.
4. Run the verify skill before saying done.
- Use the test-runner subagent for full test runs.
It returns failures only.
- Use the audit-reviewer subagent and /code-review before merging.
.claude/agents/audit-reviewer.md---
name: audit-reviewer
description: Read-only reviewer that checks a diff against the never-write, money and explainability rules in REVIEW.md. Use before /code-review and merging.
model: sonnet
effort: high
tools: Read, Grep, Glob, Bash(git diff*)
maxTurns: 15
---
You audit changes to the bookkeeping agent. You never edit files.
1. Read REVIEW.md and CLAUDE.md. Get the diff with `git diff`
(or `git diff main...HEAD` if asked to review a branch).
2. Check the diff against the REVIEW.md block rules:
- Approval path: `status: 'approved'` set anywhere outside
`src/review/approve.ts`, or any write to an accounting system.
- Float money: `parseFloat`, `Number` division or `* 0.2`
applied to amounts.
- Missing Decision: a ProposedEntry or Match without reason and evidence.
- Live calls in tests: real Claude or Stripe clients, or `data/raw`.
- PII in fixtures: realistic names, emails, IBANs or card data
not produced by the anonymiser.
- Unlogged LLM calls: calls not made through `src/llm/client.ts`
or not logged to CostLedger, and any Opus or Fable model in src/.
3. Read the golden run output. If none exists, ask the caller to
run `pnpm test:golden` first. Sample 10 ProposedEntries and
judge whether each reason would satisfy an accountant:
specific, plain English, and consistent with its evidence.
Return PASS or FAIL. Then list each finding with file:line,
the rule it breaks and a one-line fix. List weak reasons
by entry id.
.claude/agents/test-runner.md---
name: test-runner
description: Runs pnpm test, test:golden or eval:cost and returns only failures and key metrics, keeping the main context small. Use for full test runs.
model: haiku
effort: low
tools: Bash, Read, Grep
disallowedTools: Edit, Write
maxTurns: 8
---
You run tests for the bookkeeping agent. You never edit files.
Run only the commands you are asked to run:
`pnpm test`, `pnpm test:golden` or `pnpm eval:cost`.
Never run `pnpm eval:live` or any command that uses the network.
Return a compact report:
- Each command, with its exit code and pass/fail counts.
- Failing test names, with file:line.
- The assertion diff for each failure, cut to 20 lines.
- Key metrics if printed: payout match rate, categorisation
accuracy, unbalanced entries, summary diff, USD per shop,
LLM line share.
Leave out passing tests, logs and stack frames from node_modules.
If a command cannot start (missing script, install error),
report the first error line and stop.
.claude/settings.json{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"permissions": {
"allow": [
"Bash(pnpm test*)",
"Bash(pnpm typecheck)",
"Bash(pnpm lint*)",
"Bash(pnpm eval:cost)",
"Bash(pnpm test:golden)",
"Bash(pnpm fixtures:*)",
"Bash(git status)",
"Bash(git diff*)",
"Bash(git log*)"
],
"ask": [
"Bash(pnpm eval:live*)",
"Bash(git push*)",
"Bash(git commit*)",
"Bash(rm *)",
"Bash(pnpm add*)"
],
"deny": [
"Read(./.env*)",
"Read(./data/raw/**)",
"Edit(./data/raw/**)",
"Bash(curl*)",
"Bash(git push --force*)",
"Bash(rm -rf*)"
],
"blockWritesOutsideWorkingDirectories": true
},
"env": {
"CLAUDE_CODE_SUBAGENT_MODEL": "haiku"
},
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "f=$(jq -r '.tool_input.file_path // empty' 2>/dev/null); case \"$f\" in src/*|fixtures/*|evals/*|*/src/*|*/fixtures/*|*/evals/*) case \"$f\" in *.ts|*.json|*.md) pnpm exec prettier --write \"$f\" >/dev/null 2>&1 ;; esac ;; esac; exit 0"
}
]
}
]
}
}
.claude/skills/fixtures/SKILL.md---
name: fixtures
description: Use when adding test data, a new bank CSV format, edge cases, or anonymising a real export.
---
# Fixtures
All test data lives in `fixtures/`. It must be synthetic or anonymised.
Real shop data stays in `data/raw/`, which is gitignored and blocked from Claude.
Never paste real IBANs, names or emails anywhere.
## Generate a shop-month
```
pnpm fixtures:generate --seed N --shop <name> --month YYYY-MM
```
The generator writes:
- orders
- provider txns (charges, refunds, fees, payouts, adjustments)
- a bank CSV
- `expected.json` with ground-truth matches, categories and summary totals
The same seed must give byte-identical output.
Rerun the command and check that `git diff --stat fixtures/` shows no change.
## Edge cases to include
- Partial refunds
- A refund in a later month
- A disputed charge
- A payout split across 2 bank lines
- 2 payouts in 1 bank line
- A fee adjustment
- A foreign-currency card
- A zero-VAT item
- A duplicate bank row
- A new merchant that no rule has seen
Each one must be reflected in `expected.json` so the golden tests can check it.
## Anonymise a real export
1. `pnpm fixtures:anonymise data/raw/<file> --salt $FIXTURE_SALT`
2. `pnpm fixtures:scan` checks for IBAN, email, phone and PAN patterns.
It must report 0 findings.
3. Only then move the output into `fixtures/`.
The anonymiser replaces names, emails, IBANs and card fingerprints with deterministic fakes.
It keeps the amount distribution, dates and merchant categories.
## Add a bank format
- Add `config/banks/<bank>.json` with the column mapping
(date format, delimiter, encoding/BOM, signed amount vs debit/credit columns).
- Add a 20-line sample CSV.
- Add a parser test that covers it.
- Malformed rows must raise a typed error with file:line, never a silent skip.
.claude/skills/llm-call/SKILL.md---
name: llm-call
description: Use when writing or editing any prompt or Claude API call in src/llm or src/categorise.
---
# LLM calls
The LLM only categorises lines that the rules left over.
It never matches money and never does arithmetic.
## Client
- Use only the wrapper `src/llm/client.ts`.
- Default model: Haiku 5.5.
- Escalate to Sonnet 5.5 only through `escalate()`, and pass a reason.
Escalate only for flagged or ambiguous lines.
- Never call Opus or Fable from product code.
- Every call logs to `CostLedger` with shopId and tokens.
## Prompt layout
1. Stable content first: system prompt, chart of accounts, rules.
Mark it for prompt caching.
2. Variable content last: the bank lines.
3. Batch up to 50 lines per call.
## Output
- Define a JSON schema and validate it with zod.
- Each item returns `accountCode`, `vatCode`, `confidence` and a `reason` of 25 words or fewer.
- If validation fails, retry once. If it fails again, send the line to review.
- Lines below `review.minConfidence` go to the review queue with `flagReason: low_confidence`.
- The result becomes a `Decision` with `method: llm:<model>` and an `inputsHash`.
## Recordings and tests
- Tests use the replay client `src/llm/replayClient.ts` and never hit the network.
- Record new responses with `pnpm llm:record` into `evals/recordings/`.
Use fixtures only, never real data.
- Update the cost snapshot and check `pnpm eval:cost` stays under `COST_BUDGET_USD`.
## In the PR
State the model, batch size and cache usage of each new call.
Justify any escalation to Sonnet.
## Help
For questions about the SDK, caching or batching, use the bundled `/claude-api` skill.
.claude/skills/verify/SKILL.md---
name: verify
description: "Run after any code change to prove it works: typecheck, lint, unit tests, golden month, cost replay."
allowed-tools: Bash, Read, Agent
---
# Verify
Run these checks in order. Do not say a change is done until all of them pass.
## Steps
1. `pnpm typecheck` must exit 0.
2. `pnpm lint` must exit 0.
3. `pnpm test`: delegate this to the test-runner subagent.
Ask it to return failures only.
4. `pnpm test:golden`: report these numbers.
- Payout match rate
- Categorisation accuracy
- Unbalanced entries (must be 0)
- Summary diff vs `expected.json` (must be 0)
5. `pnpm eval:cost`: report these numbers.
- USD per shop vs `COST_BUDGET_USD`
- LLM line share (target under 30%)
## Smoke run
Run `pnpm dev:run --shop fixtures/shops/demo-uk --month 2026-09 --llm replay`.
Then confirm in the output:
- No entry has status `approved`.
- Every entry and match has a non-empty `decision.reason`.
## Rules
- Never run `pnpm eval:live` unless the user asks for it. It spends real API money.
- Compare against the last known numbers.
Any regression in accuracy or cost counts as a failure, even when the tests pass.
## Output
End with a short table:
| Check | Result | Key number |
|-------|--------|------------|
| typecheck | pass/fail | error count |
| lint | pass/fail | error count |
| test | pass/fail | failed / total |
| test:golden | pass/fail | match %, accuracy %, unbalanced, diff |
| eval:cost | pass/fail | USD/shop vs budget, LLM share |
| smoke run | pass/fail | approved count, missing reasons |
INSTALL.md# Install
Setup for a small team using Claude Code daily on the bookkeeping agent.
## Prerequisites
- Node 22 or newer.
- pnpm.
- Claude Code 2.1.296 or newer. Check with `claude --version`.
## Steps
1. Create the repo and add CLAUDE.md, SPEC.md, PLAN.md, REVIEW.md and `.claude/`.
2. Add `data/raw/` and `.env` to `.gitignore`.
3. Copy `.env.example` to `.env` and set:
- `ANTHROPIC_API_KEY=YOUR_API_KEY`
- `STRIPE_API_KEY=YOUR_STRIPE_RESTRICTED_READ_KEY` (read-only restricted key)
- `FIXTURE_SALT=CHANGE_ME`
- `COST_BUDGET_USD=0.50`
4. Never commit `.env`. Settings deny Claude from reading it.
5. Run `pnpm install`.
## Plugins
Install these:
```
claude plugin install code-review@claude-plugins-official
claude plugin install security-guidance@claude-plugins-official
claude plugin install commit-commands@claude-plugins-official
```
- code-review: PR review that reads CLAUDE.md, which imports REVIEW.md.
- security-guidance: catches secret leaks and unsafe handling of financial data.
- commit-commands: consistent commits with each phase's results.
## Team settings
- Commit `.claude/` so everyone shares permissions, hooks, skills and agents.
- Keep personal tweaks in `.claude/settings.local.json` (not committed).
## Permission mode
- Terminal and VS Code sessions start in auto mode when no permission mode is configured.
- Set `permissions.defaultMode` in your local settings if you want a different mode.
- The ask list still gates `git push`, `git commit`, `pnpm add`, `rm` and `pnpm eval:live` (real API spend).
- The deny list still blocks `.env`, `data/raw/`, `curl` and force pushes.
## Before Phase 2
Fill in the TODOs in SPEC.md as a team:
VAT jurisdiction, cost budget and chart of accounts.
## Running a phase
1. Open PLAN.md and find the phase.
2. Start `claude` and run `/clear`.
3. Paste the phase's `/goal` prompt. Phase 1's is the kickoff prompt.
4. When it finishes, ask Claude to use the audit-reviewer agent on the diff.
5. Run `/code-review`.
6. Commit, then `/clear` before the next phase.
## Cost tips
- Run `/usage` after each phase.
- Use `/effort high` for VAT or matching design work, medium otherwise.
- Change effort rather than model mid-task; switching models breaks the prompt cache.
- `/clear` between unrelated tasks and `/compact` at natural breaks.
- Use the test-runner subagent for full test runs so output stays out of the main context.
PLAN.md# Build plan
Six phases. Each has exit criteria, files touched, risks and a /goal prompt to paste.
How to run a phase:
1. Run `/clear` so the phase starts with a clean context.
2. Paste the phase's /goal prompt.
3. When it stops, ask Claude to use the audit-reviewer agent.
4. Run `/code-review` before merging, then commit.
The /goal evaluator only sees what is shown in the conversation,
so every goal asks Claude to show the command output.
## Phase 1: Foundations
Exit criteria:
- Strict pnpm TS project with vitest, eslint, prettier and zod.
- Domain types, Money and VAT modules with tests.
- Seeded synthetic generator for demo-uk 2026-09 (≈300 orders, ≈120 bank lines).
Files touched: `package.json`, `tsconfig.json`, `src/domain/`, `config/chart-of-accounts.default.json`, `fixtures/generator/`, `fixtures/shops/demo-uk/`.
Risks:
- Float creep in the generator. Generate amounts as integers.
- Non-deterministic output (timestamps, iteration order). Seed everything.
This is the kickoff prompt:
```
/goal Phase 1 Foundations for the bookkeeping agent (read CLAUDE.md and SPEC.md first).
Build:
1. A pnpm TypeScript project: strict tsconfig (noUncheckedIndexedAccess), vitest, eslint, prettier, zod. Scripts: test, test:golden, typecheck, lint, fixtures:generate, fixtures:scan, eval:cost (stub that prints 0 for now).
2. src/domain/: types for Order, ProviderTxn, BankLine, Match, Account, ProposedEntry, Decision, ReviewItem, MonthlySummary, CostRecord. Decision has reason, evidence[], method, confidence and inputsHash. ProposedEntry status is 'proposed' | 'approved' | 'rejected'.
3. src/domain/money.ts: integer minor units plus currency, with add/sub/negate/allocate. Throws on currency mismatch and has no float paths. src/domain/vat.ts: VAT from gross and from net with explicit per-line rounding mode.
4. A default chart of accounts JSON from SPEC.md.
5. fixtures/generator: seeded generator for shop demo-uk, month 2026-09. It writes about 300 orders, Stripe-style provider txns and payouts, a bank CSV (dd/mm/yyyy, ; delimiter, BOM) and expected.json with ground-truth matches, categories and summary totals. Include the edge cases listed in the fixtures skill. All names, emails and IBANs are fake.
6. fixtures:scan finds no PII patterns in fixtures/.
End state (show the command output in the conversation):
- `pnpm typecheck` and `pnpm lint` exit 0.
- `pnpm test` passes, with at least 25 tests covering money (including allocate remainders), VAT rounding both directions, and generator determinism (same seed gives byte-identical output).
- `pnpm fixtures:generate --seed 42 --shop demo-uk --month 2026-09` runs twice and `git diff --stat fixtures/` shows no change on the second run.
- `pnpm fixtures:scan` reports 0 findings.
- The generator's expected.json totals for revenue, refunds, fees and VAT are internally consistent: a test asserts that payouts sum to gross minus refunds minus fees.
Constraints: no network calls, no Claude API usage yet, no float arithmetic on money, nothing under data/raw. Don't add dependencies beyond typescript, vitest, eslint, prettier, zod, tsx and @faker-js/faker without asking. Use the test-runner subagent for full test runs and finish with the verify skill.
Stop after 40 turns or when every end-state check is shown passing. If stuck, stop and report the blocker.
```
## Phase 2: Ingestion
Exit criteria:
- `PaymentProvider` interface with a Stripe adapter (CSV for tests, API behind the interface).
- Bank CSV parser driven by `config/banks/<bank>.json`: BOM, `;` delimiter,
signed vs debit/credit columns, dd/mm vs ISO dates.
- Malformed rows produce typed errors with file:line.
Files touched: `src/ingest/`, `config/banks/`, `fixtures/banks/`, `src/ingest/*.test.ts`.
Risks:
- Sign conventions flipped per bank. Test each format against known totals.
- Encoding surprises (BOM, Latin-1). Detect them, never guess silently.
```
/goal Phase 2 Ingestion (read CLAUDE.md, SPEC.md "Inputs").
Build the PaymentProvider interface and Stripe adapter (CSV path used by tests, API path behind the interface and never called in tests), and the bank CSV parser driven by config/banks/<bank>.json.
End state (show output in the conversation):
- `pnpm test` green, with at least 3 bank format fixtures (signed + ; + BOM, debit/credit columns, ISO dates) and 1 malformed-file test that produces a typed error with file:line.
- `pnpm typecheck` exits 0.
Constraints: no network in tests, money stays integer minor units, no new dependencies without asking. Use the test-runner subagent.
Stop after 40 turns or when all checks are shown passing. If stuck, report the blocker.
```
## Phase 3: Deterministic matching
Exit criteria:
- Rules engine with rule ids and reason templates from SPEC.md.
- Many-to-many matches with residual detection.
- Unmatched items go to the review queue.
Files touched: `src/match/`, `src/review/queue.ts`, `evals/golden/`.
Risks:
- False matches on equal amounts. Prefer leaving items unmatched over guessing.
- Business-day windows across weekends and bank holidays.
```
/goal Phase 3 Deterministic matching (read CLAUDE.md, SPEC.md "Matching rules").
Build the rules engine (payout-id-ref, amount-date-window, aggregated-payouts), many-to-many Match with residual, and send unmatched or mismatched items to review with the right flagReason.
End state (show `pnpm test:golden` output in the conversation):
- On the golden fixture, ≥98% of payouts match with zero false matches against the generator's ground truth.
- Every Match has a non-empty reason and evidence.
- `pnpm test:golden` prints match rate, false matches and unmatched count.
Constraints: no LLM in matching, no float money, no network. Use the test-runner subagent.
Stop after 50 turns or when all checks are shown passing. If stuck, report the blocker.
```
## Phase 4: Categorisation and LLM
Exit criteria:
- Merchant normaliser and merchant rule cache.
- Claude client wrapper: Haiku 5.5 batches up to 50, Sonnet 5.5 escalation,
prompt caching on the chart of accounts, zod-validated JSON output.
- Record/replay client and `CostLedger`.
Files touched: `src/categorise/`, `src/llm/client.ts`, `src/llm/replayClient.ts`, `src/llm/costLedger.ts`, `evals/recordings/`.
Risks:
- Cache misses from unstable prompt prefixes. Keep the stable content first.
- Overusing Sonnet. Escalate only flagged or ambiguous lines.
```
/goal Phase 4 Categorisation + LLM (read CLAUDE.md and the llm-call skill).
Build the merchant normaliser, rule cache, Claude client wrapper (Haiku 5.5 batch up to 50, Sonnet 5.5 escalation via escalate(), prompt caching on the chart of accounts, zod-validated JSON), the record/replay client and CostLedger.
End state (show output in the conversation):
- Categorisation accuracy ≥90% on fixture ground truth in replay mode (`pnpm test:golden`).
- `pnpm eval:cost` reports per-shop cost ≤ COST_BUDGET_USD and the LLM line share.
- All low-confidence lines are flagged low_confidence.
- No test makes network calls.
Constraints: never call Opus or Fable, never run `pnpm eval:live` unless asked, no real data in recordings. Use the test-runner subagent.
Stop after 60 turns or when all checks are shown passing. If stuck, report the blocker.
```
## Phase 5: Proposals, review queue and monthly summary
Exit criteria:
- Double-entry ProposedEntry builder with debits = credits asserted.
- VAT calculation per line with the shop's rounding mode.
- `pnpm review --shop --month` CLI with approve/edit/reject.
- Summary JSON and Markdown with links back to entry ids.
Files touched: `src/propose/`, `src/review/`, `src/summary/`, `evals/golden/`.
Risks:
- Penny differences from rounding on totals. Round per line only.
- Approval leaking into automatic code paths.
```
/goal Phase 5 Proposals, review queue, monthly summary (read CLAUDE.md, SPEC.md).
Build the ProposedEntry builder, VAT calc, review CLI (`pnpm review --shop --month` with approve/edit/reject) and summary JSON + Markdown with links to entry ids.
End state (show output in the conversation):
- The golden month summary equals the generator's expected totals to the penny.
- Every entry balances (a test asserts debits = credits).
- No code path sets status approved except the review command; a test asserts this.
- `pnpm test`, `pnpm test:golden` and `pnpm typecheck` pass.
Constraints: no writes to any accounting system, no float money, no network. Use the test-runner subagent.
Stop after 50 turns or when all checks are shown passing. If stuck, report the blocker.
```
## Phase 6: Hardening and second-source readiness
Exit criteria:
- Anonymiser script and PII scan.
- Property-based tests on Money and VAT with fast-check.
- Audit export: CSV of entries with reasons for accountants.
- Adapter checklist for adding providers (`docs/adding-a-provider.md`).
Files touched: `scripts/anonymise.ts`, `src/domain/*.property.test.ts`, `src/audit/export.ts`, `docs/`.
Risks:
- Anonymiser leaks via free-text descriptions. Scan every column.
- CSV quoting breaks the export round trip.
```
/goal Phase 6 Hardening (read CLAUDE.md, SPEC.md "Anonymisation").
Build the anonymiser, fast-check property tests on Money/VAT (ask before adding fast-check), the audit export CSV and the adapter checklist.
End state (show output in the conversation):
- The verify skill's table shows every check passing with no accuracy or cost regression.
- The audit export round-trip test passes (export, re-parse, identical entries and reasons).
Constraints: no real data in fixtures, no network in tests. Use the test-runner subagent.
Stop after 40 turns or when all checks are shown passing. If stuck, report the blocker.
```
After every phase: `/usage` to check spend, `/clear`, then the next phase.
REVIEW.md# Review checklist
Imported by CLAUDE.md so `/code-review` applies these rules.
The audit-reviewer agent checks the same list.
## Block if
### Writes and approval
- Any write to an external accounting system (any accounting API create, update or delete call).
- Any code that sets `status: 'approved'` outside `src/review/approve.ts`.
### Money
- Any floating-point money arithmetic: `parseFloat` on amounts, `* 0.2` on amounts, `Number` division of minor units.
- Money not held as integer minor units plus an ISO 4217 currency.
### Explainability
- A `ProposedEntry` or `Match` created without a `Decision` that has a reason and evidence.
- An LLM output used without zod validation and a fallback to the review queue.
### Tests and data
- A test calls the live Claude or Stripe API.
- A fixture contains realistic personal data not produced by the anonymiser (`pnpm fixtures:anonymise`).
- `data/raw` is referenced in tests.
## Cost check
- New LLM calls must state their model, batch size and cache usage.
- Every call must log to `CostLedger` with shopId and tokens.
- Escalation to Sonnet 5.5 must be justified in code and in the PR.
- No Opus or Fable anywhere in `src/`.
## VAT and rounding
- Any VAT or rounding change needs a golden test update.
- The PR must explain why the expected figures changed.
## Rules over prompts
- Prefer deterministic rules over prompts.
- Flag prompts that ask the model to do arithmetic.
- The LLM never matches money; matching is rules only.
## Error handling
- Malformed CSV rows produce typed errors with file:line.
- Never a silent skip, never a swallowed parse error.
SPEC.md# SPEC: first-pass bookkeeping agent
Status: draft. The team edits this before Phase 2.
Items marked TODO need a decision. CLAUDE.md holds the hard rules;
this file holds the product and data detail.
## Goals
- Ingest one payment provider (Stripe first) and CSV bank exports.
- Match payouts to orders and bank lines with deterministic rules.
- Categorise expenses against a chart of accounts, rules first, LLM for leftovers.
- Propose balanced double-entry `ProposedEntry` objects, each with a readable reason.
- Produce a monthly summary: revenue, refunds, fees, top expense categories, VAT owed.
- Keep per-shop LLM cost under `COST_BUDGET_USD` per month.
## Non-goals (v1)
- Filing VAT returns.
- Writing to accounting software. Output is `ProposedEntry` with status `proposed` only.
- Payroll.
- Multi-currency FX revaluation. v1 records the currency only.
## Inputs
### Payment provider
- Stripe balance transactions and payouts, read through the `PaymentProvider` interface.
- Live source: Stripe API with a restricted read-only key.
- Offline source: the exported balance transactions CSV. All tests use this.
### Bank
- One CSV export per account per month.
- Per-bank column mapping in `config/banks/<bank>.json`:
```json
{
"bank": "example-bank",
"encoding": "utf-8",
"delimiter": ";",
"hasBom": true,
"dateFormat": "dd/MM/yyyy",
"columns": { "date": "Date", "description": "Details", "reference": "Ref",
"amount": "Amount", "debit": null, "credit": null },
"signConvention": "signed",
"currency": "GBP"
}
```
- `signConvention` is `signed` (one amount column) or `debitCredit` (two columns).
- Malformed rows produce a typed error with file and line. Never a silent skip.
## Domain types
Money is integer minor units plus an ISO 4217 currency. No floats anywhere.
```ts
type Money = { amountMinor: bigint; currency: string };
type SourceRef = { file: string; line: number; sourceId: string };
type Order = { id: string; shopId: string; createdAt: string;
gross: Money; vat: Money; vatCode: string; customerRef: string };
type ProviderTxn = { id: string;
type: 'charge' | 'refund' | 'fee' | 'payout' | 'adjustment';
amount: Money; fee: Money; net: Money; createdAt: string; availableOn: string;
orderId?: string; payoutId?: string; source: SourceRef };
type BankLine = { id: string; date: string; amount: Money; description: string;
reference: string; counterparty?: string; source: SourceRef; rawHash: string };
type Match = { id: string; providerTxnIds: string[]; bankLineIds: string[];
orderIds: string[]; residual: Money; decision: Decision }; // many-to-many
type Account = { code: string; name: string;
type: 'income' | 'expense' | 'asset' | 'liability' | 'equity';
defaultVatCode: string };
type EntryLine = { accountCode: string; debitMinor: bigint; creditMinor: bigint;
currency: string; vatCode: string };
type ProposedEntry = { id: string; shopId: string; date: string;
lines: EntryLine[]; status: 'proposed' | 'approved' | 'rejected';
decision: Decision; flagReason?: FlagReason };
type Decision = { reason: string; evidence: SourceRef[];
method: `rule:${string}` | `llm:${string}` | 'human';
confidence: number; inputsHash: string; createdAt: string };
type ReviewItem = { id: string; subjectType: 'entry' | 'match' | 'bankLine';
subjectId: string; state: ReviewState; flagReason: FlagReason;
createdAt: string; resolvedBy?: string; resolvedAt?: string; note?: string };
type MonthlySummary = { shopId: string; month: string; currency: string;
revenue: Money; refunds: Money; fees: Money; vatOwed: Money;
topExpenseCategories: { accountCode: string; total: Money }[];
links: Record<string, string[]> }; // figure name -> entry ids
type CostRecord = { id: string; shopId: string; month: string; model: string;
purpose: string; inputTokens: number; outputTokens: number;
cacheReadTokens: number; cacheWriteTokens: number; usd: number; at: string };
```
Rules:
- Every `ProposedEntry` balances: sum of debits equals sum of credits.
- `confidence` is 0 to 1. `reason` is plain English an accountant can read.
- `inputsHash` covers the source rows and the rule or prompt version.
## Chart of accounts
A small UK-style default ships in `config/chart-of-accounts.default.json`:
| Code | Name | Type | Default VAT |
|------|------|------|-------------|
| 4000 | Sales | income | STD |
| 4010 | Refunds | income | STD |
| 5000 | Cost of goods | expense | STD |
| 6100 | Payment fees | expense | EXEMPT |
| 6200 | Advertising | expense | STD |
| 7500 | Software | expense | STD |
| 2200 | VAT | liability | N/A |
| 1200 | Bank | asset | N/A |
| 1210 | Stripe clearing | asset | N/A |
Shops override it with `config/shops/<shop>/chart-of-accounts.json`.
## VAT
Per-shop config `config/shops/<shop>/vat.json`:
- `jurisdiction`: TODO, the team picks one for v1 (suggest UK).
- `scheme`: `standard` or `flatRate` (flat-rate is a placeholder in v1).
- `rates`: map of VAT code to rate in basis points (for example STD 2000, ZERO 0).
- `rounding`: `halfUp` | `halfEven` | `down`, applied per line.
VAT is computed per line with the configured rounding, then summed.
Computing VAT on totals is a bug.
## Matching rules
Applied in priority order. The first confident match wins. The LLM never matches money.
| Id | Rule | Reason template |
|----|------|-----------------|
| `payout-id-ref` | Stripe payout id appears in the bank reference | "Bank line {bankLine} reference contains payout id {payoutId}." |
| `amount-date-window` | Same amount, bank date within ±3 business days of `availableOn` | "Amount {amount} matches payout {payoutId}; bank date {date} is {n} business days from arrival." |
| `aggregated-payouts` | Sum of 2+ payouts equals one bank line, or one payout equals 2+ bank lines | "Payouts {payoutIds} sum to {amount}, equal to bank lines {bankLineIds}." |
- Each payout must reconcile: gross - fees - refunds = payout. A non-zero difference is a `residual`.
- A match with a residual goes to review with `amount_mismatch`.
- Unmatched payouts go to review with `unmatched_payout`.
## Review queue
States: `open` -> `approved` | `edited` | `rejected`.
`edited` keeps the human's version with `method: 'human'`.
`flagReason` enum:
- `low_confidence`: below `review.minConfidence` (default 0.85).
- `unmatched_payout`: no rule matched a payout.
- `amount_mismatch`: a match has a non-zero residual.
- `new_merchant`: merchant not seen before and no rule exists.
- `over_threshold`: amount above `review.maxAutoAmountMinor`.
- `vat_unclear`: VAT code missing or conflicting.
Only `src/review/approve.ts` sets status `approved`.
## Monthly summary
- `out/<shop>/<month>/summary.json` matching `MonthlySummary`.
- `out/<shop>/<month>/summary.md`: a rendered table.
- Every figure lists the entry ids it came from, so an accountant can trace it.
- Open review items are listed at the end, with counts per `flagReason`.
## Cost budget
- `COST_BUDGET_USD` per shop per month: TODO, suggest $0.50.
- LLM line share target: under 30% of bank lines after the merchant rule cache warms up.
- Haiku 5.5 for bulk categorisation (batches up to 50), Sonnet 5.5 only for flagged lines.
- Every call writes a `CostRecord`. `pnpm eval:cost` reports both numbers.
## Learning loop
- When a human approves a categorisation for a merchant, the system creates
`rule:merchant:<normalised>` with the account, VAT code, approver and date.
- Later lines from that merchant are categorised by the rule, not the LLM.
- Rule edits and removals are logged. A rejected line never creates a rule.
## Anonymisation
`pnpm fixtures:anonymise` turns a real export into a safe fixture:
- Replaces names, emails, IBANs and card fingerprints with deterministic fakes keyed by `FIXTURE_SALT`.
- The same input and salt always give the same fake, so joins across files survive.
- Keeps the distribution of amounts, the dates and the merchant categories.
- `pnpm fixtures:scan` must report 0 findings before output moves into `fixtures/`.