Draft to posted, and the door that locks

The five states an entry moves through, and why posting is the point of no return.

About 15 minutes

TheoryWhy this exists

It is four in the afternoon on the last day of the month. You are working out the depreciation entry — RM4,166 for the van, and you are still checking whether the coffee machine bought in March should be a full month or a part month. You have the entry half-built.

At the same moment, someone else pulls the management accounts and sends them to the bank supporting a loan application.

If your half-finished entry is live in the ledger, the figures that went to the bank are wrong. Not slightly — you had entered one side, and the other was still an open question.

"Finish it before you save it" does not survive contact with real work

The naive model is that an entry either exists or it does not. You do the thinking somewhere else, and you create the entry only once it is complete and correct.

That breaks immediately, in four ordinary ways:

  • Work gets interrupted. You will not finish the depreciation calculation before someone needs you.
  • Some entries are prepared before their evidence arrives. You know the accrual is coming; the invoice lands next week.
  • Somebody other than the preparer often needs to look at an entry before it counts.
  • Some entries are prepared deliberately in advance, to be recorded when the period they belong to is open.

Each of those is a state where the entry needs to exist as a durable object — visible, editable, findable tomorrow — while explicitly not being part of the accounts.

So the model has to change. An entry is not a row you insert. It is a proposal that becomes a commitment, and its state records how far along that journey it is.

The five states

Draft. A working document. It may be unbalanced, half-written, wrong, or abandoned. It exists so you can come back to it. It contributes nothing to any balance, any report, or any total. Nobody outside the person working on it should care that it exists.

Pending approval. The preparer has said "I believe this is right" and handed it to someone else. Still absent from the accounts, but no longer a private scratchpad — it is now a claim awaiting a second judgement.

Approved. A second person has agreed with the content. Notice that this is still not in the ledger. Approval is a statement about whether the entry is correct; posting is a decision about when it takes effect. Those are different questions, and collapsing them removes your ability to approve something now and record it when its period is open.

Posted. The entry is in the general ledger. Balances have moved. It appears in the trial balance, in the P&L, in the balance sheet, and in anything anyone derives from them. It is no longer a proposal about the world; it is part of the record of the world.

Reversed. A posted entry that has been countered by an equal and opposite posted entry. The original is still there, still posted, still visible. Nothing was removed. Module 4 goes into why this is the only honest way to undo something.

The asymmetry that everything hangs on

Compare a draft with a posted entry and ask a single question: has anyone relied on this?

For a draft, no. Nothing has been reported from it, no decision rests on it, no statement anywhere includes it. So you can rewrite it freely, or delete it, and nothing in the world becomes inconsistent. There is no history to protect because it has no consequences yet.

For a posted entry, yes — and you often cannot enumerate who. The month's figures went to the bank. The VAT return used them. The owner decided to hire based on the margin. A director signed the accounts. Change the entry now and every one of those artefacts silently becomes a description of something that never happened, with no trace that it changed.

Posting is the moment an entry stops being your working opinion and becomes shared fact. Before it, you own the entry. After it, the record does.

That is why posting is a one-way door, and why the door is worth making deliberate rather than incidental. Everything before it is cheap and reversible by design. Everything after it is append-only.

Why approval is a separate state, not politeness

The reason a distinct pending-approval state exists is the same reason double-entry exists: redundancy catches error.

One person preparing and committing an entry has one chance to be right. Two people, with the second one looking specifically for what the first got wrong, is a materially better error-detection system — and it works even when both are competent and honest, because the failure it catches is ordinary mistake, not malice.

It also happens to be the control that matters most against fraud. A single individual who can both invent an entry and commit it can move value with no second pair of eyes on it. Splitting preparation from approval — separation of duties — means the fraudulent path requires two people to agree. That is not a guarantee. It is a much higher bar than one.

The states are not paperwork. Each one exists because something specific goes wrong without it.

PracticeDoing it in Cynco

Open Accounting → Journal. The list you see is a mix of states, and the state is the most important column on the screen — it tells you which rows are affecting your reports and which are not.

What you can do in each state

The general shape, which holds regardless of how your workspace is configured:

  • Draft. Fully editable. Change lines, accounts, amounts, the date, the narration. You can leave it unbalanced while you work, and you can delete it outright. It affects nothing.
  • Pending approval / approved. Editable in principle, but editing a submitted entry invalidates the judgement someone made about it — expect a change to send it back for review rather than keep its approval. Still not in the ledger.
  • Posted. Not editable. Not deletable. The narration or attachments may be amendable in some systems, but the lines, amounts, accounts and date are fixed.
  • Reversed. Also not editable, and now accompanied by a second posted entry that cancels it.

Whether you see an approval step at all depends on how your workspace is set up. Plenty of small businesses run without one, and go draft to posted. That is a legitimate configuration, not a missing feature — separation of duties needs two people to be meaningful. Check what your setup does rather than assuming.

Reading the list properly

Two habits pay for themselves:

Filter to posted before you trust a total. If you are reconciling a figure against a report, drafts are noise. A draft entry sitting in the list for RM12,000 looks exactly as substantial as a posted one and contributes nothing.

Check for stale drafts at month end. A draft that has been sitting since the 3rd is usually one of two things: an entry someone abandoned, or an entry someone believes is done. The second one is how a month closes with a missing accrual. Drafts do not remind you about themselves.

Try it yourself

Ten minutes, and it makes the asymmetry concrete rather than theoretical:

  1. Go to Accounting → Journal and start a new entry. Date it today, debit any expense account RM250, credit your bank account RM250, and save it as a draft.
  2. Reopen it. Change the amount to RM310. Change the expense account to something else. Note that nothing objects — you are editing freely.
  3. Now open Reports and look at the profit and loss for this month. Your RM310 is not there.
  4. Go back and post the entry.
  5. Reload the same report. Now it is there.
  6. Return to the entry and try to change the amount.

