Deriving the P&L and balance sheet

Splitting the trial balance into two statements that meet at net income.

About 16 minutes

TheoryWhy this exists

You have a trial balance. Eight accounts, two columns, both totalling RM80,000. It proves the arithmetic holds and answers no question anyone asked.

The two questions people actually ask are the ones from module 2: did the business make money, and what is it worth. Both answers are already in front of you. What remains is a single act of sorting.

Sort by type and two statements fall out

Every account has a type, and the type already encodes which question the account answers.

  • Income and expense accounts measure what happened during a stretch of time. Put those rows on the profit and loss statement.
  • Asset, liability and equity accounts state what is true at a moment. Put those rows on the balance sheet.

That is the entire split. No account needs a second decision, nothing is computed, nothing is looked up. The classification you did when you created the chart of accounts is what makes the statements derivable at all.

The P&L for January

Income minus expenses:

RM
Sales revenue18,000
Total income18,000
Rent expense3,500
Wages expense6,200
Total expenses9,700
Net income8,300

RM8,300 of profit. Note what this figure covers: January, the whole of it and nothing else. It is a statement about a duration. "Sales revenue of RM18,000" is meaningless without the period attached, in the same way "travelled 60 kilometres" is meaningless without knowing over how long.

Note too that RM8,300 appears nowhere in the trial balance. There is no net income account. It is the result of a subtraction performed while producing the report, and the moment you close the report it ceases to exist.

The balance sheet, and why it balances

Now the other rows:

RM
Cash at bank50,300
Accounts receivable8,000
Equipment12,000
Total assets70,300
Accounts payable12,000
Total liabilities12,000
Owner's capital50,000
Net income for the period8,300
Total equity58,300

And the check:

Assets 70,300 = Liabilities 12,000 + Equity 58,300

Look closely at what made that work. Posted equity is only RM50,000 — the owner's capital, the sole equity entry in the ledger. Assets less liabilities is RM58,300. Those differ by exactly RM8,300, which is the P&L's bottom line.

This is the mechanical reason the balance sheet balances, and it is worth sitting with, because most people are taught the equation as an article of faith. The trial balance's two columns are equal. You divided its rows into two groups. Therefore the imbalance left in one group is precisely the imbalance left in the other, with the sign flipped. Net income is not added to the balance sheet to make it work out — it is the missing piece by construction, the debit-credit difference that the income and expense rows carried away with them.

Which is why a balance sheet that fails to balance is not something you fix by re-entering data. Be precise about how far the guarantee reaches: a trial balance that balances, plus a correct split, forces the equation in this derivation. Nothing more. So a failure means one of those two premises broke — an account with no type, or a type mapped to the wrong statement; a balance sheet date that does not match the P&L range; two sides rounded independently. The common thread is that the fault is in how the statement was produced, not in the entries underneath it.

A period and an instant

These two statements are not two views of the same shape.

  • The P&L covers 1 January to 31 January. Change the range to a fortnight and every figure changes.
  • The balance sheet is as at 31 January, 23:59. It has one date, not two. Cash of RM50,300 is what was there at that instant, accumulated since the business began.

Which is why the P&L resets and the balance sheet does not. Come 1 February, sales must start from zero to measure February, while cash carries on exactly where it left off. Module 7 covers the entries that perform that reset.

The idea the whole course was building to

Neither statement is stored anywhere. Both were computed from the trial balance, which was itself computed from the journal entries. The entries are the only facts in the system; everything above them is derivation.

Post one more entry — RM800 of electricity — and both statements change immediately, with nothing to refresh or recalculate, because there was never a saved figure to become stale. Reverse an entry from three weeks ago and January's profit changes the next time anyone looks.

That is a real property of accounting systems, not an implementation choice, and it is why the immutability rules from module 4 matter so much. The entries are load-bearing. Everything a business reports about itself is a function of them.

Toggle entries on and off and watch both statements recompute from scratch.

PracticeDoing it in Cynco

Both statements live under Reports — Profit and loss and Balance sheet. The P&L takes a period, the balance sheet a single as-at date; each computes and shows the result. As with the trial balance, there is nothing to save, because nothing is being created.

Running them so they agree

The P&L asks for a date range. The balance sheet asks for a date — the instant you want the snapshot taken. To tie them together they must line up: if the P&L covers 1 to 31 January, the balance sheet must be as at 31 January. Mismatch them and the net income figures will not correspond, and you will spend an afternoon hunting a discrepancy you created in the date picker.

Once aligned, the connection is visible on the page. The bottom line of the P&L appears inside the equity section of the balance sheet, usually labelled as the period's result. Same number, two statements — the hinge the whole structure turns on. Check also that total assets equals total liabilities plus total equity; Cynco shows both totals so you can see it hold.

When the balance sheet does not balance

Assume it is not your fault, because it almost certainly is not. Work through the reasoning from the theory section: your entries balanced individually because posting enforces it, so the trial balance balanced, so splitting its rows leaves an imbalance in one group that exactly mirrors the other, and net income closes it. There is no user action anywhere in that chain.

