Closing the books

Cutoffs, closing entries, and why last year's profit becomes this year's equity.

About 15 minutes

TheoryWhy this exists

On 5 February you send your January accounts to the bank: RM18,000 of sales, RM8,300 of profit. On 11 February a supplier bill dated 28 January turns up in the post and someone enters it. Nothing stopped them. January's profit is now RM7,500, and the statement the bank is holding describes a version of January that no longer exists.

Nobody did anything wrong. The books are more accurate than they were. And yet you have a serious problem, because a report you issued cannot be reproduced from your own records.

"Closing the books" is the answer to that, and it bundles two ideas that are worth pulling apart, because they solve different problems and happen on different schedules.

Idea one: the cutoff

The first is administrative. At some point after a period ends, you declare it closed, and from then on no entry may be dated inside it.

That is the whole mechanism. Its purpose is not tidiness — it is reproducibility. Once January is closed, any January report run today produces the same figures as the one produced in February, forever, because the set of entries that feed it can no longer change. Without a cutoff, every statement you issue has an implicit expiry date you cannot see.

The cost lands on judgement. That late supplier bill still has to go somewhere, and the question is materiality: would this figure change what a reader of the accounts decides?

Turnover alone does not answer it. A RM800 electricity bill against RM18,000 of monthly revenue looks like noise. The same RM800 against RM8,300 of profit is nearly a tenth of it, which few thresholds would call noise. Materiality gets weighed against profit and the size of the balance sheet as well as revenue, against whatever threshold the business has set as policy, and against who relies on the statements — a bank testing a loan covenant cares about a smaller number than an owner skimming a management pack. So deciding that this particular bill can go to February is a policy judgement someone makes and records. It is not a rule that small amounts may be deferred.

RM40,000 of unrecorded revenue clears any threshold anyone would set. There the right answer is to reopen January, correct it, and reissue the statements to everyone who received the old ones.

Which is why reopening must be an explicit, recorded act — never something that happens quietly because a piece of code needed it to. If a period can silently reopen, the cutoff guarantees nothing. Every reopening should leave a trace: who, when, why, and what was reissued.

Most businesses apply the cutoff monthly, once the bank is reconciled and the numbers reviewed.

Idea two: the year-end closing entries

The second idea is arithmetic, happens once a year, and is a genuinely different thing.

Recall from module 2 why income and expense accounts exist: they explain how equity moved during a period. That is their entire job. Sales of RM18,000 is a fact about a stretch of time, and the moment that stretch ends the counter has finished its work.

So consider what happens if you never reset them. On 1 January 2027 your Sales account still reads RM216,000 — the whole of 2026. Every 2027 P&L now measures 2026 plus however much of 2027 has elapsed, and there is no way to separate them. The accounts have stopped answering the question they were created to answer.

The reset is done with ordinary journal entries. Take each income and expense account's balance and post the opposite side of it, sending the difference to retained earnings in equity:

DebitCredit
Sales revenue18,000
Rent expense3,500
Wages expense6,200
Retained earnings8,300

Sales was credit-natured, so debiting it RM18,000 brings it to zero. The expenses were debit-natured, so crediting them zeroes those. The RM8,300 that makes the entry balance goes to retained earnings — and that RM8,300 is exactly the net income the P&L reported.

This is why last year's profit becomes this year's equity, and it is not a rule someone imposed. Profit was always the owners' claim; the income and expense accounts were only ever a temporary breakdown of how that claim changed. Closing them collapses the detail back into the single equity figure it was always describing.

Afterwards, income and expenses start at zero and can measure the new year cleanly. Assets, liabilities and equity carry straight over untouched, because a bank balance on 1 January is the same balance it was on 31 December.

The close is not a special operation

The most important structural point: those closing entries are journal entries. Debits, credits, a date, a narration. Nothing about them is privileged.

That has consequences you want. They appear in the journal like everything else, so you can read them. They are dated, so you can see when the close ran. They reverse by posting an opposing entry, under the same immutability rules as anything else — no escape hatch, no database surgery.

A close that cannot be inspected in the journal is a close nobody can audit.

If the year-end close were a hidden operation that rewrote balances in place, you would have no way to verify it happened correctly and no way to undo it if it did not. Making it ordinary entries is what keeps the ledger's guarantees intact through the one moment in the year when it is most tempting to break them.

PracticeDoing it in Cynco

Periods are managed under Accounting → Periods, where you will see the periods your financial year is divided into, each open or closed. Closing one is a deliberate action; reopening one is a separate deliberate action that Cynco records.

The pre-close checklist

Everything on this list is cheaper to fix before the door shuts than after. That is the entire reason for having one.

  • Unposted drafts. Check Accounting → Journal filtered to drafts. Anything dated in the period gets posted now or gets a decision about where it belongs. A draft stranded behind a closed period is an awkward conversation later.
  • Unreconciled bank lines. Under Banking, find transactions in the period not yet matched. Unreconciled cash is the commonest cause of a reopening, because it is where missing transactions surface.
  • Control account gaps. Receivables should equal what customers actually owe you, payables what you owe suppliers. If either is off, something was posted straight to the control account.
  • Accruals and prepayments. Expenses incurred but not billed, and costs paid in advance belonging to a later period. Skip these and profit is wrong in a way no arithmetic check catches.
  • Run the statements. Read them as a person, not a machine. A figure three times last month's deserves an explanation before you lock it in.

Then close the period. From that point, postings dated inside it are refused.

The year-end close

At year-end there is the additional step from the theory section: zeroing income and expense accounts into retained earnings. Cynco generates the closing entries — a convenience, not a black box, because the output is journal entries you can read.

