Receivables and payables as subledgers

Why 'who owes me' lives beside the GL rather than inside it.

About 15 minutes

TheoryWhy this exists

Your trial balance has a line on it: Accounts receivable — RM8,000. It is correct, it is useful for the balance sheet, and it cannot answer the only question you actually have on a Monday morning: who owes me that, and how late are they?

RM8,000 owed by one customer sixty days overdue and RM8,000 spread across four customers who all pay on time are the same number and completely different businesses. The general ledger has no way to tell you which one you are.

The naive fix, and why it collapses

The instinct is to split the account. If you need per-customer detail, create per-customer accounts: "Receivable — Lim Enterprises", "Receivable — Tan Trading", one for each. The GL already supports as many accounts as you like.

Try it for a year and watch what happens.

  • The chart of accounts becomes unreadable. A chart with 15 meaningful accounts and 400 customer accounts is not a classification scheme any more, it is a contact list with balances.
  • It never stops changing. Every new customer is a schema change to your chart of accounts. Every customer who leaves is either a dead account you must keep forever or a deletion that orphans historical entries. The chart of accounts is supposed to be the stable part of your books — the thing whose comparability across years lets you say "marketing spend is up 12%".
  • It conflates two different kinds of fact. An account answers what kind of thing is this? — an asset, a category of expense, a form of equity. A customer answers who is the counterparty? Those change on completely different timescales and are used by completely different readers. Merging them means neither dimension can be queried cleanly: you cannot ask "total receivables" without summing 400 accounts, and you cannot ask "everything involving Lim Enterprises" without knowing that Lim also appears in deposits and credit notes.

The failure here is a general one. Putting a high-cardinality, fast-churning identity into a low-cardinality, slow-changing classification destroys the classification.

Subledgers: one account, detail beside it

The solution splits the two dimensions apart.

The GL keeps one receivables account. It is called a control account, and its job is to carry the total. Alongside it, outside the GL, sits the accounts receivable subledger: one record per customer, with the individual invoices, credit notes and payments that make up each balance.

Binding them is a single invariant:

The control account balance equals the sum of all balances in its subledger. Always, exactly.

That is not documentation. It is a check you can run, and it is the reason the design is safe rather than merely tidy. If the AR control account says RM8,000 and the customer balances add to RM7,650, you have not discovered a business fact. You have discovered a defect — an invoice that posted to the GL but never landed in the subledger, a payment applied twice, a manual entry someone posted straight to the control account bypassing any document. There is no legitimate state of the world in which those two figures disagree, which is what makes the comparison worth automating.

Payables works identically, mirrored: one Accounts payable control account, one subledger record per vendor.

The invoice lifecycle through this lens

Now the double-entry mechanics of a sale become easy to reason about, because there are two separate events, not one.

You issue an invoice for RM8,000. You have done the work; the customer owes you. Two facts:

  • Debit Accounts receivable RM8,000 — a new asset, a right to collect.
  • Credit Revenue RM8,000 — you earned it.

Revenue is recognised here, at the point of performance, not when the money shows up. Simultaneously, an RM8,000 entry appears in that customer's subledger record.

Three weeks later they pay. Nothing new has been earned. What changed is the form of the asset:

  • Debit Bank RM8,000 — cash arrives.
  • Credit Accounts receivable RM8,000 — the right to collect is extinguished.

Total assets are unchanged. Revenue is untouched.

That second point is the single most common beginner error, and it comes from bank-statement thinking: money landed, so surely that is income. Credit revenue again and you have recorded RM16,000 of sales for RM8,000 of work, and the receivable never clears — it sits there forever, quietly wrong, on a balance sheet that still balances.

Revenue is credited exactly once per sale, at issue. Everything after that is asset shuffling.

PracticeDoing it in Cynco

You have been using subledgers since the first time you raised an invoice. Every invoice in Cynco is an AR subledger document; every bill is an AP one. What follows is how to read them as such.

