跳到正文
DEV Community· Damin Mutti·· 3 小時前AI 評分51

ZeraBooks 如何以狀態機建模月末結帳,並加入鎖定日期與撤銷日誌

Modelling a month-end close as a state machine, with a lock date and an undo log

AI 導讀

Damin Mutti 分享 ZeraBooks 的月末結帳設計,以帳戶月份狀態管理核對與簽核,並用單一日期鎖定整本帳簿。帳戶月份須通過金額核算、期初期末銜接及帳簿餘額三項檢查,才可標記為已核對;鎖定日期當日及之前的分錄不能新增、修改或刪除。

正文

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.

來源:DEV Community · dev.to