Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 9 additions & 0 deletions scripts/backfill_admin_revenue_dashboard_metrics.ts
Original file line number Diff line number Diff line change
@@ -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()
105 changes: 79 additions & 26 deletions scripts/backfill_revenue_trend_metrics.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -50,6 +53,7 @@ type GlobalStatsRow = Pick<
| 'date_id'
| 'mrr'
| 'new_paying_orgs'
| 'paying'
| 'paying_monthly'
| 'paying_yearly'
| 'plan_enterprise'
Expand All @@ -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<Database['public']['Tables']['plans']['Row'], 'name' | 'price_m' | 'price_m_id' | 'price_y' | 'price_y_id'>
Expand All @@ -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
}
Expand All @@ -98,6 +103,7 @@ interface DailyCounters {
churnRevenue: number
churnRevenueByPlan: Record<PlanKey, number>
newCustomerIds: Set<string>
upgradedCustomerIds: Set<string>
}

export interface RevenueTrendMetricValues {
Expand All @@ -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
Expand All @@ -128,6 +135,7 @@ export interface RevenueTrendMetricValues {
revenue_solo: number
revenue_team: number
total_revenue: number
upgraded_orgs: number
}

export interface RevenueTrendBackfillRow extends RevenueTrendMetricValues {
Expand Down Expand Up @@ -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,
Expand All @@ -229,6 +238,7 @@ function createEmptyMetrics(): RevenueTrendMetricValues {
revenue_solo: 0,
revenue_team: 0,
total_revenue: 0,
upgraded_orgs: 0,
}
}

Expand Down Expand Up @@ -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<string, PriceLookupEntry>): 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
Expand Down Expand Up @@ -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
Expand All @@ -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,
}
Expand Down Expand Up @@ -492,6 +519,7 @@ function createDailyCounters(): DailyCounters {
enterprise: 0,
},
newCustomerIds: new Set<string>(),
upgradedCustomerIds: new Set<string>(),
}
}

Expand All @@ -500,14 +528,20 @@ function recordTransition(
seenPaidCustomerIds: Set<string>,
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)
Expand All @@ -518,18 +552,23 @@ 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
}

if (currentMrr > nextMrr) {
const lostMrr = currentMrr - nextMrr
daily.churnRevenue += lostMrr
if (currentState)
if (currentState?.plan)
daily.churnRevenueByPlan[currentState.plan] += lostMrr
}
}
Expand All @@ -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)
Expand Down Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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)
}
}
}
Expand All @@ -657,22 +698,28 @@ function expireStatesForDate(states: Map<string, RevenueSubscriptionState>, 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<RevenueSubscriptionState>, daily: DailyCounters = createDailyCounters()): RevenueTrendMetricValues {
const metrics = createEmptyMetrics()
const payingCustomerIds = new Set<string>()

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')
Expand Down Expand Up @@ -710,6 +757,8 @@ export function summarizeRevenueSnapshot(states: Iterable<RevenueSubscriptionSta
metrics.new_paying_orgs = daily.newCustomerIds.size
metrics.canceled_orgs = daily.canceledCustomerIds.size
metrics.churn_revenue = daily.churnRevenue
metrics.paying = payingCustomerIds.size
Comment thread
riderx marked this conversation as resolved.
metrics.upgraded_orgs = daily.upgradedCustomerIds.size
metrics.churn_revenue_solo = daily.churnRevenueByPlan.solo
metrics.churn_revenue_maker = daily.churnRevenueByPlan.maker
metrics.churn_revenue_team = daily.churnRevenueByPlan.team
Expand Down Expand Up @@ -908,6 +957,7 @@ async function fetchGlobalStatsRows(supabase: SupabaseClient, fromDateId: string
paying_monthly,
new_paying_orgs,
canceled_orgs,
paying,
mrr,
total_revenue,
revenue_solo,
Expand All @@ -930,7 +980,8 @@ async function fetchGlobalStatsRows(supabase: SupabaseClient, fromDateId: string
churn_revenue_solo,
churn_revenue_maker,
churn_revenue_team,
churn_revenue_enterprise
churn_revenue_enterprise,
upgraded_orgs
`)
.gte('date_id', fromDateId)
.lte('date_id', toDateId)
Expand Down Expand Up @@ -961,6 +1012,7 @@ function toGlobalStatsUpdate(row: RevenueTrendBackfillRow): GlobalStatsUpdate {
churn_revenue_team: row.churn_revenue_team,
mrr: row.mrr,
new_paying_orgs: row.new_paying_orgs,
paying: row.paying,
paying_monthly: row.paying_monthly,
paying_yearly: row.paying_yearly,
plan_enterprise: row.plan_enterprise,
Expand All @@ -980,6 +1032,7 @@ function toGlobalStatsUpdate(row: RevenueTrendBackfillRow): GlobalStatsUpdate {
revenue_solo: row.revenue_solo,
revenue_team: row.revenue_team,
total_revenue: row.total_revenue,
upgraded_orgs: row.upgraded_orgs,
}
}

Expand All @@ -995,11 +1048,11 @@ async function updateGlobalStatsRow(supabase: SupabaseClient, row: RevenueTrendB

function printSampleRows(rows: RevenueTrendBackfillRow[]) {
for (const row of rows.slice(0, 10)) {
console.log(`${row.date_id}: monthly=${row.paying_monthly}, yearly=${row.paying_yearly}, mrr=$${row.mrr.toFixed(2)}, arr=$${row.total_revenue.toFixed(2)}, new=${row.new_paying_orgs}, canceled=${row.canceled_orgs}, churn=$${row.churn_revenue.toFixed(2)}, churn_plans=$${row.churn_revenue_solo.toFixed(2)}/$${row.churn_revenue_maker.toFixed(2)}/$${row.churn_revenue_team.toFixed(2)}/$${row.churn_revenue_enterprise.toFixed(2)}, plans=${row.plan_solo}/${row.plan_maker}/${row.plan_team}/${row.plan_enterprise}`)
console.log(`${row.date_id}: paying=${row.paying}, monthly=${row.paying_monthly}, yearly=${row.paying_yearly}, mrr=$${row.mrr.toFixed(2)}, arr=$${row.total_revenue.toFixed(2)}, new=${row.new_paying_orgs}, canceled=${row.canceled_orgs}, upgraded=${row.upgraded_orgs}, churn=$${row.churn_revenue.toFixed(2)}, churn_plans=$${row.churn_revenue_solo.toFixed(2)}/$${row.churn_revenue_maker.toFixed(2)}/$${row.churn_revenue_team.toFixed(2)}/$${row.churn_revenue_enterprise.toFixed(2)}, plans=${row.plan_solo}/${row.plan_maker}/${row.plan_team}/${row.plan_enterprise}`)
}
}

async function main(args = process.argv.slice(2), runtimeEnv: Record<string, string | undefined> = process.env) {
export async function main(args = process.argv.slice(2), runtimeEnv: Record<string, string | undefined> = process.env) {
const apply = args.includes('--apply')
const skipSubscriptionBaseline = args.includes('--skip-subscription-baseline')
const envFile = getArgValue(args, '--env-file') ?? DEFAULT_ENV_FILE
Expand Down
Loading
Loading