Where each dimension lives

  • Sales → Invoices is the AR subledger, document by document: who, how much, when it is due, what has been paid against it.
  • Sales → Customers aggregates it per counterparty. A customer's page is their subledger record — the invoices, the payments applied, the balance outstanding.
  • Purchases → Bills and Purchases → Vendors are the same two views for payables.
  • Accounting → Journal is the GL side. The control account totals live here, and so do the entries each document posted.

Different questions belong to different places, which is the point of the split. "How much are we owed altogether?" is a GL question — it is one control account balance. "How much of that do we expect to collect?" and "should we chase Lim Enterprises before doing more work for them?" are both subledger questions: they need the individual invoices, how old each one is, and what you know about the customer behind it.

Aging

Reports → AR aging buckets outstanding invoices by how overdue they are. It is a pure subledger report: the GL knows the RM8,000 total and nothing about the dates behind it.

Read it as an operational document rather than an accounting one. RM8,000 sitting in the 90-day-plus column is still a receivable, and stays one until it is collected, provided against under your impairment policy, or written off. What the age tells you is how likely you are to see the money — collection risk, not recognition. Reducing the carrying amount is a separate, deliberate decision: you reach it from the aging report and then record it in the ledger.

The reconciliation habit

Once a month, before you consider the period closed, compare two figures:

  1. The AR control account balance in the GL.
  2. The total of customer balances in the subledger.

Then the same for AP and vendors. They should be identical. If they are not, stop — do not close the period and do not send the accounts anywhere. A gap means something posted to one side and not the other, and the most common cause is a manual journal entry posted directly to the control account, bypassing any invoice. That entry is real in the GL and invisible to every customer statement, so the difference will not resolve itself.

Cynco surfaces both numbers, though which report you get them from depends on how your books are set up. Find the pair once and note where. The habit matters more than the route.

Try it yourself

In a test context, follow one sale all the way through:

  1. Sales → Invoices — issue an invoice for RM2,400 to any customer.
  2. Accounting → Journal — find the entry it posted. Confirm: receivables debited RM2,400, revenue credited RM2,400.
  3. Back on the invoice, record a payment of RM2,400.
  4. Accounting → Journal again — find the second entry. Bank debited RM2,400, receivables credited RM2,400.
  5. Now the actual test: search both entries for revenue. It appears once, in step 2.
  6. Open the customer's page and confirm their balance is zero.

Two entries, four lines, one credit to revenue, and a receivable that opened and closed.

Then do the mirror version with a bill from Purchases → Bills, and notice that the expense is recognised once, at the bill, not when you pay it.

TechnologyHow it is built

The control-account pattern is, in database terms, a deliberate denormalisation guarded by a reconciliation check. That framing explains both why it is designed this way and where the bugs live.

Documents are not ledger rows

An invoice has a life of its own. It is drafted, edited, issued, emailed, partially paid, perhaps disputed, credited, or written off. It has a due date, payment terms, line items with tax codes, a PDF someone downloaded. None of that belongs in a ledger entry, whose job is narrow: an immutable statement of two or more balanced movements between accounts on a date.

So they are separate tables with separate rules:

text
invoices            id, customer_id, issue_date, due_date, status, total, ...
invoice_lines       invoice_id, description, quantity, unit_price, tax_code
journal_entries     id, date, narration, posted_at
journal_entry_lines entry_id, account_id, debit, credit

An invoice posts to the GL; it is not stored in it. That is what lets the two obey different rules. Invoices are mutable while in draft and carry rich, evolving structure. Ledger entries are append-only and structurally boring, which is exactly what makes them cheap to verify.

It also localises churn. Adding a field to invoices — a payment-terms code, a delivery address — cannot destabilise the ledger, because the ledger never learned what an invoice was.

Idempotency, or how revenue silently doubles

Posting an invoice to the GL is a write triggered by an event, and events in distributed systems arrive more than once:

  • The user double-clicks Issue.
  • The HTTP request times out client-side; the client retries; the original succeeded.
  • A payment webhook is redelivered because your 200 was lost.
  • A background job is retried after a deploy killed the worker mid-flight.

