Revenue Recognition (ASC 606 / IFRS 15)

Billing tells you what a customer owes; revenue recognition tells you what you have actually earned, and the two almost never agree on any given day. When a customer pays $1,200 upfront for an annual plan, you have $1,200 in cash but zero dollars of revenue — you have a $1,200 obligation to deliver twelve months of service. This page is part of Tax, Compliance & Revenue Recognition and covers how a SaaS billing system implements ASC 606 (and its near-identical international twin IFRS 15): how it separates a deferred revenue liability from recognized revenue, models performance obligations, builds recognition schedules, and keeps those schedules correct through upgrades, downgrades, and refunds. Get this wrong and your income statement is fiction; get it right and every billing event mechanically produces an auditable recognition entry.

The core mental model is a five-step pipeline that ASC 606 prescribes: identify the contract, identify the performance obligations within it, determine the transaction price, allocate that price across the obligations, and recognize revenue as each obligation is satisfied. For a flat monthly or annual SaaS subscription, the obligation is “provide access to the service for the term,” it is satisfied continuously over time, and so revenue is recognized ratably — usually straight-line daily — across the service period. The interesting engineering lives in the edges: mid-term plan changes that re-slice the remaining schedule, refunds that reverse unearned revenue, and the daily job that has to be exactly-once so you never double-recognize a cent.

The single most important architectural decision is to keep the recognition schedule strictly separate from the billing schedule, even though both derive from the same subscription_id. The billing schedule answers “when do we charge the card and for how much,” and it is driven by next_billing_date, dunning retries, and payment state. The recognition schedule answers “when have we earned this money,” and it is driven only by the passage of service time. These two clocks diverge constantly: a customer who pays an annual invoice on day one has recognized nothing yet, while a customer on net-30 terms who has not paid a cent may already have earned three weeks of revenue against a contract_asset. Engineers who collapse the two into one table inevitably corrupt the income statement the first time a payment fails, because a failed charge must not un-earn service that was actually delivered. Treat the invoice total_amount as the only handshake between the two systems, and let recognition run on its own service-time clock from there.

One more framing worth internalizing before any code: revenue recognition is a projection of billing events, not an authority over them. The recognition engine never decides prices, never issues refunds, and never cancels subscriptions — it observes those decisions after the fact and produces the matching journal entries. This makes it safe to rebuild the entire recognition ledger from scratch by replaying the immutable invoice and modification history, which is exactly what you want during an audit or after fixing a schedule-generation bug. If your recognition logic ever needs to reach back into billing to mutate an invoice, you have almost certainly put a decision in the wrong layer.

Prerequisites

Recognition is a downstream consumer of billing, so it needs the accounts to post against, immutable invoice amounts, UTC service periods, a schedule table, and an idempotent daily job. The stack lists them before the checklist.

Rev-rec prerequisites Deferred and recognized revenue accounts, immutable invoice totals, UTC service periods, a schedule table, and an idempotent daily job underpin recognition. Recognition engine Rev accounts deferred/recog Invoice total immutable Service period UTC bounds Schedule table planned amounts Daily job idempotent
Five foundations — the deferred/recognized split and the idempotent daily job are what keep the income statement honest.

Architecture & Data Flow

Recognition is a downstream consumer of billing events, never a side effect baked into invoicing. When an invoice is finalized and cash (or an accounts-receivable balance) is created, the full amount lands in a deferred revenue liability. A schedule is generated that plans how that liability will be drawn down day by day across the service period. A daily job then “earns” one day’s slice at a time, debiting deferred revenue and crediting recognized revenue. The invoice amount is the input; a stream of small recognition entries spread over months is the output.

Revenue recognition timeline An invoice posts its full amount to a deferred revenue liability, and a daily recognition job draws that liability down into recognized revenue evenly across the service period. Invoice / cash $1,200 annual Deferred revenue liability $1,200 Recognized revenue bill earn daily Service period: 365 days, straight-line recognized so far still deferred day 0 today day 365 deferred + recognized always equals the invoiced amount
Cash arrives once; revenue is earned one day at a time, and the liability shrinks as recognized revenue grows.

