SaaS Revenue Recognition: MRR, ARR, Deferred Revenue, and Churn Accounting
Build accurate SaaS revenue metrics: calculate MRR and ARR correctly across plan changes, account for deferred revenue on annual prepayments, measure net revenue retention, and implement ASC 606 compliant revenue recognition in PostgreSQL.
SaaS revenue metrics seem simple until you encounter plan upgrades mid-cycle, annual prepayments, refunds, failed payments, and multi-seat pricing. At that point, most founders discover their "MRR" number doesn't match their bank account, their accountant is confused, and their investor dashboard is showing different numbers than their analytics tool.
The fix is getting the data model right before building the metrics layer.
The Core Data Model
-- Subscription lifecycle events โ append-only ledger
CREATE TABLE subscription_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
subscription_id UUID NOT NULL,
event_type TEXT NOT NULL,
-- Types: 'new', 'upgrade', 'downgrade', 'reactivation',
-- 'churn', 'pause', 'unpause', 'price_change'
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Plan details at time of event
plan_id TEXT NOT NULL,
billing_period TEXT NOT NULL, -- 'monthly' | 'annual'
mrr_amount NUMERIC(10,2) NOT NULL, -- Monthly value at time of event
arr_amount NUMERIC(10,2) NOT NULL, -- = mrr_amount * 12
seat_count INTEGER NOT NULL DEFAULT 1,
-- For tracking revenue changes
mrr_delta NUMERIC(10,2) NOT NULL DEFAULT 0, -- Change from previous MRR
metadata JSONB NOT NULL DEFAULT '{}'
);
-- Invoices for deferred revenue tracking
CREATE TABLE invoices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
subscription_id UUID NOT NULL,
stripe_invoice_id TEXT UNIQUE,
amount_cents INTEGER NOT NULL,
currency CHAR(3) NOT NULL DEFAULT 'USD',
billing_period_start DATE NOT NULL,
billing_period_end DATE NOT NULL,
status TEXT NOT NULL, -- 'draft'|'open'|'paid'|'void'|'uncollectible'
paid_at TIMESTAMPTZ,
-- Deferred revenue columns (populated by nightly job)
recognized_amount_cents INTEGER NOT NULL DEFAULT 0,
deferred_amount_cents INTEGER NOT NULL DEFAULT 0
);
MRR Calculation
The key insight: MRR is a snapshot metric (what is the recurring monthly value right now?), not a cumulative metric.
-- Current MRR snapshot
-- Sum of the most recent subscription event per active tenant
WITH latest_events AS (
SELECT DISTINCT ON (subscription_id)
subscription_id,
tenant_id,
event_type,
mrr_amount,
occurred_at
FROM subscription_events
ORDER BY subscription_id, occurred_at DESC
),
active_subscriptions AS (
SELECT *
FROM latest_events
WHERE event_type NOT IN ('churn', 'pause') -- Exclude inactive
)
SELECT
SUM(mrr_amount) AS total_mrr,
COUNT(*) AS active_subscriptions,
SUM(mrr_amount) * 12 AS arr
FROM active_subscriptions;
-- MRR over time (for charting)
WITH monthly_snapshots AS (
SELECT
DATE_TRUNC('month', gs.month)::DATE AS snapshot_month,
se.subscription_id,
-- Get the most recent event for each subscription as of this month
LAST_VALUE(se.mrr_amount) OVER (
PARTITION BY se.subscription_id, DATE_TRUNC('month', gs.month)
ORDER BY se.occurred_at
RANGE BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) AS mrr_at_month
FROM GENERATE_SERIES(
'2026-01-01'::DATE,
CURRENT_DATE,
'1 month'::INTERVAL
) gs(month)
JOIN subscription_events se ON se.occurred_at <= gs.month + INTERVAL '1 month'
WHERE se.event_type NOT IN ('churn', 'pause')
)
SELECT
snapshot_month,
SUM(mrr_at_month) AS mrr
FROM monthly_snapshots
GROUP BY snapshot_month
ORDER BY snapshot_month;
๐ SaaS MVP in 8 Weeks โ Seriously
We have launched 50+ SaaS platforms. Multi-tenant architecture, Stripe billing, auth, role-based access, and cloud deployment โ all handled by one senior team.
- Week 1โ2: Architecture design + wireframes
- Week 3โ6: Core features built + tested
- Week 7โ8: Launch-ready on AWS/Vercel with CI/CD
- Post-launch: Maintenance plans from month 3
MRR Movement Waterfall
The MRR waterfall breaks down what caused MRR to change month-over-month:
-- MRR movement components for a given month
-- New MRR + Expansion - Contraction - Churn = Net New MRR
WITH month_events AS (
SELECT
event_type,
mrr_delta,
tenant_id,
subscription_id
FROM subscription_events
WHERE occurred_at >= DATE_TRUNC('month', CURRENT_DATE)
AND occurred_at < DATE_TRUNC('month', CURRENT_DATE) + INTERVAL '1 month'
)
SELECT
-- New MRR: brand new subscriptions
SUM(mrr_delta) FILTER (WHERE event_type = 'new') AS new_mrr,
-- Expansion MRR: upgrades and seat additions (positive deltas on existing subs)
SUM(mrr_delta) FILTER (
WHERE event_type IN ('upgrade', 'price_change') AND mrr_delta > 0
) AS expansion_mrr,
-- Contraction MRR: downgrades (negative deltas on existing subs)
ABS(SUM(mrr_delta) FILTER (
WHERE event_type IN ('downgrade', 'price_change') AND mrr_delta < 0
)) AS contraction_mrr,
-- Churned MRR: subscriptions that ended
ABS(SUM(mrr_delta) FILTER (WHERE event_type = 'churn')) AS churned_mrr,
-- Reactivation MRR: previously churned returning
SUM(mrr_delta) FILTER (WHERE event_type = 'reactivation') AS reactivation_mrr,
-- Net New MRR = New + Expansion + Reactivation - Contraction - Churn
SUM(mrr_delta) AS net_new_mrr
FROM month_events;
Deferred Revenue (Annual Plans)
When a customer pays $1,200 for annual access, you've received $1,200 cash but only earned $100/month as you deliver the service. The unearned portion is deferred revenue โ a liability on your balance sheet.
// src/services/revenue/deferred-revenue.service.ts
interface Invoice {
id: string;
amountCents: number;
billingPeriodStart: Date;
billingPeriodEnd: Date;
paidAt: Date | null;
}
interface RevenueScheduleLine {
month: Date;
recognizableAmountCents: number;
cumulativeRecognizedCents: number;
remainingDeferredCents: number;
}
// Calculate monthly revenue recognition schedule for an invoice
export function buildRevenueSchedule(invoice: Invoice): RevenueScheduleLine[] {
if (!invoice.paidAt) return []; // Can't recognize revenue on unpaid invoices
const startDate = invoice.billingPeriodStart;
const endDate = invoice.billingPeriodEnd;
const totalDays =
(endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24);
// Build month-by-month schedule
const schedule: RevenueScheduleLine[] = [];
let cumulativeRecognized = 0;
let currentMonth = new Date(startDate);
currentMonth.setDate(1); // Start of first month
while (currentMonth <= endDate) {
const monthEnd = new Date(currentMonth);
monthEnd.setMonth(monthEnd.getMonth() + 1);
monthEnd.setDate(0); // Last day of month
// Days in this month that fall within the billing period
const periodStart = Math.max(
startDate.getTime(),
currentMonth.getTime()
);
const periodEnd = Math.min(endDate.getTime(), monthEnd.getTime());
const daysInPeriod = Math.max(
0,
(periodEnd - periodStart) / (1000 * 60 * 60 * 24)
);
const recognizableThisMonth = Math.round(
(daysInPeriod / totalDays) * invoice.amountCents
);
cumulativeRecognized += recognizableThisMonth;
schedule.push({
month: new Date(currentMonth),
recognizableAmountCents: recognizableThisMonth,
cumulativeRecognizedCents: cumulativeRecognized,
remainingDeferredCents:
invoice.amountCents - cumulativeRecognized,
});
// Advance to next month
currentMonth.setMonth(currentMonth.getMonth() + 1);
}
return schedule;
}
// Nightly job: update invoice recognition amounts
export async function updateDeferredRevenue(): Promise<void> {
const unpaidInvoices = await db.query<Invoice>(
`SELECT * FROM invoices
WHERE status = 'paid'
AND billing_period_end >= CURRENT_DATE - INTERVAL '24 months'`
);
for (const invoice of unpaidInvoices.rows) {
const schedule = buildRevenueSchedule(invoice);
const today = new Date();
// Revenue recognized = sum of all months up to today
const recognized = schedule
.filter((line) => line.month <= today)
.reduce((sum, line) => sum + line.recognizableAmountCents, 0);
const deferred = invoice.amountCents - recognized;
await db.query(
`UPDATE invoices
SET recognized_amount_cents = $1,
deferred_amount_cents = $2
WHERE id = $3`,
[recognized, deferred, invoice.id]
);
}
}
๐ก The Difference Between a SaaS Demo and a SaaS Business
Anyone can build a demo. We build SaaS products that handle real load, real users, and real payments โ with architecture that does not need to be rewritten at 1,000 users.
- Multi-tenant PostgreSQL with row-level security
- Stripe subscriptions, usage billing, annual plans
- SOC2-ready infrastructure from day one
- We own zero equity โ you own everything
Net Revenue Retention (NRR)
NRR is the most important metric for SaaS health. It measures how much revenue you retain and expand from existing customers:
NRR = (Starting MRR + Expansion - Contraction - Churn) / Starting MRR ร 100%
NRR > 120%: World-class (growth from existing customers alone)
NRR 100โ120%: Healthy (customers expand faster than they churn)
NRR 80โ100%: Concerning (must acquire to compensate for churn)
NRR < 80%: Broken unit economics
-- Monthly Net Revenue Retention
WITH monthly_cohorts AS (
-- Starting MRR for each tenant at the beginning of the period
SELECT DISTINCT ON (tenant_id)
tenant_id,
mrr_amount AS starting_mrr
FROM subscription_events
WHERE occurred_at < DATE_TRUNC('month', CURRENT_DATE) -- Before this month
ORDER BY tenant_id, occurred_at DESC
),
current_mrr AS (
-- Current MRR for those same tenants
SELECT DISTINCT ON (se.tenant_id)
se.tenant_id,
se.mrr_amount AS current_mrr,
se.event_type
FROM subscription_events se
JOIN monthly_cohorts mc ON mc.tenant_id = se.tenant_id
ORDER BY se.tenant_id, se.occurred_at DESC
)
SELECT
COUNT(DISTINCT mc.tenant_id) AS cohort_size,
SUM(mc.starting_mrr) AS starting_mrr,
SUM(
CASE
WHEN cm.event_type IN ('churn', 'pause') THEN 0
ELSE COALESCE(cm.current_mrr, 0)
END
) AS ending_mrr,
ROUND(
100.0 * SUM(
CASE
WHEN cm.event_type IN ('churn', 'pause') THEN 0
ELSE COALESCE(cm.current_mrr, 0)
END
) / NULLIF(SUM(mc.starting_mrr), 0),
1
) AS nrr_pct
FROM monthly_cohorts mc
LEFT JOIN current_mrr cm ON cm.tenant_id = mc.tenant_id;
ASC 606 Compliance Notes
ASC 606 (US GAAP revenue recognition standard) requires:
1. Identify the contract with the customer
โ Each subscription is a contract; auto-renewals are contract modifications
2. Identify performance obligations
โ SaaS: typically one obligation (continuous access to software)
โ Professional services: separate obligation from subscription
3. Determine the transaction price
โ Exclude variable consideration (refunds, usage-based) until probable
4. Allocate to performance obligations
โ Bundle discounts must be allocated proportionally
5. Recognize revenue when obligation is satisfied
โ SaaS: ratably over the service period (not on payment receipt)
โ One-time setup fees: typically ratably over estimated customer life
Implementation note: For small/seed-stage companies, cash-basis accounting with MRR tracking is usually sufficient. ASC 606 compliance becomes critical at Series B+ when institutional investors require audited financials.
Cost Ranges for Revenue System Build
| Scope | Timeline | Estimated Cost |
|---|---|---|
| Basic MRR/ARR dashboard (internal) | 2โ4 weeks | $8,000โ$20,000 |
| Full subscription event ledger + waterfall | 4โ8 weeks | $20,000โ$50,000 |
| ASC 606 deferred revenue schedule | 4โ6 weeks | $15,000โ$35,000 |
| Stripe โ database sync pipeline | 2โ3 weeks | $8,000โ$18,000 |
| Full revenue reporting system | 3โ4 months | $60,000โ$120,000 |
See Also
- SaaS Metrics Benchmarks โ industry benchmarks for NRR, churn
- SaaS Pricing Strategy โ pricing models and impact on MRR
- Stripe Billing Engineering โ Stripe subscription integration
- SaaS Churn Prediction โ predicting churn before it happens
Working With Viprasol
Revenue metrics built on the wrong data model produce the wrong numbers โ and wrong numbers lead to wrong decisions. We design subscription event ledgers, implement deferred revenue schedules, and build revenue dashboards that match your accountant's view of the business โ so your metrics and your bank account tell the same story.
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Building a SaaS Product?
We've helped launch 50+ SaaS platforms. Let's build yours โ fast.
Free consultation โข No commitment โข Response within 24 hours
Add AI automation to your SaaS product?
Viprasol builds custom AI agent crews that plug into any SaaS workflow โ automating repetitive tasks, qualifying leads, and responding across every channel your customers use.