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.

Prerequisites

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.

Implementation Walkthrough

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.

Edge Cases & Failure Modes

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

Performance & Scale

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.

Testing Strategy

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.

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.