Run the posting logic twice and you get two balanced, valid journal entries for one invoice. Revenue is now RM4,800 for RM2,400 of work, and receivables are overstated by the same amount. Nothing failed loudly. Both entries balance. Every arithmetic check passes.

Immutability makes this worse rather than better: you cannot delete the duplicate. Cleanup means posting a reversal and explaining it in a narration forever.

The fix is an idempotency key — a uniqueness claim on the effect, not the request. Conceptually, a posting is identified by what it is for:

sql
create unique index on gl_postings (source_type, source_id, purpose);
-- ('invoice', 4471, 'issue')   → at most one issuance posting, ever
-- ('invoice', 4471, 'payment_9912') → at most one posting per payment

The second attempt now hits a unique-constraint violation instead of writing a duplicate. The caller treats that violation as success — the desired state already exists — and returns the existing entry. The database, not the application's memory of what it has already done, is what makes this safe: two concurrent workers can both pass a SELECT-then-INSERT check, but they cannot both win a unique index.

In Cynco, document-to-GL posting is idempotent for this reason. Note where the guarantee has to live: inside the single write path. An idempotency check that each calling feature implements for itself is a check the next feature forgets.

Any write triggered by an event that can be redelivered needs an idempotency key. In financial software, "we haven't seen a duplicate yet" is not evidence; it is a duplicate you have not found.

Proving control = subledger

The invariant from the theory section is a query:

sql
-- Illustrative client-scoped form. $1 = the client id, $2 = the as-of date. Both
-- sides, identically. A firm-scoped tenant swaps client_id for accounting_firm_id.
select
  (select coalesce(sum(l.debit) - sum(l.credit), 0)
     from journal_entry_lines l
     join journal_entries e on e.id = l.entry_id
     join accounts a on a.id = l.account_id
    where a.code = 'AR_CONTROL'
      and a.client_id = $1
      and e.client_id = $1
      and e.status = 'posted'
      and e.date <= $2)                              as gl_balance,
  (select coalesce(sum(i.total - i.amount_paid), 0)
     from invoices i
    where i.client_id = $1
      and i.status in ('issued', 'partially_paid')
      and i.issue_date <= $2)                        as subledger_total;

Two predicates in there are the whole difference between a check and a coincidence.

Exactly one tenant column. Drop the tenant filter and the query sums every tenant's ledger against every tenant's invoices. It will often still come out at zero, which is worse than failing, because it reports health it cannot see. In Cynco this predicate comes from buildTenantFilter(), which picks whichever column scopes the table — and the schema guarantees the pair is exclusive, with a CHECK constraint requiring client_id XOR accounting_firm_id on every tenant-scoped table. Wherever it comes from, it belongs on every table in the query rather than only the one you thought of.

The same cutoff on both sides. An as-of date on one side and current state on the other produces a difference for every invoice issued in the gap, and none of them are bugs. Note the honest limit of the version above: amount_paid is current state, not state as of $2, so it is a correct comparison only when $2 is today. A true historical as-of has to derive the paid figure from payment allocations dated on or before the cutoff — which is more work, and the reason most systems run this check as of now, nightly.

Any remaining non-zero difference is a bug, and the shape of the difference usually names it: a round number often means a manual entry posted straight to the control account; a difference equal to one invoice means a posting that failed and was never retried; a difference equal to exactly double an invoice means the idempotency gap above.

Run it on a schedule, not on request. An on-demand check catches breakage the day someone thinks to look, by which point the accounts may already have been filed. A nightly check bounds the damage to one day and tells you when the drift started, which is most of the diagnosis. As with the balance-chain reconciliation from the previous module: a consistency check is worth about as much as its frequency.

Design consequence worth internalising: if you allow manual journal entries to hit a control account directly, you have made the invariant breakable by ordinary user action. Most systems restrict posting to control accounts to the document engine for exactly that reason.