The invariant that makes the whole system auditable is that, for any contract on any date, deferred_balance + recognized_to_date == invoiced_amount (less refunds). If that equation ever fails to hold, you have a recognition bug, and it is far better to catch it with a nightly assertion than during an audit.

Contract liabilities versus contract assets

The word “deferred revenue” is the everyday name for a contract liability: you have been paid ahead of delivering the service. The mirror image, which surprises teams the first time it appears, is the contract asset — you have delivered service ahead of having the unconditional right to bill for it. Monthly-in-arrears usage plans produce contract assets routinely: on the 20th of the month you have earned twenty days of metered service but you will not invoice until the 1st, so the earned amount sits in an unbilled receivable rather than in cash or accounts receivable. Both balances flow through the same recognition machinery; the only difference is which balance sheet account the recognition entry draws down. Model this by letting a schedule’s funding source be either an issued invoice_id or a pending accrual, and by posting recognition against contract_asset until the invoice materializes, at which point you reclassify the accrued balance into accounts receivable. Conflating the two — treating every earned dollar as if it were already invoiced — overstates receivables and hides genuine billing lag from finance.

Where the ledger postings actually land

Every recognition event is a two-line journal entry, and getting the account mapping right up front saves painful restatements later. At billing time the entry is a debit to cash or accounts_receivable and a credit to deferred_revenue for the full invoice total_amount; no revenue touches the income statement yet. Each night the recognition job posts the opposite half against deferred: a debit to deferred_revenue and a credit to recognized_revenue for that day’s slice. Because the crediting side of the nightly entry is the only place recognized revenue is ever created, the income statement for any period is simply the sum of recognized_revenue credits dated within that period — a query that a range-scan index answers in milliseconds. Keep tax entirely out of this flow: sales tax and VAT are collected as a liability at billing time and never become revenue, so a schedule’s total_amount must be the net-of-tax figure. Feeding a tax-inclusive total into the recognition engine is one of the most common ways an early-stage billing system quietly overstates revenue by seven to twenty percent.

Implementation Walkthrough

The four steps model the schedule, generate it at billing time, run the daily earning job, and re-derive on modifications. The ASC 606 five-step model sits behind it — identify contract and obligation, price it, allocate, and recognize over time. The diagram shows the five-step model feeding the daily engine.

ASC 606 five-step model Identify the contract and performance obligation, determine and allocate the transaction price, then recognize ratably via the daily job. 1 Contract identify 2 Obligation access/term 3 Price transaction 4 Allocate across POs 5 Recognize ratably
The five-step model resolves to "recognize ratably over the term" for a flat subscription — the daily job does step five.

1. Model the schedule table

The schedule is the plan. Store one row per recognition period (daily for precision, or monthly if your auditors accept it) so the daily job is a simple lookup rather than an arithmetic re-derivation every night.

