Deferred Revenue Schedules for Annual Subscriptions
An annual subscription is the canonical revenue-recognition problem because the gap between cash and earned revenue is at its widest: you collect twelve months of money on day one and then owe service for the next 364 days. The deferred revenue schedule is the artifact that bridges that gap โ a row-by-row plan of how a single upfront payment is earned over time. This page sits under Revenue Recognition (ASC 606 / IFRS 15) and is the practical build: the exact arithmetic of straight-line daily recognition, the journal entries that post each night, how mid-term upgrades and refunds adjust an in-flight schedule, and what reconciliation has to prove at month-end close. You reach for this when finance asks why the deferred revenue line on the balance sheet does not tie to the sum of open subscriptions, and you need a schedule that is correct to the cent.
Trade-offs
Before writing code, decide the granularity and rounding policy of the schedule. The choices have real consequences for storage, audit clarity, and how cleanly mid-cycle changes prorate.
| Approach | Recognition granularity | Storage per annual contract | Mid-cycle proration | Rounding risk | Best for |
|---|---|---|---|---|---|
| Monthly straight-line | 12 entries | ~12 rows | Coarse (whole-month) | 11 even months + 1 plug | Simple plans, auditor-friendly |
| Daily, materialized | 365 entries | ~365 rows | Exact to the day | Remainder on final day | Frequent upgrades, short cancel windows |
| Daily, computed on demand | derived | 1 header row | Exact to the day | Computed each query | High contract volume, storage-sensitive |
| Usage-blended | per usage period | variable | Per metered window | Per-line rounding | Hybrid subscription + usage plans |
For most SaaS teams the sweet spot is a daily schedule computed from a single header row: storage stays flat at one row per contract, proration is exact, and you materialize entries only into the ledger as they are recognized. The examples below use that model.
Step-by-Step Implementation
1. Post the invoice to deferred revenue
At finalization the entire annual amount becomes a liability. Nothing is revenue yet. In double-entry terms, you debit cash (or accounts receivable) and credit deferred revenue for the full invoiced total in cents.
-- $1,200.00 annual plan, billed upfront on 2026-01-01
INSERT INTO ledger_entries (ledger_entry_id, account, direction, amount, subscription_id, posted_at)
VALUES
(gen_random_uuid(), 'cash', 'debit', 120000, '...', '2026-01-01T00:00:00Z'),
(gen_random_uuid(), 'deferred_revenue', 'credit', 120000, '...', '2026-01-01T00:00:00Z');
The schedule header records the service window and the immutable original amount:
INSERT INTO rev_rec_schedules
(schedule_id, subscription_id, invoice_id, service_start, service_end, total_amount, recognized_amount, status)
VALUES
(gen_random_uuid(), '...', '...', '2026-01-01', '2026-12-31', 120000, 0, 'active');
2. Derive the per-day amount with remainder handling
Straight-line means equal recognition per day, but $120,000 cents over 365 days is $328.767โฆ, which does not divide evenly. Use integer division and push the leftover cents onto the final day so the schedule sums to exactly $1,200.
def daily_amount(total_cents: int, day_index: int, days: int) -> int:
base = total_cents // days
remainder = total_cents - base * days # cents that don't divide evenly
# โ
the last `remainder` days each get one extra cent
return base + (1 if day_index >= days - remainder else 0)
# 120000 over 365 days -> base 328, remainder 280
# days 0..84 recognize 328c; days 85..364 recognize 329c; sum == 120000
Never recognize the remainder upfront or smear it with floating point โ both leave a sub-cent drift that compounds across thousands of contracts into a visible balance-sheet discrepancy.
3. Recognize a dayโs slice with a journal entry
The nightly job moves one day from the liability to the income statement. The journal entry is always the same shape: debit deferred revenue, credit recognized revenue.
def recognize_day(schedule_id: str, run_date: date) -> None:
s = load_schedule(schedule_id)
idx = (run_date - s.service_start).days
days = (s.service_end - s.service_start).days + 1
slice_cents = daily_amount(s.total_amount, idx, days)
with db.transaction():
# idempotency: UNIQUE(schedule_id, recognition_date) skips re-runs
insert_rev_rec_entry(schedule_id, run_date, slice_cents)
post_ledger_pair(
debit="deferred_revenue", # โฌ liability shrinks
credit="recognized_revenue", # โฌ revenue earned
amount=slice_cents,
subscription_id=s.subscription_id,
)
bump_recognized_amount(schedule_id, slice_cents)
After day 85 of the example, recognized revenue is roughly $279.05 and deferred is roughly $920.95 โ and they always sum back to $1,200.00.
4. Adjust for a mid-term upgrade
Say on day 100 the customer upgrades to a $2,400/yr plan and is billed a prorated upgrade charge for the remaining 265 days. You do not touch the 100 days already recognized. You close the old schedule at the boundary, recognize nothing retroactively, and open a new schedule for the new effective price across the remaining days.
def upgrade(schedule_id: str, change_date: date, new_remaining_invoice_cents: int) -> None:
old = load_schedule(schedule_id)
with db.transaction():
# recognize through the day before the change, then close it
complete_schedule(schedule_id, effective=change_date)
# the prorated upgrade invoice funds a fresh schedule for the tail
create_schedule(
subscription_id=old.subscription_id,
total_amount=new_remaining_invoice_cents,
service_start=change_date,
service_end=old.service_end,
)
The still-deferred balance from the original plan plus the new prorated charge together fund the remaining 265 days. Because billing already prorated the cash side, rev-rec just mirrors the same effective date.
5. Adjust for a refund
A cancellation with a refund on day 200 reverses only what is still deferred. The 200 days of service already delivered stay recognized โ you earned them.
def refund_unearned(schedule_id: str, cancel_date: date) -> int:
s = load_schedule(schedule_id)
still_deferred = s.total_amount - s.recognized_amount
with db.transaction():
post_ledger_pair(
debit="deferred_revenue", # clear the remaining liability
credit="cash", # money returned to the customer
amount=still_deferred,
subscription_id=s.subscription_id,
)
mark_schedule_reversed(schedule_id, effective=cancel_date)
return still_deferred # โ ๏ธ never reverse recognized revenue
Verification & Testing
The schedule has to prove three things: it sums to the invoiced total, the daily job is idempotent, and the deferred balance always equals total minus recognized. Test all three with a controlled clock.
def test_schedule_sums_to_total():
total, days = 120000, 365
s = sum(daily_amount(total, i, days) for i in range(days))
assert s == total # not 119720, not 120280
def test_reconciliation_holds_every_day():
setup_schedule(total=120000, start=date(2026,1,1), end=date(2026,12,31))
recognized = 0
for i in range(365):
d = date(2026,1,1) + timedelta(days=i)
recognize_day(SCHED_ID, d)
recognized += daily_amount(120000, i, 365)
assert deferred_balance(SCHED_ID) == 120000 - recognized
assert deferred_balance(SCHED_ID) == 0 # fully earned at year end
For month-end close, the reconciliation query must show that the deferred revenue control account equals the sum of remaining schedule balances:
SELECT
(SELECT balance FROM ledger_balances WHERE account = 'deferred_revenue') AS gl_deferred,
(SELECT COALESCE(SUM(total_amount - recognized_amount), 0)
FROM rev_rec_schedules WHERE status = 'active') AS schedule_deferred;
-- gl_deferred MUST equal schedule_deferred; any delta is a recognition bug
Gotchas & Production Pitfalls
- Recognizing on a UTC boundary but billing in local time. If billing sets
service_startfrom a local-midnight cutover and rev-rec runs at UTC midnight, the first and last day can be off by one, leaving a one-day fractional gap at year end. Pin both to UTC dates. - Smearing the rounding remainder with floats.
120000 / 365 = 328.767...recognized as a float drifts a fraction of a cent per day. Across 100k contracts that is real money on the balance sheet. Always integer-divide and carry the remainder to the tail. - Reversing recognized revenue on a refund. A refund only clears the still-deferred portion. Reversing already-earned revenue silently restates prior periods and will fail an audit โ use a current-period contra-revenue entry if you must adjust a closed month.
- Letting cancellations leave a dangling deferred balance. If you stop the nightly job for a canceled subscription but never reverse the remaining liability, deferred revenue inflates forever. On cancel, recognize through the paid-through date, then explicitly reverse or roll forward the rest.
- Catch-up runs double-posting. A job that missed three nights must recognize all three missing days, not three copies of today. Drive recognition by
recognition_datewith a unique constraint so re-runs are exact, never additive.