Subscription Revenue Forecast Model
Overview
This query produces 25-months of subscription revenue data based on historical Stripe data and forecast assumptions.
The output includes:
- Historical revenue (actual Stripe invoices)
- Projected revenue from existing subscriptions
- Projected revenue from forecasted new subscription cohorts
- Subscriber creation, conversion, and billing counts
- 12 month outputs for the existing_no_churn can be used to calculated monthly or annual recurring revenue (MRR or ARR)
The model anchors on the most recently completed month and generates:
- 12 historical months
- 1 anchor month
- 12 forecast months
Total window: 25 months.
Data Sources
Stripe data is sourced from Snowflake tables:
| Table | Purpose |
|---|---|
RAW_DB.STRIPE.INVOICES |
Paid invoices used to calculate historical revenue |
RAW_DB.STRIPE.SUBSCRIPTIONS |
Subscription lifecycle data |
first_invoices CTE |
Derived earliest paid invoice per subscription |
Forecast Methodology
The forecast combines three revenue components.
1. Historical Revenue
Revenue from paid invoices:
Filtered to:
STATUS = 'paid'SUBSCRIPTION IS NOT NULL
2. Existing Subscription Revenue
Existing subscribers generate recurring revenue based on:
- billing interval
- billing amount
- churn probability
Churn Rate
Calculated from the trailing 6 completed months of paying subscribers.
Key rules:
- months must have ≥1000 subscribers
- Dec 2025 excluded due to launch anomaly
- trial non-conversions excluded
Churn is applied only to forecast months.
3. New Subscriber Revenue
Forecast new subscriptions based on recent acquisition velocity.
Acquisition Rate
Derived from trailing 21 days of subscription creation:
daily_monthly_rate (21-day rate at which new MONTHLY subscriptions are being CREATED)
daily_annual_rate (21-day rate at which new ANNUAL subscriptions are being CREATED)
Monthly forecasts scale by:
Trial Conversion Modeling
Trial subscriptions convert to paid using a calculated trial conversion rate.
Conversion rate:
This rate is applied to both:
- new subscription cohorts
- existing trialing subscriptions
Trial Lag Distribution
Subscriptions may convert to paid with delay.
Lag distribution calculated from:
- trailing 6 months of subscription cohorts
- excluding the most recent 2 months
- excluding Nov/Dec 2025 launch anomalies
Lag buckets:
Separate distributions are computed for:
- monthly plans
- annual plans
Holiday Seasonality
December acquisitions receive a multiplier:
This is the dumbest assumption in the whole forecast, but I honestly believe we have no solid foundation for estimating how big December seasonality will be going forward. It was based on December 2025's rate of new subscription creations compared to January & February 2026. This will need to be revisited--we can probably start to get a sense of how many subscriptions will be created in December as orders start to ramp up in fall?
Revenue Components
Final revenue forecast includes:
| Component | Description |
|---|---|
| existing_only_with_churn | revenue from current subscribers with churn applied |
| existing_no_churn | baseline recurring revenue |
| new_monthly_contrib | revenue from new monthly subscriptions |
| new_annual_contrib | revenue from new annual subscriptions |
Subscriber Metrics
The model also produces subscriber-level metrics:
| Metric | Description |
|---|---|
| created_mo | new monthly subscriptions created |
| created_ann | new annual subscriptions created |
| converted_mo | trial conversions to monthly |
| converted_ann | trial conversions to annual |
| exist_billing_mo | recurring monthly renewals |
| exist_billing_ann | annual renewals |
Historical values come from Stripe data; forecast values come from the model.
Output
Each row represents a billing month.
Key columns:
| Column | Description |
|---|---|
| billing_month | calendar month |
| actual_revenue | historical Stripe revenue |
| expected_full_model | total forecast revenue |
| existing_only_with_churn | projected recurring revenue with churn |
| existing_no_churn | recurring revenue (standard definition) |
| new_monthly_contrib | forecast from new monthly subscriptions |
| new_annual_contrib | forecast from new annual subscriptions |
Current Month Pro-Rating
The first forecast month (the current incomplete month) is a hybrid of actual and forecast data to avoid double-counting subscriptions that already exist in Stripe:
- Created counts: actual creations so far this month + forecast for remaining days
- New sub revenue: forecast uses only the remaining days in the month (
CURRENT_DATEto month end), not the full month - Existing sub revenue: projected normally from all subs currently in Stripe (including those created this month)
- Converted / existing billing counts: handled by the forecast model for the full month (lag-weighted conversions and pool-based billing)
This ensures subs created earlier in the current month appear only in the existing projection, while the new-sub forecast covers only the days not yet elapsed.
Model Notes
- Forecast horizon: 12 months
- Lag distribution capped at 2 months
- Churn applied only to forecast period
- December acquisition surge modeled via multiplier
- Annual renewals modeled via 12-month cohort anniversaries
Author
Pete Z
Query
-- Subscription Revenue Forecast Model
-- See subscription_revenue_forecast_model.md for documentation
WITH RECURSIVE
-- ============================================================
-- DYNAMIC DATE RANGE
-- Anchors on the most recently completed month, then extends
-- 12 months back and 12 months forward (25 months total).
--
-- month_idx 0..12 = historical (actual data expected)
-- month_idx 13..24 = forecast (projected from model)
-- month_idx 12 = anchor (most recently completed month)
-- ============================================================
date_params AS (
SELECT DATEADD('month', -13, DATE_TRUNC('month', CURRENT_DATE))::DATE AS range_start,
DATEADD('month', 11, DATE_TRUNC('month', CURRENT_DATE))::DATE AS range_end
),
all_months AS (
SELECT DATEADD('month', SEQ4(), (SELECT range_start FROM date_params)) AS billing_month,
SEQ4() AS month_idx
FROM TABLE(GENERATOR(ROWCOUNT => 25))
),
-- ACTUAL: paid subscription invoices
actual AS (
SELECT DATE_TRUNC('month', TO_TIMESTAMP(i.CREATED::NUMBER)) AS billing_month,
SUM(i.AMOUNT_PAID / 100.0) AS revenue
FROM RAW_DB.STRIPE.INVOICES i
WHERE i.STATUS = 'paid' AND i.SUBSCRIPTION IS NOT NULL
AND TO_TIMESTAMP(i.CREATED::NUMBER) >= (SELECT range_start FROM date_params)
AND TO_TIMESTAMP(i.CREATED::NUMBER) < DATE_TRUNC('month', CURRENT_DATE)
GROUP BY 1
),
-- TRIAL CONVERSION RATE: % of resolved trials that converted to paying
trial_conversion AS (
SELECT ROUND(
SUM(CASE WHEN STATUS IN ('active','past_due') THEN 1 ELSE 0 END) * 1.0 / COUNT(*),
4) AS rate
FROM RAW_DB.STRIPE.SUBSCRIPTIONS
WHERE TRIAL_END IS NOT NULL
AND TO_TIMESTAMP(TRIAL_END::NUMBER) < CURRENT_TIMESTAMP
),
-- NEW SUB ACQUISITION PARAMS: trailing 3-week daily rates
-- Counts paid-plan subs created in the last 21 days, derives a daily rate.
-- Each forecast month multiplies daily rate x days-in-month for its count.
new_sub_params AS (
SELECT
COUNT(CASE WHEN s.PLAN:interval::TEXT = 'month' THEN 1 END) / 21.0
AS daily_monthly_rate,
COUNT(CASE WHEN s.PLAN:interval::TEXT = 'year' THEN 1 END) / 21.0
AS daily_annual_rate,
9.99 AS avg_monthly_rev,
99.99 AS avg_annual_rev
FROM RAW_DB.STRIPE.SUBSCRIPTIONS s
WHERE TO_TIMESTAMP(s.CREATED::NUMBER) >= DATEADD('day', -21, CURRENT_DATE)
AND TO_TIMESTAMP(s.CREATED::NUMBER) < CURRENT_DATE
AND s.PLAN:interval::TEXT IS NOT NULL
AND s.PLAN:amount::NUMBER > 0
AND s.STATUS NOT IN ('incomplete_expired')
),
-- HOLIDAY SEASONALITY: December multiplier for gift subscriptions
-- Christmas Day 2025 alone drove 13,793 paid subs (40% of Dec total)
-- Dec 2025 total ~33,946 paid subs vs ~8,980 steady-state = 3.8x
holiday_season AS (
SELECT 3.8 AS dec_multiplier
),
-- ============================================================
-- CHURN RATE: weighted avg for PAYING subs (post-trial only)
-- Trailing 6 completed months (month_idx 7..12), excluding Dec 2025
-- (launch anomaly: 10.3% churn on small base, one-off).
-- paying_at_start >= 1000 filter drops any months with too few subs.
-- Trial non-conversions are handled separately by trial_conversion rate.
-- ============================================================
churn_calc AS (
SELECT cal_month,
COUNT(DISTINCT CASE
WHEN TO_TIMESTAMP(s.BILLING_CYCLE_ANCHOR::NUMBER) < cal_month
AND (s.TRIAL_END IS NULL OR TO_TIMESTAMP(s.TRIAL_END::NUMBER) < cal_month)
AND (s.ENDED_AT IS NULL OR TO_TIMESTAMP(s.ENDED_AT::NUMBER) >= cal_month)
THEN s.ID END) AS paying_at_start,
COUNT(DISTINCT CASE
WHEN s.ENDED_AT IS NOT NULL
AND DATE_TRUNC('month', TO_TIMESTAMP(s.ENDED_AT::NUMBER)) = cal_month
AND (s.TRIAL_END IS NULL OR TO_TIMESTAMP(s.TRIAL_END::NUMBER) < cal_month)
THEN s.ID END) AS churned_paying
FROM (
SELECT billing_month AS cal_month
FROM all_months
WHERE month_idx BETWEEN 7 AND 12
AND billing_month != '2025-12-01'::DATE -- permanent exclusion: launch anomaly
) months
CROSS JOIN RAW_DB.STRIPE.SUBSCRIPTIONS s
WHERE s.STATUS NOT IN ('incomplete_expired')
GROUP BY cal_month
),
churn_rate AS (
SELECT ROUND(SUM(churned_paying) * 1.0 / SUM(paying_at_start), 5) AS monthly_churn
FROM churn_calc
WHERE paying_at_start >= 1000
),
-- ============================================================
-- FIRST INVOICES: earliest paid invoice per subscription
-- Shared foundation for lag distributions and historical counts
-- ============================================================
first_invoices AS (
SELECT SUBSCRIPTION, MIN(CREATED) AS first_paid
FROM RAW_DB.STRIPE.INVOICES
WHERE STATUS = 'paid' AND AMOUNT_PAID > 0 AND SUBSCRIPTION IS NOT NULL
GROUP BY SUBSCRIPTION
),
-- ============================================================
-- TRIAL LAG: dynamic distributions from trailing 6 months
-- Excludes last 2 completed months (lag not yet fully resolved)
-- and Nov/Dec 2025 forever (extended trials from launch anomaly).
-- Lag = months between sub creation and first paid invoice.
-- Capped at 2 months; weights normalized to sum to 1.
-- ============================================================
lag_sample AS (
SELECT s.ID,
s.PLAN:interval::TEXT AS billing_interval,
LEAST(DATEDIFF('month',
DATE_TRUNC('month', TO_TIMESTAMP(s.CREATED::NUMBER)),
DATE_TRUNC('month', TO_TIMESTAMP(fi.first_paid::NUMBER))
), 2) AS lag_months
FROM RAW_DB.STRIPE.SUBSCRIPTIONS s
INNER JOIN first_invoices fi ON fi.SUBSCRIPTION = s.ID
WHERE DATE_TRUNC('month', TO_TIMESTAMP(s.CREATED::NUMBER)) IN (
SELECT billing_month FROM all_months
WHERE month_idx BETWEEN 5 AND 10 -- 6-month window, ending 2 months before current
AND billing_month NOT IN ('2025-11-01'::DATE, '2025-12-01'::DATE) -- permanent exclusion
)
AND s.PLAN:interval::TEXT IN ('month', 'year')
AND s.PLAN:amount::NUMBER > 0
AND s.STATUS NOT IN ('incomplete_expired')
),
monthly_lag AS (
SELECT lag_months AS lag,
ROUND(COUNT(*) * 1.0 / SUM(COUNT(*)) OVER (), 4) AS weight
FROM lag_sample
WHERE billing_interval = 'month'
GROUP BY lag_months
),
annual_lag AS (
SELECT lag_months AS lag,
ROUND(COUNT(*) * 1.0 / SUM(COUNT(*)) OVER (), 4) AS weight
FROM lag_sample
WHERE billing_interval = 'year'
GROUP BY lag_months
),
-- ============================================================
-- EXISTING SUBS: billing cadence + trial conversion + churn
-- Uses SUBSCRIPTIONS.PLAN for interval/amount (no SUBSCRIPTION_ITEMS join needed)
-- Churn applied only to forecast months (month_idx > 12)
-- ============================================================
all_subs AS (
SELECT s.ID AS subscription_id, s.STATUS,
s.PLAN:interval::TEXT AS billing_interval,
s.PLAN:interval_count::NUMBER AS interval_count,
s.QUANTITY * s.PLAN:amount::NUMBER / 100.0 AS billing_amount,
DATE_TRUNC('month', TO_TIMESTAMP(s.BILLING_CYCLE_ANCHOR::NUMBER)) AS first_billing_month,
CASE
WHEN s.ENDED_AT IS NOT NULL THEN DATE_TRUNC('month', TO_TIMESTAMP(s.ENDED_AT::NUMBER))
WHEN s.CANCEL_AT IS NOT NULL THEN DATE_TRUNC('month', TO_TIMESTAMP(s.CANCEL_AT::NUMBER))
WHEN s.CANCEL_AT_PERIOD_END = TRUE THEN DATE_TRUNC('month', TO_TIMESTAMP(s.CURRENT_PERIOD_END::NUMBER))
ELSE (SELECT range_end FROM date_params)
END AS last_billing_month
FROM RAW_DB.STRIPE.SUBSCRIPTIONS s
WHERE s.PLAN:interval::TEXT IS NOT NULL
AND s.STATUS NOT IN ('incomplete_expired')
),
existing_expected AS (
SELECT m.billing_month, m.month_idx,
SUM(CASE WHEN a.STATUS = 'trialing' THEN a.billing_amount * tc.rate ELSE a.billing_amount END
* CASE WHEN m.month_idx <= 12 THEN 1.0
ELSE POWER(1.0 - cr.monthly_churn, m.month_idx - 12) END
) AS rev_with_churn,
SUM(CASE WHEN a.STATUS = 'trialing' THEN a.billing_amount * tc.rate
ELSE a.billing_amount END) AS rev_no_churn
FROM all_months m
CROSS JOIN all_subs a
CROSS JOIN trial_conversion tc
CROSS JOIN churn_rate cr
WHERE m.billing_month >= a.first_billing_month AND m.billing_month <= a.last_billing_month
AND MOD(DATEDIFF('month', a.first_billing_month, m.billing_month),
CASE a.billing_interval WHEN 'month' THEN a.interval_count
WHEN 'year' THEN 12 * a.interval_count END) = 0
GROUP BY 1, 2
),
-- ============================================================
-- NEW SUBS: each forecast month spawns a cohort
-- Trial lag delays when subs start paying
-- Trial conversion rate applied to all new subs
-- Churn applied from first payment date forward
-- ============================================================
-- Each forecast month is a cohort; precompute days-in-month for count scaling
cohort_months AS (
SELECT m.month_idx AS acq_idx,
m.billing_month AS acq_month,
CASE WHEN m.month_idx = 13
THEN GREATEST(DATEDIFF('day', CURRENT_DATE, DATEADD('month', 1, m.billing_month)), 0)
ELSE DATEDIFF('day', m.billing_month, DATEADD('month', 1, m.billing_month))
END AS days_in_month
FROM all_months m
WHERE m.month_idx >= 13
),
-- New MONTHLY subs: daily_rate x days_in_month -> cohort count
-- Pay after trial lag, then every month with churn
-- December cohorts get holiday_season.dec_multiplier applied to count
new_monthly_sub_rev AS (
SELECT m.billing_month, m.month_idx,
SUM(
p.daily_monthly_rate * cm.days_in_month
* CASE WHEN MONTH(cm.acq_month) = 12
THEN hs.dec_multiplier ELSE 1.0 END
* tc.rate * ml.weight * p.avg_monthly_rev
* POWER(1.0 - cr.monthly_churn, m.month_idx - cm.acq_idx - ml.lag)
) AS revenue
FROM all_months m
CROSS JOIN cohort_months cm
CROSS JOIN new_sub_params p
CROSS JOIN trial_conversion tc
CROSS JOIN churn_rate cr
CROSS JOIN monthly_lag ml
CROSS JOIN holiday_season hs
WHERE m.month_idx >= cm.acq_idx + ml.lag
AND m.month_idx >= 13
GROUP BY 1, 2
),
-- New ANNUAL subs: daily_rate x days_in_month -> cohort count
-- Pay after trial lag, then again 12 months later with churn
-- December cohorts get holiday count multiplier (same lag as other months)
new_annual_sub_rev AS (
SELECT m.billing_month, m.month_idx,
SUM(
CASE
WHEN m.month_idx = cm.acq_idx + al.lag THEN
p.daily_annual_rate * cm.days_in_month
* CASE WHEN MONTH(cm.acq_month) = 12
THEN hs.dec_multiplier ELSE 1.0 END
* tc.rate * al.weight * p.avg_annual_rev
WHEN m.month_idx = cm.acq_idx + al.lag + 12 THEN
p.daily_annual_rate * cm.days_in_month
* CASE WHEN MONTH(cm.acq_month) = 12
THEN hs.dec_multiplier ELSE 1.0 END
* tc.rate * al.weight * p.avg_annual_rev
* POWER(1.0 - cr.monthly_churn, 12)
ELSE 0
END
) AS revenue
FROM all_months m
CROSS JOIN cohort_months cm
CROSS JOIN new_sub_params p
CROSS JOIN trial_conversion tc
CROSS JOIN churn_rate cr
CROSS JOIN annual_lag al
CROSS JOIN holiday_season hs
WHERE m.month_idx >= 13
AND (m.month_idx = cm.acq_idx + al.lag OR m.month_idx = cm.acq_idx + al.lag + 12)
GROUP BY 1, 2
),
-- ============================================================
-- SUBSCRIBER COUNTS: historical (from actual data)
-- ============================================================
-- Created: paid-plan subs created each month, by interval
hist_created_counts AS (
SELECT DATE_TRUNC('month', TO_TIMESTAMP(s.CREATED::NUMBER))::DATE AS billing_month,
SUM(CASE WHEN s.PLAN:interval::TEXT = 'month' THEN 1 ELSE 0 END) AS created_mo,
SUM(CASE WHEN s.PLAN:interval::TEXT = 'year' THEN 1 ELSE 0 END) AS created_ann
FROM RAW_DB.STRIPE.SUBSCRIPTIONS s
WHERE s.PLAN:amount::NUMBER > 0
AND s.STATUS NOT IN ('incomplete_expired')
AND s.PLAN:interval::TEXT IN ('month', 'year')
AND TO_TIMESTAMP(s.CREATED::NUMBER) >= (SELECT range_start FROM date_params)
AND TO_TIMESTAMP(s.CREATED::NUMBER) < CURRENT_DATE
GROUP BY 1
),
-- Converted: first paid invoice landed this month (trial-to-paid conversion)
hist_converted_counts AS (
SELECT DATE_TRUNC('month', TO_TIMESTAMP(fi.first_paid::NUMBER))::DATE AS billing_month,
SUM(CASE WHEN s.PLAN:interval::TEXT = 'month' THEN 1 ELSE 0 END) AS converted_mo,
SUM(CASE WHEN s.PLAN:interval::TEXT = 'year' THEN 1 ELSE 0 END) AS converted_ann
FROM RAW_DB.STRIPE.SUBSCRIPTIONS s
JOIN first_invoices fi ON fi.SUBSCRIPTION = s.ID
WHERE s.PLAN:amount::NUMBER > 0
AND s.PLAN:interval::TEXT IN ('month', 'year')
AND TO_TIMESTAMP(fi.first_paid::NUMBER) >= (SELECT range_start FROM date_params)
AND TO_TIMESTAMP(fi.first_paid::NUMBER) < DATE_TRUNC('month', CURRENT_DATE)
GROUP BY 1
),
-- Existing billings: recurring (non-first) paid invoices per month
hist_existing_billings AS (
SELECT DATE_TRUNC('month', TO_TIMESTAMP(i.CREATED::NUMBER))::DATE AS billing_month,
SUM(CASE WHEN s.PLAN:interval::TEXT = 'month' THEN 1 ELSE 0 END) AS exist_billing_mo,
SUM(CASE WHEN s.PLAN:interval::TEXT = 'year' THEN 1 ELSE 0 END) AS exist_billing_ann
FROM RAW_DB.STRIPE.INVOICES i
JOIN RAW_DB.STRIPE.SUBSCRIPTIONS s ON i.SUBSCRIPTION = s.ID
JOIN first_invoices fi ON fi.SUBSCRIPTION = s.ID
WHERE i.STATUS = 'paid'
AND i.AMOUNT_PAID > 0
AND i.CREATED != fi.first_paid
AND s.PLAN:amount::NUMBER > 0
AND s.PLAN:interval::TEXT IN ('month', 'year')
AND TO_TIMESTAMP(i.CREATED::NUMBER) >= (SELECT range_start FROM date_params)
AND TO_TIMESTAMP(i.CREATED::NUMBER) < DATE_TRUNC('month', CURRENT_DATE)
GROUP BY 1
),
-- ============================================================
-- SUBSCRIBER COUNTS: forecast (from model parameters)
-- ============================================================
-- Forecast created counts: daily_rate x days_in_month x dec_multiplier
forecast_created_counts AS (
SELECT cm.acq_month AS billing_month,
cm.acq_idx AS month_idx,
ROUND(p.daily_monthly_rate * cm.days_in_month
* CASE WHEN MONTH(cm.acq_month) = 12 THEN hs.dec_multiplier ELSE 1.0 END
) AS created_mo,
ROUND(p.daily_annual_rate * cm.days_in_month
* CASE WHEN MONTH(cm.acq_month) = 12 THEN hs.dec_multiplier ELSE 1.0 END
) AS created_ann
FROM cohort_months cm
CROSS JOIN new_sub_params p
CROSS JOIN holiday_season hs
),
-- Union historical + forecast created counts (needed for lag lookups)
all_created_counts AS (
-- Fully historical months
SELECT m.month_idx, m.billing_month,
COALESCE(hc.created_mo, 0) AS created_mo,
COALESCE(hc.created_ann, 0) AS created_ann
FROM all_months m
LEFT JOIN hist_created_counts hc ON hc.billing_month = m.billing_month
WHERE m.month_idx < 13
UNION ALL
-- Current month: actual partial + forecast remainder
SELECT fc.month_idx, fc.billing_month,
fc.created_mo + COALESCE(hc.created_mo, 0) AS created_mo,
fc.created_ann + COALESCE(hc.created_ann, 0) AS created_ann
FROM forecast_created_counts fc
LEFT JOIN hist_created_counts hc ON hc.billing_month = fc.billing_month
WHERE fc.month_idx = 13
UNION ALL
-- Future forecast months
SELECT fc.month_idx, fc.billing_month, fc.created_mo, fc.created_ann
FROM forecast_created_counts fc
WHERE fc.month_idx > 13
),
-- Forecast converted counts: lag-weighted cohort conversions
-- Split into monthly/annual to avoid cartesian product between lag tables
forecast_converted_mo AS (
SELECT m.billing_month, m.month_idx,
COALESCE(ROUND(SUM(acc.created_mo * tc.rate * ml.weight)), 0) AS converted_mo
FROM all_months m
CROSS JOIN trial_conversion tc
CROSS JOIN monthly_lag ml
JOIN all_created_counts acc ON acc.month_idx = m.month_idx - ml.lag
WHERE m.month_idx >= 13
GROUP BY 1, 2
),
forecast_converted_ann AS (
SELECT m.billing_month, m.month_idx,
COALESCE(ROUND(SUM(acc.created_ann * tc.rate * al.weight)), 0) AS converted_ann
FROM all_months m
CROSS JOIN trial_conversion tc
CROSS JOIN annual_lag al
JOIN all_created_counts acc ON acc.month_idx = m.month_idx - al.lag
WHERE m.month_idx >= 13
GROUP BY 1, 2
),
-- Union historical + forecast converted counts (needed for annual anniversary lookup)
all_converted_counts AS (
SELECT m.month_idx, m.billing_month,
COALESCE(hcv.converted_mo, 0) AS converted_mo,
COALESCE(hcv.converted_ann, 0) AS converted_ann
FROM all_months m
LEFT JOIN hist_converted_counts hcv ON hcv.billing_month = m.billing_month
WHERE m.month_idx < 13
UNION ALL
SELECT fcm.month_idx, fcm.billing_month, fcm.converted_mo, fca.converted_ann
FROM forecast_converted_mo fcm
JOIN forecast_converted_ann fca ON fca.month_idx = fcm.month_idx
),
-- ============================================================
-- FORECAST EXISTING BILLINGS
-- Monthly: pool-based (recursive) -- each month's existing billings
-- = prior pool surviving churn; pool grows by new conversions
-- Annual: anniversary-based -- renewals from 12 months prior
-- ============================================================
-- Initial pool: paying monthly subs at forecast start
paying_mo_pool_init AS (
SELECT COUNT(*) AS pool_size
FROM RAW_DB.STRIPE.SUBSCRIPTIONS s
WHERE s.STATUS IN ('active', 'past_due')
AND s.PLAN:amount::NUMBER > 0
AND s.PLAN:interval::TEXT = 'month'
),
-- Recursive CTE: pool-based monthly existing billings
forecast_exist_monthly AS (
-- Base case: first forecast month (month_idx = 13)
SELECT
m.billing_month,
m.month_idx,
ROUND(pi.pool_size * (1.0 - cr.monthly_churn)) AS exist_billing_mo,
ROUND(pi.pool_size * (1.0 - cr.monthly_churn))
+ COALESCE(fc.converted_mo, 0) AS next_pool
FROM all_months m
CROSS JOIN paying_mo_pool_init pi
CROSS JOIN churn_rate cr
LEFT JOIN forecast_converted_mo fc ON fc.month_idx = m.month_idx
WHERE m.month_idx = 13
UNION ALL
-- Recursive step: each subsequent month
SELECT
m.billing_month,
m.month_idx,
ROUND(prev.next_pool * (1.0 - cr.monthly_churn)) AS exist_billing_mo,
ROUND(prev.next_pool * (1.0 - cr.monthly_churn))
+ COALESCE(fc.converted_mo, 0) AS next_pool
FROM forecast_exist_monthly prev
JOIN all_months m ON m.month_idx = prev.month_idx + 1
CROSS JOIN churn_rate cr
LEFT JOIN forecast_converted_mo fc ON fc.month_idx = m.month_idx
WHERE prev.month_idx < 24
),
-- Anniversary-based annual existing billings: cohort from 12 months prior
forecast_exist_annual AS (
SELECT m.billing_month, m.month_idx,
COALESCE(
ROUND(acc.converted_ann * POWER(1.0 - cr.monthly_churn, 12)),
0
) AS exist_billing_ann
FROM all_months m
CROSS JOIN churn_rate cr
LEFT JOIN all_converted_counts acc ON acc.month_idx = m.month_idx - 12
WHERE m.month_idx >= 13
)
-- ============================================================
-- FINAL OUTPUT: revenue + subscriber counts for all 25 months
-- ============================================================
SELECT
m.billing_month,
ROUND(a.revenue,2) AS actual_revenue,
ROUND(COALESCE(e.rev_with_churn, 0)
+ COALESCE(nm.revenue, 0)
+ COALESCE(na.revenue, 0), 2) AS expected_full_model,
ROUND(COALESCE(e.rev_with_churn, 0), 2) AS existing_only_with_churn,
ROUND(COALESCE(e.rev_no_churn, 0), 2) AS existing_no_churn,
ROUND(COALESCE(nm.revenue, 0), 2) AS new_monthly_contrib,
ROUND(COALESCE(na.revenue, 0), 2) AS new_annual_contrib,
-- Subscriber counts (historical from actual data, forecast from model)
COALESCE(hcc.created_mo, 0) + COALESCE(fcc.created_mo, 0) AS created_mo,
COALESCE(hcc.created_ann, 0) + COALESCE(fcc.created_ann, 0) AS created_ann,
COALESCE(hcv.converted_mo, fcvm.converted_mo, 0) AS converted_mo,
COALESCE(hcv.converted_ann, fcva.converted_ann, 0) AS converted_ann,
COALESCE(heb.exist_billing_mo, fem.exist_billing_mo, 0) AS exist_billing_mo,
COALESCE(heb.exist_billing_ann, fea.exist_billing_ann, 0) AS exist_billing_ann
FROM all_months m
LEFT JOIN actual a ON a.billing_month = m.billing_month
LEFT JOIN existing_expected e ON e.billing_month = m.billing_month
LEFT JOIN new_monthly_sub_rev nm ON nm.billing_month = m.billing_month
LEFT JOIN new_annual_sub_rev na ON na.billing_month = m.billing_month
-- Historical subscriber counts
LEFT JOIN hist_created_counts hcc ON hcc.billing_month = m.billing_month
LEFT JOIN hist_converted_counts hcv ON hcv.billing_month = m.billing_month
LEFT JOIN hist_existing_billings heb ON heb.billing_month = m.billing_month
-- Forecast subscriber counts
LEFT JOIN forecast_created_counts fcc ON fcc.billing_month = m.billing_month
LEFT JOIN forecast_converted_mo fcvm ON fcvm.billing_month = m.billing_month
LEFT JOIN forecast_converted_ann fcva ON fcva.billing_month = m.billing_month
LEFT JOIN forecast_exist_monthly fem ON fem.billing_month = m.billing_month
LEFT JOIN forecast_exist_annual fea ON fea.billing_month = m.billing_month
ORDER BY m.billing_month;