diff --git a/package.json b/package.json index eebac5d590..71e32d5b73 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 3ec264fe94..9b85c50022 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' @@ -50,6 +53,7 @@ type GlobalStatsRow = Pick< | 'date_id' | 'mrr' | 'new_paying_orgs' + | 'paying' | 'paying_monthly' | 'paying_yearly' | 'plan_enterprise' @@ -69,6 +73,7 @@ type GlobalStatsRow = Pick< | 'revenue_solo' | 'revenue_team' | 'total_revenue' + | 'upgraded_orgs' > type GlobalStatsUpdate = Database['public']['Tables']['global_stats']['Update'] type PlanRow = Pick @@ -86,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 } @@ -98,6 +103,7 @@ interface DailyCounters { churnRevenue: number churnRevenueByPlan: Record newCustomerIds: Set + upgradedCustomerIds: Set } export interface RevenueTrendMetricValues { @@ -109,6 +115,7 @@ export interface RevenueTrendMetricValues { churn_revenue_team: number mrr: number new_paying_orgs: number + paying: number paying_monthly: number paying_yearly: number plan_enterprise: number @@ -128,6 +135,7 @@ export interface RevenueTrendMetricValues { revenue_solo: number revenue_team: number total_revenue: number + upgraded_orgs: number } export interface RevenueTrendBackfillRow extends RevenueTrendMetricValues { @@ -210,6 +218,7 @@ function createEmptyMetrics(): RevenueTrendMetricValues { churn_revenue_team: 0, mrr: 0, new_paying_orgs: 0, + paying: 0, paying_monthly: 0, paying_yearly: 0, plan_enterprise: 0, @@ -229,6 +238,7 @@ function createEmptyMetrics(): RevenueTrendMetricValues { revenue_solo: 0, revenue_team: 0, total_revenue: 0, + upgraded_orgs: 0, } } @@ -336,6 +346,24 @@ 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 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 @@ -401,9 +429,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 ?? getLookupOrItemBillingInterval(item, priceLookup) const status = options.status ?? subscription.status const eventSeconds = options.eventSeconds ?? null @@ -423,9 +450,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, } @@ -492,6 +519,7 @@ function createDailyCounters(): DailyCounters { enterprise: 0, }, newCustomerIds: new Set(), + upgradedCustomerIds: new Set(), } } @@ -500,14 +528,20 @@ function recordTransition( seenPaidCustomerIds: Set, currentState: RevenueSubscriptionState | null, nextState: RevenueSubscriptionState | null, + options: { cadenceUpgrade?: boolean } = {}, ) { 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 (daily && nextActive && options.cadenceUpgrade) + daily.upgradedCustomerIds.add(customerId) + + if (!currentActive && nextActive) { if (!seenPaidCustomerIds.has(customerId)) { daily?.newCustomerIds.add(customerId) seenPaidCustomerIds.add(customerId) @@ -518,10 +552,15 @@ function recordTransition( if (!daily) return - if (currentMrr > 0 && nextMrr <= 0) { + const isRevenueUpgrade = currentMrr > 0 && nextMrr > currentMrr + const isCadenceUpgrade = options.cadenceUpgrade || (currentState?.interval === 'monthly' && nextState?.interval === 'yearly') + if (currentActive && nextActive && (isRevenueUpgrade || isCadenceUpgrade)) + daily.upgradedCustomerIds.add(customerId) + + if (currentActive && !nextActive) { daily.canceledCustomerIds.add(customerId) daily.churnRevenue += currentMrr - if (currentState) + if (currentState?.plan) daily.churnRevenueByPlan[currentState.plan] += currentMrr return } @@ -529,7 +568,7 @@ function recordTransition( if (currentMrr > nextMrr) { const lostMrr = currentMrr - nextMrr daily.churnRevenue += lostMrr - if (currentState) + if (currentState?.plan) daily.churnRevenueByPlan[currentState.plan] += lostMrr } } @@ -552,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) @@ -582,8 +625,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) } } @@ -639,8 +681,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) } } } @@ -657,22 +698,28 @@ 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)) } } 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') metrics.paying_monthly++ - else + else if (state.interval === 'yearly') metrics.paying_yearly++ + if (!state.plan) + continue + if (state.plan === 'solo') { metrics.plan_solo++ if (state.interval === 'monthly') @@ -710,6 +757,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 a232555fea..c221745e1f 100644 --- a/tests/backfill-revenue-trend-metrics.unit.test.ts +++ b/tests/backfill-revenue-trend-metrics.unit.test.ts @@ -24,6 +24,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, @@ -43,20 +44,23 @@ function globalStatsRow(dateId: string) { revenue_solo: 0, revenue_team: 0, total_revenue: 0, + upgraded_orgs: 0, } } -function subscriptionItem(priceId: string, currentPeriodEnd?: number, usageType = 'licensed') { +function subscriptionItem(priceId: string, currentPeriodEnd?: number, usageType = 'licensed', interval?: 'month' | 'year') { return { id: `si_${priceId}`, object: 'subscription_item', current_period_end: currentPeriodEnd, plan: { id: priceId, + ...(interval ? { interval } : {}), usage_type: usageType, }, price: { id: priceId, + ...(interval ? { recurring: { interval } } : {}), }, } as unknown as Stripe.SubscriptionItem } @@ -68,6 +72,7 @@ function subscription( created = DAY_1, status: Stripe.Subscription.Status = 'active', currentPeriodEnd?: number, + interval?: 'month' | 'year', ) { return { id: subscriptionId, @@ -78,7 +83,7 @@ function subscription( customer: customerId, ended_at: status === 'canceled' ? created : null, items: { - data: [subscriptionItem(priceId, currentPeriodEnd)], + data: [subscriptionItem(priceId, currentPeriodEnd, 'licensed', interval)], }, status, } as unknown as Stripe.Subscription @@ -94,7 +99,9 @@ function subscriptionEvent( options: { currentPeriodEnd?: number previousPriceId?: string + previousPriceInterval?: 'month' | 'year' previousStatus?: Stripe.Subscription.Status + priceInterval?: 'month' | 'year' status?: Stripe.Subscription.Status subscriptionCreated?: number } = {}, @@ -106,12 +113,13 @@ function subscriptionEvent( options.subscriptionCreated ?? created, options.status ?? (type === 'customer.subscription.deleted' ? 'canceled' : 'active'), options.currentPeriodEnd, + options.priceInterval, ) const previousAttributes: Partial = {} 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) @@ -149,6 +157,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, @@ -162,6 +171,7 @@ describe('revenue trend backfill metrics', () => { churn_revenue_solo: 12, mrr: 0, new_paying_orgs: 0, + paying: 0, paying_monthly: 0, plan_solo: 0, total_revenue: 0, @@ -219,6 +229,7 @@ describe('revenue trend backfill metrics', () => { expect(rows[0]).toMatchObject({ mrr: 78, + paying: 2, paying_monthly: 1, paying_yearly: 1, plan_maker: 1, @@ -260,6 +271,7 @@ describe('revenue trend backfill metrics', () => { churn_revenue: 0, mrr: 49, plan_solo: 0, + upgraded_orgs: 1, plan_team: 1, revenue_team: 588, }) @@ -310,11 +322,121 @@ 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({ + 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, + }) + }) + + 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'), + ], { + 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'),