From 11ac8f2f0fbdbb97bd446b3f8bbfdf37e775bb39 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Mon, 11 May 2026 13:18:46 +0200 Subject: [PATCH 1/2] feat(admin): add Stripe LTV metrics --- package.json | 2 + scripts/admin_stripe_backfill_utils.ts | 2 +- scripts/backfill_ltv_metrics.ts | 360 ++++++++++++++++++ .../backfill_stripe_subscription_end_dates.ts | 277 ++++++++++++++ src/pages/admin/dashboard/revenue.vue | 47 +++ .../_backend/triggers/logsnag_insights.ts | 93 +++++ .../_backend/triggers/stripe_event.ts | 25 +- supabase/functions/_backend/utils/pg.ts | 9 + supabase/functions/_backend/utils/stripe.ts | 65 ++-- .../functions/_backend/utils/stripe_event.ts | 20 +- .../_backend/utils/supabase.types.ts | 9 + .../20260511101826_add_ltv_global_stats.sql | 11 + tests/backfill-ltv-metrics.unit.test.ts | 100 +++++ ...stripe-subscription-end-dates.unit.test.ts | 52 +++ tests/stripe-subscription-events.unit.test.ts | 42 +- 15 files changed, 1075 insertions(+), 39 deletions(-) create mode 100644 scripts/backfill_ltv_metrics.ts create mode 100644 scripts/backfill_stripe_subscription_end_dates.ts create mode 100644 supabase/migrations/20260511101826_add_ltv_global_stats.sql create mode 100644 tests/backfill-ltv-metrics.unit.test.ts create mode 100644 tests/backfill-stripe-subscription-end-dates.unit.test.ts diff --git a/package.json b/package.json index 39a3d933a7..3fc9191a76 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,8 @@ "stripe:backfill-retention-metrics": "bun scripts/backfill_retention_metrics.ts", "stripe:backfill-org-conversion-rate": "bun scripts/backfill_org_conversion_rate_trend.ts", "stripe:backfill-customer-countries": "bun scripts/backfill_stripe_customer_countries.ts", + "stripe:backfill-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:sync-org-names": "bun scripts/sync_stripe_org_names.ts", diff --git a/scripts/admin_stripe_backfill_utils.ts b/scripts/admin_stripe_backfill_utils.ts index 43bf3ff195..5bb4189418 100644 --- a/scripts/admin_stripe_backfill_utils.ts +++ b/scripts/admin_stripe_backfill_utils.ts @@ -75,7 +75,7 @@ export function createStripeClient(secretKey: string, apiBaseUrl?: string) { type StripeApiVersion = NonNullable[1]>['apiVersion'] return new Stripe(secretKey, { - apiVersion: '2026-03-25.dahlia' as StripeApiVersion, + apiVersion: '2026-04-22.dahlia' as StripeApiVersion, httpClient: Stripe.createFetchHttpClient(), ...hostConfig, }) diff --git a/scripts/backfill_ltv_metrics.ts b/scripts/backfill_ltv_metrics.ts new file mode 100644 index 0000000000..9af82ce787 --- /dev/null +++ b/scripts/backfill_ltv_metrics.ts @@ -0,0 +1,360 @@ +/* + * Backfill estimated LTV metrics stored in public.global_stats. + * + * LTV is estimated from the customer's stored Stripe plan price and paid + * lifetime. Plan changes before the current stored plan are not reconstructed. + * + * Dry run every stored global_stats row: + * bun run stripe:backfill-ltv-metrics + * + * Apply a date range: + * bun run stripe:backfill-ltv-metrics --apply --from=2026-04-01 --to=2026-04-30 + */ +import type { Database } from '../supabase/functions/_backend/utils/supabase.types.ts' +import process from 'node:process' +import { asyncPool, createSupabaseServiceClient, DEFAULT_ENV_FILE, getArgValue, loadEnv, parsePositiveInteger } from './admin_stripe_backfill_utils.ts' + +const DEFAULT_CONCURRENCY = 10 +const DEFAULT_PAGE_SIZE = 1000 +const DATE_ID_REGEX = /^\d{4}-\d{2}-\d{2}$/ +const MONTH_MS = (365.2425 / 12) * 24 * 60 * 60 * 1000 + +type SupabaseClient = ReturnType +type GlobalStatsLtvRow = Pick< + Database['public']['Tables']['global_stats']['Row'], + 'average_ltv' | 'date_id' | 'longest_ltv' | 'shortest_ltv' +> +type GlobalStatsUpdate = Database['public']['Tables']['global_stats']['Update'] + +export interface LtvSourcePlan { + name: string | null + price_m: number | null + price_m_id: string | null + price_y: number | null + price_y_id: string | null +} + +export interface LtvSourceRow { + canceled_at: string | null + created_at: string + customer_id: string + is_good_plan: boolean | null + paid_at: string | null + price_id: string | null + status: Database['public']['Enums']['stripe_status'] | null + subscription_anchor_end: string | null + subscription_anchor_start: string | null + plans: LtvSourcePlan | LtvSourcePlan[] | null +} + +export interface LtvMetricValues { + average_ltv: number + shortest_ltv: number + longest_ltv: number +} + +export interface LtvBackfillRow extends LtvMetricValues { + changed: boolean + current: Partial + date_id: string +} + +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 compareDateIds(left: string, right: string) { + return left.localeCompare(right) +} + +function toDate(value: string | null | undefined) { + if (!value) + return null + const date = new Date(value) + return Number.isNaN(date.getTime()) ? null : date +} + +function toMoney(value: number) { + return Number(value.toFixed(2)) +} + +function toMetricNumber(value: number | string | null | undefined) { + const numberValue = Number(value ?? 0) + return Number.isFinite(numberValue) ? numberValue : 0 +} + +function getPlan(row: LtvSourceRow) { + return Array.isArray(row.plans) ? row.plans[0] ?? null : row.plans +} + +function getBillingValue(row: LtvSourceRow) { + const plan = getPlan(row) + if (!plan || !row.price_id) + return null + + if (row.price_id === plan.price_y_id) { + return { + amount: Number(plan.price_y) || 0, + periodMonths: 12, + } + } + + if (row.price_id === plan.price_m_id) { + return { + amount: Number(plan.price_m) || 0, + periodMonths: 1, + } + } + + return null +} + +function getPaidStart(row: LtvSourceRow) { + return toDate(row.paid_at) +} + +function getKnownSubscriptionEnd(row: LtvSourceRow) { + const canceledAt = toDate(row.canceled_at) + if (canceledAt) + return canceledAt + + if (row.status === 'canceled' || row.status === 'deleted') { + return toDate(row.subscription_anchor_end) + } + + return null +} + +export function estimateCustomerLtv(row: LtvSourceRow, snapshotExclusiveEnd: Date) { + if (row.is_good_plan !== true) + return null + + const billingValue = getBillingValue(row) + if (!billingValue || billingValue.amount <= 0) + return null + + const start = getPaidStart(row) + if (!start || start.getTime() >= snapshotExclusiveEnd.getTime()) + return null + + const knownEnd = getKnownSubscriptionEnd(row) + const effectiveEnd = knownEnd && knownEnd.getTime() < snapshotExclusiveEnd.getTime() + ? knownEnd + : snapshotExclusiveEnd + + if (effectiveEnd.getTime() <= start.getTime()) + return null + + const elapsedMonths = (effectiveEnd.getTime() - start.getTime()) / MONTH_MS + const paidPeriods = Math.max(1, Math.ceil((elapsedMonths / billingValue.periodMonths) - 1e-9)) + return toMoney(billingValue.amount * paidPeriods) +} + +export function calculateLtvMetrics(rows: LtvSourceRow[], dateId: string): LtvMetricValues { + const snapshotExclusiveEnd = new Date(`${dateId}T00:00:00.000Z`) + snapshotExclusiveEnd.setUTCDate(snapshotExclusiveEnd.getUTCDate() + 1) + + const values = rows + .map(row => estimateCustomerLtv(row, snapshotExclusiveEnd)) + .filter((value): value is number => value !== null && value > 0) + + if (values.length === 0) { + return { + average_ltv: 0, + shortest_ltv: 0, + longest_ltv: 0, + } + } + + const total = values.reduce((sum, value) => sum + value, 0) + + return { + average_ltv: toMoney(total / values.length), + shortest_ltv: toMoney(Math.min(...values)), + longest_ltv: toMoney(Math.max(...values)), + } +} + +export function buildLtvBackfillRows(globalStatsRows: GlobalStatsLtvRow[], ltvSourceRows: LtvSourceRow[]) { + return globalStatsRows.map((row): LtvBackfillRow => { + const metrics = calculateLtvMetrics(ltvSourceRows, row.date_id) + const current = { + average_ltv: toMetricNumber(row.average_ltv), + shortest_ltv: toMetricNumber(row.shortest_ltv), + longest_ltv: toMetricNumber(row.longest_ltv), + } + const changed = current.average_ltv !== metrics.average_ltv + || current.shortest_ltv !== metrics.shortest_ltv + || current.longest_ltv !== metrics.longest_ltv + + return { + date_id: row.date_id, + current, + changed, + ...metrics, + } + }) +} + +async function fetchGlobalStatsRows(supabase: SupabaseClient, fromDateId: string | null, toDateId: string | null) { + const rows: GlobalStatsLtvRow[] = [] + let offset = 0 + + while (true) { + let query = supabase + .from('global_stats') + .select('date_id, average_ltv, shortest_ltv, longest_ltv') + .order('date_id', { ascending: true }) + .range(offset, offset + DEFAULT_PAGE_SIZE - 1) + + if (fromDateId) + query = query.gte('date_id', fromDateId) + if (toDateId) + query = query.lte('date_id', toDateId) + + const { data, error } = await query + if (error) + throw error + if (!data?.length) + break + + rows.push(...data) + if (data.length < DEFAULT_PAGE_SIZE) + break + offset += DEFAULT_PAGE_SIZE + } + + return rows +} + +async function fetchLtvSourceRows(supabase: SupabaseClient) { + const rows: LtvSourceRow[] = [] + let lastSeenCustomerId: string | null = null + + while (true) { + let query = supabase + .from('stripe_info') + .select(` + customer_id, + created_at, + paid_at, + subscription_anchor_start, + subscription_anchor_end, + canceled_at, + price_id, + status, + is_good_plan, + plans!stripe_info_product_id_fkey(name, price_m, price_y, price_m_id, price_y_id) + `) + .order('customer_id', { ascending: true }) + .limit(DEFAULT_PAGE_SIZE) + + if (lastSeenCustomerId) + query = query.gt('customer_id', lastSeenCustomerId) + + const { data, error } = await query + if (error) + throw error + if (!data?.length) + break + + rows.push(...data as unknown as LtvSourceRow[]) + if (data.length < DEFAULT_PAGE_SIZE) + break + lastSeenCustomerId = data.at(-1)?.customer_id ?? null + } + + return rows +} + +function toGlobalStatsUpdate(row: LtvBackfillRow): GlobalStatsUpdate { + return { + average_ltv: row.average_ltv, + shortest_ltv: row.shortest_ltv, + longest_ltv: row.longest_ltv, + } +} + +async function updateGlobalStatsRow(supabase: SupabaseClient, row: LtvBackfillRow) { + const { error } = await supabase + .from('global_stats') + .update(toGlobalStatsUpdate(row)) + .eq('date_id', row.date_id) + + if (error) + throw error +} + +function printSampleRows(rows: LtvBackfillRow[]) { + for (const row of rows.slice(0, 10)) { + console.log(`${row.date_id}: average=$${row.average_ltv.toFixed(2)}, shortest=$${row.shortest_ltv.toFixed(2)}, longest=$${row.longest_ltv.toFixed(2)}`) + } +} + +async function main(args = process.argv.slice(2), runtimeEnv: Record = process.env) { + const apply = args.includes('--apply') + const envFile = getArgValue(args, '--env-file') ?? DEFAULT_ENV_FILE + const fromDateId = getArgValue(args, '--from') + const toDateId = getArgValue(args, '--to') + const concurrency = parsePositiveInteger(getArgValue(args, '--concurrency'), '--concurrency', DEFAULT_CONCURRENCY) + + const from = fromDateId ? assertDateId(fromDateId, '--from') : null + const to = toDateId ? assertDateId(toDateId, '--to') : null + if (from && to && compareDateIds(from, to) > 0) + throw new Error('--from must be before or equal to --to') + + const fileEnv = await loadEnv(envFile) + const env = { + ...fileEnv, + ...runtimeEnv, + } + const supabase = createSupabaseServiceClient(env) + + console.log(`Env file: ${envFile}`) + if (from || to) + console.log(`Backfill range: ${from ?? 'first'}..${to ?? 'last'}`) + else + console.log('Backfill range: all global_stats rows') + if (!apply) + console.log('Dry run only. Pass --apply to update global_stats.') + + const [globalStatsRows, ltvSourceRows] = await Promise.all([ + fetchGlobalStatsRows(supabase, from, to), + fetchLtvSourceRows(supabase), + ]) + + const rows = buildLtvBackfillRows(globalStatsRows, ltvSourceRows) + const changedRows = rows.filter(row => row.changed) + + console.log(`Loaded ${globalStatsRows.length} global_stats rows`) + console.log(`Loaded ${ltvSourceRows.length} stripe_info LTV source rows`) + 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} LTV rows.`) +} + +if (import.meta.main) + await main() diff --git a/scripts/backfill_stripe_subscription_end_dates.ts b/scripts/backfill_stripe_subscription_end_dates.ts new file mode 100644 index 0000000000..5485c2732a --- /dev/null +++ b/scripts/backfill_stripe_subscription_end_dates.ts @@ -0,0 +1,277 @@ +/* + * Backfill Stripe subscription end dates into public.stripe_info.canceled_at. + * + * Dry run for rows missing canceled_at: + * bun run stripe:backfill-subscription-end-dates + * + * Apply missing end dates: + * bun run stripe:backfill-subscription-end-dates --apply + * + * Refresh existing end dates and billing anchors too: + * bun run stripe:backfill-subscription-end-dates --apply --refresh-existing + */ +import type Stripe from 'stripe' +import type { Database } from '../supabase/functions/_backend/utils/supabase.types.ts' +import { mkdir, writeFile } from 'node:fs/promises' +import process from 'node:process' +import { asyncPool, createStripeClient, createSupabaseServiceClient, DEFAULT_ENV_FILE, getArgValue, getRequiredEnv, isActionableStripeCustomerId, loadEnv, parsePositiveInteger } from './admin_stripe_backfill_utils.ts' + +const DEFAULT_CONCURRENCY = 8 +const DEFAULT_PAGE_SIZE = 1000 +const FAILURE_OUTPUT = './tmp/stripe_subscription_end_date_backfill_failures.json' + +type SupabaseClient = ReturnType +type StripeInfoSubscriptionEndRow = Pick< + Database['public']['Tables']['stripe_info']['Row'], + 'canceled_at' | 'customer_id' | 'subscription_anchor_end' | 'subscription_anchor_start' | 'subscription_id' +> + +export interface StripeSubscriptionEndBackfillCandidate { + current_anchor_end: string | null + current_anchor_start: string | null + current_canceled_at: string | null + customer_id: string + next_anchor_end: string | null + next_anchor_start: string | null + next_canceled_at: string | null + subscription_id: string +} + +interface BackfillFailure { + error: string + subscriptionId: string | null + customerId: string +} + +function toIsoFromSeconds(seconds: number | null | undefined) { + if (typeof seconds !== 'number' || !Number.isFinite(seconds)) + return null + return new Date(seconds * 1000).toISOString() +} + +function getLicensedSubscriptionItem(subscription: Stripe.Subscription) { + return subscription.items.data.find(item => item.plan?.usage_type === 'licensed') ?? subscription.items.data[0] ?? null +} + +export function getStripeSubscriptionEndSnapshot(subscription: Stripe.Subscription) { + const item = getLicensedSubscriptionItem(subscription) + const anchorStart = toIsoFromSeconds(item?.current_period_start) + const anchorEnd = toIsoFromSeconds(item?.current_period_end) + const itemPeriodEnd = typeof item?.current_period_end === 'number' ? item.current_period_end : null + const endedAtSeconds = subscription.ended_at + ?? subscription.cancel_at + ?? (subscription.cancel_at_period_end ? itemPeriodEnd : null) + + return { + subscription_anchor_start: anchorStart, + subscription_anchor_end: anchorEnd, + canceled_at: toIsoFromSeconds(endedAtSeconds), + } +} + +function normalizeIso(value: string | null | undefined) { + if (!value) + return null + const parsed = new Date(value) + if (Number.isNaN(parsed.getTime())) + return null + return parsed.toISOString() +} + +function hasSnapshotChanged(row: StripeInfoSubscriptionEndRow, snapshot: ReturnType, refreshExisting: boolean) { + const canceledAtChanged = normalizeIso(row.canceled_at) !== snapshot.canceled_at + const anchorsChanged = normalizeIso(row.subscription_anchor_start) !== snapshot.subscription_anchor_start + || normalizeIso(row.subscription_anchor_end) !== snapshot.subscription_anchor_end + + if (refreshExisting) + return canceledAtChanged || anchorsChanged + + return !row.canceled_at && !!snapshot.canceled_at +} + +async function fetchStripeInfoRows( + supabase: SupabaseClient, + options: { + customerId?: string | null + missingOnly: boolean + }, +) { + const rows: StripeInfoSubscriptionEndRow[] = [] + let lastSeenCustomerId: string | null = null + + while (true) { + let query = supabase + .from('stripe_info') + .select('customer_id, subscription_id, subscription_anchor_start, subscription_anchor_end, canceled_at') + .not('subscription_id', 'is', null) + .order('customer_id', { ascending: true }) + .limit(DEFAULT_PAGE_SIZE) + + if (options.customerId) + query = query.eq('customer_id', options.customerId) + else if (lastSeenCustomerId) + query = query.gt('customer_id', lastSeenCustomerId) + + if (options.missingOnly) + query = query.is('canceled_at', null) + + const { data, error } = await query + if (error) + throw error + if (!data?.length) + break + + rows.push(...data) + + if (options.customerId || data.length < DEFAULT_PAGE_SIZE) + break + lastSeenCustomerId = data.at(-1)?.customer_id ?? null + } + + return rows +} + +async function updateSubscriptionEndSnapshot(supabase: SupabaseClient, candidate: StripeSubscriptionEndBackfillCandidate) { + const update: Database['public']['Tables']['stripe_info']['Update'] = { + canceled_at: candidate.next_canceled_at, + } + if (candidate.next_anchor_start) + update.subscription_anchor_start = candidate.next_anchor_start + if (candidate.next_anchor_end) + update.subscription_anchor_end = candidate.next_anchor_end + + const { error } = await supabase + .from('stripe_info') + .update(update) + .eq('customer_id', candidate.customer_id) + + if (error) + throw error +} + +async function writeFailures(failures: BackfillFailure[]) { + if (failures.length === 0) + return + + await mkdir('./tmp', { recursive: true }) + await writeFile(FAILURE_OUTPUT, `${JSON.stringify(failures, null, 2)}\n`) + console.log(`Failure details written to ${FAILURE_OUTPUT}`) +} + +async function main(args = process.argv.slice(2), runtimeEnv: Record = process.env) { + const apply = args.includes('--apply') + const refreshExisting = args.includes('--refresh-existing') + const envFile = getArgValue(args, '--env-file') ?? DEFAULT_ENV_FILE + const customerId = getArgValue(args, '--customer-id') + const limitArg = getArgValue(args, '--limit') + const limit = limitArg ? parsePositiveInteger(limitArg, '--limit', 0) : null + const concurrency = parsePositiveInteger(getArgValue(args, '--concurrency'), '--concurrency', DEFAULT_CONCURRENCY) + + 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 rows = await fetchStripeInfoRows(supabase, { + customerId, + missingOnly: !refreshExisting, + }) + const actionableRows = rows.filter(row => isActionableStripeCustomerId(row.customer_id) && !!row.subscription_id) + const limitedRows = limit ? actionableRows.slice(0, limit) : actionableRows + + console.log(`Loaded ${rows.length} stripe_info rows (${actionableRows.length} actionable)`) + console.log(`Env file: ${envFile}`) + if (customerId) + console.log(`Scoped to customer: ${customerId}`) + if (refreshExisting) + console.log('Mode: refresh canceled_at end dates and billing anchors') + else + console.log('Mode: fill missing canceled_at end dates only') + if (!apply) + console.log('Dry run only. Pass --apply to update stripe_info.') + + const failures: BackfillFailure[] = [] + const candidates: StripeSubscriptionEndBackfillCandidate[] = [] + let checked = 0 + + await asyncPool(concurrency, limitedRows, async (row) => { + try { + const subscriptionId = row.subscription_id! + const subscription = await stripe.subscriptions.retrieve(subscriptionId, { + expand: ['items.data.price'], + }) + const snapshot = getStripeSubscriptionEndSnapshot(subscription) + + if (hasSnapshotChanged(row, snapshot, refreshExisting)) { + candidates.push({ + customer_id: row.customer_id, + subscription_id: subscriptionId, + current_anchor_start: normalizeIso(row.subscription_anchor_start), + current_anchor_end: normalizeIso(row.subscription_anchor_end), + current_canceled_at: normalizeIso(row.canceled_at), + next_anchor_start: snapshot.subscription_anchor_start, + next_anchor_end: snapshot.subscription_anchor_end, + next_canceled_at: snapshot.canceled_at, + }) + } + checked++ + if (checked % 100 === 0 || checked === limitedRows.length) + console.log(`Checked ${checked}/${limitedRows.length}`) + } + catch (error) { + failures.push({ + customerId: row.customer_id, + subscriptionId: row.subscription_id, + error: error instanceof Error ? error.message : String(error), + }) + } + }) + + console.log(`Candidates needing update: ${candidates.length}`) + if (candidates.length > 0) { + console.log('Sample updates:') + for (const candidate of candidates.slice(0, 10)) { + console.log(`${candidate.customer_id}: canceled_at ${candidate.current_canceled_at ?? 'null'} -> ${candidate.next_canceled_at ?? 'null'}, anchor_end ${candidate.current_anchor_end ?? 'null'} -> ${candidate.next_anchor_end ?? 'null'}`) + } + } + + if (!apply) { + await writeFailures(failures) + if (failures.length > 0) + throw new Error(`Stripe subscription end-date backfill dry run had ${failures.length} failures`) + return + } + + let updated = 0 + await asyncPool(concurrency, candidates, async (candidate) => { + try { + await updateSubscriptionEndSnapshot(supabase, candidate) + updated++ + if (updated % 100 === 0 || updated === candidates.length) + console.log(`Updated ${updated}/${candidates.length}`) + } + catch (error) { + failures.push({ + customerId: candidate.customer_id, + subscriptionId: candidate.subscription_id, + error: error instanceof Error ? error.message : String(error), + }) + } + }) + + await writeFailures(failures) + if (failures.length > 0) + throw new Error(`Stripe subscription end-date backfill had ${failures.length} failures`) + + console.log(`Done. Updated ${updated}/${candidates.length} stripe_info rows.`) +} + +if (import.meta.main) + await main() diff --git a/src/pages/admin/dashboard/revenue.vue b/src/pages/admin/dashboard/revenue.vue index 71e0923399..f12c76dff8 100644 --- a/src/pages/admin/dashboard/revenue.vue +++ b/src/pages/admin/dashboard/revenue.vue @@ -74,6 +74,9 @@ const globalStatsTrendData = ref>([]) const isLoadingGlobalStatsTrend = ref(false) @@ -439,6 +442,38 @@ const planARRSeries = computed(() => { ] }) +const ltvSeries = computed(() => { + if (globalStatsTrendData.value.length === 0) + return [] + + return [ + { + label: 'Average LTV ($)', + data: globalStatsTrendData.value.map(item => ({ + date: item.date, + value: item.average_ltv || 0, + })), + color: '#119eff', + }, + { + label: 'Shortest LTV ($)', + data: globalStatsTrendData.value.map(item => ({ + date: item.date, + value: item.shortest_ltv || 0, + })), + color: '#f59e0b', + }, + { + label: 'Longest LTV ($)', + data: globalStatsTrendData.value.map(item => ({ + date: item.date, + value: item.longest_ltv || 0, + })), + color: '#10b981', + }, + ] +}) + const totalPayingOrgsSeries = computed(() => { if (globalStatsTrendData.value.length === 0) return [] @@ -750,6 +785,18 @@ displayStore.defaultBack = '/dashboard' value-prefix="$" /> + + + + diff --git a/supabase/functions/_backend/triggers/logsnag_insights.ts b/supabase/functions/_backend/triggers/logsnag_insights.ts index 899dc1c756..d6fb8f4629 100644 --- a/supabase/functions/_backend/triggers/logsnag_insights.ts +++ b/supabase/functions/_backend/triggers/logsnag_insights.ts @@ -86,6 +86,11 @@ interface PaidProductActivityStats { builder_active_paying_clients_60d: number live_updates_active_paying_clients_60d: number } +interface LtvStats { + average_ltv: number + shortest_ltv: number + longest_ltv: number +} interface GlobalStats { apps: PromiseLike updates: PromiseLike @@ -116,6 +121,7 @@ interface GlobalStats { build_stats: PromiseLike retention_metrics: PromiseLike paid_product_activity_stats: PromiseLike + ltv_stats: PromiseLike } interface CustomerIdRow { customer_id: string @@ -578,6 +584,87 @@ async function getPaidProductActivityStats(c: Context, window: CurrentDayWindow) } } +async function getLtvStats(c: Context, window: CurrentDayWindow): Promise { + const pgClient = getPgClient(c, false) + const drizzleClient = getDrizzleClient(pgClient) + const snapshotExclusiveEnd = window.nextDayStart.toISOString() + const monthSeconds = (365.2425 / 12) * 24 * 60 * 60 + + try { + const result = await drizzleClient.execute(sql` + WITH source AS ( + SELECT + CASE + WHEN si.price_id = p.price_y_id THEN p.price_y::double precision + WHEN si.price_id = p.price_m_id THEN p.price_m::double precision + ELSE 0::double precision + END AS amount, + CASE + WHEN si.price_id = p.price_y_id THEN 12::double precision + WHEN si.price_id = p.price_m_id THEN 1::double precision + ELSE NULL::double precision + END AS period_months, + si.paid_at AS paid_start, + COALESCE( + si.canceled_at, + CASE + WHEN si.status IN ('canceled', 'deleted') THEN si.subscription_anchor_end + ELSE NULL + END + ) AS known_end + FROM public.stripe_info si + INNER JOIN public.plans p ON p.stripe_id = si.product_id + WHERE si.is_good_plan = true + AND si.paid_at IS NOT NULL + ), + ltv_values AS ( + SELECT + amount + * GREATEST( + 1::double precision, + CEIL( + ( + EXTRACT(EPOCH FROM ( + LEAST(COALESCE(known_end, ${snapshotExclusiveEnd}::timestamptz), ${snapshotExclusiveEnd}::timestamptz) + - paid_start + )) + / (${monthSeconds}::double precision * period_months) + ) - 0.000000001 + ) + ) AS ltv + FROM source + WHERE amount > 0 + AND period_months IS NOT NULL + AND paid_start < ${snapshotExclusiveEnd}::timestamptz + AND LEAST(COALESCE(known_end, ${snapshotExclusiveEnd}::timestamptz), ${snapshotExclusiveEnd}::timestamptz) > paid_start + ) + SELECT + COALESCE(ROUND(AVG(ltv)::numeric, 2), 0)::double precision AS average_ltv, + COALESCE(ROUND(MIN(ltv)::numeric, 2), 0)::double precision AS shortest_ltv, + COALESCE(ROUND(MAX(ltv)::numeric, 2), 0)::double precision AS longest_ltv + FROM ltv_values + `) + + const row = result.rows[0] as Partial | undefined + return { + average_ltv: Number(row?.average_ltv) || 0, + shortest_ltv: Number(row?.shortest_ltv) || 0, + longest_ltv: Number(row?.longest_ltv) || 0, + } + } + catch (error) { + cloudlogErr({ requestId: c.get('requestId'), message: 'ltv stats error', error }) + return { + average_ltv: 0, + shortest_ltv: 0, + longest_ltv: 0, + } + } + finally { + closeClient(c, pgClient) + } +} + async function getRevenueRetentionMetrics(c: Context, dateId: string): Promise { const pgClient = getPgClient(c, false) const drizzleClient = getDrizzleClient(pgClient) @@ -958,6 +1045,7 @@ function getStats(c: Context, window?: DailyWindow): GlobalStats { build_stats: getBuildStats(c, window), retention_metrics: getRevenueRetentionMetrics(c, metricWindow.dayDateId), paid_product_activity_stats: getPaidProductActivityStats(c, metricWindow), + ltv_stats: getLtvStats(c, metricWindow), } } @@ -1006,6 +1094,7 @@ app.post('/', middlewareAPISecret, async (c) => { build_stats, retention_metrics, paid_product_activity_stats, + ltv_stats, ] = await Promise.all([ res.apps, res.updates, @@ -1039,6 +1128,7 @@ app.post('/', middlewareAPISecret, async (c) => { return null }), res.paid_product_activity_stats, + res.ltv_stats, ]) const not_paying = users - customers.total - plans.Trial const org_conversion_rate = calculateConversionRate(paying_orgs_for_conversion, orgs) @@ -1125,6 +1215,9 @@ app.post('/', middlewareAPISecret, async (c) => { plugin_version_ladder: plugin_breakdown.version_ladder as unknown as Json, builder_active_paying_clients_60d: paid_product_activity_stats.builder_active_paying_clients_60d, live_updates_active_paying_clients_60d: paid_product_activity_stats.live_updates_active_paying_clients_60d, + average_ltv: ltv_stats.average_ltv, + shortest_ltv: ltv_stats.shortest_ltv, + longest_ltv: ltv_stats.longest_ltv, // Build statistics (all time) builds_total: build_stats.total, builds_ios: build_stats.ios, diff --git a/supabase/functions/_backend/triggers/stripe_event.ts b/supabase/functions/_backend/triggers/stripe_event.ts index 373be5483e..0d575df20c 100644 --- a/supabase/functions/_backend/triggers/stripe_event.ts +++ b/supabase/functions/_backend/triggers/stripe_event.ts @@ -983,13 +983,32 @@ async function getOrg(c: Context, stripeData: StripeData) { return org } -async function cancelingOrFinished(c: Context, stripeEvent: Stripe.Event, stripeData: Database['public']['Tables']['stripe_info']['Insert']) { +async function cancelingOrFinished( + c: Context, + stripeEvent: Stripe.Event, + stripeData: Database['public']['Tables']['stripe_info']['Insert'], + currentStripeInfo: Pick | null | undefined, +) { + const eventOccurredAtIso = new Date(stripeEvent.created * 1000).toISOString() + if (isStaleStripeEvent(currentStripeInfo, eventOccurredAtIso)) { + cloudlog({ + requestId: c.get('requestId'), + message: 'Skipping stale Stripe cancel-at-period-end toggle', + customerId: stripeData.customer_id, + eventOccurredAtIso, + currentStripeInfoLastStripeEventAt: currentStripeInfo?.last_stripe_event_at, + subscriptionId: stripeData.subscription_id, + }) + return c.json(BRES) + } + const previousAttributes = stripeEvent.data.previous_attributes ?? {} as any if (stripeEvent.data.object.object === 'subscription' && stripeEvent.data.object.cancel_at_period_end === true && typeof previousAttributes.cancel_at_period_end === 'boolean' && previousAttributes.cancel_at_period_end === false) { // cloudlog('USER CANCELLED!!!!!!!!!!!!!!!') + const canceledAt = stripeData.canceled_at ?? new Date().toISOString() const { error: dbError2 } = await supabaseAdmin(c) .from('stripe_info') - .update({ canceled_at: new Date().toISOString() }) + .update({ canceled_at: canceledAt }) .eq('customer_id', stripeData.customer_id) if (dbError2) { return quickError(404, 'user_cancelled_customer_id_not_found', `USER CANCELLED, customer_id not found`, { dbError2, stripeData }) @@ -1129,7 +1148,7 @@ app.post('/', middlewareStripeWebhook(), async (c) => { cloudlog({ requestId: c.get('requestId'), message: 'Ignoring canceled/deleted webhook for subscription not in database', subscriptionInDb: customer?.subscription_id, webhookSubscription: stripeData.data.subscription_id }) } } - return cancelingOrFinished(c, stripeEvent, stripeData.data) + return cancelingOrFinished(c, stripeEvent, stripeData.data, customer) }) export const stripeEventTestUtils = { diff --git a/supabase/functions/_backend/utils/pg.ts b/supabase/functions/_backend/utils/pg.ts index 747f8510d9..d49396707b 100644 --- a/supabase/functions/_backend/utils/pg.ts +++ b/supabase/functions/_backend/utils/pg.ts @@ -1130,6 +1130,9 @@ export interface AdminGlobalStatsTrend { revenue_maker: number revenue_team: number revenue_enterprise: number + average_ltv: number + shortest_ltv: number + longest_ltv: number credits_bought: number credits_consumed: number builds_total: number @@ -1224,6 +1227,9 @@ export async function getAdminGlobalStatsTrend( revenue_maker::float, revenue_team::float, revenue_enterprise::float, + COALESCE(NULLIF(to_jsonb(gs) ->> 'average_ltv', '')::float, 0)::float AS average_ltv, + COALESCE(NULLIF(to_jsonb(gs) ->> 'shortest_ltv', '')::float, 0)::float AS shortest_ltv, + COALESCE(NULLIF(to_jsonb(gs) ->> 'longest_ltv', '')::float, 0)::float AS longest_ltv, COALESCE(credits_bought, 0)::float AS credits_bought, COALESCE(credits_consumed, 0)::float AS credits_consumed, COALESCE(builds_total, 0)::int AS builds_total, @@ -1350,6 +1356,9 @@ export async function getAdminGlobalStatsTrend( revenue_maker: Number(row.revenue_maker) || 0, revenue_team: Number(row.revenue_team) || 0, revenue_enterprise: Number(row.revenue_enterprise) || 0, + average_ltv: Number(row.average_ltv) || 0, + shortest_ltv: Number(row.shortest_ltv) || 0, + longest_ltv: Number(row.longest_ltv) || 0, credits_bought: Number(row.credits_bought) || 0, credits_consumed: Number(row.credits_consumed) || 0, builds_total: Number(row.builds_total) || 0, diff --git a/supabase/functions/_backend/utils/stripe.ts b/supabase/functions/_backend/utils/stripe.ts index b7d5264dc9..f16c9102fa 100644 --- a/supabase/functions/_backend/utils/stripe.ts +++ b/supabase/functions/_backend/utils/stripe.ts @@ -110,6 +110,35 @@ export function getStripe(c: Context): Stripe { }) } +function getLicensedSubscriptionItem(items: Stripe.SubscriptionItem[] | undefined) { + return items?.find(item => item.plan.usage_type === 'licensed') ?? items?.[0] ?? null +} + +function getSubscriptionProductId(c: Context, item: Stripe.SubscriptionItem | null) { + if (!item) + return null + + const price = item.price + if (typeof price === 'object' && price !== null && typeof price.product === 'string') + return price.product + + cloudlog({ requestId: c.get('requestId'), message: 'Price or product data missing/invalid type in subscription item', itemId: item.id }) + return null +} + +function stripeTimestampToIso(seconds: number | null | undefined) { + return seconds ? new Date(seconds * 1000).toISOString() : null +} + +function getSubscriptionEndDate(subscription: Stripe.Subscription, item: Stripe.SubscriptionItem | null) { + const endSeconds = subscription.ended_at + ?? subscription.cancel_at + ?? (subscription.cancel_at_period_end ? item?.current_period_end : null) + ?? null + + return stripeTimestampToIso(endSeconds) +} + export async function getSubscriptionData(c: Context, customerId: string, subscriptionId: string | null) { if (!subscriptionId) return null @@ -129,40 +158,18 @@ export async function getSubscriptionData(c: Context, customerId: string, subscr subscriptionStatus: subscription.status, }) - // // Get the subscription - Removed: already have the subscription object - // const subscription = subscriptions.data[0] - - // Extract product ID from the first subscription item - let productId = null - if (subscription.items.data.length > 0) { - const item = subscription.items.data[0] - // Ensure price and product are objects before accessing properties - if (typeof item.price === 'object' && item.price !== null && typeof item.price.product === 'string') { - productId = item.price.product - } - else { - cloudlog({ requestId: c.get('requestId'), message: 'Price or product data missing/invalid type in subscription item', itemId: item.id }) - } - } - - // subscription.billing_cycle_anchor - Not used, using current period from item - // Format dates from epoch to ISO string - // Access cycle dates from the first item - const firstItem = subscription.items.data.length > 0 ? subscription.items.data[0] : null - - const cycleStart = firstItem?.current_period_start - ? new Date(firstItem.current_period_start * 1000).toISOString() - : null - - const cycleEnd = firstItem?.current_period_end - ? new Date(firstItem.current_period_end * 1000).toISOString() - : null + const currentItem = getLicensedSubscriptionItem(subscription.items.data) + const productId = getSubscriptionProductId(c, currentItem) + const cycleStart = stripeTimestampToIso(currentItem?.current_period_start) + const cycleEnd = stripeTimestampToIso(currentItem?.current_period_end) + const canceledAt = getSubscriptionEndDate(subscription, currentItem) return { productId, status: subscription.status, cycleStart, cycleEnd, + canceledAt, subscriptionId: subscription.id, cancel_at_period_end: subscription.cancel_at_period_end, } @@ -276,6 +283,8 @@ export async function syncSubscriptionData(c: Context, customerId: string, subsc if (subscriptionData?.cycleEnd) { updateData.subscription_anchor_end = subscriptionData.cycleEnd } + if (subscriptionData) + updateData.canceled_at = subscriptionData.canceledAt ?? null const { error: updateError } = await supabaseAdmin(c) .from('stripe_info') diff --git a/supabase/functions/_backend/utils/stripe_event.ts b/supabase/functions/_backend/utils/stripe_event.ts index fcb3e7a18a..5825e3bb1a 100644 --- a/supabase/functions/_backend/utils/stripe_event.ts +++ b/supabase/functions/_backend/utils/stripe_event.ts @@ -29,6 +29,15 @@ function getSubscriptionInterval(item: Stripe.SubscriptionItem | undefined) { return undefined } +function getSubscriptionEndDate(subscription: Stripe.Subscription, item: Stripe.SubscriptionItem | null) { + const itemPeriodEnd = typeof item?.current_period_end === 'number' ? item.current_period_end : null + const endSeconds = subscription.ended_at + ?? subscription.cancel_at + ?? (subscription.cancel_at_period_end ? itemPeriodEnd : null) + + return endSeconds ? new Date(endSeconds * 1000).toISOString() : null +} + function subscriptionUpdated(c: Context, event: Stripe.CustomerSubscriptionCreatedEvent | Stripe.CustomerSubscriptionDeletedEvent | Stripe.CustomerSubscriptionUpdatedEvent, data: Database['public']['Tables']['stripe_info']['Insert']) { let isUpgrade = false const subscription = event.data.object @@ -50,13 +59,14 @@ function subscriptionUpdated(c: Context, event: Stripe.CustomerSubscriptionCreat // current_period_start is epoch and current_period_end is epoch // subscription_anchor_start is date and subscription_anchor_end is date // convert epoch to date - const firstItem = subscription.items.data.length > 0 ? subscription.items.data[0] : null - data.subscription_anchor_start = firstItem?.current_period_start - ? new Date(firstItem.current_period_start * 1000).toISOString() + const currentCycleItem = currentLicensedItem ?? null + data.subscription_anchor_start = currentCycleItem?.current_period_start + ? new Date(currentCycleItem.current_period_start * 1000).toISOString() : undefined - data.subscription_anchor_end = firstItem?.current_period_end - ? new Date(firstItem.current_period_end * 1000).toISOString() + data.subscription_anchor_end = currentCycleItem?.current_period_end + ? new Date(currentCycleItem.current_period_end * 1000).toISOString() : undefined + data.canceled_at = getSubscriptionEndDate(subscription, currentCycleItem) data.price_id = currentLicensedItem?.plan.id data.product_id = currentLicensedItem?.plan.product ? String(currentLicensedItem.plan.product) diff --git a/supabase/functions/_backend/utils/supabase.types.ts b/supabase/functions/_backend/utils/supabase.types.ts index 9aa3980ebb..34083ec86f 100644 --- a/supabase/functions/_backend/utils/supabase.types.ts +++ b/supabase/functions/_backend/utils/supabase.types.ts @@ -1262,7 +1262,9 @@ export type Database = { devices_last_month: number | null devices_last_month_android: number | null devices_last_month_ios: number | null + average_ltv: number live_updates_active_paying_clients_60d: number + longest_ltv: number mrr: number need_upgrade: number | null new_paying_orgs: number @@ -1298,6 +1300,7 @@ export type Database = { revenue_maker: number revenue_solo: number revenue_team: number + shortest_ltv: number stars: number success_rate: number | null total_revenue: number @@ -1343,7 +1346,9 @@ export type Database = { devices_last_month?: number | null devices_last_month_android?: number | null devices_last_month_ios?: number | null + average_ltv?: number live_updates_active_paying_clients_60d?: number + longest_ltv?: number mrr?: number need_upgrade?: number | null new_paying_orgs?: number @@ -1379,6 +1384,7 @@ export type Database = { revenue_maker?: number revenue_solo?: number revenue_team?: number + shortest_ltv?: number stars: number success_rate?: number | null total_revenue?: number @@ -1424,7 +1430,9 @@ export type Database = { devices_last_month?: number | null devices_last_month_android?: number | null devices_last_month_ios?: number | null + average_ltv?: number live_updates_active_paying_clients_60d?: number + longest_ltv?: number mrr?: number need_upgrade?: number | null new_paying_orgs?: number @@ -1460,6 +1468,7 @@ export type Database = { revenue_maker?: number revenue_solo?: number revenue_team?: number + shortest_ltv?: number stars?: number success_rate?: number | null total_revenue?: number diff --git a/supabase/migrations/20260511101826_add_ltv_global_stats.sql b/supabase/migrations/20260511101826_add_ltv_global_stats.sql new file mode 100644 index 0000000000..344fd6c05c --- /dev/null +++ b/supabase/migrations/20260511101826_add_ltv_global_stats.sql @@ -0,0 +1,11 @@ +ALTER TABLE public.global_stats +ADD COLUMN IF NOT EXISTS average_ltv double precision DEFAULT 0 NOT NULL, +ADD COLUMN IF NOT EXISTS shortest_ltv double precision DEFAULT 0 NOT NULL, +ADD COLUMN IF NOT EXISTS longest_ltv double precision DEFAULT 0 NOT NULL; + +COMMENT ON COLUMN public.global_stats.average_ltv IS +'Average estimated customer LTV in dollars for the daily snapshot.'; +COMMENT ON COLUMN public.global_stats.shortest_ltv IS +'Lowest estimated customer LTV in dollars for the daily snapshot.'; +COMMENT ON COLUMN public.global_stats.longest_ltv IS +'Highest estimated customer LTV in dollars for the daily snapshot.'; diff --git a/tests/backfill-ltv-metrics.unit.test.ts b/tests/backfill-ltv-metrics.unit.test.ts new file mode 100644 index 0000000000..23a2f84b12 --- /dev/null +++ b/tests/backfill-ltv-metrics.unit.test.ts @@ -0,0 +1,100 @@ +import type { LtvSourceRow } from '../scripts/backfill_ltv_metrics.ts' +import { describe, expect, it } from 'vitest' +import { buildLtvBackfillRows, calculateLtvMetrics, estimateCustomerLtv } from '../scripts/backfill_ltv_metrics.ts' + +function ltvRow(overrides: Partial): LtvSourceRow { + return { + canceled_at: null, + created_at: '2026-01-01T00:00:00.000Z', + customer_id: 'cus_default', + is_good_plan: true, + paid_at: '2026-01-01T00:00:00.000Z', + price_id: 'price_solo_monthly', + status: 'succeeded', + subscription_anchor_end: '2026-02-01T00:00:00.000Z', + subscription_anchor_start: '2026-01-01T00:00:00.000Z', + plans: { + name: 'Solo', + price_m: 12, + price_m_id: 'price_solo_monthly', + price_y: 120, + price_y_id: 'price_solo_yearly', + }, + ...overrides, + } +} + +describe('estimated LTV metric backfill helpers', () => { + it.concurrent('estimates monthly LTV by started billing periods', () => { + const value = estimateCustomerLtv(ltvRow({}), new Date('2026-02-15T00:00:00.000Z')) + + expect(value).toBe(24) + }) + + it.concurrent('counts a yearly subscription as one full yearly payment at start', () => { + const value = estimateCustomerLtv(ltvRow({ + price_id: 'price_solo_yearly', + paid_at: '2026-01-10T00:00:00.000Z', + }), new Date('2026-01-11T00:00:00.000Z')) + + expect(value).toBe(120) + }) + + it.concurrent('stops LTV at the stored canceled_at date', () => { + const value = estimateCustomerLtv(ltvRow({ + paid_at: '2026-01-01T00:00:00.000Z', + status: 'canceled', + canceled_at: '2026-01-20T00:00:00.000Z', + }), new Date('2026-04-01T00:00:00.000Z')) + + expect(value).toBe(12) + }) + + it.concurrent('ignores good-plan rows without a paid timestamp', () => { + const value = estimateCustomerLtv(ltvRow({ + paid_at: null, + }), new Date('2026-02-15T00:00:00.000Z')) + + expect(value).toBeNull() + }) + + it.concurrent('builds average, shortest, and longest LTV metrics', () => { + const metrics = calculateLtvMetrics([ + ltvRow({ + customer_id: 'cus_monthly', + paid_at: '2026-01-01T00:00:00.000Z', + }), + ltvRow({ + customer_id: 'cus_yearly', + paid_at: '2026-01-10T00:00:00.000Z', + price_id: 'price_solo_yearly', + }), + ], '2026-02-15') + + expect(metrics).toEqual({ + average_ltv: 72, + shortest_ltv: 24, + longest_ltv: 120, + }) + }) + + it.concurrent('marks rows changed when stored metrics differ', () => { + const rows = buildLtvBackfillRows([ + { + average_ltv: 0, + date_id: '2026-02-15', + longest_ltv: 0, + shortest_ltv: 0, + }, + ], [ + ltvRow({}), + ]) + + expect(rows[0]).toMatchObject({ + changed: true, + average_ltv: 24, + shortest_ltv: 24, + longest_ltv: 24, + }) + }) +}) diff --git a/tests/backfill-stripe-subscription-end-dates.unit.test.ts b/tests/backfill-stripe-subscription-end-dates.unit.test.ts new file mode 100644 index 0000000000..24cef6a047 --- /dev/null +++ b/tests/backfill-stripe-subscription-end-dates.unit.test.ts @@ -0,0 +1,52 @@ +import type Stripe from 'stripe' +import { describe, expect, it } from 'vitest' +import { getStripeSubscriptionEndSnapshot } from '../scripts/backfill_stripe_subscription_end_dates.ts' + +function subscription(overrides: Partial): Stripe.Subscription { + return { + id: 'sub_test', + object: 'subscription', + cancel_at: null, + cancel_at_period_end: false, + customer: 'cus_test', + ended_at: null, + items: { + data: [ + { + current_period_end: 1_777_766_400, + current_period_start: 1_775_174_400, + plan: { + usage_type: 'licensed', + }, + } as Stripe.SubscriptionItem, + ], + }, + ...overrides, + } as Stripe.Subscription +} + +describe('stripe subscription end-date backfill helpers', () => { + it.concurrent('uses Stripe ended_at as the final subscription end date', () => { + const snapshot = getStripeSubscriptionEndSnapshot(subscription({ + ended_at: 1_777_507_200, + })) + + expect(snapshot.canceled_at).toBe('2026-04-30T00:00:00.000Z') + }) + + it.concurrent('uses current period end for cancel-at-period-end subscriptions', () => { + const snapshot = getStripeSubscriptionEndSnapshot(subscription({ + cancel_at_period_end: true, + })) + + expect(snapshot.canceled_at).toBe('2026-05-03T00:00:00.000Z') + expect(snapshot.subscription_anchor_start).toBe('2026-04-03T00:00:00.000Z') + expect(snapshot.subscription_anchor_end).toBe('2026-05-03T00:00:00.000Z') + }) + + it.concurrent('clears subscription end date for active subscriptions without a scheduled cancellation', () => { + const snapshot = getStripeSubscriptionEndSnapshot(subscription({})) + + expect(snapshot.canceled_at).toBeNull() + }) +}) diff --git a/tests/stripe-subscription-events.unit.test.ts b/tests/stripe-subscription-events.unit.test.ts index b1237e748a..1a12cab9b5 100644 --- a/tests/stripe-subscription-events.unit.test.ts +++ b/tests/stripe-subscription-events.unit.test.ts @@ -7,22 +7,26 @@ const mockContext = { } as any function makeSubscriptionItem({ + currentPeriodEnd = 1_714_517_200, interval, priceId, productId, + usageType = 'licensed', }: { + currentPeriodEnd?: number interval: 'month' | 'year' priceId: string productId: string + usageType?: 'licensed' | 'metered' }) { return { - current_period_end: 1_714_517_200, + current_period_end: currentPeriodEnd, current_period_start: 1_711_925_200, plan: { id: priceId, interval, product: productId, - usage_type: 'licensed', + usage_type: usageType, }, } as any } @@ -104,6 +108,40 @@ describe('stripe subscription event classification', () => { }) }) + it.concurrent('uses the licensed subscription item for cancel-at-period-end timestamps', () => { + const stripeData = extractDataEvent(mockContext, { + data: { + object: { + cancel_at_period_end: true, + customer: 'cus_multi_item', + id: 'sub_multi_item', + items: { + data: [ + makeSubscriptionItem({ + currentPeriodEnd: 1_720_000_000, + interval: 'month', + priceId: 'price_metered', + productId: 'prod_metered', + usageType: 'metered', + }), + makeSubscriptionItem({ + currentPeriodEnd: 1_714_517_200, + interval: 'month', + priceId: 'price_licensed', + productId: 'prod_licensed', + }), + ], + }, + }, + }, + type: 'customer.subscription.updated', + } as any) + + expect(stripeData.data.canceled_at).toBe('2024-04-30T22:46:40.000Z') + expect(stripeData.data.product_id).toBe('prod_licensed') + expect(stripeData.data.price_id).toBe('price_licensed') + }) + it.concurrent('keeps same-cadence plan switches as plan changes instead of upgrades', () => { const stripeData = extractDataEvent(mockContext, { data: { From 8ff2ff1da788e61a8adb5553584694ece75f3819 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Mon, 11 May 2026 16:48:58 +0200 Subject: [PATCH 2/2] test: isolate chart refresh RPC auth fixture --- tests/chart-refresh-rpc.test.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/tests/chart-refresh-rpc.test.ts b/tests/chart-refresh-rpc.test.ts index 723239a077..4a01dc0c78 100644 --- a/tests/chart-refresh-rpc.test.ts +++ b/tests/chart-refresh-rpc.test.ts @@ -10,8 +10,10 @@ import { SUPABASE_ANON_KEY, SUPABASE_BASE_URL, USER_EMAIL, + USER_EMAIL_NONMEMBER, USER_ID, USER_PASSWORD, + USER_PASSWORD_NONMEMBER, } from './test-utils.ts' const orgId = randomUUID() @@ -59,14 +61,17 @@ describe('chart refresh RPCs', () => { const unauthorizedClient = createAuthClient() beforeAll(async () => { - await authorizedClient.auth.signInWithPassword({ + const { error: authorizedSignInError } = await authorizedClient.auth.signInWithPassword({ email: USER_EMAIL, password: USER_PASSWORD, }) - await unauthorizedClient.auth.signInWithPassword({ - email: 'test2@capgo.app', - password: USER_PASSWORD, + expect(authorizedSignInError).toBeNull() + + const { error: unauthorizedSignInError } = await unauthorizedClient.auth.signInWithPassword({ + email: USER_EMAIL_NONMEMBER, + password: USER_PASSWORD_NONMEMBER, }) + expect(unauthorizedSignInError).toBeNull() await getSupabaseClient().from('orgs').insert({ created_by: USER_ID,