CREATE TABLE rev_rec_schedules (
    schedule_id      UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    subscription_id  UUID NOT NULL,
    invoice_id       UUID NOT NULL,
    performance_obligation TEXT NOT NULL,   -- e.g. 'subscription_access'
    service_start    DATE NOT NULL,
    service_end      DATE NOT NULL,         -- inclusive last service day
    total_amount     BIGINT NOT NULL,       -- cents, immutable original price
    recognized_amount BIGINT NOT NULL DEFAULT 0,
    status           TEXT NOT NULL DEFAULT 'active',  -- active | completed | reversed
    created_at       TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at       TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE rev_rec_entries (
    entry_id         UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    schedule_id      UUID NOT NULL REFERENCES rev_rec_schedules(schedule_id),
    recognition_date DATE NOT NULL,
    amount           BIGINT NOT NULL,       -- cents recognized on this date
    created_at       TIMESTAMPTZ NOT NULL DEFAULT now(),
    -- exactly-once guard: one entry per schedule per day
    UNIQUE (schedule_id, recognition_date)
);

The UNIQUE (schedule_id, recognition_date) constraint is what makes the daily job idempotent — a re-run for a date that already posted simply violates the constraint and is skipped.

2. Generate the schedule at billing time

When an invoice is finalized, compute the per-day amount with integer arithmetic and carry the rounding remainder onto the final day so the schedule sums exactly to the invoiced total.

from dataclasses import dataclass
from datetime import date, timedelta

@dataclass
class DailyPlan:
    recognition_date: date
    amount: int  # cents

def build_daily_plan(total_cents: int, start: date, end: date) -> list[DailyPlan]:
    days = (end - start).days + 1            # inclusive
    base = total_cents // days
    remainder = total_cents - base * days    # cents that don't divide evenly

    plan: list[DailyPlan] = []
    for i in range(days):
        amount = base + (1 if i >= days - remainder else 0)  # ✅ remainder on the tail
        plan.append(DailyPlan(start + timedelta(days=i), amount))

    assert sum(p.amount for p in plan) == total_cents  # never lose a cent
    return plan

You do not have to materialize one row per day if storage matters — many teams store only the schedule header and the running recognized_amount, and compute the daily slice on the fly. Materializing entries, though, gives you a clean audit trail and trivially answers “how much did we recognize on March 14?”

3. Run the daily recognition job

The job walks every active schedule whose service window includes the run date, posts the earned slice as a ledger entry, and bumps the running total — all inside one transaction per schedule so a crash mid-run leaves no half-state.

def recognize_for_date(run_date: date) -> None:
    schedules = fetch_active_schedules_covering(run_date)
    for s in schedules:
        slice_cents = daily_slice(s, run_date)  # base + tail remainder logic
        try:
            with db.transaction():
                # ✅ unique (schedule_id, recognition_date) blocks double-posting
                insert_rev_rec_entry(s.schedule_id, run_date, slice_cents)
                post_ledger_entry(
                    debit="deferred_revenue",
                    credit="recognized_revenue",
                    amount=slice_cents,
                    subscription_id=s.subscription_id,
                )
                bump_recognized_amount(s.schedule_id, slice_cents)
        except UniqueViolation:
            continue  # ⚠️ already recognized for this date — safe no-op

Because recognition is keyed by date, a job that failed on Tuesday can be safely re-run on Wednesday for both Tuesday and Wednesday; the Tuesday entries that already posted are skipped and only the missing day is filled.

4. Handle contract modifications

ASC 606 treats a mid-term change as either a separate contract or a modification of the existing one. For the common SaaS case — a plan upgrade that takes effect immediately — you prospectively re-allocate the remaining (unrecognized) transaction price over the remaining service period. Close out the old schedule’s remaining deferred balance and open a new schedule for the new price across the days that are left.

def modify_schedule(schedule_id: str, change_date: date, new_total_cents: int) -> None:
    old = load_schedule(schedule_id)
    remaining_days = (old.service_end - change_date).days + 1

    with db.transaction():
        # stop the old schedule at the change boundary
        complete_schedule(schedule_id, effective=change_date)
        # the upgrade invoice's prorated amount funds the new schedule
        build_schedule(
            subscription_id=old.subscription_id,
            total_amount=new_total_cents,
            start=change_date,
            end=old.service_end,
            days=remaining_days,
        )

Downgrades and refunds run the same machinery in reverse: a refund of unearned revenue reverses the still-deferred portion, never the already-recognized portion — you cannot un-earn revenue you have already reported.

5. Allocate across multiple performance obligations

A flat subscription has one obligation, but real contracts bundle several: platform access, a one-time onboarding or implementation fee, a fixed block of premium support, and perhaps a discounted first year. ASC 606 step four requires you to allocate the single contract price across these obligations in proportion to their standalone selling price (SSP), not in proportion to what the line items happen to say on the order form. If an annual deal is signed for 120000 cents that bundles platform access with an SSP of 108000 and onboarding with an SSP of 24000, the total SSP is 132000, so the contract discount of 12000 is spread pro-rata: platform access is allocated 120000 * 108000 // 132000 and onboarding gets the remainder. Each allocated amount then funds its own schedule with its own recognition pattern — onboarding is typically recognized at a point in time when the service is delivered, while access recognizes ratably. The engineering rule is to allocate first, in integer minor units with the rounding remainder carried to the largest obligation, and only then build one schedule per obligation. The allocation itself is a single pass: compute each obligation’s share as contract_total * ssp_amount // total_ssp using floor division, sum the shares, and add the leftover cents to the largest obligation so the allocation reconciles exactly to contract_total. Allocating after building schedules, or letting each line item recognize its own list price, is how bundled discounts leak revenue into the wrong period. A subtle trap here is renewals: SSP is re-estimated each contract, so a customer who onboarded once should not be re-allocated an onboarding obligation on renewal — the renewal contract contains only the access obligation, and its full price recognizes ratably.

6. Variable consideration and the recognition constraint

Usage-based and tiered plans introduce variable consideration: the transaction price is not known until the meter closes. ASC 606 lets you either estimate the variable amount up front (expected value or most-likely amount) or, far more commonly for SaaS metered usage, apply the practical expedient and recognize revenue in the amount you have the right to invoice as usage accrues. The second approach is simpler and safer because it needs no estimate and no true-up: as events land in the meter, you accrue recognized revenue against a contract asset, and the month-end invoice merely reclassifies that accrual into a receivable. Where you do estimate — for example a committed-use discount that rebates at year end once a spend threshold is crossed — the standard imposes the constraint: recognize variable consideration only to the extent it is highly probable that a significant reversal will not occur. In code that means capping the accrued estimate and holding a reserve until the uncertainty resolves, then releasing the reserve in the period the threshold is actually met rather than restating prior months.

Edge Cases & Failure Modes

The recognition edge cases split by what changes: the contract (upgrade/downgrade), the money (refund/rounding), or the job (double-run). Each has a distinct rule, and the unifying invariant is that you never un-earn recognized revenue. The map sorts them.

Rev-rec edge cases Contract changes re-slice the remaining schedule, refunds reverse only deferred, rounding carries to the tail, and a double job-run is a no-op via the unique constraint. Contract upgrade / downgrade cancellation → re-slice remaining Money refund unearned rounding drift → reverse deferred only Job runs twice month-end boundary → unique (schedule, date)
Three categories — contract, money, and job — unified by one rule: never un-earn recognized revenue.
Scenario Failure if mishandled Mitigation
Mid-term upgrade Old schedule keeps recognizing at the old rate Close old schedule at change date, open new one for remaining days
Refund after partial service Reversing recognized revenue restates a closed period Reverse only the still-deferred balance; recognized stays earned
Rounding drift over 365 days Schedule sums to $1,199.64, not $1,200.00 Carry remainder onto the final day; assert the sum equals the total
Daily job runs twice Revenue double-counted UNIQUE (schedule_id, recognition_date) makes re-runs no-ops
Service period crosses a month-end close Auditors see a gap on the boundary Recognize inclusive of both boundary days; reconcile at close
Cancellation before service ends Deferred balance lingers forever On cancel, recognize through the paid-through date, then reverse the rest

Time zones, leap days, and the boundary-day problem

Straight-line daily recognition is only as correct as its notion of a “day,” and that notion breaks in two places engineers routinely miss. The first is the time zone: a service period defined in the customer’s local time will drift against a recognition job that runs in UTC, so a subscription that starts at 11pm Pacific looks like it started the next day to a UTC job, and one day’s slice lands in the wrong month at every quarter boundary. Fix this by defining service_start and service_end in UTC once, at billing time, and never re-interpreting them in another zone. The second is the inclusive-day count: a schedule from January 1 to December 31 is 365 recognition days, but the same annual term starting February 29 in a leap year has an end date a year later that must still resolve to a whole number of inclusive days. Compute day counts with explicit (end - start).days + 1 arithmetic on dates, never by dividing a month count by an assumed 30 or 365, or you will systematically over- or under-recognize by a fraction that compounds across a large book.

Cancellations that create a refund liability

Cancellation is the edge case most likely to be modeled wrong because two distinct policies collide. Under a cancel-at-period-end policy, nothing changes for recognition: the customer keeps access through the already-paid term, and the schedule runs to its natural service_end before the subscription simply does not renew. Under an immediate-cancel-with-prorated-refund policy, you must stop recognition at the effective cancel date, recognize everything earned through that date, and then reverse only the still-deferred remainder — which simultaneously becomes a refund payable to the customer. The dangerous mistake is reversing the full original deferred balance as if no service had been delivered; that restates a period you have already closed and reported. Anchor the reversal to total_amount - recognized_to_date computed as of the cancel date, and let the refund amount fall out of that same figure so the cash refund and the deferred reversal are guaranteed to agree to the cent.

Performance & Scale

The daily job is the hot path — 100k schedules touched nightly. The levers are batched bulk inserts, an aggregated ledger movement per account, a range-scan index on active schedules, and monthly partitioning of the entries table. The diagram shows them.

Rev-rec scale levers Batch schedule pages, bulk-insert the day's entries, aggregate the ledger movement per account, and partition the entries table by recognition month. Batch pages active schedules paginated Bulk insert day's entries one statement Aggregate post per account not per sub Partition by month close prunes
Four levers turn 100k nightly schedules into a few bulk statements — batching and partitioning do the heavy lifting.

The daily job is the hot path. At 100k active annual subscriptions, that is 100k schedule rows to touch every night, each producing one ledger entry — comfortably a few hundred thousand small inserts. Batch them: select active schedules in pages, build the day’s entries as a bulk INSERT, and post a single aggregated ledger movement per account where your ledger model allows, rather than 100k individual postings. Index rev_rec_schedules (status, service_start, service_end) so the “schedules active on date D” query is a range scan, not a full table sweep. The rev_rec_entries table grows by one row per schedule per day, so it is the table most in need of monthly partitioning by recognition_date; partitioning also makes month-end close queries (sum entries within a calendar month) prune to a single partition.

Catch-up runs and the missed-night problem

The daily job’s throughput budget has to account for the day it does not run. If the job is down for three nights over a long weekend, Monday’s execution must post four days of entries, not one, and the naive design that fetches “schedules active on today’s date” silently drops the three missed days on the floor. Because recognition is keyed by recognition_date, the correct pattern is to make the job accept a date range and iterate each missing calendar date, relying on the UNIQUE (schedule_id, recognition_date) constraint to make already-posted dates no-ops. That turns a catch-up run into a safe, idempotent replay: the job asks the entries table for the latest posted recognition_date per schedule, then fills forward to the current date. Sizing the batch matters here — a four-day catch-up at 100k schedules is 400k inserts, so keep the bulk-insert page size bounded (a few thousand rows per statement) and commit per page so a failure midway does not force the whole backlog to replay from zero.

Why aggregate the ledger side

The temptation at scale is to post one ledger entry per subscription per night, but the ledger, not the schedule table, is usually the write bottleneck because it carries stricter durability and often serializes on account balances. Since every one of those 100k nightly entries debits the same deferred_revenue account and credits the same recognized_revenue account, you can collapse them into a single aggregated movement per account per run, provided your ledger model keeps the per-subscription detail elsewhere (in rev_rec_entries) for drill-down. This reduces ledger contention from 100k hot-row updates to two, and the sub-level attribution needed for revenue-by-customer reporting still lives in the partitioned entries table where it belongs.

Testing Strategy

The tests hinge on a controllable clock and the closing invariant: after replaying a full year, deferred is zero and recognized equals invoiced. Around that sit the even-schedule sum, idempotent re-run, and modification tests. The panel lists them.

Rev-rec tests An even schedule sums exactly, the daily job is idempotent on re-run, a full-year replay closes to zero deferred, and a mid-term upgrade splits the rate. Sum exact remainder tail no cent lost Idempotent re-run same day not doubled Year replay deferred = 0 recog = invoiced Modification upgrade day 100 rate splits
The full-year replay closing to zero deferred is the headline invariant — everything must tie out to the cent.

Recognition tests must be deterministic, which means controlling the clock. Inject the run date rather than calling date.today(), then replay a full service period day by day and assert the running totals.

def test_annual_schedule_recognizes_evenly_and_sums_exactly():
    plan = build_daily_plan(120000, date(2026, 1, 1), date(2026, 12, 31))  # $1,200
    assert len(plan) == 365
    assert sum(p.amount for p in plan) == 120000   # no cent lost
    assert plan[0].amount in (328, 329)            # ~$3.29/day
    assert plan[-1].amount >= plan[0].amount       # remainder on the tail

def test_daily_job_is_idempotent():
    setup_schedule(total=120000, start=date(2026, 1, 1), end=date(2026, 12, 31))
    recognize_for_date(date(2026, 1, 1))
    recognize_for_date(date(2026, 1, 1))           # re-run same day
    assert recognized_total() == daily_slice_for(date(2026, 1, 1))  # not doubled

Add a reconciliation test that, after replaying the whole year, asserts deferred_balance == 0 and recognized_total == invoiced_amount. Add a modification test that upgrades on day 100 and asserts the pre-change days recognized at the old rate while the remaining days recognize at the new one.

Property tests over hand-picked dates

Straight-line schedules are a good fit for property-based testing because the closing invariant must hold for every possible input, not just the tidy 365-day annual case. Generate random triples of total_amount (say 1 to 10,000,000 cents), a service_start, and a term length from one day to 1,095 days, build the schedule, and assert three properties every time: the entries sum exactly to total_amount, no single daily slice differs from any other by more than one cent, and the number of entries equals the inclusive day count. This surfaces the rounding pathologies that fixed examples miss — a 31-cent charge spread over 30 days, or a prime-numbered total over a prime-numbered term — where the remainder-on-the-tail logic is most likely to drop or duplicate a cent. Pair it with a replay property: for any generated schedule, running the daily job across the full period in random order, with random duplicate runs injected, must land on the same closing balance as a clean forward replay, which is the strongest possible statement of idempotency.

Testing modifications and refunds against the invariant

The modification and refund tests are where subtle sign errors hide, so anchor each to the same deferred + recognized == invoiced (less refunds) invariant rather than to hand-computed expected slices. For an upgrade on day 100 of a 365-day term, assert that the sum of old-rate recognition for days 1 through 99 plus new-rate recognition for days 100 through 365 still equals the sum of the two invoiced amounts, and that no day is recognized twice or skipped at the boundary. For a refund after four months of a twelve-month term, assert that recognized revenue is unchanged by the refund and that the deferred balance drops by exactly the refunded amount. Building these tests to check the invariant rather than a golden number means they keep protecting you even after you legitimately change the daily-slice arithmetic.

Frequently Asked Questions

What is the difference between deferred and recognized revenue? Deferred revenue is a liability — money you have collected (or invoiced) but not yet earned because you still owe the customer service. Recognized revenue is the portion you have earned by delivering service over time. For an annual plan billed upfront, you start with the entire amount deferred and move a slice to recognized each day until, at the end of the term, deferred is zero and the full amount is recognized.

Do I have to recognize daily, or is monthly enough? Monthly straight-line recognition is acceptable to most auditors for simple subscriptions and is far cheaper to compute. Daily recognition matters when you have frequent mid-cycle changes, short cancellation windows, or you want clean proration of recognized revenue on the exact day a contract is modified. Many teams store a monthly schedule but compute a daily slice on demand for proration.

How does a refund affect already-recognized revenue? It does not — you can only reverse revenue that is still deferred. If a customer paid $1,200 for a year, used four months, and is refunded the remaining eight, you reverse the eight months of still-deferred balance. The four months of service you already delivered stay recognized. Refunding into a closed accounting period instead requires a contra-revenue adjustment in the current period, not a restatement.

Is ASC 606 different from IFRS 15 for SaaS subscriptions? For the standard “access to software over a term” obligation, the two are functionally identical — both use the same five-step model and recognize ratably over the service period. Differences surface in areas like contract-cost capitalization and certain license distinctions, but the deferred-to-recognized machinery on this page satisfies both.