Back to blogRevenue recognition belongs in the ledger, not in BI

Revenue recognition belongs in the ledger, not in BI

Fintech·August 11, 2026·9 min read·By CodeDecoders Engineering

Revenue recognition is the rule that decides when cash you collected becomes revenue you earned. Those are two different events on two different dates, and the whole point of ASC 606 is to keep them apart: money can arrive in January for a service you deliver across twelve months, and only one twelfth of it is revenue in January. The rest is a liability you owe the customer.

If that liability exists only as a formula in your BI tool, you do not have revenue recognition. You have a chart. The correct home for it is the same double-entry ledger that already records the cash, because deferred revenue, unbilled receivables and recognition schedules are all ordinary postings. Model them that way and the ledger stays the single answer to "what did we earn". Model them in Looker and you get a number that nobody can tie back to a journal entry when diligence asks.

The open-source billing layer stops one step short

This is worth saying now because the billing layer moved this week. Lago (10.3k stars), Polar (10.2k), Akaunting (10.0k) and Invoice Ninja (about 10k) were all pushed inside seven days. Every one of them models metering, pricing and invoices properly. None of them recognises revenue.

That is not an oversight, it is a boundary. Lago's job ends at a finalised invoice and a payment gateway handshake, which is why it ships connectors to NetSuite and Xero rather than a recognition engine. Polar operates as merchant of record and owns receipts, dunning and VAT. Invoice Ninja is explicit that it is not a complete bookkeeping system and leans cash basis. The invoice lifecycle and the service period are genuinely different domains.

The problem is what teams do with that gap. They fill it with a query. Someone writes SUM(amount) / months_in_term in the warehouse, finance exports it to a spreadsheet, and for two years nothing visibly breaks. Then an auditor asks for the deferred revenue balance at 31 March and the reconciliation between three systems, and you discover that the invoice system, the dashboard and the general ledger disagree by a number nobody can explain.

Four accounts, and a 2x2 that explains all of them

The entire model is two independent questions asked about the same dollar: have you billed it, and have you earned it. Every combination is a different account.

Not yet earnedEarned
BilledDeferred revenue (liability)Accounts receivable and Revenue
Not billednothing posts yetContract asset / unbilled receivable, and Revenue

