From 9c0237ffd29a62d5fb987852da30167c1127ce6f Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Wed, 6 May 2026 12:46:30 +0200 Subject: [PATCH 1/4] fix(admin): backfill revenue dashboard metrics --- package.json | 1 + ...ackfill_admin_revenue_dashboard_metrics.ts | 9 ++++ scripts/backfill_revenue_trend_metrics.ts | 44 ++++++++++++++++--- ...ackfill-revenue-trend-metrics.unit.test.ts | 29 ++++++++++++ 4 files changed, 76 insertions(+), 7 deletions(-) create mode 100644 scripts/backfill_admin_revenue_dashboard_metrics.ts diff --git a/package.json b/package.json index ecc3d73d93..b3826f047f 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,7 @@ "stripe:backfill-org-conversion-rate": "bun scripts/backfill_org_conversion_rate_trend.ts", "stripe:backfill-customer-countries": "bun scripts/backfill_stripe_customer_countries.ts", "stripe:backfill-revenue-trends": "bun scripts/backfill_revenue_trend_metrics.ts", + "stripe:backfill-admin-revenue-dashboard": "bun scripts/backfill_admin_revenue_dashboard_metrics.ts", "stripe:sync-org-names": "bun scripts/sync_stripe_org_names.ts", "lint": "eslint \"src/**/*.{vue,ts,js}\"", "fmt": "bun run lint:fix && bun run lint:sql", diff --git a/scripts/backfill_admin_revenue_dashboard_metrics.ts b/scripts/backfill_admin_revenue_dashboard_metrics.ts new file mode 100644 index 0000000000..08f14e4f09 --- /dev/null +++ b/scripts/backfill_admin_revenue_dashboard_metrics.ts @@ -0,0 +1,9 @@ +/* + * Backfill admin revenue dashboard metrics from Stripe into public.global_stats. + * + * Implementation is shared with backfill_revenue_trend_metrics.ts so legacy + * and dashboard-specific package scripts stay behaviorally identical. + */ +import { main } from './backfill_revenue_trend_metrics.ts' + +await main() diff --git a/scripts/backfill_revenue_trend_metrics.ts b/scripts/backfill_revenue_trend_metrics.ts index f97a35f566..16bb1b661e 100644 --- a/scripts/backfill_revenue_trend_metrics.ts +++ b/scripts/backfill_revenue_trend_metrics.ts @@ -1,15 +1,18 @@ /* - * Backfill admin revenue trend metrics stored in public.global_stats. + * Backfill admin revenue dashboard metrics stored in public.global_stats. + * + * Covers Subscription Type, Subscription Flow, MRR, ARR, ARR by Plan, + * Churn Revenue - Lost MRR, Total Paying Organizations, and upgraded orgs. * * Dry run, defaulting to the last 30 UTC calendar days: - * bun run stripe:backfill-revenue-trends + * bun run stripe:backfill-admin-revenue-dashboard * * Apply a date range: - * bun run stripe:backfill-revenue-trends --apply --from=2026-04-01 --to=2026-04-30 + * bun run stripe:backfill-admin-revenue-dashboard --apply --from=2026-04-01 --to=2026-04-30 * * Older history should use an exported Stripe events JSON file that includes * enough pre-range subscription events to seed the opening state: - * bun run stripe:backfill-revenue-trends --events-file=./tmp/stripe-events.json --from=2026-01-01 --to=2026-04-30 + * bun run stripe:backfill-admin-revenue-dashboard --events-file=./tmp/stripe-events.json --from=2026-01-01 --to=2026-04-30 */ import type Stripe from 'stripe' import type { Database } from '../supabase/functions/_backend/utils/supabase.types.ts' @@ -46,6 +49,7 @@ type GlobalStatsRow = Pick< | 'date_id' | 'mrr' | 'new_paying_orgs' + | 'paying' | 'paying_monthly' | 'paying_yearly' | 'plan_enterprise' @@ -65,6 +69,7 @@ type GlobalStatsRow = Pick< | 'revenue_solo' | 'revenue_team' | 'total_revenue' + | 'upgraded_orgs' > type GlobalStatsUpdate = Database['public']['Tables']['global_stats']['Update'] type PlanRow = Pick @@ -93,6 +98,7 @@ interface DailyCounters { canceledCustomerIds: Set churnRevenue: number newCustomerIds: Set + upgradedCustomerIds: Set } export interface RevenueTrendMetricValues { @@ -100,6 +106,7 @@ export interface RevenueTrendMetricValues { churn_revenue: number mrr: number new_paying_orgs: number + paying: number paying_monthly: number paying_yearly: number plan_enterprise: number @@ -119,6 +126,7 @@ export interface RevenueTrendMetricValues { revenue_solo: number revenue_team: number total_revenue: number + upgraded_orgs: number } export interface RevenueTrendBackfillRow extends RevenueTrendMetricValues { @@ -197,6 +205,7 @@ function createEmptyMetrics(): RevenueTrendMetricValues { churn_revenue: 0, mrr: 0, new_paying_orgs: 0, + paying: 0, paying_monthly: 0, paying_yearly: 0, plan_enterprise: 0, @@ -216,6 +225,7 @@ function createEmptyMetrics(): RevenueTrendMetricValues { revenue_solo: 0, revenue_team: 0, total_revenue: 0, + upgraded_orgs: 0, } } @@ -473,6 +483,7 @@ function createDailyCounters() { canceledCustomerIds: new Set(), churnRevenue: 0, newCustomerIds: new Set(), + upgradedCustomerIds: new Set(), } } @@ -499,6 +510,17 @@ function recordTransition( if (!daily) return + if ( + currentMrr > 0 + && nextMrr > 0 + && ( + nextMrr > currentMrr + || (currentState?.interval === 'monthly' && nextState?.interval === 'yearly') + ) + ) { + daily.upgradedCustomerIds.add(customerId) + } + if (currentMrr > 0 && nextMrr <= 0) { daily.canceledCustomerIds.add(customerId) daily.churnRevenue += currentMrr @@ -638,8 +660,10 @@ function expireStatesForDate(states: Map, date export function summarizeRevenueSnapshot(states: Iterable, daily: DailyCounters = createDailyCounters()): RevenueTrendMetricValues { const metrics = createEmptyMetrics() + const payingCustomerIds = new Set() for (const state of states) { + payingCustomerIds.add(state.customerId) metrics.mrr += state.mrr if (state.interval === 'monthly') @@ -684,6 +708,8 @@ export function summarizeRevenueSnapshot(states: Iterable = process.env) { +export async function main(args = process.argv.slice(2), runtimeEnv: Record = process.env) { const apply = args.includes('--apply') const skipSubscriptionBaseline = args.includes('--skip-subscription-baseline') const envFile = getArgValue(args, '--env-file') ?? DEFAULT_ENV_FILE diff --git a/tests/backfill-revenue-trend-metrics.unit.test.ts b/tests/backfill-revenue-trend-metrics.unit.test.ts index 63b416da4e..81fcaf562d 100644 --- a/tests/backfill-revenue-trend-metrics.unit.test.ts +++ b/tests/backfill-revenue-trend-metrics.unit.test.ts @@ -20,6 +20,7 @@ function globalStatsRow(dateId: string) { date_id: dateId, mrr: 0, new_paying_orgs: 0, + paying: 0, paying_monthly: 0, paying_yearly: 0, plan_enterprise: 0, @@ -39,6 +40,7 @@ function globalStatsRow(dateId: string) { revenue_solo: 0, revenue_team: 0, total_revenue: 0, + upgraded_orgs: 0, } } @@ -145,6 +147,7 @@ describe('revenue trend backfill metrics', () => { churn_revenue: 0, mrr: 12, new_paying_orgs: 1, + paying: 1, paying_monthly: 1, paying_yearly: 0, plan_solo: 1, @@ -157,6 +160,7 @@ describe('revenue trend backfill metrics', () => { churn_revenue: 12, mrr: 0, new_paying_orgs: 0, + paying: 0, paying_monthly: 0, plan_solo: 0, total_revenue: 0, @@ -214,6 +218,7 @@ describe('revenue trend backfill metrics', () => { expect(rows[0]).toMatchObject({ mrr: 78, + paying: 2, paying_monthly: 1, paying_yearly: 1, plan_maker: 1, @@ -255,6 +260,7 @@ describe('revenue trend backfill metrics', () => { churn_revenue: 0, mrr: 49, plan_solo: 0, + upgraded_orgs: 1, plan_team: 1, revenue_team: 588, }) @@ -304,11 +310,34 @@ describe('revenue trend backfill metrics', () => { expect(rows[0]).toMatchObject({ mrr: 12, new_paying_orgs: 1, + paying: 1, paying_monthly: 1, plan_solo: 1, }) }) + it.concurrent('counts monthly-to-yearly changes as upgraded orgs', () => { + const rows = buildRevenueTrendBackfillRows([ + globalStatsRow('2026-04-01'), + ], { + events: [ + subscriptionEvent('evt_yearly_upgrade', 'customer.subscription.updated', DAY_1 + 3600, 'cus_yearly_upgrade', 'sub_yearly_upgrade', 'price_solo_yearly', { + previousPriceId: 'price_solo_monthly', + subscriptionCreated: DAY_1 - 86400, + }), + ], + fromDateId: '2026-04-01', + plans, + toDateId: '2026-04-01', + }) + + expect(rows[0]).toMatchObject({ + paying: 1, + paying_yearly: 1, + upgraded_orgs: 1, + }) + }) + it.concurrent('keeps cancel-at-period-end subscriptions active until the period expires', () => { const rows = buildRevenueTrendBackfillRows([ globalStatsRow('2026-04-01'), From c6d4539f64505eff244121eba6daed35b2da4c87 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Wed, 6 May 2026 13:44:08 +0200 Subject: [PATCH 2/4] test(admin): tighten upgrade backfill assertion --- tests/backfill-revenue-trend-metrics.unit.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/backfill-revenue-trend-metrics.unit.test.ts b/tests/backfill-revenue-trend-metrics.unit.test.ts index 81fcaf562d..28fa0e9715 100644 --- a/tests/backfill-revenue-trend-metrics.unit.test.ts +++ b/tests/backfill-revenue-trend-metrics.unit.test.ts @@ -332,8 +332,15 @@ describe('revenue trend backfill metrics', () => { }) expect(rows[0]).toMatchObject({ + mrr: 10, paying: 1, + paying_monthly: 0, paying_yearly: 1, + plan_solo: 1, + plan_solo_monthly: 0, + plan_solo_yearly: 1, + revenue_solo: 120, + total_revenue: 120, upgraded_orgs: 1, }) }) From 46e9322b147ab079ab0174705eb017aab898f6dc Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Wed, 6 May 2026 14:37:36 +0200 Subject: [PATCH 3/4] fix(admin): count legacy price subscriptions in backfill --- scripts/backfill_revenue_trend_metrics.ts | 64 +++++++++++-------- ...ackfill-revenue-trend-metrics.unit.test.ts | 64 ++++++++++++++++++- 2 files changed, 98 insertions(+), 30 deletions(-) diff --git a/scripts/backfill_revenue_trend_metrics.ts b/scripts/backfill_revenue_trend_metrics.ts index 7ff0638af4..dd06bf4271 100644 --- a/scripts/backfill_revenue_trend_metrics.ts +++ b/scripts/backfill_revenue_trend_metrics.ts @@ -91,9 +91,9 @@ interface PriceLookupEntry { interface RevenueSubscriptionState { activeUntilSeconds: number | null customerId: string - interval: BillingInterval + interval: BillingInterval | null mrr: number - plan: PlanKey + plan: PlanKey | null priceId: string subscriptionId: string } @@ -346,6 +346,19 @@ function getItemPriceId(item: Stripe.SubscriptionItem | null | undefined) { return item.plan?.id ?? toStripeId(item.price) ?? null } +function getItemBillingInterval(item: Stripe.SubscriptionItem | null | undefined): BillingInterval | null { + const priceInterval = (item?.price as { recurring?: { interval?: unknown } } | undefined)?.recurring?.interval + const planInterval = (item?.plan as { interval?: unknown } | undefined)?.interval + const interval = priceInterval ?? planInterval + + if (interval === 'month') + return 'monthly' + if (interval === 'year') + return 'yearly' + + return null +} + function getItemPeriodEndSeconds(item: Stripe.SubscriptionItem | null | undefined) { const periodEnd = (item as { current_period_end?: number } | null | undefined)?.current_period_end return typeof periodEnd === 'number' && Number.isFinite(periodEnd) ? periodEnd : null @@ -411,9 +424,8 @@ function buildStateFromSubscription( if (!priceId) return null - const price = priceLookup.get(priceId) - if (!price) - return null + const price = priceLookup.get(priceId) ?? null + const interval = price?.interval ?? getItemBillingInterval(item) const status = options.status ?? subscription.status const eventSeconds = options.eventSeconds ?? null @@ -433,9 +445,9 @@ function buildStateFromSubscription( return { activeUntilSeconds: endSeconds && !activeByStatus ? endSeconds : subscription.cancel_at_period_end ? endSeconds : null, customerId, - interval: price.interval, - mrr: price.mrr, - plan: price.plan, + interval, + mrr: price?.mrr ?? 0, + plan: price?.plan ?? null, priceId, subscriptionId: subscription.id, } @@ -514,11 +526,13 @@ function recordTransition( ) { const currentMrr = currentState?.mrr ?? 0 const nextMrr = nextState?.mrr ?? 0 + const currentActive = Boolean(currentState) + const nextActive = Boolean(nextState) const customerId = nextState?.customerId ?? currentState?.customerId if (!customerId) return - if (currentMrr <= 0 && nextMrr > 0) { + if (!currentActive && nextActive) { if (!seenPaidCustomerIds.has(customerId)) { daily?.newCustomerIds.add(customerId) seenPaidCustomerIds.add(customerId) @@ -529,21 +543,15 @@ function recordTransition( if (!daily) return - if ( - currentMrr > 0 - && nextMrr > 0 - && ( - nextMrr > currentMrr - || (currentState?.interval === 'monthly' && nextState?.interval === 'yearly') - ) - ) { + const isRevenueUpgrade = currentMrr > 0 && nextMrr > currentMrr + const isCadenceUpgrade = currentState?.interval === 'monthly' && nextState?.interval === 'yearly' + if (currentActive && nextActive && (isRevenueUpgrade || isCadenceUpgrade)) daily.upgradedCustomerIds.add(customerId) - } - if (currentMrr > 0 && nextMrr <= 0) { + if (currentActive && !nextActive) { daily.canceledCustomerIds.add(customerId) daily.churnRevenue += currentMrr - if (currentState) + if (currentState?.plan) daily.churnRevenueByPlan[currentState.plan] += currentMrr return } @@ -551,7 +559,7 @@ function recordTransition( if (currentMrr > nextMrr) { const lostMrr = currentMrr - nextMrr daily.churnRevenue += lostMrr - if (currentState) + if (currentState?.plan) daily.churnRevenueByPlan[currentState.plan] += lostMrr } } @@ -604,8 +612,7 @@ function seedBaselineStatesFromSubscriptions( if (subscription.created >= fromStartSeconds) continue states.set(getStateKey(state), state) - if (state.mrr > 0) - seenPaidCustomerIds.add(state.customerId) + seenPaidCustomerIds.add(state.customerId) } } @@ -661,8 +668,7 @@ function seedOpeningStateFromFirstRangeEvents( const previousState = buildPreviousStateFromEvent(event, priceLookup) if (previousState) { states.set(getStateKey(previousState), previousState) - if (previousState.mrr > 0) - seenPaidCustomerIds.add(previousState.customerId) + seenPaidCustomerIds.add(previousState.customerId) } } } @@ -679,7 +685,8 @@ function expireStatesForDate(states: Map, date daily.canceledCustomerIds.add(state.customerId) daily.churnRevenue += state.mrr - daily.churnRevenueByPlan[state.plan] += state.mrr + if (state.plan) + daily.churnRevenueByPlan[state.plan] += state.mrr states.delete(getStateKey(state)) } } @@ -694,9 +701,12 @@ export function summarizeRevenueSnapshot(states: Iterable = {} if (options.previousPriceId) { previousAttributes.items = { - data: [subscriptionItem(options.previousPriceId)], + data: [subscriptionItem(options.previousPriceId, undefined, 'licensed', options.previousPriceInterval)], } as unknown as Stripe.ApiList } if (options.previousStatus) @@ -351,6 +357,58 @@ describe('revenue trend backfill metrics', () => { }) }) + it.concurrent('counts active legacy price subscriptions as paying organizations', () => { + const rows = buildRevenueTrendBackfillRows([ + globalStatsRow('2026-04-01'), + ], { + baselineSubscriptions: [ + subscription('cus_legacy', 'sub_legacy', 'price_legacy_monthly', DAY_1 - 86400, 'active', undefined, 'month'), + ], + events: [], + fromDateId: '2026-04-01', + plans, + toDateId: '2026-04-01', + }) + + expect(rows[0]).toMatchObject({ + mrr: 0, + new_paying_orgs: 0, + paying: 1, + paying_monthly: 1, + paying_yearly: 0, + plan_solo: 0, + total_revenue: 0, + }) + }) + + it.concurrent('counts legacy monthly-to-yearly cadence changes as upgraded orgs', () => { + const rows = buildRevenueTrendBackfillRows([ + globalStatsRow('2026-04-01'), + ], { + events: [ + subscriptionEvent('evt_legacy_yearly_upgrade', 'customer.subscription.updated', DAY_1 + 3600, 'cus_legacy_yearly_upgrade', 'sub_legacy_yearly_upgrade', 'price_legacy_yearly', { + previousPriceId: 'price_legacy_monthly', + previousPriceInterval: 'month', + priceInterval: 'year', + subscriptionCreated: DAY_1 - 86400, + }), + ], + fromDateId: '2026-04-01', + plans, + toDateId: '2026-04-01', + }) + + expect(rows[0]).toMatchObject({ + mrr: 0, + paying: 1, + paying_monthly: 0, + paying_yearly: 1, + plan_solo: 0, + total_revenue: 0, + upgraded_orgs: 1, + }) + }) + it.concurrent('keeps cancel-at-period-end subscriptions active until the period expires', () => { const rows = buildRevenueTrendBackfillRows([ globalStatsRow('2026-04-01'), From 61d6b4eb372893eeb4ed842c1211cc63060fdf5d Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Wed, 6 May 2026 14:58:41 +0200 Subject: [PATCH 4/4] fix(admin): count activation cadence upgrades --- scripts/backfill_revenue_trend_metrics.ts | 19 +++++++++++-- ...ackfill-revenue-trend-metrics.unit.test.ts | 28 +++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/scripts/backfill_revenue_trend_metrics.ts b/scripts/backfill_revenue_trend_metrics.ts index dd06bf4271..9b85c50022 100644 --- a/scripts/backfill_revenue_trend_metrics.ts +++ b/scripts/backfill_revenue_trend_metrics.ts @@ -359,6 +359,11 @@ function getItemBillingInterval(item: Stripe.SubscriptionItem | null | undefined return null } +function getLookupOrItemBillingInterval(item: Stripe.SubscriptionItem | null | undefined, priceLookup: Map): BillingInterval | null { + const priceId = getItemPriceId(item) + return (priceId ? priceLookup.get(priceId)?.interval : null) ?? getItemBillingInterval(item) +} + function getItemPeriodEndSeconds(item: Stripe.SubscriptionItem | null | undefined) { const periodEnd = (item as { current_period_end?: number } | null | undefined)?.current_period_end return typeof periodEnd === 'number' && Number.isFinite(periodEnd) ? periodEnd : null @@ -425,7 +430,7 @@ function buildStateFromSubscription( return null const price = priceLookup.get(priceId) ?? null - const interval = price?.interval ?? getItemBillingInterval(item) + const interval = price?.interval ?? getLookupOrItemBillingInterval(item, priceLookup) const status = options.status ?? subscription.status const eventSeconds = options.eventSeconds ?? null @@ -523,6 +528,7 @@ function recordTransition( seenPaidCustomerIds: Set, currentState: RevenueSubscriptionState | null, nextState: RevenueSubscriptionState | null, + options: { cadenceUpgrade?: boolean } = {}, ) { const currentMrr = currentState?.mrr ?? 0 const nextMrr = nextState?.mrr ?? 0 @@ -532,6 +538,9 @@ function recordTransition( if (!customerId) return + if (daily && nextActive && options.cadenceUpgrade) + daily.upgradedCustomerIds.add(customerId) + if (!currentActive && nextActive) { if (!seenPaidCustomerIds.has(customerId)) { daily?.newCustomerIds.add(customerId) @@ -544,7 +553,7 @@ function recordTransition( return const isRevenueUpgrade = currentMrr > 0 && nextMrr > currentMrr - const isCadenceUpgrade = currentState?.interval === 'monthly' && nextState?.interval === 'yearly' + const isCadenceUpgrade = options.cadenceUpgrade || (currentState?.interval === 'monthly' && nextState?.interval === 'yearly') if (currentActive && nextActive && (isRevenueUpgrade || isCadenceUpgrade)) daily.upgradedCustomerIds.add(customerId) @@ -582,8 +591,12 @@ function applySubscriptionEventToStates( const existingState = states.get(subscriptionId) ?? null const previousState = existingState ?? buildPreviousStateFromEvent(event, priceLookup) const nextState = buildNextStateFromEvent(event, priceLookup) + const previousInterval = previousState?.interval ?? getLookupOrItemBillingInterval(getLicensedSubscriptionItem(getPreviousSubscriptionItems(event)), priceLookup) + const nextInterval = nextState?.interval ?? getLookupOrItemBillingInterval(getLicensedSubscriptionItem(getSubscriptionItems(subscription)), priceLookup) - recordTransition(daily, seenPaidCustomerIds, previousState, nextState) + recordTransition(daily, seenPaidCustomerIds, previousState, nextState, { + cadenceUpgrade: previousInterval === 'monthly' && nextInterval === 'yearly', + }) if (nextState) states.set(getStateKey(nextState), nextState) diff --git a/tests/backfill-revenue-trend-metrics.unit.test.ts b/tests/backfill-revenue-trend-metrics.unit.test.ts index 1ac65448d2..c221745e1f 100644 --- a/tests/backfill-revenue-trend-metrics.unit.test.ts +++ b/tests/backfill-revenue-trend-metrics.unit.test.ts @@ -357,6 +357,34 @@ describe('revenue trend backfill metrics', () => { }) }) + it.concurrent('counts activation cadence changes as new paying and upgraded orgs', () => { + const rows = buildRevenueTrendBackfillRows([ + globalStatsRow('2026-04-01'), + ], { + events: [ + subscriptionEvent('evt_activation_yearly_upgrade', 'customer.subscription.updated', DAY_1 + 3600, 'cus_activation_yearly_upgrade', 'sub_activation_yearly_upgrade', 'price_solo_yearly', { + previousPriceId: 'price_solo_monthly', + previousStatus: 'incomplete', + subscriptionCreated: DAY_1 - 86400, + }), + ], + fromDateId: '2026-04-01', + plans, + toDateId: '2026-04-01', + }) + + expect(rows[0]).toMatchObject({ + mrr: 10, + new_paying_orgs: 1, + paying: 1, + paying_monthly: 0, + paying_yearly: 1, + plan_solo: 1, + plan_solo_yearly: 1, + upgraded_orgs: 1, + }) + }) + it.concurrent('counts active legacy price subscriptions as paying organizations', () => { const rows = buildRevenueTrendBackfillRows([ globalStatsRow('2026-04-01'),