A month-end close is a state machine on two levels. Each account's month moves from open to tied to finalized, and the book as a whole is locked through a single date. AI can draft entries in open periods, but every write path, human or model, goes through the same lock check, and every AI action is logged so a person can reverse it. Get those rules right and the AI part is the easy bit.
I run ZeraBooks, so this is one design (ours), not a standard. ZeraBooks is an AI-native general ledger, bank statement converter and done-for-you bookkeeping service for small businesses and accounting firms. The code is a sketch of the shape, not our source.
What states does one account's month go through?
The unit is one bank or card account for one statement period:
UPLOADED -> TIED | NOT_TIED
NOT_TIED -> TIED (after a fix)
TIED -> FINALIZED (someone signs off)
FINALIZED -> TIED (any number in the period changes)
A period becomes TIED when three invariants hold:
// amounts in integer cents
type Stmt = { opening: number; credits: number; debits: number; closing: number; periodEnd: Date }
function isTied(s: Stmt, prev: Stmt | null, ledgerBalance: (d: Date) => number) {
const adds = s.opening + s.credits - s.debits === s.closing
const chains = prev === null || s.opening === prev.closing
const agrees = ledgerBalance(s.periodEnd) === s.closing
return { tied: adds && chains && agrees, gap: ledgerBalance(s.periodEnd) - s.closing }
}
Each invariant catches a different failure. adds fails when the statement was read wrong. chains fails when a statement is missing, because the next one opens on a number nobody closed on. agrees is the only one about the books. Return the gap in cents along with the boolean. A person can search a month for 21460. Nobody can search for false.
The interesting edge is FINALIZED back to TIED. If someone recategorizes a line or posts an entry dated inside a signed-off month, the sign-off drops on its own. A month that no longer matches what was approved shouldn't keep the approval.
What does locking the book add?
Finalizing is per account. Locking is per book, and it's a date, not a set of months:
type Book = { closedThrough: Date | null }
function canWrite(entryDate: Date, book: Book): boolean {
return book.closedThrough === null || entryDate > book.closedThrough
}
Close through March 31 and nothing dated on or before that day can be added, edited or deleted. ZeraBooks lets you add an optional closing password on top.
Reopening is deliberately blunt: it clears closedThrough entirely rather than reopening one month. That keeps the model to one nullable field, and a fix to February usually changes balances that March already reported anyway. Correct it, then lock through the same date again.
The single date also makes the guard cheap. There's no per-month status table to drift out of sync with the entries.
Where does the AI write?
Autopilot, the AI part of ZeraBooks, prepares a few kinds of month-end journal: depreciation on a straight line, the interest part of a loan payment at a rate the user supplies, and monthly slices of a prepaid cost. It also raises a question when a vendor that bills every month didn't. Each draft is dated the last day of the month, carries an AUTO- reference, and is posted through canWrite like anything else. Locked periods are skipped.
One rule I'd insist on in any system like this: the model decides what an entry is, and the ledger does the arithmetic.
function spreadPrepaid(totalCents: number, months: number): number[] {
const base = Math.floor(totalCents / months)
const remainder = totalCents - base * months
// the last month absorbs the rounding so the slices always sum to the total
return Array.from({ length: months }, (_, i) => (i === months - 1 ? base + remainder : base))
}
spreadPrepaid(100000, 12) // a 1,000.00 premium: eleven slices of 83.33, one of 83.37
Let a model do its own division and one day the slices add up to 999.99 and nobody can explain where the cent went.
Why log every AI action?
Because "the AI did it" isn't an audit trail. Each action becomes a row:
type ActionLog = {
id: string
at: Date
actor: 'autopilot' | 'user'
entryDate: Date
before: unknown // what the record looked like
after: unknown // what it looks like now
reason: string // why the model did it, in plain words
undoOf?: string // set when this row reverses another
}
Two rules keep it honest. First, undo goes through canWrite too. You can't reverse an action into a locked period without reopening first, or undo turns into a back door around the lock. Second, an undo is itself a new row. The log only grows.
And because an undo changes numbers, it can knock a finalized month back to TIED, so the state machine and the log always agree about what was reviewed.
Can the close run without a person?
Partly. With the setting turned on, Autopilot can lock the book itself, but only when every account's month is finalized, nothing is waiting on a question, and at least 15 days have passed since the month ended. The wait leaves room for a late statement or a bill that shows up on the 9th.
The precondition is the design. The machine locks only what people, or the three invariants, have already proved.
The steps that stay with a person, like entering payroll from the provider's register and tying out sales tax, are in the full month-end close checklist. The ledger these rules run in is described on the ZeraBooks general ledger page.
If you build one of these, write canWrite first and route everything through it. Everything else is easier to change later.
Top comments (1)
"A person can search a month for 21460. Nobody can search for false." That's the sentence the whole design rests on, and it generalises well past accounting. A boolean tells you something broke; a signed gap tells you where to look.
The edge I'd push on is where the lock and the undo log meet. canWrite guards on entry date. The undo log promises a person can reverse any AI action. Those two are fine while the period is open and they collide once it isn't: Autopilot posted an entry on 12 March, the book is now closed through 31 March, and someone decides the depreciation rate was wrong. The reversal is dated inside the lock. Either the undo path bypasses canWrite, in which case the lock isn't a lock and the closing password is your only real control, or it doesn't, and the reversibility you promised a person quietly expired at close.
Payments gave up on reversal for the same reason. A refund after settlement is a new payment in the other direction with its own date and its own id, not an edit to the batch that settled. I'd make the undo log emit a dated correcting entry in the open period rather than an undo of the original. Then the lock never needs an exception, and the trail shows the mistake and the fix instead of showing neither.
One thing I can't tell from the post: whether clearing closedThrough is itself recorded. Blunt is a defensible choice, but if reopening leaves no trace then the lock is a thing that was true over an unknown set of intervals, which is a worse property than being coarse.