CloseBooks: a multi-tenant month-end close with an LLM in the loop
A multi-tenant month-end close for CPA firms that I designed, built and deployed alone: 100 API routes over Postgres with row-level security, and an LLM pipeline that maps every bank line to the client’s chart of accounts with a confidence it has to earn, or a reviewer’s approval, before it is exported.
- GUSTO PAYROLL
- STRIPE FEE
- Client payment Harbor Dental
- WEWORK MEMBERSHIP
- UBER *TRIP HELP.UBER.COM
- REFUND AMZN MKTP
- Venmo
- SQ *BLUE BOTTLE
| Line | Suggested account | Stated confidence | After the rules | Status |
|---|---|---|---|---|
| GUSTO PAYROLL | 6000 Payroll | 0.99 | 0.99 | approved |
| STRIPE FEE | 6150 Merchant Fees | 0.96 | 0.88 | approved |
| Client payment Harbor Dental | 4000 Consulting Revenue | 0.94 | 0.94 | approved |
| WEWORK MEMBERSHIP | 6200 Rent Expense | 0.97 | 0.55 | flagged |
| UBER *TRIP HELP.UBER.COM | 6300 Travel | 0.82 | 0.82 | pending |
| REFUND AMZN MKTP | 6100 Office Supplies | 0.93 | 0.60 | pending |
| Venmo | 6350 Meals | 0.88 | 0.60 | pending |
| SQ *BLUE BOTTLE | 6350 Meals | 0.86 | 0.78 | pending |
What it does
A small accounting firm closes every client’s books every month: pull the bank statements, decide which account each transaction belongs to, review, and post. The categorisation is the step that eats the week, and it is pattern recognition against a chart of accounts that is different for every client.
CloseBooks takes bank statements in as CSV or PDF, categorises every line against that client’s own chart with a model in the loop, puts what it is unsure of in front of a reviewer, and exports the result or pushes journal entries to QuickBooks Online. Around it: firms, clients and roles in a multi-tenant Postgres database, a client portal, and Stripe subscriptions across three tiers. I designed, built and deployed all of it — 100 API routes, 87 dashboard pages and 17 SQL migrations.
The AI pipeline
Transactions go to the Claude API in batches of 20, numbered by position inside the batch rather than by database id, because a model echoes small integers reliably and mangles long identifiers. Results are matched back by that index, so a response that drops or garbles one row flags that row and leaves the other nineteen intact. Transient failures retry with exponential backoff; a malformed response fails fast instead of being retried into the same malformation; a batch that fails outright flags only its own rows and the run moves on.
What comes back is treated as evidence, not an answer. The rules in Fig. 1 are the product’s: confidence comes down for small amounts and uninformative descriptions; the suggested account is resolved against the client’s chart by code and then by name, and the category written is always the chart’s own, never the model’s free text; a posting in the wrong direction goes to review whatever the model said. A reviewer’s most recent corrections are fed into the next run’s prompt, so the pipeline picks up each firm’s habits.
Statements arrive in whatever shape a bank exports. The CSV parser finds the real header row beneath a bank’s preamble lines, reads parenthesised negatives, and handles both signed-amount and split debit-and-credit layouts; a PDF’s text layer is extracted and structured by the model into dated, signed lines.
Tenant isolation
Isolation is enforced in the database, not the interface. Row-level security policies scope every query to firm membership, and a five-level role hierarchy — owner, admin, senior accountant, staff, read-only — is expressed as security-definer functions, so reading, writing, approving and managing billing are separate privileges checked in SQL.
- Next.js 14 · React 18 · TypeScript · Supabase Postgres · Claude API · Stripe · QuickBooks Online