If the totals differ, the useful next steps are diagnostic, not corrective:

  1. Run Reports → Trial balance for the same end date and check its two columns.
  2. Check whether any account has a type that does not match what it is used for. A misclassified account lands on the wrong statement.
  3. Note the figures, the date range and the difference, and report it.

Do not plug the gap with a journal entry. A balancing entry against a software fault hides the fault and leaves a fictitious posting in the ledger forever.

Try it yourself

  1. Open Reports → Profit and loss for the current month. Write down net income.
  2. Open Reports → Balance sheet as at the last day of that same month.
  3. Find your figure in the equity section. It is there.
  4. Confirm total assets equals total liabilities plus total equity.
  5. Now post something. A small expense entry in Accounting → Journal is enough — RM250 of stationery, debit the expense, credit cash.
  6. Re-run both reports. Profit dropped by RM250, cash dropped by RM250, and the balance sheet still balances. Nothing was recalculated on your behalf — the statements were derived afresh from a ledger with one more entry in it.

What to do with them

The P&L tells you whether the way you trade works. The balance sheet tells you whether you survive the next three months. The insight is in reading them together: profitable businesses run out of cash constantly, and the pair is what shows it happening — RM8,300 of profit alongside RM8,000 sitting in receivables nobody has paid.

TechnologyHow it is built

Financial statements are the best example in commercial software of a place where functional purity is not an aesthetic preference. It is what makes the numbers defensible.

Pure functions all the way down

Every derivation in this module has the same shape: data in, data out, no reads of ambient state, no writes at all.

ts
type Balances = ReadonlyMap<AccountId, Money>;

function profitAndLoss(balances: Balances, chart: Chart): PandL;
function balanceSheet(
    balances: Balances,
    chart: Chart,
    netIncome: Money,
): BalanceSheet;

Neither function touches a database, a clock, a session, or a feature flag. That has consequences worth naming:

  • They are trivially testable. Construct a handful of balances in memory, assert the output. No fixtures, no transactions to roll back, no test database.
  • They are safe to run anywhere. Concurrently, in a background job, on a read replica, in the browser.
  • They are deterministic. Given the same entries, two people in two countries get identical statements. That is a hard requirement for anything an auditor will look at.

The purity also lets you test the accounting equation as a property rather than as a handful of examples. Generate random balanced entries, derive both statements, and assert that assets always equal liabilities plus equity:

ts
import { test } from "@fast-check/vitest";

test.prop([arbitraryBalancedEntries()])(
    "balance sheet always balances",
    (entries) => {
        const bs = balanceSheet(
            trialBalance(entries),
            chart,
            netIncomeOf(entries),
        );
        expect(bs.totalAssets).toEqual(bs.totalLiabilities + bs.totalEquity);
    },
);

That test does not check that the derivation works on the case you thought of. It checks the invariant across thousands of cases you did not.

Never store net income

The tempting shortcut is a net_income column on a periods table, written when the period closes and read by every report thereafter. It is fast and it is wrong.

The moment a correcting entry lands in that period — a reversal, a late accrual, an adjustment an auditor asked for — the column is stale. Nothing errors. Reports return a figure that no longer matches the entries beneath it, and the discrepancy is found months later by someone who cannot tell which number to trust.

The rule generalises past accounting. If a value is a function of other stored values, storing it creates two sources of truth that must be kept in agreement forever, and no amount of care makes that free. Derive it, or cache it with an explicit invalidation path and the ability to prove the cache still equals the derivation. Never quietly duplicate it into a column.

Reports go stale the instant an entry lands

Caching derived statements is legitimate — recomputing over years of history on every page load is not viable. What makes it safe is knowing precisely when a cached result died.

For a financial report, the answer is exact: a report for a period becomes invalid when any entry is posted into that period. This is why the posting engine records which reporting periods a posting invalidated. Posting is the only write path into the ledger, so it is the only place that could possibly know, and centralising the knowledge there means a cache layer added later inherits correct invalidation instead of guessing at it.

Compare the alternatives. Time-based expiry gives you a window in which reports are knowably wrong. Manual refresh buttons make correctness a user responsibility. Neither is acceptable for a figure that goes on a tax return.

Generating a report must not write anything

One last trap, and it is a real one that appears in production systems.

A report function that lazily creates a missing period row, or stamps a last_generated_at, or writes an audit record inside the same code path, has stopped being a read. The symptoms are memorable:

  • The report fails against a read replica, or in a read-only transaction.
  • Two users opening the same report concurrently deadlock, or duplicate a row.
  • A report run during a closed period mutates that period, which was the one thing the close was supposed to prevent.
  • A colleague adds retry-on-failure to the report endpoint and it now writes twice.

If asking a question changes the answer, you no longer have a reporting layer — you have an undocumented write path.

Logging that a report was viewed is a fine requirement. Do it outside the derivation, in the request handler, and let the pure function stay pure. The separation is what keeps "run the balance sheet" a thing anyone can do at any time without wondering what it did to the ledger.