Skip to content

Ilia DudaCo-op Jan 2027

CloseBooks: a multi-tenant month-end close with an LLM in the loop

Founder and sole engineer · April 2026 – present

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.


Fig. 1
A bank feed through the categorisation pipelinesynthetic feed and model outputs · the rules applied to them are the product’s own, from the shipped code

Approve a row waiting for review, or remap a blocked one, and watch the export gate

  1. 0GUSTO PAYROLL−$18,420.00
    → 6000 Payrollconfidence 0.99approved
  2. 1STRIPE FEE−$12.40
    → 6150 Merchant Feesunder $20 → 0.88confidence 0.88approved
  3. 2Client payment Harbor Dental+$4,500.00
    → 4000 Consulting Revenueconfidence 0.94approved
  4. 3WEWORK MEMBERSHIP−$650.00
    → 6200 Rent Expense6200 is not in this client’s chart → 0.55confidence 0.55
  5. 4UBER *TRIP HELP.UBER.COM−$23.50
    → 6300 Travelbelow the 0.85 thresholdconfidence 0.82
  6. 5REFUND AMZN MKTP+$64.99
    → 6100 Office Suppliescredit to an expense account → 0.60confidence 0.60
  7. 6Venmo−$42.00
    → 6350 Mealsdescription too short → 0.60confidence 0.60
  8. 7SQ *BLUE BOTTLE−$14.25
    → 6350 Mealsunder $20 → 0.78confidence 0.78

The model’s confidence is an input, not a verdict. It is taken down for amounts under $20, capped when a description is too short to carry information, and capped again when the suggested account does not exist in this client’s chart or posts in the wrong direction. Only an unflagged row at or above 0.85 is approved by the rules; nothing waiting or blocked can be exported.
Each synthetic line, the account suggested, the confidence after the rules, and the status
LineSuggested accountStated confidenceAfter the rulesStatus
GUSTO PAYROLL6000 Payroll0.990.99approved
STRIPE FEE6150 Merchant Fees0.960.88approved
Client payment Harbor Dental4000 Consulting Revenue0.940.94approved
WEWORK MEMBERSHIP6200 Rent Expense0.970.55flagged
UBER *TRIP HELP.UBER.COM6300 Travel0.820.82pending
REFUND AMZN MKTP6100 Office Supplies0.930.60pending
Venmo6350 Meals0.880.60pending
SQ *BLUE BOTTLE6350 Meals0.860.78pending

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.

Fig. 2
Two enforcement paths to one firm’s rowsCloseBooks · isolation by policy and by owner check
Two enforcement paths to one firm’s rowsOf 100 API routes, 83 reach client data through the Supabase client and are constrained by row-level security policies keyed on firm membership and role rank. The other 17 use the service-role key, which bypasses row-level security entirely, and are constrained instead by an owner check written by hand in each route. Both paths end at the same rows.row-level security83 routesSupabase clientfirm membershipand role rank17 routesservice-role keybypassedowner checkwritten by handone firm’s rows
The 17 routes that need the service-role key — billing and the QuickBooks integration among them — bypass row-level security by design, so each carries its own owner check. Both paths end at the same rows.

stack
Next.js 14 · React 18 · TypeScript · Supabase Postgres · Claude API · Stripe · QuickBooks Online