From 173c1a6340bde5127cd2ae95185c10fb21a15e77 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Tue, 12 May 2026 23:47:44 +0200 Subject: [PATCH] fix(stripe): rebuild admin revenue backfill from invoices --- package.json | 4 +- ...ackfill_admin_revenue_dashboard_metrics.ts | 9 - scripts/backfill_revenue_trend_metrics.ts | 1170 ----------------- ..._stripe_admin_revenue_dashboard_metrics.ts | 1108 ++++++++++++++++ ...admin-stripe-backfill-scripts.unit.test.ts | 179 +++ ...ackfill-revenue-trend-metrics.unit.test.ts | 473 ------- 6 files changed, 1289 insertions(+), 1654 deletions(-) delete mode 100644 scripts/backfill_admin_revenue_dashboard_metrics.ts delete mode 100644 scripts/backfill_revenue_trend_metrics.ts create mode 100644 scripts/fix_stripe_admin_revenue_dashboard_metrics.ts delete mode 100644 tests/backfill-revenue-trend-metrics.unit.test.ts diff --git a/package.json b/package.json index d790c3c916..043b73a01d 100644 --- a/package.json +++ b/package.json @@ -91,8 +91,8 @@ "stripe:backfill-customer-countries": "bun scripts/backfill_stripe_customer_countries.ts", "stripe:backfill-subscription-end-dates": "bun scripts/backfill_stripe_subscription_end_dates.ts", "stripe:backfill-ltv-metrics": "bun scripts/backfill_ltv_metrics.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:backfill-revenue-trends": "bun scripts/fix_stripe_admin_revenue_dashboard_metrics.ts", + "stripe:backfill-admin-revenue-dashboard": "bun scripts/fix_stripe_admin_revenue_dashboard_metrics.ts", "stripe:export-six-month-org-emails": "bun scripts/export_stripe_six_month_org_emails.ts", "stripe:sync-org-names": "bun scripts/sync_stripe_org_names.ts", "lint": "eslint \"src/**/*.{vue,ts,js}\"", diff --git a/scripts/backfill_admin_revenue_dashboard_metrics.ts b/scripts/backfill_admin_revenue_dashboard_metrics.ts deleted file mode 100644 index 08f14e4f09..0000000000 --- a/scripts/backfill_admin_revenue_dashboard_metrics.ts +++ /dev/null @@ -1,9 +0,0 @@ -/* - * 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 deleted file mode 100644 index 9b85c50022..0000000000 --- a/scripts/backfill_revenue_trend_metrics.ts +++ /dev/null @@ -1,1170 +0,0 @@ -/* - * 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-admin-revenue-dashboard - * - * Apply a date range: - * 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-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' -import { readFile } from 'node:fs/promises' -import process from 'node:process' -import { - asyncPool, - createStripeClient, - createSupabaseServiceClient, - DEFAULT_ENV_FILE, - getArgValue, - getRequiredEnv, - loadEnv, - parsePositiveInteger, -} from './admin_stripe_backfill_utils.ts' - -const DEFAULT_LOOKBACK_DAYS = 30 -const DEFAULT_CONCURRENCY = 10 -const DEFAULT_PAGE_SIZE = 1000 -const STRIPE_PAGE_SIZE = 100 -const DATE_ID_REGEX = /^\d{4}-\d{2}-\d{2}$/ -const SUBSCRIPTION_EVENT_TYPES = [ - 'customer.subscription.created', - 'customer.subscription.updated', - 'customer.subscription.deleted', -] as const -const PLAN_NAMES = ['Solo', 'Maker', 'Team', 'Enterprise'] as const - -type SupabaseClient = ReturnType -type GlobalStatsRow = Pick< - Database['public']['Tables']['global_stats']['Row'], - | 'canceled_orgs' - | 'churn_revenue' - | 'churn_revenue_enterprise' - | 'churn_revenue_maker' - | 'churn_revenue_solo' - | 'churn_revenue_team' - | 'date_id' - | 'mrr' - | 'new_paying_orgs' - | 'paying' - | 'paying_monthly' - | 'paying_yearly' - | 'plan_enterprise' - | 'plan_enterprise_monthly' - | 'plan_enterprise_yearly' - | 'plan_maker' - | 'plan_maker_monthly' - | 'plan_maker_yearly' - | 'plan_solo' - | 'plan_solo_monthly' - | 'plan_solo_yearly' - | 'plan_team' - | 'plan_team_monthly' - | 'plan_team_yearly' - | 'revenue_enterprise' - | 'revenue_maker' - | 'revenue_solo' - | 'revenue_team' - | 'total_revenue' - | 'upgraded_orgs' -> -type GlobalStatsUpdate = Database['public']['Tables']['global_stats']['Update'] -type PlanRow = Pick -type SubscriptionEventType = typeof SUBSCRIPTION_EVENT_TYPES[number] -type PlanName = typeof PLAN_NAMES[number] -type PlanKey = Lowercase -type BillingInterval = 'monthly' | 'yearly' - -interface PriceLookupEntry { - interval: BillingInterval - mrr: number - plan: PlanKey -} - -interface RevenueSubscriptionState { - activeUntilSeconds: number | null - customerId: string - interval: BillingInterval | null - mrr: number - plan: PlanKey | null - priceId: string - subscriptionId: string -} - -interface DailyCounters { - canceledCustomerIds: Set - churnRevenue: number - churnRevenueByPlan: Record - newCustomerIds: Set - upgradedCustomerIds: Set -} - -export interface RevenueTrendMetricValues { - canceled_orgs: number - churn_revenue: number - churn_revenue_enterprise: number - churn_revenue_maker: number - churn_revenue_solo: number - churn_revenue_team: number - mrr: number - new_paying_orgs: number - paying: number - paying_monthly: number - paying_yearly: number - plan_enterprise: number - plan_enterprise_monthly: number - plan_enterprise_yearly: number - plan_maker: number - plan_maker_monthly: number - plan_maker_yearly: number - plan_solo: number - plan_solo_monthly: number - plan_solo_yearly: number - plan_team: number - plan_team_monthly: number - plan_team_yearly: number - revenue_enterprise: number - revenue_maker: number - revenue_solo: number - revenue_team: number - total_revenue: number - upgraded_orgs: number -} - -export interface RevenueTrendBackfillRow extends RevenueTrendMetricValues { - changed: boolean - current: Partial - date_id: string -} - -interface BuildRevenueTrendRowsOptions { - baselineSubscriptions?: Stripe.Subscription[] - customerId?: string | null - events: Stripe.Event[] - fromDateId: string - plans: PlanRow[] - toDateId: string -} - -function getDateId(targetDate = new Date()) { - return new Date(Date.UTC(targetDate.getUTCFullYear(), targetDate.getUTCMonth(), targetDate.getUTCDate())).toISOString().slice(0, 10) -} - -function assertDateId(value: string, label: string) { - if (!DATE_ID_REGEX.test(value)) - throw new Error(`${label} must use YYYY-MM-DD`) - - const parsed = new Date(`${value}T00:00:00.000Z`) - if (Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== value) - throw new Error(`${label} must be a valid UTC date`) - - return value -} - -function getDefaultFromDateId(referenceDate = new Date()) { - const date = new Date(Date.UTC(referenceDate.getUTCFullYear(), referenceDate.getUTCMonth(), referenceDate.getUTCDate())) - date.setUTCDate(date.getUTCDate() - DEFAULT_LOOKBACK_DAYS + 1) - return getDateId(date) -} - -function getDateIdsBetween(fromDateId: string, toDateId: string) { - const dates: string[] = [] - const cursor = new Date(`${fromDateId}T00:00:00.000Z`) - const end = new Date(`${toDateId}T00:00:00.000Z`) - - while (cursor.getTime() <= end.getTime()) { - dates.push(getDateId(cursor)) - cursor.setUTCDate(cursor.getUTCDate() + 1) - } - - return dates -} - -function dateIdToStartSeconds(dateId: string) { - return Math.floor(new Date(`${dateId}T00:00:00.000Z`).getTime() / 1000) -} - -function dateIdToEndSeconds(dateId: string) { - return Math.floor(new Date(`${dateId}T23:59:59.999Z`).getTime() / 1000) -} - -function compareDateIds(left: string, right: string) { - return left.localeCompare(right) -} - -function toMetricNumber(value: number | string | null | undefined) { - const numberValue = Number(value ?? 0) - return Number.isFinite(numberValue) ? numberValue : 0 -} - -function roundMoney(value: number) { - return Number(value.toFixed(2)) -} - -function createEmptyMetrics(): RevenueTrendMetricValues { - return { - canceled_orgs: 0, - churn_revenue: 0, - churn_revenue_enterprise: 0, - churn_revenue_maker: 0, - churn_revenue_solo: 0, - churn_revenue_team: 0, - mrr: 0, - new_paying_orgs: 0, - paying: 0, - paying_monthly: 0, - paying_yearly: 0, - plan_enterprise: 0, - plan_enterprise_monthly: 0, - plan_enterprise_yearly: 0, - plan_maker: 0, - plan_maker_monthly: 0, - plan_maker_yearly: 0, - plan_solo: 0, - plan_solo_monthly: 0, - plan_solo_yearly: 0, - plan_team: 0, - plan_team_monthly: 0, - plan_team_yearly: 0, - revenue_enterprise: 0, - revenue_maker: 0, - revenue_solo: 0, - revenue_team: 0, - total_revenue: 0, - upgraded_orgs: 0, - } -} - -function isSubscriptionEventType(type: string): type is SubscriptionEventType { - return SUBSCRIPTION_EVENT_TYPES.includes(type as SubscriptionEventType) -} - -function getEventDateId(event: Stripe.Event) { - return getDateId(new Date(event.created * 1000)) -} - -function sortStripeEvents(events: Stripe.Event[]) { - return events - .map((event, index) => ({ event, index })) - .sort((left, right) => { - if (left.event.created !== right.event.created) - return left.event.created - right.event.created - return left.index - right.index - }) - .map(item => item.event) -} - -function parseStripeEventCreatedSeconds(value: unknown) { - if (typeof value === 'number' && Number.isFinite(value)) - return value - - if (typeof value !== 'string') - return null - - const numericValue = Number(value) - if (Number.isFinite(numericValue)) - return numericValue - - const parsedDate = Date.parse(value) - if (Number.isNaN(parsedDate)) - return null - - return Math.floor(parsedDate / 1000) -} - -function toStripeId(value: unknown) { - if (!value) - return null - if (typeof value === 'string') - return value - if (typeof value === 'object' && 'id' in value && typeof value.id === 'string') - return value.id - return null -} - -function getPlanKey(name: string): PlanKey | null { - const normalized = name.toLowerCase() - if (normalized === 'solo' || normalized === 'maker' || normalized === 'team' || normalized === 'enterprise') - return normalized - return null -} - -function buildPriceLookup(plans: PlanRow[]) { - const lookup = new Map() - - for (const plan of plans) { - const planKey = getPlanKey(plan.name) - if (!planKey) - continue - - const monthlyPriceId = plan.price_m_id?.trim() - if (monthlyPriceId) { - lookup.set(monthlyPriceId, { - interval: 'monthly', - mrr: Number(plan.price_m) || 0, - plan: planKey, - }) - } - - const yearlyPriceId = plan.price_y_id?.trim() - if (yearlyPriceId) { - lookup.set(yearlyPriceId, { - interval: 'yearly', - mrr: (Number(plan.price_y) || 0) / 12, - plan: planKey, - }) - } - } - - return lookup -} - -function getSubscriptionItems(subscription: Stripe.Subscription) { - return subscription.items?.data as Stripe.SubscriptionItem[] | undefined -} - -function getPreviousSubscriptionItems(event: Stripe.Event) { - const previousAttributes = event.data.previous_attributes as Partial | undefined - return previousAttributes?.items?.data as Stripe.SubscriptionItem[] | undefined -} - -function getLicensedSubscriptionItem(items: Stripe.SubscriptionItem[] | undefined) { - return items?.find(item => item.plan?.usage_type === 'licensed') ?? items?.[0] ?? null -} - -function getItemPriceId(item: Stripe.SubscriptionItem | null | undefined) { - if (!item) - return null - - 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 -} - -function getSubscriptionEndSeconds(subscription: Stripe.Subscription, item: Stripe.SubscriptionItem | null, fallbackSeconds: number | null) { - const itemPeriodEnd = getItemPeriodEndSeconds(item) - const endedAt = typeof subscription.ended_at === 'number' ? subscription.ended_at : null - const canceledAt = typeof subscription.canceled_at === 'number' ? subscription.canceled_at : null - const cancelAt = typeof subscription.cancel_at === 'number' ? subscription.cancel_at : null - - if (itemPeriodEnd && itemPeriodEnd > (fallbackSeconds ?? 0)) - return itemPeriodEnd - return endedAt ?? cancelAt ?? canceledAt ?? fallbackSeconds -} - -function isRevenueActiveStatus(status: unknown) { - return status === 'active' - || status === 'trialing' - || status === 'past_due' - || status === 'unpaid' - || status === 'succeeded' -} - -function isInactiveStatus(status: unknown) { - return status === 'canceled' - || status === 'deleted' - || status === 'incomplete' - || status === 'incomplete_expired' - || status === 'paused' -} - -function getPreviousSubscriptionStatus(event: Stripe.Event) { - const previousAttributes = event.data.previous_attributes as Partial | undefined - if (!previousAttributes || !Object.hasOwn(previousAttributes, 'status')) - return { hasStatus: false, status: null as unknown } - - return { - hasStatus: true, - status: previousAttributes.status, - } -} - -function buildStateFromSubscription( - subscription: Stripe.Subscription, - priceLookup: Map, - options: { - activeAtSeconds?: number - eventSeconds?: number - forceActive?: boolean - item?: Stripe.SubscriptionItem | null - status?: unknown - } = {}, -): RevenueSubscriptionState | null { - const customerId = toStripeId(subscription.customer) - if (!customerId || !subscription.id) - return null - - const item = Object.hasOwn(options, 'item') - ? options.item ?? null - : getLicensedSubscriptionItem(getSubscriptionItems(subscription)) - const priceId = getItemPriceId(item) - if (!priceId) - 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 - const activeAtSeconds = options.activeAtSeconds ?? eventSeconds ?? null - const endSeconds = getSubscriptionEndSeconds(subscription, item, eventSeconds) - const activeByStatus = isRevenueActiveStatus(status) - const activeByFutureEnd = Boolean( - activeAtSeconds - && endSeconds - && endSeconds > activeAtSeconds - && (status === 'canceled' || status === 'deleted'), - ) - const active = options.forceActive || activeByStatus || activeByFutureEnd - if (!active || (isInactiveStatus(status) && !activeByFutureEnd && !options.forceActive)) - return null - - return { - activeUntilSeconds: endSeconds && !activeByStatus ? endSeconds : subscription.cancel_at_period_end ? endSeconds : null, - customerId, - interval, - mrr: price?.mrr ?? 0, - plan: price?.plan ?? null, - priceId, - subscriptionId: subscription.id, - } -} - -function buildPreviousStateFromEvent(event: Stripe.Event, priceLookup: Map) { - if (!isSubscriptionEventType(event.type)) - return null - - const subscription = event.data.object as Stripe.Subscription - if (!subscription.id) - return null - - if (event.type === 'customer.subscription.created') - return null - - const eventSeconds = event.created - const previousItem = getLicensedSubscriptionItem(getPreviousSubscriptionItems(event)) - const previousStatus = getPreviousSubscriptionStatus(event) - const currentItem = getLicensedSubscriptionItem(getSubscriptionItems(subscription)) - const item = previousItem ?? (previousStatus.hasStatus && isRevenueActiveStatus(previousStatus.status) ? currentItem : null) - - if (event.type === 'customer.subscription.deleted') { - return buildStateFromSubscription(subscription, priceLookup, { - activeAtSeconds: eventSeconds, - eventSeconds, - forceActive: true, - item: currentItem, - status: 'active', - }) - } - - if (!item && !previousStatus.hasStatus) - return null - - return buildStateFromSubscription(subscription, priceLookup, { - activeAtSeconds: eventSeconds, - eventSeconds, - item, - status: previousStatus.hasStatus ? previousStatus.status : 'active', - }) -} - -function buildNextStateFromEvent(event: Stripe.Event, priceLookup: Map) { - const subscription = event.data.object as Stripe.Subscription - return buildStateFromSubscription(subscription, priceLookup, { - activeAtSeconds: event.created, - eventSeconds: event.created, - }) -} - -function getStateKey(state: Pick) { - return state.subscriptionId -} - -function createDailyCounters(): DailyCounters { - return { - canceledCustomerIds: new Set(), - churnRevenue: 0, - churnRevenueByPlan: { - solo: 0, - maker: 0, - team: 0, - enterprise: 0, - }, - newCustomerIds: new Set(), - upgradedCustomerIds: new Set(), - } -} - -function recordTransition( - daily: DailyCounters | null, - 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 (daily && nextActive && options.cadenceUpgrade) - daily.upgradedCustomerIds.add(customerId) - - if (!currentActive && nextActive) { - if (!seenPaidCustomerIds.has(customerId)) { - daily?.newCustomerIds.add(customerId) - seenPaidCustomerIds.add(customerId) - } - return - } - - if (!daily) - return - - 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?.plan) - daily.churnRevenueByPlan[currentState.plan] += currentMrr - return - } - - if (currentMrr > nextMrr) { - const lostMrr = currentMrr - nextMrr - daily.churnRevenue += lostMrr - if (currentState?.plan) - daily.churnRevenueByPlan[currentState.plan] += lostMrr - } -} - -function applySubscriptionEventToStates( - states: Map, - seenPaidCustomerIds: Set, - event: Stripe.Event, - priceLookup: Map, - daily: DailyCounters | null, -) { - if (!isSubscriptionEventType(event.type)) - return - - const subscription = event.data.object as Stripe.Subscription - const subscriptionId = subscription.id - if (!subscriptionId) - return - - 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, { - cadenceUpgrade: previousInterval === 'monthly' && nextInterval === 'yearly', - }) - - if (nextState) - states.set(getStateKey(nextState), nextState) - else - states.delete(subscriptionId) -} - -function seedBaselineStatesFromSubscriptions( - states: Map, - seenPaidCustomerIds: Set, - subscriptions: Stripe.Subscription[], - priceLookup: Map, - fromDateId: string, - customerId?: string | null, -) { - const fromStartSeconds = dateIdToStartSeconds(fromDateId) - - for (const subscription of subscriptions) { - const state = buildStateFromSubscription(subscription, priceLookup, { - activeAtSeconds: fromStartSeconds, - }) - if (!state) - continue - if (customerId && state.customerId !== customerId) - continue - if (subscription.created >= fromStartSeconds) - continue - states.set(getStateKey(state), state) - seenPaidCustomerIds.add(state.customerId) - } -} - -function replayPreRangeEvents( - states: Map, - seenPaidCustomerIds: Set, - events: Stripe.Event[], - priceLookup: Map, - fromDateId: string, - customerId?: string | null, -) { - for (const event of sortStripeEvents(events)) { - if (compareDateIds(getEventDateId(event), fromDateId) >= 0) - continue - - const subscription = event.data.object as Stripe.Subscription - const eventCustomerId = toStripeId(subscription.customer) - if (customerId && eventCustomerId !== customerId) - continue - - applySubscriptionEventToStates(states, seenPaidCustomerIds, event, priceLookup, null) - } -} - -function seedOpeningStateFromFirstRangeEvents( - states: Map, - seenPaidCustomerIds: Set, - events: Stripe.Event[], - priceLookup: Map, - fromDateId: string, - toDateId: string, - customerId?: string | null, -) { - const seenSubscriptionIds = new Set() - const fromStartSeconds = dateIdToStartSeconds(fromDateId) - - for (const event of sortStripeEvents(events)) { - const dateId = getEventDateId(event) - if (compareDateIds(dateId, fromDateId) < 0 || compareDateIds(dateId, toDateId) > 0) - continue - - const subscription = event.data.object as Stripe.Subscription - const eventCustomerId = toStripeId(subscription.customer) - if (customerId && eventCustomerId !== customerId) - continue - if (!subscription.id || seenSubscriptionIds.has(subscription.id)) - continue - - seenSubscriptionIds.add(subscription.id) - if (subscription.created >= fromStartSeconds) - continue - - const previousState = buildPreviousStateFromEvent(event, priceLookup) - if (previousState) { - states.set(getStateKey(previousState), previousState) - seenPaidCustomerIds.add(previousState.customerId) - } - } -} - -function expireStatesForDate(states: Map, dateId: string, daily: DailyCounters) { - const dayStartSeconds = dateIdToStartSeconds(dateId) - const dayEndSeconds = dateIdToEndSeconds(dateId) - - for (const state of [...states.values()]) { - if (!state.activeUntilSeconds) - continue - if (state.activeUntilSeconds < dayStartSeconds || state.activeUntilSeconds > dayEndSeconds) - continue - - daily.canceledCustomerIds.add(state.customerId) - daily.churnRevenue += 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 if (state.interval === 'yearly') - metrics.paying_yearly++ - - if (!state.plan) - continue - - if (state.plan === 'solo') { - metrics.plan_solo++ - if (state.interval === 'monthly') - metrics.plan_solo_monthly++ - else - metrics.plan_solo_yearly++ - metrics.revenue_solo += state.mrr * 12 - } - else if (state.plan === 'maker') { - metrics.plan_maker++ - if (state.interval === 'monthly') - metrics.plan_maker_monthly++ - else - metrics.plan_maker_yearly++ - metrics.revenue_maker += state.mrr * 12 - } - else if (state.plan === 'team') { - metrics.plan_team++ - if (state.interval === 'monthly') - metrics.plan_team_monthly++ - else - metrics.plan_team_yearly++ - metrics.revenue_team += state.mrr * 12 - } - else { - metrics.plan_enterprise++ - if (state.interval === 'monthly') - metrics.plan_enterprise_monthly++ - else - metrics.plan_enterprise_yearly++ - metrics.revenue_enterprise += state.mrr * 12 - } - } - - metrics.new_paying_orgs = daily.newCustomerIds.size - metrics.canceled_orgs = daily.canceledCustomerIds.size - metrics.churn_revenue = daily.churnRevenue - metrics.paying = payingCustomerIds.size - 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 - metrics.churn_revenue_enterprise = daily.churnRevenueByPlan.enterprise - metrics.mrr = roundMoney(metrics.mrr) - metrics.total_revenue = roundMoney(metrics.mrr * 12) - metrics.revenue_solo = roundMoney(metrics.revenue_solo) - metrics.revenue_maker = roundMoney(metrics.revenue_maker) - metrics.revenue_team = roundMoney(metrics.revenue_team) - metrics.revenue_enterprise = roundMoney(metrics.revenue_enterprise) - metrics.churn_revenue = roundMoney(metrics.churn_revenue) - metrics.churn_revenue_solo = roundMoney(metrics.churn_revenue_solo) - metrics.churn_revenue_maker = roundMoney(metrics.churn_revenue_maker) - metrics.churn_revenue_team = roundMoney(metrics.churn_revenue_team) - metrics.churn_revenue_enterprise = roundMoney(metrics.churn_revenue_enterprise) - - return metrics -} - -function valuesChanged(current: Partial>, next: RevenueTrendMetricValues) { - for (const [key, value] of Object.entries(next) as Array<[keyof RevenueTrendMetricValues, number]>) { - if (Math.abs(toMetricNumber(current[key]) - value) > 0.0001) - return true - } - return false -} - -export function buildRevenueTrendBackfillRows( - existingRows: GlobalStatsRow[], - options: BuildRevenueTrendRowsOptions, -): RevenueTrendBackfillRow[] { - const existingRowsByDateId = new Map(existingRows.map(row => [row.date_id, row])) - const priceLookup = buildPriceLookup(options.plans) - const states = new Map() - const seenPaidCustomerIds = new Set() - const sortedEvents = sortStripeEvents(options.events) - - seedBaselineStatesFromSubscriptions(states, seenPaidCustomerIds, options.baselineSubscriptions ?? [], priceLookup, options.fromDateId, options.customerId) - replayPreRangeEvents(states, seenPaidCustomerIds, sortedEvents, priceLookup, options.fromDateId, options.customerId) - seedOpeningStateFromFirstRangeEvents(states, seenPaidCustomerIds, sortedEvents, priceLookup, options.fromDateId, options.toDateId, options.customerId) - - const eventsByDateId = new Map() - for (const event of sortedEvents) { - const dateId = getEventDateId(event) - if (compareDateIds(dateId, options.fromDateId) < 0 || compareDateIds(dateId, options.toDateId) > 0) - continue - - const subscription = event.data.object as Stripe.Subscription - const eventCustomerId = toStripeId(subscription.customer) - if (options.customerId && eventCustomerId !== options.customerId) - continue - - const eventsForDate = eventsByDateId.get(dateId) ?? [] - eventsForDate.push(event) - eventsByDateId.set(dateId, eventsForDate) - } - - const rows: RevenueTrendBackfillRow[] = [] - for (const dateId of getDateIdsBetween(options.fromDateId, options.toDateId)) { - const daily = createDailyCounters() - for (const event of eventsByDateId.get(dateId) ?? []) - applySubscriptionEventToStates(states, seenPaidCustomerIds, event, priceLookup, daily) - - expireStatesForDate(states, dateId, daily) - - const next = summarizeRevenueSnapshot(states.values(), daily) - const current = existingRowsByDateId.get(dateId) ?? null - rows.push({ - ...next, - changed: !current || valuesChanged(current, next), - current: current - ? Object.fromEntries(Object.keys(next).map(key => [key, toMetricNumber(current[key as keyof RevenueTrendMetricValues])])) - : {}, - date_id: dateId, - }) - } - - return rows -} - -function normalizeStripeEventFromFile(event: unknown, index: number): Stripe.Event { - if (typeof event !== 'object' || event === null) - throw new Error(`--events-file contains malformed Stripe event at index ${index}: event must be an object`) - - const candidate = event as { - created?: unknown - data?: { object?: unknown } - id?: unknown - type?: unknown - } - if (typeof candidate.id !== 'string') - throw new Error(`--events-file contains malformed Stripe event at index ${index}: missing string id`) - if (typeof candidate.type !== 'string') - throw new Error(`--events-file contains malformed Stripe event at index ${index}: missing string type`) - if (!isSubscriptionEventType(candidate.type)) - throw new Error(`--events-file contains unsupported Stripe event type at index ${index}: ${candidate.type}`) - - const created = parseStripeEventCreatedSeconds(candidate.created) - if (created === null) - throw new Error(`--events-file contains malformed Stripe event at index ${index}: missing numeric or parseable created value`) - - const dataObject = candidate.data?.object - if (typeof dataObject !== 'object' || dataObject === null) - throw new Error(`--events-file contains malformed Stripe event at index ${index}: missing data.object`) - - if (!toStripeId((dataObject as { customer?: unknown }).customer)) - throw new Error(`--events-file contains malformed Stripe event at index ${index}: missing data.object.customer`) - - return { - ...(event as Stripe.Event), - created, - } -} - -async function loadEventsFile(filePath: string): Promise { - const payload = JSON.parse(await readFile(filePath, 'utf8')) as unknown - const events = Array.isArray(payload) - ? payload - : Array.isArray((payload as { data?: unknown }).data) - ? (payload as { data: unknown[] }).data - : Array.isArray((payload as { events?: unknown }).events) - ? (payload as { events: unknown[] }).events - : null - - if (!events) - throw new Error('--events-file must contain a JSON array, or an object with data/events array') - - return sortStripeEvents(events.map(normalizeStripeEventFromFile)) -} - -async function fetchStripeEvents(stripe: Pick, 'events'>, fromDateId: string, toDateId: string, limit: number | null) { - const events: Stripe.Event[] = [] - const params = { - created: { - gte: dateIdToStartSeconds(fromDateId), - lte: dateIdToEndSeconds(toDateId), - }, - limit: STRIPE_PAGE_SIZE, - types: [...SUBSCRIPTION_EVENT_TYPES], - } as Stripe.EventListParams - - for await (const event of stripe.events.list(params)) { - events.push(event) - if (limit && events.length >= limit) { - return { - events: sortStripeEvents(events), - reachedLimit: true, - } - } - } - - return { - events: sortStripeEvents(events), - reachedLimit: false, - } -} - -async function fetchBaselineSubscriptions(stripe: Pick, 'subscriptions'>, fromDateId: string, customerId?: string | null) { - const subscriptions: Stripe.Subscription[] = [] - const params = { - created: { lt: dateIdToStartSeconds(fromDateId) }, - customer: customerId ?? undefined, - expand: ['data.items.data.price'], - limit: STRIPE_PAGE_SIZE, - status: 'all', - } as Stripe.SubscriptionListParams - - for await (const subscription of stripe.subscriptions.list(params)) - subscriptions.push(subscription) - - return subscriptions -} - -async function fetchPlans(supabase: SupabaseClient) { - const { data, error } = await supabase - .from('plans') - .select('name, price_m, price_y, price_m_id, price_y_id') - .in('name', [...PLAN_NAMES]) - - if (error) - throw error - - return data ?? [] -} - -async function fetchGlobalStatsRows(supabase: SupabaseClient, fromDateId: string, toDateId: string) { - const rows: GlobalStatsRow[] = [] - let offset = 0 - - while (true) { - const { data, error } = await supabase - .from('global_stats') - .select(` - date_id, - paying_yearly, - paying_monthly, - new_paying_orgs, - canceled_orgs, - paying, - mrr, - total_revenue, - revenue_solo, - revenue_maker, - revenue_team, - revenue_enterprise, - plan_solo, - plan_maker, - plan_team, - plan_enterprise, - plan_solo_monthly, - plan_solo_yearly, - plan_maker_monthly, - plan_maker_yearly, - plan_team_monthly, - plan_team_yearly, - plan_enterprise_monthly, - plan_enterprise_yearly, - churn_revenue, - churn_revenue_solo, - churn_revenue_maker, - churn_revenue_team, - churn_revenue_enterprise, - upgraded_orgs - `) - .gte('date_id', fromDateId) - .lte('date_id', toDateId) - .order('date_id', { ascending: true }) - .range(offset, offset + DEFAULT_PAGE_SIZE - 1) - - if (error) - throw error - if (!data?.length) - break - - rows.push(...data) - if (data.length < DEFAULT_PAGE_SIZE) - break - offset += DEFAULT_PAGE_SIZE - } - - return rows -} - -function toGlobalStatsUpdate(row: RevenueTrendBackfillRow): GlobalStatsUpdate { - return { - canceled_orgs: row.canceled_orgs, - churn_revenue: row.churn_revenue, - churn_revenue_enterprise: row.churn_revenue_enterprise, - churn_revenue_maker: row.churn_revenue_maker, - churn_revenue_solo: row.churn_revenue_solo, - 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, - plan_enterprise_monthly: row.plan_enterprise_monthly, - plan_enterprise_yearly: row.plan_enterprise_yearly, - plan_maker: row.plan_maker, - plan_maker_monthly: row.plan_maker_monthly, - plan_maker_yearly: row.plan_maker_yearly, - plan_solo: row.plan_solo, - plan_solo_monthly: row.plan_solo_monthly, - plan_solo_yearly: row.plan_solo_yearly, - plan_team: row.plan_team, - plan_team_monthly: row.plan_team_monthly, - plan_team_yearly: row.plan_team_yearly, - revenue_enterprise: row.revenue_enterprise, - revenue_maker: row.revenue_maker, - revenue_solo: row.revenue_solo, - revenue_team: row.revenue_team, - total_revenue: row.total_revenue, - upgraded_orgs: row.upgraded_orgs, - } -} - -async function updateGlobalStatsRow(supabase: SupabaseClient, row: RevenueTrendBackfillRow) { - const { error } = await supabase - .from('global_stats') - .update(toGlobalStatsUpdate(row)) - .eq('date_id', row.date_id) - - if (error) - throw error -} - -function printSampleRows(rows: RevenueTrendBackfillRow[]) { - for (const row of rows.slice(0, 10)) { - 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}`) - } -} - -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 - const eventsFile = getArgValue(args, '--events-file') - const customerId = getArgValue(args, '--customer-id') - const limit = getArgValue(args, '--limit') - const concurrency = parsePositiveInteger(getArgValue(args, '--concurrency'), '--concurrency', DEFAULT_CONCURRENCY) - const eventLimit = limit ? parsePositiveInteger(limit, '--limit', DEFAULT_PAGE_SIZE) : null - const fromDateId = assertDateId(getArgValue(args, '--from') ?? getDefaultFromDateId(), '--from') - const toDateId = assertDateId(getArgValue(args, '--to') ?? getDateId(), '--to') - - if (compareDateIds(fromDateId, toDateId) > 0) - throw new Error('--from must be before or equal to --to') - if (customerId && !customerId.startsWith('cus_')) - throw new Error('--customer-id must be a Stripe customer id that starts with cus_') - if (apply && customerId) - throw new Error('--apply cannot be combined with --customer-id because global_stats metrics are global aggregates') - - const fileEnv = await loadEnv(envFile) - const env = { - ...fileEnv, - ...runtimeEnv, - } - const supabase = createSupabaseServiceClient(env) - - console.log(`Backfill range: ${fromDateId}..${toDateId}`) - console.log(`Env file: ${envFile}`) - if (customerId) - console.log(`Scoped to customer: ${customerId}`) - if (!apply) - console.log('Dry run only. Pass --apply to update global_stats.') - - let events: Stripe.Event[] - let baselineSubscriptions: Stripe.Subscription[] = [] - let reachedEventFetchLimit = false - - if (eventsFile) { - events = await loadEventsFile(eventsFile) - console.log(`Loaded ${events.length} subscription events from ${eventsFile}`) - } - else { - const stripeSecretKey = getRequiredEnv(env, 'STRIPE_SECRET_KEY') - const stripe = createStripeClient(stripeSecretKey, env.STRIPE_API_BASE_URL?.trim()) - const oldestEventApiDateId = getDefaultFromDateId() - const fetchFromDateId = compareDateIds(fromDateId, oldestEventApiDateId) > 0 ? oldestEventApiDateId : fromDateId - const startsBeforeEventApiHistory = compareDateIds(fromDateId, oldestEventApiDateId) < 0 - - if (startsBeforeEventApiHistory) - console.warn('Stripe Events API only exposes recent events. Use --events-file for older archived Stripe events.') - if (apply && startsBeforeEventApiHistory) - throw new Error('--apply for ranges older than recent Stripe event history requires --events-file so daily subscription flow and churn are complete.') - - const fetchedEvents = await fetchStripeEvents(stripe, fetchFromDateId, toDateId, eventLimit) - events = fetchedEvents.events - reachedEventFetchLimit = fetchedEvents.reachedLimit - console.log(`Fetched ${events.length} subscription events from Stripe`) - if (fetchFromDateId !== fromDateId) - console.log(`Fetched events from ${fetchFromDateId} to seed subscription changes before ${fromDateId}`) - if (reachedEventFetchLimit) - console.warn(`Stripe event fetch stopped at --limit=${eventLimit}`) - - if (!skipSubscriptionBaseline) { - baselineSubscriptions = await fetchBaselineSubscriptions(stripe, fromDateId, customerId) - console.log(`Fetched ${baselineSubscriptions.length} pre-range Stripe subscriptions for opening state`) - } - } - - if (apply && reachedEventFetchLimit) - throw new Error('--apply cannot use a truncated Stripe event snapshot. Increase or remove --limit, or provide --events-file.') - - const [plans, globalStatsRows] = await Promise.all([ - fetchPlans(supabase), - fetchGlobalStatsRows(supabase, fromDateId, toDateId), - ]) - - const rows = buildRevenueTrendBackfillRows(globalStatsRows, { - baselineSubscriptions, - customerId, - events, - fromDateId, - plans, - toDateId, - }) - const rowsWithExistingGlobalStats = rows.filter(row => globalStatsRows.some(existing => existing.date_id === row.date_id)) - const missingGlobalStatsDates = rows.filter(row => !globalStatsRows.some(existing => existing.date_id === row.date_id)).map(row => row.date_id) - const changedRows = rowsWithExistingGlobalStats.filter(row => row.changed) - - console.log(`Loaded ${plans.length} revenue plans`) - console.log(`Loaded ${globalStatsRows.length} global_stats rows`) - console.log(`Computed ${rows.length} revenue trend rows`) - if (missingGlobalStatsDates.length > 0) - console.warn(`Skipped ${missingGlobalStatsDates.length} dates with no global_stats row: ${missingGlobalStatsDates.slice(0, 10).join(', ')}${missingGlobalStatsDates.length > 10 ? ', ...' : ''}`) - console.log(`Rows needing update: ${changedRows.length}`) - - if (changedRows.length > 0) { - console.log('Sample updates:') - printSampleRows(changedRows) - } - - if (!apply) - return - - let updated = 0 - await asyncPool(concurrency, changedRows, async (row) => { - await updateGlobalStatsRow(supabase, row) - updated++ - if (updated % 100 === 0 || updated === changedRows.length) - console.log(`Updated ${updated}/${changedRows.length}`) - }) - - console.log(`Done. Updated ${updated}/${changedRows.length} revenue trend rows.`) -} - -if (import.meta.main) - await main() diff --git a/scripts/fix_stripe_admin_revenue_dashboard_metrics.ts b/scripts/fix_stripe_admin_revenue_dashboard_metrics.ts new file mode 100644 index 0000000000..2b2010ad0f --- /dev/null +++ b/scripts/fix_stripe_admin_revenue_dashboard_metrics.ts @@ -0,0 +1,1108 @@ +/* + * Rebuild admin revenue dashboard metrics from Stripe for every day since 2022. + * + * This script has no dry-run/apply mode by design: + * bun run stripe:backfill-admin-revenue-dashboard + */ +import type Stripe from 'stripe' +import type { Database } from '../supabase/functions/_backend/utils/supabase.types.ts' +import process from 'node:process' +import { + asyncPool, + createStripeClient, + createSupabaseServiceClient, + DEFAULT_ENV_FILE, + getRequiredEnv, + loadEnv, +} from './admin_stripe_backfill_utils.ts' + +const BACKFILL_FROM_DATE_ID = '2022-01-01' +const DEFAULT_CONCURRENCY = 10 +const DEFAULT_PAGE_SIZE = 1000 +const STRIPE_PAGE_SIZE = 100 +const PLAN_KEYS = ['solo', 'maker', 'team', 'enterprise'] as const +const PLAN_NAMES = ['Solo', 'Maker', 'Team', 'Enterprise'] as const +const ONE_DAY_MS = 24 * 60 * 60 * 1000 +const ONE_MONTH_MS = (365.2425 / 12) * ONE_DAY_MS + +type SupabaseClient = ReturnType +type PlanKey = typeof PLAN_KEYS[number] +type BillingInterval = 'monthly' | 'yearly' +type GlobalStatsUpdate = Database['public']['Tables']['global_stats']['Update'] +type GlobalStatsInsert = Database['public']['Tables']['global_stats']['Insert'] +type PlanRow = Pick< + Database['public']['Tables']['plans']['Row'], + 'name' | 'price_m' | 'price_m_id' | 'price_y' | 'price_y_id' | 'stripe_id' +> +type GlobalStatsRow = Pick< + Database['public']['Tables']['global_stats']['Row'], + | 'canceled_orgs' + | 'churn_revenue' + | 'churn_revenue_enterprise' + | 'churn_revenue_maker' + | 'churn_revenue_solo' + | 'churn_revenue_team' + | 'date_id' + | 'mrr' + | 'new_paying_orgs' + | 'paying' + | 'paying_monthly' + | 'paying_yearly' + | 'plan_enterprise' + | 'plan_enterprise_monthly' + | 'plan_enterprise_yearly' + | 'plan_maker' + | 'plan_maker_monthly' + | 'plan_maker_yearly' + | 'plan_solo' + | 'plan_solo_monthly' + | 'plan_solo_yearly' + | 'plan_team' + | 'plan_team_monthly' + | 'plan_team_yearly' + | 'revenue_enterprise' + | 'revenue_maker' + | 'revenue_solo' + | 'revenue_team' + | 'total_revenue' + | 'upgraded_orgs' +> + +export interface StripePriceLookupEntry { + interval: BillingInterval | null + mrr: number + plan: PlanKey | null + priceId: string + productId: string | null +} + +export interface StripeRevenueInterval { + customerId: string + endMs: number + interval: BillingInterval | null + mrr: number + plan: PlanKey | null + priceId: string | null + sourceId: string + startMs: number + subscriptionId: string +} + +interface RevenueSubscriptionState { + customerId: string + interval: BillingInterval | null + mrr: number + plan: PlanKey | null + subscriptionId: string +} + +interface DailyCounters { + canceledCustomerIds: Set + churnRevenue: number + churnRevenueByPlan: Record + newCustomerIds: Set + upgradedCustomerIds: Set +} + +export interface StripeRevenueMetricValues { + canceled_orgs: number + churn_revenue: number + churn_revenue_enterprise: number + churn_revenue_maker: number + churn_revenue_solo: number + churn_revenue_team: number + mrr: number + new_paying_orgs: number + paying: number + paying_monthly: number + paying_yearly: number + plan_enterprise: number + plan_enterprise_monthly: number + plan_enterprise_yearly: number + plan_maker: number + plan_maker_monthly: number + plan_maker_yearly: number + plan_solo: number + plan_solo_monthly: number + plan_solo_yearly: number + plan_team: number + plan_team_monthly: number + plan_team_yearly: number + revenue_enterprise: number + revenue_maker: number + revenue_solo: number + revenue_team: number + total_revenue: number + upgraded_orgs: number +} + +export interface StripeRevenueBackfillRow extends StripeRevenueMetricValues { + changed: boolean + current: Partial + date_id: string + exists: boolean +} + +export interface BuildStripeInvoiceRevenueRowsOptions { + fromDateId: string + intervals: StripeRevenueInterval[] + toDateId: string +} + +function getDateId(targetDate = new Date()) { + return new Date(Date.UTC(targetDate.getUTCFullYear(), targetDate.getUTCMonth(), targetDate.getUTCDate())).toISOString().slice(0, 10) +} + +function dateIdToStartMs(dateId: string) { + return new Date(`${dateId}T00:00:00.000Z`).getTime() +} + +function dateIdToEndMs(dateId: string) { + return new Date(`${dateId}T23:59:59.999Z`).getTime() +} + +function getDateIdsBetween(fromDateId: string, toDateId: string) { + const dates: string[] = [] + const cursor = new Date(`${fromDateId}T00:00:00.000Z`) + const end = new Date(`${toDateId}T00:00:00.000Z`) + + while (cursor.getTime() <= end.getTime()) { + dates.push(getDateId(cursor)) + cursor.setUTCDate(cursor.getUTCDate() + 1) + } + + return dates +} + +function toMetricNumber(value: number | string | null | undefined) { + const numberValue = Number(value ?? 0) + return Number.isFinite(numberValue) ? numberValue : 0 +} + +function roundMoney(value: number) { + return Number(value.toFixed(2)) +} + +function toStripeId(value: unknown) { + if (!value) + return null + if (typeof value === 'string') + return value + if (typeof value === 'object' && 'id' in value && typeof value.id === 'string') + return value.id + return null +} + +function createEmptyMetrics(): StripeRevenueMetricValues { + return { + canceled_orgs: 0, + churn_revenue: 0, + churn_revenue_enterprise: 0, + churn_revenue_maker: 0, + churn_revenue_solo: 0, + churn_revenue_team: 0, + mrr: 0, + new_paying_orgs: 0, + paying: 0, + paying_monthly: 0, + paying_yearly: 0, + plan_enterprise: 0, + plan_enterprise_monthly: 0, + plan_enterprise_yearly: 0, + plan_maker: 0, + plan_maker_monthly: 0, + plan_maker_yearly: 0, + plan_solo: 0, + plan_solo_monthly: 0, + plan_solo_yearly: 0, + plan_team: 0, + plan_team_monthly: 0, + plan_team_yearly: 0, + revenue_enterprise: 0, + revenue_maker: 0, + revenue_solo: 0, + revenue_team: 0, + total_revenue: 0, + upgraded_orgs: 0, + } +} + +function createDailyCounters(): DailyCounters { + return { + canceledCustomerIds: new Set(), + churnRevenue: 0, + churnRevenueByPlan: { + solo: 0, + maker: 0, + team: 0, + enterprise: 0, + }, + newCustomerIds: new Set(), + upgradedCustomerIds: new Set(), + } +} + +function getPlanKey(name: string | null | undefined): PlanKey | null { + const normalized = name?.trim().toLowerCase() + if (normalized === 'solo' || normalized === 'maker' || normalized === 'team' || normalized === 'enterprise') + return normalized + return null +} + +export function classifyPlanKeyFromText(...values: Array) { + const text = values + .filter((value): value is string => !!value) + .join(' ') + .toLowerCase() + const normalized = text.replace(/[^a-z0-9]+/g, ' ') + + for (const planKey of PLAN_KEYS) { + if (new RegExp(`(^|\\s)${planKey}(\\s|$)`).test(normalized)) + return planKey + } + + return null +} + +function getProductName(product: string | Stripe.Product | Stripe.DeletedProduct | null | undefined) { + if (!product || typeof product === 'string') + return null + if ('deleted' in product && product.deleted) + return null + return product.name ?? null +} + +function getProductId(product: string | Stripe.Product | Stripe.DeletedProduct | null | undefined) { + return toStripeId(product) +} + +function getStripePriceAmount(price: Stripe.Price) { + if (typeof price.unit_amount === 'number') + return price.unit_amount / 100 + if (price.unit_amount_decimal) { + const amount = Number(price.unit_amount_decimal) + return Number.isFinite(amount) ? amount / 100 : 0 + } + return 0 +} + +function getNormalizedMonthlyAmount(amount: number, interval: string | null | undefined, intervalCount: number | null | undefined) { + const count = intervalCount && intervalCount > 0 ? intervalCount : 1 + if (interval === 'month') + return amount / count + if (interval === 'year') + return amount / (12 * count) + if (interval === 'week') + return (amount * 52) / (12 * count) + if (interval === 'day') + return (amount * 365.2425) / (12 * count) + return 0 +} + +function getBillingInterval(interval: string | null | undefined): BillingInterval | null { + if (interval === 'month') + return 'monthly' + if (interval === 'year') + return 'yearly' + return null +} + +function buildDbPlanLookup(plans: PlanRow[]) { + const byPriceId = new Map() + const byProductId = new Map() + + for (const plan of plans) { + const planKey = getPlanKey(plan.name) + if (!planKey) + continue + + if (plan.stripe_id) + byProductId.set(plan.stripe_id, planKey) + + if (plan.price_m_id) { + byPriceId.set(plan.price_m_id, { + interval: 'monthly', + mrr: Number(plan.price_m) || 0, + plan: planKey, + priceId: plan.price_m_id, + productId: plan.stripe_id ?? null, + }) + } + + if (plan.price_y_id) { + byPriceId.set(plan.price_y_id, { + interval: 'yearly', + mrr: (Number(plan.price_y) || 0) / 12, + plan: planKey, + priceId: plan.price_y_id, + productId: plan.stripe_id ?? null, + }) + } + } + + return { byPriceId, byProductId } +} + +export function buildStripePriceLookup(prices: Stripe.Price[], plans: PlanRow[]) { + const dbLookup = buildDbPlanLookup(plans) + const lookup = new Map(dbLookup.byPriceId) + + for (const price of prices) { + const recurring = price.recurring + const interval = getBillingInterval(recurring?.interval) + const productId = getProductId(price.product) + const productName = getProductName(price.product) + const existing = lookup.get(price.id) + let plan = existing?.plan ?? null + plan ??= productId ? dbLookup.byProductId.get(productId) ?? null : null + plan ??= getPlanKey(price.metadata?.plan) + plan ??= getPlanKey(price.metadata?.plan_name) + plan ??= classifyPlanKeyFromText(price.lookup_key, price.nickname, productName) + const mrr = existing?.mrr + ?? getNormalizedMonthlyAmount(getStripePriceAmount(price), recurring?.interval, recurring?.interval_count) + + lookup.set(price.id, { + interval: existing?.interval ?? interval, + mrr: roundMoney(mrr), + plan, + priceId: price.id, + productId, + }) + } + + return lookup +} + +function getLinePriceId(line: Stripe.InvoiceLineItem) { + const typedLine = line as Stripe.InvoiceLineItem & { + plan?: Stripe.Plan | null + price?: Stripe.Price | null + pricing?: { price_details?: { price?: string | null } | null } | null + } + + return toStripeId(typedLine.price) + ?? toStripeId(typedLine.plan) + ?? typedLine.pricing?.price_details?.price + ?? null +} + +function getLineProductId(line: Stripe.InvoiceLineItem) { + const typedLine = line as Stripe.InvoiceLineItem & { + plan?: Stripe.Plan | null + price?: Stripe.Price | null + pricing?: { price_details?: { product?: string | null } | null } | null + } + const priceProduct = typeof typedLine.price === 'object' && typedLine.price !== null + ? toStripeId(typedLine.price.product) + : null + const planProduct = typeof typedLine.plan === 'object' && typedLine.plan !== null + ? toStripeId(typedLine.plan.product) + : null + + return priceProduct + ?? planProduct + ?? typedLine.pricing?.price_details?.product + ?? null +} + +function getLineSubscriptionId(line: Stripe.InvoiceLineItem, invoice: Stripe.Invoice) { + const typedLine = line as Stripe.InvoiceLineItem & { + parent?: { + subscription_item_details?: { subscription?: string | null } | null + } | null + subscription?: string | Stripe.Subscription | null + } + const typedInvoice = invoice as Stripe.Invoice & { + parent?: { subscription_details?: { subscription?: string | null } | null } | null + subscription?: string | Stripe.Subscription | null + } + + return typedLine.parent?.subscription_item_details?.subscription + ?? toStripeId(typedLine.subscription) + ?? typedInvoice.parent?.subscription_details?.subscription + ?? toStripeId(typedInvoice.subscription) + ?? null +} + +function getLineQuantity(line: Stripe.InvoiceLineItem) { + const quantity = (line as { quantity?: number | null }).quantity + return typeof quantity === 'number' && quantity > 0 ? quantity : 1 +} + +function getLineAmount(line: Stripe.InvoiceLineItem) { + const amount = (line as { amount?: number | null }).amount + return typeof amount === 'number' ? amount / 100 : 0 +} + +function isSubscriptionLine(line: Stripe.InvoiceLineItem) { + const typedLine = line as Stripe.InvoiceLineItem & { + parent?: { + subscription_item_details?: { + subscription?: string | null + subscription_item?: string | null + } + type?: string | null + } | null + subscription?: string | null + subscription_item?: string | null + type?: string | null + } + + return typedLine.type === 'subscription' + || !!typedLine.subscription + || !!typedLine.subscription_item + || typedLine.parent?.type === 'subscription_item_details' + || !!typedLine.parent?.subscription_item_details?.subscription + || !!typedLine.parent?.subscription_item_details?.subscription_item +} + +function getLinePeriod(line: Stripe.InvoiceLineItem) { + const period = (line as { period?: { end?: number | null, start?: number | null } | null }).period + if (typeof period?.start !== 'number' || typeof period.end !== 'number' || period.end <= period.start) + return null + + return { + startMs: period.start * 1000, + endMs: period.end * 1000, + } +} + +function getPeriodBillingInterval(startMs: number, endMs: number): BillingInterval | null { + const durationMonths = (endMs - startMs) / ONE_MONTH_MS + if (durationMonths > 10) + return 'yearly' + if (durationMonths > 0) + return 'monthly' + return null +} + +function getLineFallbackMrr(line: Stripe.InvoiceLineItem, startMs: number, endMs: number) { + const amount = getLineAmount(line) + if (amount <= 0) + return 0 + + const durationMonths = (endMs - startMs) / ONE_MONTH_MS + if (durationMonths <= 0) + return 0 + + return amount / durationMonths +} + +function getLineRevenueInfo(line: Stripe.InvoiceLineItem, lookup: Map) { + const priceId = getLinePriceId(line) + const entry = priceId ? lookup.get(priceId) : null + const period = getLinePeriod(line) + const quantity = getLineQuantity(line) + const mrr = entry?.mrr + ? entry.mrr * quantity + : period + ? getLineFallbackMrr(line, period.startMs, period.endMs) + : 0 + + return { + interval: entry?.interval ?? (period ? getPeriodBillingInterval(period.startMs, period.endMs) : null), + mrr: roundMoney(mrr), + plan: entry?.plan ?? classifyPlanKeyFromText(line.description, getLineProductId(line)), + priceId, + } +} + +function getInvoiceAmountPaid(invoice: Stripe.Invoice) { + const amountPaid = (invoice as { amount_paid?: number | null }).amount_paid + return typeof amountPaid === 'number' ? amountPaid : 0 +} + +function shouldUseInvoice(invoice: Stripe.Invoice) { + return invoice.status === 'paid' && getInvoiceAmountPaid(invoice) > 0 +} + +function addInterval(intervalsByKey: Map, interval: StripeRevenueInterval) { + if (interval.endMs <= interval.startMs) + return + if (interval.mrr < 0) + return + + const key = `${interval.subscriptionId}:${interval.priceId ?? 'unknown'}:${interval.startMs}:${interval.endMs}` + if (!intervalsByKey.has(key)) + intervalsByKey.set(key, interval) +} + +function addInvoiceLineInterval( + intervalsByKey: Map, + invoice: Stripe.Invoice, + line: Stripe.InvoiceLineItem, + priceLookup: Map, + options: { fromStartMs: number, toEndMs: number }, +) { + if (!isSubscriptionLine(line)) + return + if (getLineAmount(line) <= 0) + return + + const period = getLinePeriod(line) + if (!period) + return + if (period.endMs <= options.fromStartMs || period.startMs > options.toEndMs) + return + + const customerId = toStripeId(invoice.customer) + if (!customerId) + return + + const revenueInfo = getLineRevenueInfo(line, priceLookup) + const subscriptionId = getLineSubscriptionId(line, invoice) ?? `${customerId}:${line.id}` + addInterval(intervalsByKey, { + customerId, + endMs: period.endMs, + interval: revenueInfo.interval, + mrr: revenueInfo.mrr, + plan: revenueInfo.plan, + priceId: revenueInfo.priceId, + sourceId: invoice.id, + startMs: period.startMs, + subscriptionId, + }) +} + +function isRevenueActiveSubscription(subscription: Stripe.Subscription) { + return subscription.status === 'active' + || subscription.status === 'trialing' + || subscription.status === 'past_due' + || subscription.status === 'unpaid' +} + +function getSubscriptionItems(subscription: Stripe.Subscription) { + return subscription.items?.data as Stripe.SubscriptionItem[] | undefined +} + +function getLicensedSubscriptionItems(subscription: Stripe.Subscription) { + return (getSubscriptionItems(subscription) ?? []).filter(item => item.plan?.usage_type !== 'metered') +} + +function getSubscriptionItemPriceId(item: Stripe.SubscriptionItem) { + return toStripeId(item.price) ?? item.plan?.id ?? null +} + +function getSubscriptionItemPeriod(item: Stripe.SubscriptionItem, subscription: Stripe.Subscription) { + const typedItem = item as Stripe.SubscriptionItem & { + current_period_end?: number | null + current_period_start?: number | null + } + const typedSubscription = subscription as Stripe.Subscription & { + current_period_end?: number | null + current_period_start?: number | null + } + const start = typedItem.current_period_start ?? typedSubscription.current_period_start + const end = typedItem.current_period_end ?? typedSubscription.current_period_end + + if (typeof start !== 'number' || typeof end !== 'number' || end <= start) + return null + + return { + startMs: start * 1000, + endMs: end * 1000, + } +} + +function addCurrentSubscriptionInterval( + intervalsByKey: Map, + subscription: Stripe.Subscription, + priceLookup: Map, + options: { fromStartMs: number, toEndMs: number }, +) { + if (!isRevenueActiveSubscription(subscription)) + return + + const customerId = toStripeId(subscription.customer) + if (!customerId) + return + + for (const item of getLicensedSubscriptionItems(subscription)) { + const period = getSubscriptionItemPeriod(item, subscription) + if (!period || period.endMs <= options.fromStartMs || period.startMs > options.toEndMs) + continue + + const priceId = getSubscriptionItemPriceId(item) + const price = priceId ? priceLookup.get(priceId) : null + if (!price) + continue + + addInterval(intervalsByKey, { + customerId, + endMs: period.endMs, + interval: price.interval, + mrr: roundMoney(price.mrr * (item.quantity ?? 1)), + plan: price.plan, + priceId, + sourceId: subscription.id, + startMs: period.startMs, + subscriptionId: subscription.id, + }) + } +} + +async function getInvoiceLines(stripe: Stripe, invoice: Stripe.Invoice) { + const lines = [...invoice.lines.data] + if (!invoice.lines.has_more) + return lines + + const params = { limit: STRIPE_PAGE_SIZE, expand: ['data.price.product'] } as Stripe.InvoiceListLineItemsParams + const startingAfter = lines.at(-1)?.id + if (startingAfter) + params.starting_after = startingAfter + + for await (const line of stripe.invoices.listLineItems(invoice.id, params)) + lines.push(line) + + return lines +} + +async function fetchStripePrices(stripe: Stripe) { + const pricesById = new Map() + for (const active of [true, false]) { + const params = { active, expand: ['data.product'], limit: STRIPE_PAGE_SIZE } as Stripe.PriceListParams + for await (const price of stripe.prices.list(params)) + pricesById.set(price.id, price) + } + return [...pricesById.values()] +} + +async function fetchStripeRevenueIntervals( + stripe: Stripe, + priceLookup: Map, + options: { fromDateId: string, toDateId: string }, +) { + const intervalsByKey = new Map() + const fromStartMs = dateIdToStartMs(options.fromDateId) + const toEndMs = dateIdToEndMs(options.toDateId) + + let checkedInvoices = 0 + let matchedLines = 0 + const invoiceParams = { + expand: ['data.lines.data.price.product'], + limit: STRIPE_PAGE_SIZE, + status: 'paid', + } as Stripe.InvoiceListParams + + for await (const invoice of stripe.invoices.list(invoiceParams)) { + checkedInvoices++ + if (shouldUseInvoice(invoice)) { + const lines = await getInvoiceLines(stripe, invoice) + for (const line of lines) { + const before = intervalsByKey.size + addInvoiceLineInterval(intervalsByKey, invoice, line, priceLookup, { fromStartMs, toEndMs }) + if (intervalsByKey.size > before) + matchedLines++ + } + } + + if (checkedInvoices % 500 === 0) + console.log(`Checked ${checkedInvoices} paid Stripe invoices (${matchedLines} subscription lines matched)`) + } + + let checkedSubscriptions = 0 + const subscriptionParams = { + expand: ['data.items.data.price.product'], + limit: STRIPE_PAGE_SIZE, + status: 'all', + } as Stripe.SubscriptionListParams + + for await (const subscription of stripe.subscriptions.list(subscriptionParams)) { + checkedSubscriptions++ + addCurrentSubscriptionInterval(intervalsByKey, subscription, priceLookup, { fromStartMs, toEndMs }) + if (checkedSubscriptions % 500 === 0) + console.log(`Checked ${checkedSubscriptions} Stripe subscriptions`) + } + + console.log(`Checked ${checkedInvoices} paid Stripe invoices (${matchedLines} subscription lines matched)`) + console.log(`Checked ${checkedSubscriptions} Stripe subscriptions`) + return [...intervalsByKey.values()].sort((left, right) => { + if (left.startMs !== right.startMs) + return left.startMs - right.startMs + return left.sourceId.localeCompare(right.sourceId) + }) +} + +function getStateForDate(intervals: StripeRevenueInterval[], dateId: string) { + const dayEndMs = dateIdToEndMs(dateId) + const activeBySubscriptionId = new Map() + + for (const interval of intervals) { + if (interval.startMs > dayEndMs || interval.endMs <= dayEndMs) + continue + + const existing = activeBySubscriptionId.get(interval.subscriptionId) + if (!existing || interval.startMs > existing.startMs || (interval.startMs === existing.startMs && interval.endMs > existing.endMs)) + activeBySubscriptionId.set(interval.subscriptionId, interval) + } + + return new Map([...activeBySubscriptionId.entries()].map(([subscriptionId, interval]) => [subscriptionId, { + customerId: interval.customerId, + interval: interval.interval, + mrr: interval.mrr, + plan: interval.plan, + subscriptionId, + } satisfies RevenueSubscriptionState])) +} + +function recordDailyTransitions( + previousState: Map, + currentState: Map, + seenPaidCustomerIds: Set, +) { + const daily = createDailyCounters() + const previousActiveCustomers = new Set([...previousState.values()].map(state => state.customerId)) + const currentActiveCustomers = new Set([...currentState.values()].map(state => state.customerId)) + + for (const customerId of currentActiveCustomers) { + if (!seenPaidCustomerIds.has(customerId)) { + daily.newCustomerIds.add(customerId) + seenPaidCustomerIds.add(customerId) + } + } + + for (const [subscriptionId, previous] of previousState.entries()) { + const current = currentState.get(subscriptionId) ?? null + if (!current) { + daily.canceledCustomerIds.add(previous.customerId) + daily.churnRevenue += previous.mrr + if (previous.plan) + daily.churnRevenueByPlan[previous.plan] += previous.mrr + continue + } + + if ( + (previous.interval === 'monthly' && current.interval === 'yearly') + || (previous.mrr > 0 && current.mrr > previous.mrr) + ) { + daily.upgradedCustomerIds.add(current.customerId) + } + + if (current.mrr < previous.mrr) { + const lostMrr = previous.mrr - current.mrr + daily.churnRevenue += lostMrr + if (previous.plan) + daily.churnRevenueByPlan[previous.plan] += lostMrr + } + } + + for (const customerId of previousActiveCustomers) { + if (!currentActiveCustomers.has(customerId)) + daily.canceledCustomerIds.add(customerId) + } + + return daily +} + +function summarizeRevenueSnapshot(states: Iterable, daily: DailyCounters): StripeRevenueMetricValues { + 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 if (state.interval === 'yearly') + metrics.paying_yearly++ + + if (!state.plan) + continue + + if (state.plan === 'solo') { + metrics.plan_solo++ + if (state.interval === 'monthly') + metrics.plan_solo_monthly++ + else if (state.interval === 'yearly') + metrics.plan_solo_yearly++ + metrics.revenue_solo += state.mrr * 12 + } + else if (state.plan === 'maker') { + metrics.plan_maker++ + if (state.interval === 'monthly') + metrics.plan_maker_monthly++ + else if (state.interval === 'yearly') + metrics.plan_maker_yearly++ + metrics.revenue_maker += state.mrr * 12 + } + else if (state.plan === 'team') { + metrics.plan_team++ + if (state.interval === 'monthly') + metrics.plan_team_monthly++ + else if (state.interval === 'yearly') + metrics.plan_team_yearly++ + metrics.revenue_team += state.mrr * 12 + } + else { + metrics.plan_enterprise++ + if (state.interval === 'monthly') + metrics.plan_enterprise_monthly++ + else if (state.interval === 'yearly') + metrics.plan_enterprise_yearly++ + metrics.revenue_enterprise += state.mrr * 12 + } + } + + metrics.new_paying_orgs = daily.newCustomerIds.size + metrics.canceled_orgs = daily.canceledCustomerIds.size + metrics.churn_revenue = daily.churnRevenue + metrics.paying = payingCustomerIds.size + 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 + metrics.churn_revenue_enterprise = daily.churnRevenueByPlan.enterprise + metrics.mrr = roundMoney(metrics.mrr) + metrics.total_revenue = roundMoney(metrics.mrr * 12) + metrics.revenue_solo = roundMoney(metrics.revenue_solo) + metrics.revenue_maker = roundMoney(metrics.revenue_maker) + metrics.revenue_team = roundMoney(metrics.revenue_team) + metrics.revenue_enterprise = roundMoney(metrics.revenue_enterprise) + metrics.churn_revenue = roundMoney(metrics.churn_revenue) + metrics.churn_revenue_solo = roundMoney(metrics.churn_revenue_solo) + metrics.churn_revenue_maker = roundMoney(metrics.churn_revenue_maker) + metrics.churn_revenue_team = roundMoney(metrics.churn_revenue_team) + metrics.churn_revenue_enterprise = roundMoney(metrics.churn_revenue_enterprise) + + return metrics +} + +function valuesChanged(current: Partial>, next: StripeRevenueMetricValues) { + for (const [key, value] of Object.entries(next) as Array<[keyof StripeRevenueMetricValues, number]>) { + if (Math.abs(toMetricNumber(current[key]) - value) > 0.0001) + return true + } + return false +} + +export function buildStripeInvoiceRevenueBackfillRows( + existingRows: GlobalStatsRow[], + options: BuildStripeInvoiceRevenueRowsOptions, +): StripeRevenueBackfillRow[] { + const existingRowsByDateId = new Map(existingRows.map(row => [row.date_id, row])) + const rows: StripeRevenueBackfillRow[] = [] + const seenPaidCustomerIds = new Set() + const previousDate = new Date(`${options.fromDateId}T00:00:00.000Z`) + previousDate.setUTCDate(previousDate.getUTCDate() - 1) + let previousState = getStateForDate(options.intervals, getDateId(previousDate)) + + for (const state of previousState.values()) + seenPaidCustomerIds.add(state.customerId) + + for (const dateId of getDateIdsBetween(options.fromDateId, options.toDateId)) { + const currentState = getStateForDate(options.intervals, dateId) + const daily = recordDailyTransitions(previousState, currentState, seenPaidCustomerIds) + const next = summarizeRevenueSnapshot(currentState.values(), daily) + const current = existingRowsByDateId.get(dateId) ?? null + rows.push({ + ...next, + changed: !current || valuesChanged(current, next), + current: current + ? Object.fromEntries(Object.keys(next).map(key => [key, toMetricNumber(current[key as keyof StripeRevenueMetricValues])])) + : {}, + date_id: dateId, + exists: !!current, + }) + previousState = currentState + } + + return rows +} + +async function fetchPlans(supabase: SupabaseClient) { + const { data, error } = await supabase + .from('plans') + .select('name, stripe_id, price_m, price_y, price_m_id, price_y_id') + .in('name', [...PLAN_NAMES]) + + if (error) + throw error + + return data ?? [] +} + +async function fetchGlobalStatsRows(supabase: SupabaseClient, fromDateId: string, toDateId: string) { + const rows: GlobalStatsRow[] = [] + let offset = 0 + + while (true) { + const { data, error } = await supabase + .from('global_stats') + .select(` + date_id, + paying_yearly, + paying_monthly, + new_paying_orgs, + canceled_orgs, + paying, + mrr, + total_revenue, + revenue_solo, + revenue_maker, + revenue_team, + revenue_enterprise, + plan_solo, + plan_maker, + plan_team, + plan_enterprise, + plan_solo_monthly, + plan_solo_yearly, + plan_maker_monthly, + plan_maker_yearly, + plan_team_monthly, + plan_team_yearly, + plan_enterprise_monthly, + plan_enterprise_yearly, + churn_revenue, + churn_revenue_solo, + churn_revenue_maker, + churn_revenue_team, + churn_revenue_enterprise, + upgraded_orgs + `) + .gte('date_id', fromDateId) + .lte('date_id', toDateId) + .order('date_id', { ascending: true }) + .range(offset, offset + DEFAULT_PAGE_SIZE - 1) + + if (error) + throw error + if (!data?.length) + break + + rows.push(...data) + if (data.length < DEFAULT_PAGE_SIZE) + break + offset += DEFAULT_PAGE_SIZE + } + + return rows +} + +function toGlobalStatsUpdate(row: StripeRevenueBackfillRow): GlobalStatsUpdate { + return { + canceled_orgs: row.canceled_orgs, + churn_revenue: row.churn_revenue, + churn_revenue_enterprise: row.churn_revenue_enterprise, + churn_revenue_maker: row.churn_revenue_maker, + churn_revenue_solo: row.churn_revenue_solo, + 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, + plan_enterprise_monthly: row.plan_enterprise_monthly, + plan_enterprise_yearly: row.plan_enterprise_yearly, + plan_maker: row.plan_maker, + plan_maker_monthly: row.plan_maker_monthly, + plan_maker_yearly: row.plan_maker_yearly, + plan_solo: row.plan_solo, + plan_solo_monthly: row.plan_solo_monthly, + plan_solo_yearly: row.plan_solo_yearly, + plan_team: row.plan_team, + plan_team_monthly: row.plan_team_monthly, + plan_team_yearly: row.plan_team_yearly, + revenue_enterprise: row.revenue_enterprise, + revenue_maker: row.revenue_maker, + revenue_solo: row.revenue_solo, + revenue_team: row.revenue_team, + total_revenue: row.total_revenue, + upgraded_orgs: row.upgraded_orgs, + } +} + +function toGlobalStatsInsert(row: StripeRevenueBackfillRow): GlobalStatsInsert { + return { + apps: 0, + date_id: row.date_id, + stars: 0, + updates: 0, + ...toGlobalStatsUpdate(row), + } +} + +async function writeGlobalStatsRow(supabase: SupabaseClient, row: StripeRevenueBackfillRow) { + if (row.exists) { + const { error } = await supabase + .from('global_stats') + .update(toGlobalStatsUpdate(row)) + .eq('date_id', row.date_id) + + if (error) + throw error + return + } + + const { error } = await supabase + .from('global_stats') + .insert(toGlobalStatsInsert(row)) + + if (error) + throw error +} + +function printSampleRows(rows: StripeRevenueBackfillRow[]) { + for (const row of rows.slice(0, 10)) { + 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}`) + } +} + +export async function main(runtimeEnv: Record = process.env) { + const envFile = DEFAULT_ENV_FILE + const fileEnv = await loadEnv(envFile) + const env = { + ...fileEnv, + ...runtimeEnv, + } + const supabase = createSupabaseServiceClient(env) + const stripe = createStripeClient(getRequiredEnv(env, 'STRIPE_SECRET_KEY'), env.STRIPE_API_BASE_URL?.trim()) + const fromDateId = BACKFILL_FROM_DATE_ID + const toDateId = getDateId() + + console.log(`Fixing Stripe admin revenue dashboard metrics: ${fromDateId}..${toDateId}`) + console.log(`Env file: ${envFile}`) + + const [plans, prices, globalStatsRows] = await Promise.all([ + fetchPlans(supabase), + fetchStripePrices(stripe), + fetchGlobalStatsRows(supabase, fromDateId, toDateId), + ]) + const priceLookup = buildStripePriceLookup(prices, plans) + console.log(`Loaded ${plans.length} DB revenue plans`) + console.log(`Loaded ${prices.length} Stripe prices`) + console.log(`Loaded ${globalStatsRows.length} global_stats rows`) + + const intervals = await fetchStripeRevenueIntervals(stripe, priceLookup, { fromDateId, toDateId }) + const rows = buildStripeInvoiceRevenueBackfillRows(globalStatsRows, { + fromDateId, + intervals, + toDateId, + }) + const changedRows = rows.filter(row => row.changed) + const missingRows = changedRows.filter(row => !row.exists) + + console.log(`Loaded ${intervals.length} Stripe revenue intervals`) + console.log(`Computed ${rows.length} daily revenue rows`) + console.log(`Rows needing write: ${changedRows.length} (${missingRows.length} missing global_stats rows will be inserted)`) + + if (changedRows.length > 0) { + console.log('Sample writes:') + printSampleRows(changedRows) + } + + let written = 0 + await asyncPool(DEFAULT_CONCURRENCY, changedRows, async (row) => { + await writeGlobalStatsRow(supabase, row) + written++ + if (written % 100 === 0 || written === changedRows.length) + console.log(`Wrote ${written}/${changedRows.length}`) + }) + + console.log(`Done. Wrote ${written}/${changedRows.length} Stripe revenue dashboard rows.`) +} + +if (import.meta.main) + await main() diff --git a/tests/admin-stripe-backfill-scripts.unit.test.ts b/tests/admin-stripe-backfill-scripts.unit.test.ts index 43fbeebda0..50306d0c7a 100644 --- a/tests/admin-stripe-backfill-scripts.unit.test.ts +++ b/tests/admin-stripe-backfill-scripts.unit.test.ts @@ -3,6 +3,42 @@ import { describe, expect, it } from 'vitest' import { isActionableStripeCustomerId } from '../scripts/admin_stripe_backfill_utils.ts' import { buildOrgConversionRateBackfillRows, calculateOrgConversionRate } from '../scripts/backfill_org_conversion_rate_trend.ts' import { getCustomerProfileCountry, normalizeStripeCountryCode, shouldUpdateCustomerCountry } from '../scripts/backfill_stripe_customer_countries.ts' +import { buildStripeInvoiceRevenueBackfillRows, buildStripePriceLookup, classifyPlanKeyFromText } from '../scripts/fix_stripe_admin_revenue_dashboard_metrics.ts' + +function globalStatsRevenueRow(dateId: string) { + return { + canceled_orgs: 0, + churn_revenue: 0, + churn_revenue_enterprise: 0, + churn_revenue_maker: 0, + churn_revenue_solo: 0, + churn_revenue_team: 0, + date_id: dateId, + mrr: 0, + new_paying_orgs: 0, + paying: 0, + paying_monthly: 0, + paying_yearly: 0, + plan_enterprise: 0, + plan_enterprise_monthly: 0, + plan_enterprise_yearly: 0, + plan_maker: 0, + plan_maker_monthly: 0, + plan_maker_yearly: 0, + plan_solo: 0, + plan_solo_monthly: 0, + plan_solo_yearly: 0, + plan_team: 0, + plan_team_monthly: 0, + plan_team_yearly: 0, + revenue_enterprise: 0, + revenue_maker: 0, + revenue_solo: 0, + revenue_team: 0, + total_revenue: 0, + upgraded_orgs: 0, + } +} describe('admin Stripe backfill scripts', () => { it.concurrent('calculates org conversion rates from paying and org snapshots', () => { @@ -169,4 +205,147 @@ describe('admin Stripe backfill scripts', () => { expect(isActionableStripeCustomerId('pending_org_id')).toBe(false) expect(isActionableStripeCustomerId('')).toBe(false) }) + + it.concurrent('classifies legacy Stripe prices from product and price text', () => { + expect(classifyPlanKeyFromText('Capgo Solo yearly')).toBe('solo') + expect(classifyPlanKeyFromText('maker subscription')).toBe('maker') + expect(classifyPlanKeyFromText('Enterprise annual')).toBe('enterprise') + expect(classifyPlanKeyFromText('storage credits')).toBeNull() + }) + + it.concurrent('builds Stripe price lookup for inactive historical prices', () => { + const lookup = buildStripePriceLookup([ + { + id: 'price_legacy_team_yearly', + object: 'price', + active: false, + billing_scheme: 'per_unit', + created: 0, + currency: 'usd', + livemode: false, + lookup_key: null, + metadata: {}, + nickname: 'Legacy Team yearly', + product: { + id: 'prod_legacy_team', + object: 'product', + active: false, + created: 0, + default_price: null, + description: null, + images: [], + livemode: false, + marketing_features: [], + metadata: {}, + name: 'Team', + package_dimensions: null, + shippable: null, + statement_descriptor: null, + tax_code: null, + type: 'service', + updated: 0, + url: null, + }, + recurring: { + aggregate_usage: null, + interval: 'year', + interval_count: 1, + meter: null, + trial_period_days: null, + usage_type: 'licensed', + }, + tax_behavior: 'unspecified', + tiers_mode: null, + transform_quantity: null, + type: 'recurring', + unit_amount: 58800, + unit_amount_decimal: '58800', + } as unknown as Stripe.Price, + ], []) + + expect(lookup.get('price_legacy_team_yearly')).toMatchObject({ + interval: 'yearly', + mrr: 49, + plan: 'team', + }) + }) + + it.concurrent('rebuilds revenue trend snapshots, flow, churn, and plan breakdown from Stripe intervals', () => { + const rows = buildStripeInvoiceRevenueBackfillRows([ + globalStatsRevenueRow('2022-01-01'), + globalStatsRevenueRow('2022-01-02'), + globalStatsRevenueRow('2022-01-03'), + ], { + fromDateId: '2022-01-01', + intervals: [ + { + customerId: 'cus_team', + endMs: Date.parse('2022-01-04T00:00:00.000Z'), + interval: 'monthly', + mrr: 49, + plan: 'team', + priceId: 'price_team_monthly', + sourceId: 'in_team', + startMs: Date.parse('2022-01-01T00:00:00.000Z'), + subscriptionId: 'sub_team', + }, + { + customerId: 'cus_team', + endMs: Date.parse('2022-02-02T00:00:00.000Z'), + interval: 'monthly', + mrr: 12, + plan: 'solo', + priceId: 'price_solo_monthly', + sourceId: 'in_solo', + startMs: Date.parse('2022-01-02T12:00:00.000Z'), + subscriptionId: 'sub_team', + }, + { + customerId: 'cus_yearly', + endMs: Date.parse('2023-01-03T00:00:00.000Z'), + interval: 'yearly', + mrr: 10, + plan: 'solo', + priceId: 'price_solo_yearly', + sourceId: 'in_yearly', + startMs: Date.parse('2022-01-03T00:00:00.000Z'), + subscriptionId: 'sub_yearly', + }, + ], + toDateId: '2022-01-03', + }) + + expect(rows[0]).toMatchObject({ + mrr: 49, + new_paying_orgs: 1, + paying: 1, + paying_monthly: 1, + plan_team: 1, + revenue_team: 588, + total_revenue: 588, + }) + expect(rows[1]).toMatchObject({ + churn_revenue: 37, + churn_revenue_team: 37, + mrr: 12, + new_paying_orgs: 0, + paying: 1, + plan_solo: 1, + plan_team: 0, + revenue_solo: 144, + total_revenue: 144, + }) + expect(rows[2]).toMatchObject({ + mrr: 22, + new_paying_orgs: 1, + paying: 2, + paying_monthly: 1, + paying_yearly: 1, + plan_solo: 2, + plan_solo_monthly: 1, + plan_solo_yearly: 1, + revenue_solo: 264, + total_revenue: 264, + }) + }) }) diff --git a/tests/backfill-revenue-trend-metrics.unit.test.ts b/tests/backfill-revenue-trend-metrics.unit.test.ts deleted file mode 100644 index c221745e1f..0000000000 --- a/tests/backfill-revenue-trend-metrics.unit.test.ts +++ /dev/null @@ -1,473 +0,0 @@ -import type Stripe from 'stripe' -import { describe, expect, it } from 'vitest' -import { buildRevenueTrendBackfillRows } from '../scripts/backfill_revenue_trend_metrics.ts' - -const DAY_1 = 1775001600 // 2026-04-01T00:00:00.000Z -const DAY_2 = 1775088000 // 2026-04-02T00:00:00.000Z -const DAY_3_NOON = 1775217600 // 2026-04-03T12:00:00.000Z - -const plans = [ - { name: 'Solo', price_m: 12, price_y: 120, price_m_id: 'price_solo_monthly', price_y_id: 'price_solo_yearly' }, - { name: 'Maker', price_m: 29, price_y: 290, price_m_id: 'price_maker_monthly', price_y_id: 'price_maker_yearly' }, - { name: 'Team', price_m: 49, price_y: 588, price_m_id: 'price_team_monthly', price_y_id: 'price_team_yearly' }, - { name: 'Enterprise', price_m: 199, price_y: 2388, price_m_id: 'price_enterprise_monthly', price_y_id: 'price_enterprise_yearly' }, -] - -function globalStatsRow(dateId: string) { - return { - canceled_orgs: 0, - churn_revenue: 0, - churn_revenue_enterprise: 0, - churn_revenue_maker: 0, - churn_revenue_solo: 0, - churn_revenue_team: 0, - date_id: dateId, - mrr: 0, - new_paying_orgs: 0, - paying: 0, - paying_monthly: 0, - paying_yearly: 0, - plan_enterprise: 0, - plan_enterprise_monthly: 0, - plan_enterprise_yearly: 0, - plan_maker: 0, - plan_maker_monthly: 0, - plan_maker_yearly: 0, - plan_solo: 0, - plan_solo_monthly: 0, - plan_solo_yearly: 0, - plan_team: 0, - plan_team_monthly: 0, - plan_team_yearly: 0, - revenue_enterprise: 0, - revenue_maker: 0, - revenue_solo: 0, - revenue_team: 0, - total_revenue: 0, - upgraded_orgs: 0, - } -} - -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 -} - -function subscription( - customerId: string, - subscriptionId: string, - priceId: string, - created = DAY_1, - status: Stripe.Subscription.Status = 'active', - currentPeriodEnd?: number, - interval?: 'month' | 'year', -) { - return { - id: subscriptionId, - object: 'subscription', - cancel_at_period_end: false, - canceled_at: status === 'canceled' ? created : null, - created, - customer: customerId, - ended_at: status === 'canceled' ? created : null, - items: { - data: [subscriptionItem(priceId, currentPeriodEnd, 'licensed', interval)], - }, - status, - } as unknown as Stripe.Subscription -} - -function subscriptionEvent( - id: string, - type: 'customer.subscription.created' | 'customer.subscription.deleted' | 'customer.subscription.updated', - created: number, - customerId: string, - subscriptionId: string, - priceId: string, - options: { - currentPeriodEnd?: number - previousPriceId?: string - previousPriceInterval?: 'month' | 'year' - previousStatus?: Stripe.Subscription.Status - priceInterval?: 'month' | 'year' - status?: Stripe.Subscription.Status - subscriptionCreated?: number - } = {}, -) { - const eventSubscription = subscription( - customerId, - subscriptionId, - priceId, - 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, undefined, 'licensed', options.previousPriceInterval)], - } as unknown as Stripe.ApiList - } - if (options.previousStatus) - previousAttributes.status = options.previousStatus - - return { - id, - object: 'event', - created, - data: { - object: eventSubscription, - previous_attributes: Object.keys(previousAttributes).length > 0 ? previousAttributes : undefined, - }, - type, - } as Stripe.Event -} - -describe('revenue trend backfill metrics', () => { - it.concurrent('builds new and canceled subscription flow with MRR and ARR snapshots', () => { - const rows = buildRevenueTrendBackfillRows([ - globalStatsRow('2026-04-01'), - globalStatsRow('2026-04-02'), - ], { - events: [ - subscriptionEvent('evt_create', 'customer.subscription.created', DAY_1 + 3600, 'cus_new', 'sub_new', 'price_solo_monthly'), - subscriptionEvent('evt_delete', 'customer.subscription.deleted', DAY_2 + 3600, 'cus_new', 'sub_new', 'price_solo_monthly'), - ], - fromDateId: '2026-04-01', - plans, - toDateId: '2026-04-02', - }) - - expect(rows[0]).toMatchObject({ - canceled_orgs: 0, - churn_revenue: 0, - mrr: 12, - new_paying_orgs: 1, - paying: 1, - paying_monthly: 1, - paying_yearly: 0, - plan_solo: 1, - plan_solo_monthly: 1, - revenue_solo: 144, - total_revenue: 144, - }) - expect(rows[1]).toMatchObject({ - canceled_orgs: 1, - churn_revenue: 12, - churn_revenue_solo: 12, - mrr: 0, - new_paying_orgs: 0, - paying: 0, - paying_monthly: 0, - plan_solo: 0, - total_revenue: 0, - }) - }) - - it.concurrent('does not count reactivated customers as new paying orgs again', () => { - const rows = buildRevenueTrendBackfillRows([ - globalStatsRow('2026-04-01'), - globalStatsRow('2026-04-02'), - globalStatsRow('2026-04-03'), - ], { - events: [ - subscriptionEvent('evt_create_first', 'customer.subscription.created', DAY_1 + 3600, 'cus_reactivated', 'sub_reactivated_first', 'price_solo_monthly'), - subscriptionEvent('evt_delete_first', 'customer.subscription.deleted', DAY_2 + 3600, 'cus_reactivated', 'sub_reactivated_first', 'price_solo_monthly'), - subscriptionEvent('evt_create_again', 'customer.subscription.created', DAY_3_NOON, 'cus_reactivated', 'sub_reactivated_second', 'price_solo_monthly'), - ], - fromDateId: '2026-04-01', - plans, - toDateId: '2026-04-03', - }) - - expect(rows[0]).toMatchObject({ - mrr: 12, - new_paying_orgs: 1, - plan_solo: 1, - }) - expect(rows[1]).toMatchObject({ - canceled_orgs: 1, - churn_revenue: 12, - mrr: 0, - new_paying_orgs: 0, - plan_solo: 0, - }) - expect(rows[2]).toMatchObject({ - mrr: 12, - new_paying_orgs: 0, - plan_solo: 1, - }) - }) - - it.concurrent('counts yearly and monthly baseline subscriptions by plan', () => { - const rows = buildRevenueTrendBackfillRows([ - globalStatsRow('2026-04-01'), - ], { - baselineSubscriptions: [ - subscription('cus_team', 'sub_team', 'price_team_yearly', DAY_1 - 86400), - subscription('cus_maker', 'sub_maker', 'price_maker_monthly', DAY_1 - 86400), - ], - events: [], - fromDateId: '2026-04-01', - plans, - toDateId: '2026-04-01', - }) - - expect(rows[0]).toMatchObject({ - mrr: 78, - paying: 2, - paying_monthly: 1, - paying_yearly: 1, - plan_maker: 1, - plan_maker_monthly: 1, - plan_team: 1, - plan_team_yearly: 1, - revenue_maker: 348, - revenue_team: 588, - total_revenue: 936, - }) - }) - - it.concurrent('uses previous Stripe attributes for the opening plan before a first in-range update', () => { - const rows = buildRevenueTrendBackfillRows([ - globalStatsRow('2026-04-01'), - globalStatsRow('2026-04-02'), - ], { - baselineSubscriptions: [ - subscription('cus_upgrade', 'sub_upgrade', 'price_team_monthly', DAY_1 - 86400), - ], - events: [ - subscriptionEvent('evt_update', 'customer.subscription.updated', DAY_2 + 3600, 'cus_upgrade', 'sub_upgrade', 'price_team_monthly', { - previousPriceId: 'price_solo_monthly', - subscriptionCreated: DAY_1 - 86400, - }), - ], - fromDateId: '2026-04-01', - plans, - toDateId: '2026-04-02', - }) - - expect(rows[0]).toMatchObject({ - mrr: 12, - plan_solo: 1, - plan_team: 0, - revenue_solo: 144, - }) - expect(rows[1]).toMatchObject({ - churn_revenue: 0, - mrr: 49, - plan_solo: 0, - upgraded_orgs: 1, - plan_team: 1, - revenue_team: 588, - }) - }) - - it.concurrent('records downgrades as lost MRR without counting a cancellation', () => { - const rows = buildRevenueTrendBackfillRows([ - globalStatsRow('2026-04-01'), - ], { - baselineSubscriptions: [ - subscription('cus_downgrade', 'sub_downgrade', 'price_team_monthly', DAY_1 - 86400), - ], - events: [ - subscriptionEvent('evt_downgrade', 'customer.subscription.updated', DAY_1 + 3600, 'cus_downgrade', 'sub_downgrade', 'price_solo_monthly', { - previousPriceId: 'price_team_monthly', - subscriptionCreated: DAY_1 - 86400, - }), - ], - fromDateId: '2026-04-01', - plans, - toDateId: '2026-04-01', - }) - - expect(rows[0]).toMatchObject({ - canceled_orgs: 0, - churn_revenue: 37, - churn_revenue_team: 37, - mrr: 12, - plan_solo: 1, - plan_team: 0, - }) - }) - - it.concurrent('counts status-only activations as new paying subscriptions', () => { - const rows = buildRevenueTrendBackfillRows([ - globalStatsRow('2026-04-01'), - ], { - events: [ - subscriptionEvent('evt_activation', 'customer.subscription.updated', DAY_1 + 3600, 'cus_activation', 'sub_activation', 'price_solo_monthly', { - previousStatus: 'incomplete', - }), - ], - fromDateId: '2026-04-01', - plans, - toDateId: '2026-04-01', - }) - - 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'), - globalStatsRow('2026-04-02'), - globalStatsRow('2026-04-03'), - ], { - events: [ - subscriptionEvent('evt_create', 'customer.subscription.created', DAY_1 + 3600, 'cus_cancel_later', 'sub_cancel_later', 'price_team_monthly', { - currentPeriodEnd: DAY_3_NOON, - }), - subscriptionEvent('evt_delete', 'customer.subscription.deleted', DAY_2 + 3600, 'cus_cancel_later', 'sub_cancel_later', 'price_team_monthly', { - currentPeriodEnd: DAY_3_NOON, - }), - ], - fromDateId: '2026-04-01', - plans, - toDateId: '2026-04-03', - }) - - expect(rows[1]).toMatchObject({ - canceled_orgs: 0, - churn_revenue: 0, - mrr: 49, - plan_team: 1, - }) - expect(rows[2]).toMatchObject({ - canceled_orgs: 1, - churn_revenue: 49, - churn_revenue_team: 49, - mrr: 0, - plan_team: 0, - }) - }) -})