Under ASC 606-10-45, a contract liability (the standard's name for deferred revenue) arises when the customer pays or owes payment before you satisfy the obligation. A contract asset is the mirror image: you satisfied the obligation but your right to payment is still conditional on something other than the passage of time. Once it is unconditional, it stops being a contract asset and becomes an ordinary receivable.

Engineers usually meet the top-right and bottom-left cells first and assume they are edge cases. They are not. Annual prepaid plans live in the top-left forever. Usage-based billing lives in the bottom-left every single month, because you earn the revenue as the customer burns tokens and you invoice it three weeks later. If your schema has no place to put "earned but unbilled", your month-end revenue is wrong by design.

The postings for one annual contract

Take a 12,000 USD annual subscription invoiced on 1 January and paid on 5 January. Three separate postings, each triggered by a different real-world event, none of them derived at read time.

One annual contract, three kinds of posting

Step 1 of 5

Invoice finalises (1 Jan)

Debit Accounts receivable 12,000, credit Deferred revenue 12,000. You now owe the customer a year of service. No revenue exists yet.

Stripe's Revenue Recognition product is built exactly this way, on a double-entry ledger with a defined chart of accounts, and it is a useful reference implementation even if you never buy it. Its account list is longer than four (long-term deferred revenue for service periods beyond twelve periods, unbilled accounts receivable for prorations, FX loss for the rate drift between invoice and payment), but the shape is the same: every billing activity emits a balanced journal entry, and reports are folds over those entries rather than queries over invoices.

Where teams actually put this, and what each choice costs

Where the number livesDashboard SQLApp database tableJournal entries
Tie-out to trial balanceNoneManual exportBy construction
Restating a prior periodSilently, on refreshRow update, no trailReversing entry, full trail
Handles unbilled revenueUsually notSometimesYes, as a contract asset
Survives an audit sampleNoWeaklyYes
Cost to buildHoursDaysWeeks

The middle column is the trap, because it looks like real engineering. A revenue_schedules table with amounts per month is genuinely useful, but on its own it is a parallel truth. When someone patches a row to fix a proration, nothing anywhere records that a correction happened. The fix is small: make the schedule a plan for postings rather than a substitute for them.

Schema: schedules are just postings that have not happened yet

Five tables carry the whole model, and only one of them is new relative to a normal ledger.

  • revenue_contract: the customer agreement, its term, and the total transaction price. This is step 1 and step 3 of the five-step model.
  • performance_obligation: one row per distinct promise, with the transaction price allocated to it and the recognition method (ratable, point-in-time, usage). Steps 2 and 4.
  • recognition_schedule: one row per obligation per period, with period_start, period_end, amount, status and a nullable journal_entry_id. This is the new table, and it is a queue of future postings.
  • journal_entry and journal_line: the existing double-entry tables described in double-entry ledgers, the backbone of every fintech app. Lines sum to zero, entries are append-only.

The recogniser is then a boring scheduled job: select schedule rows where period_end <= now() and status = 'pending', post the debit and credit, write back journal_entry_id, flip the status. Run it with the same discipline you would give a payments endpoint, because a double-run silently doubles your revenue. A unique constraint on (schedule_row_id) in the journal table plus a request key gets you there, the same pattern covered in idempotency keys for payment APIs.

Teams already running an event-sourced core will notice this fits neatly on top: recognition becomes another projection driven by InvoiceFinalised, PeriodClosed and ContractAmended events, which is the approach discussed in core banking architecture with event sourcing and CQRS.

The invariants that make it defensible

Auditors are not testing your code, they are testing whether your numbers reconcile. Encode these as assertions and run them at every close:

  1. Every journal entry sums to zero. Non-negotiable, and cheap to enforce with a check constraint or a database trigger.
  2. For each obligation, the sum of its schedule amounts equals the transaction price allocated to it. Amendments change both sides or neither.
  3. The deferred revenue balance for a contract equals the sum of its pending schedule rows. This one catches almost every real bug, because it forces the ledger and the schedule to agree continuously rather than at year end.
  4. Cumulative recognised revenue for an obligation never exceeds its allocated price. A proration bug usually announces itself here first.
  5. No posting lands in a closed period. Corrections post to the current period as reversing entries, which is the difference between a restatement you can explain and one you cannot.

Invariant 3 is the one worth writing first. It is the same instinct behind a matching ladder in building a payment reconciliation engine that survives: you do not trust two systems to agree, you continuously prove that they do.

What actually breaks in production

Mid-term plan changes are the biggest source of pain. An upgrade on day 21 of a monthly cycle produces both a positive line item for the new plan and a negative one for the unused old plan, and both of them touch periods that have already partly closed. Stripe's own documentation walks through a downgrade that generates seven journal entries across two months for what the customer experiences as one click, which is a fair signal of the real complexity.

Usage-based pricing is the second. Revenue is earned continuously and billed in arrears, so at every month end you have earned revenue with no invoice behind it. That posts as unbilled receivable against revenue, then converts to ordinary AR when the invoice finalises. Teams that skip this understate revenue every month and then book a lumpy catch-up, which is exactly the pattern that makes a diligence team slow down.

Multi-currency is the third. The rate on the invoice date and the rate on the payment date differ, and the difference is an FX gain or loss, not a revenue adjustment. Booking it as revenue quietly corrupts your growth rate.

None of these are exotic. They are the normal life of a subscription business, and they are all tractable once recognition lives in postings rather than in a SELECT statement. The build is genuinely a few weeks, not a quarter, and the right time to do it is while you have a hundred contracts rather than ten thousand. If you are mapping billing to ledger and want a second pair of eyes on the schema before it hardens, our fintech engineering team does this work, and you can tell us what you are building.

Newsletter

New posts, in your inbox

Get an email when we publish a new deep-dive. No spam, unsubscribe anytime.

Start a Project

Let's build something extraordinary together.

Free consultation·Response within 24h·No commitment

info@codedecoders.io