Step 6 is the whole lesson. Between step 2 and step 6 you changed nothing about your own authority or the entry's content — you changed only whether anything downstream depends on it. The system's willingness to let you edit tracks that dependency exactly.

If your workspace has approval enabled, do the same walk with the extra hop: submit it, approve it, then post it, and note that the report only changes at the last step.

To undo the exercise, reverse the entry rather than looking for a delete button. There is not one, and module 4 explains why that is the correct design rather than an inconvenience.

The simulator below lets you run the same sequence without touching real books — compose an entry, post it, and watch the trial balance rebuild.

Compose a balanced entry, post it, and watch the trial balance rebuild itself.

TechnologyHow it is built

Every engineer building this for the first time reaches for boolean flags. It reads naturally in the schema and it is wrong in a way that takes about a year to become expensive.

Four booleans describe sixteen states, twelve of which are nonsense

sql
is_draft     boolean not null default true,
is_approved  boolean not null default false,
is_posted    boolean not null default false,
is_reversed  boolean not null default false

Four independent flags is 2⁴ = 16 combinations, and the schema permits all of them:

  • is_draft and is_posted both true — a draft that is in the ledger.
  • is_reversed true, is_posted false — reversed something that never posted.
  • is_posted true, is_approved false — in the accounts, never approved.
  • all four false — the entry is in no state at all.

You know these are impossible. The database does not, and neither does the code someone writes in 2029. Any of them is one missed update away.

The subtler damage is at read time. "Which entries count?" becomes a predicate every caller has to compose:

sql
where is_posted = true and is_reversed = false and is_draft = false

That fragment gets copied into the trial balance, the P&L, the balance sheet, the AR ageing, the tax return, the dashboard — each place independently responsible for remembering all three clauses. One of them omits is_reversed and reversed entries get counted anyway: a report wrong by exactly the amount of the corrections you made, which is the hardest kind of discrepancy to spot.

One column, and a table of legal moves

sql
entry_status as enum (
  'draft', 'pending_approval', 'approved', 'posted', 'reversed'
)

Five values instead of sixteen combinations, and now the nonsense is not merely discouraged — it cannot be written down.

Then state which moves are legal, once, as data:

ts
const TRANSITIONS: Record<EntryStatus, EntryStatus[]> = {
  draft:            ["pending_approval"],
  pending_approval: ["approved", "draft"],
  approved:         ["posted", "draft"],
  posted:           ["reversed"],
  reversed:         [],
};

Read that map and the accounting model is visible in six lines. Rejection sends an entry back to draft rather than to a separate "rejected" state, because a rejected entry is a working document again. posted has exactly one exit and it is not draft — the one-way door from the theory section, expressed as an absence. reversed is terminal.

Note the edge that is not there: nothing goes from draft straight to posted. Every posting passes through approval, which is the separation of duties from the theory section encoded as a shape rather than written down as a policy. A sole trader or a one-person finance team still has to post their own work, and that is a permission — a role allowed to approve entries it prepared — not an extra route through the map. Model it as a permission and the log still records both transitions and who made them. Model it as a shortcut edge and the approval simply never happened, in a system whose whole value is that it can say what happened.

The transition function is the only mutator

Declaring legal transitions achieves nothing if code can assign status directly. So status has exactly one writer:

ts
transition(entryId, to, actor, reason?)

It checks the move against TRANSITIONS, records who did it and when, runs the side effects, and commits all of it in one database transaction. Nothing else touches the column. This is the same chokepoint pattern as the single GL write path from module 1, applied to a different invariant: funnel the writes and the rule stops being optional.

Concurrency makes the chokepoint mandatory rather than tidy. Two users click post on the same approved entry at the same moment. Read-then-write loses the race: both reads see approved, both proceed, and the ledger gets the entry twice. The fix is to make the check and the write one atomic operation — a compare-and-swap:

sql
update journal_entries
   set status = 'posted'
 where id = $1
   and status = 'approved'
   and client_id = $2;   -- one tenant column, never both, never neither

If that reports zero rows updated, someone else got there first — or the entry is not yours — and you stop. The current state is the concurrency token.

The tenant predicate has to be inside this statement. Load the entry, check its tenant in application code, then update on id alone and you have reopened the race you just closed, with a worse failure than before: the row you write is not provably the row you checked. Cynco builds the predicate with buildTenantFilter() from ~/utils/tenant.server.ts, which resolves to exactly one of clientId or accountingFirmId — the other must be absent, and a query carrying both or neither is a defect, not a broader search. This is the same shape as idempotent document-to-GL posting: posting an invoice twice must not double the ledger, and the guard is the state, not a hopeful check earlier in the request.

Posting must record what it invalidated

The last piece is the one most designs miss. Posting is not only a status change — it dirties derived data.

Financial statements are pure functions over posted entries, which makes them cacheable. And a back-dated entry posted today into the March period invalidates every derived figure for March and every period after it: the March P&L, the closing balance sheet, Q1, the year to date.

So the transition has to record the reporting period it affected, not just the timestamp of the click. Without that, cache invalidation has two options and both are bad: recompute everything on every post, or serve a stale March statement that nobody knows is stale. With it, invalidation is a query — every cached report whose period range includes the affected period is dropped.

A state machine's transitions are the natural place to hang side effects, precisely because they are the only moments the system's meaning changes.

Two more things fall out for free. The transition log is your audit trail — who submitted, who approved, when it posted — which booleans cannot express at all, since a flag flipping leaves no record of the flip. And permissions attach to edges: approval rights live on pending_approval → approved rather than being scattered across route handlers.