So read them. Open Accounting → Journal for the closing date. You should see one line per income and expense account that had a balance, each posted in the direction that brings it to zero, and a balancing line to retained earnings equal to the year's net income.

Then run the P&L for the new year's first day: every figure is zero. Run the balance sheet: unchanged from the day before, except the prior year's profit now sits in retained earnings rather than as a current-period result.

Try it yourself

If you have a prior year in your books:

  1. Find the year-end date and open Accounting → Journal for it. Locate the closing entries.
  2. Pick one income account and note the amount on its closing line.
  3. Run Reports → Profit and loss for that full financial year and find the same account. The figures match — the closing entry is exactly the balance the P&L reported.
  4. Add up all the closing lines: income one side, expenses the other, and the difference is what went to retained earnings. That is the year's profit, moved into equity in one entry.

Tracing that once makes the year-end close permanently unmysterious.

If you need to reopen

Sometimes you must — an auditor finds something material, or a large transaction was missed. Reopen explicitly, post the correction, close again, then the part people skip: tell whoever received the old statements that they are superseded. Corrected accounts only help if the people acting on the old ones find out.

TechnologyHow it is built

A closed period is a rule about writes: no entry may be dated inside it. Where you enforce that rule is an instructive engineering question, because the two obvious answers are each individually insufficient.

Two layers, and why one is not enough

In the application, the check sits in the posting engine — the single write path into the ledger. It runs before anything is stored, it knows the full context of the attempted posting, and it can say something useful:

ts
if (period.status === "closed") {
    throw new PostingError(
        `Cannot post to ${period.label}: closed on ${period.closedAt}. ` +
        `Post to the current period instead, or ask an administrator to reopen it.`,
    );
}

That message is the reason to have the layer: a user who hits it knows what happened and what to do. But it protects only the paths that run through the application. A data migration, a remediation script, a backfill job, a new code path written by someone who did not know the rule existed — each can reach the tables directly. The check is advisory in exactly the situations where you most need it absolute.

In the database, the guard applies to every connection whatever code opened it, and it is still there in five years when everyone who wrote the application layer has moved on. Be exact about the mechanism, though. A CHECK constraint sees only the row being written, and "is this period closed?" is a fact in a different table — so a CHECK cannot express this rule at all. What can is a trigger: a before insert or update function on the ledger tables that reads the period row and raises. Cynco enforces the closed-period rule at both levels for this reason.

Be equally exact about how far that reaches, because "unbypassable" is not true and believing it is its own risk. A trigger holds against the ORM, against a psql session, against a migration or a 2am backfill — as long as all of them connect as a role with INSERT/UPDATE and nothing more. It does not hold against the table owner or a superuser, who can alter table ... disable trigger, drop the function, or reshape the schema underneath it. So the honest statement of the guarantee is: a closed period is unwritable by anyone who does not hold DDL rights on the ledger tables. Which makes the privilege split part of the control rather than an infrastructure detail — the application's role can write rows and cannot alter schema, and owner-level access is a separate credential you audit.

So you want both layers, and you want to be clear about their different jobs:

  • The application check exists to produce a good error message. It will be bypassed occasionally.
  • The database guard exists to make the rule true for every writer below the privilege line. It will produce a bad error message occasionally.

This generalises to any invariant that must not be violated. The friendly check is the user interface; the lower check is the guarantee, and its strength is exactly the privilege boundary it sits behind. Treating either as sufficient alone is how a rule you believed was enforced turns out not to have been.

Retained earnings as an accumulator

Notice what the year-end close does to retained earnings: it adds the year's profit to whatever was there before. The account is an accumulator whose balance is the sum of every year the business has traded. Which gives you a property that must always hold:

Retained earnings at any date equals the sum of all closing entries posted up to that date. If it does not, either a close was skipped or something was posted to retained earnings directly.

A direct posting to retained earnings is almost always a mistake — it is a figure that should only change by way of a close. Worth a warning in the interface.

Idempotency and reversibility

Closes fail halfway: the process times out, the connection drops, a deploy lands mid-run. So the close needs two properties that are easy to skip when writing the happy path.

Idempotent. Running it twice must not post the entries twice. Doubling every closing entry would double retained earnings, and the trial balance would still balance, so nothing would object. The usual mechanism is a deterministic idempotency key derived from the tenant and the period, with a unique constraint behind it, so the second attempt collides instead of duplicating. Tenant scope belongs in that key — two tenants closing the same period must not collide.

Reversible. The close is journal entries, so it reverses as any posting does: post the opposite entry, leaving both in the ledger. No editing, no deleting, no special-cased undo.

Note the ordering that the guard above forces on you. A reversal of the close is dated inside the closed period, so the trigger refuses it — correctly. Reopening is therefore a separate, explicitly authorised operation that runs first, recording who reopened the period and why, as the theory section requires. Only then does the reversal post like any other entry. Reopening as an implicit side effect of a reversal would mean the cutoff yields to whichever code path needed it to, which is the same as having no cutoff.

Where does "closed" live?

State on a period row — a periods table with a status column. Explicit, queryable, easy to attach closedAt, closedBy and a reopening history to. The cost is that periods must exist as rows before anything can be posted, and a missing row is a new failure mode.

Derived from a date — one books_closed_through date per tenant, and any entry dated on or before it is refused. Fewer moving parts, no rows to create. But you cannot close March while February is open, and you have nowhere natural to record who closed what.

State on a row usually wins in a system that must be auditable, because the audit trail needs somewhere to live. Deriving it from a date looks simpler until you enumerate what the close has to remember — and by then you are storing the row anyway, with less thought behind it.