diff --git a/cli/src/types/supabase.types.ts b/cli/src/types/supabase.types.ts index a05c6eb07f..cf1785bbd5 100644 --- a/cli/src/types/supabase.types.ts +++ b/cli/src/types/supabase.types.ts @@ -1609,6 +1609,7 @@ export type Database = { updates: number updates_external: number | null updates_last_month: number | null + upgrade_rate_12m: number upgraded_orgs: number trial_extended_orgs: number trial_extended_subscribed_orgs: number @@ -1700,6 +1701,7 @@ export type Database = { updates: number updates_external?: number | null updates_last_month?: number | null + upgrade_rate_12m?: number upgraded_orgs?: number trial_extended_orgs?: number trial_extended_subscribed_orgs?: number @@ -1791,6 +1793,7 @@ export type Database = { updates?: number updates_external?: number | null updates_last_month?: number | null + upgrade_rate_12m?: number upgraded_orgs?: number trial_extended_orgs?: number trial_extended_subscribed_orgs?: number diff --git a/messages/en.json b/messages/en.json index 815396fee0..f0b29e58b2 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1840,7 +1840,7 @@ "password-policy-updated": "Password policy updated successfully", "password-update-org-access": "Update your password to access this organization. Your current password doesn't meet the security requirements.", "password-verified-successfully": "Password verified successfully", - "password-verify-it": "just verify it to regain access.", + "auth-verify-access-hint": "Just verify it to regain access.", "patch": "Patch", "paying-client-product-activity-trend": "Paying Client Product Activity (60d Active)", "paying-orgs-trend": "Total Paying Organizations", @@ -2347,6 +2347,8 @@ "updated-default-upload-channel": "Updated the default upload channel", "updated-devices": "Updated devices", "updated-min-version": "Updated minimal version", + "upgrade-rate-12m": "12-Month Upgrade Rate", + "upgrade-rate-12m-description": "Share of organizations with a plan upgrade in the trailing 12 months", "upgraded-organizations": "Upgraded Organizations", "upgraded-organizations-latest-day": "Plan upgrades in the latest completed day", "updates": "Updates", diff --git a/package.json b/package.json index 283dddae39..a5503f9cde 100644 --- a/package.json +++ b/package.json @@ -104,6 +104,7 @@ "admin:backfill-missing-store-urls": "bun scripts/backfill_missing_store_urls.ts", "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-upgrade-rate-12m": "bun scripts/backfill_upgrade_rate_12m.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", diff --git a/scripts/backfill_upgrade_rate_12m.ts b/scripts/backfill_upgrade_rate_12m.ts new file mode 100644 index 0000000000..12d7cd245d --- /dev/null +++ b/scripts/backfill_upgrade_rate_12m.ts @@ -0,0 +1,343 @@ +/* + * Backfill public.global_stats.upgrade_rate_12m. + * + * For each snapshot day, compute: + * orgs with stripe_info.upgraded_at in [dayEnd-12 calendar months, dayEnd) + * / orgs with created_at < dayEnd + * * 100 + * + * Limitation: stripe_info.upgraded_at stores only the latest upgrade timestamp, + * so historical rows use that last-known upgrade (same contract as the daily + * revenue shard). Orgs that upgraded earlier in-window but again later may be + * undercounted on older snapshots. + * + * Dry run, defaulting to the last 30 UTC calendar days: + * bun run stripe:backfill-upgrade-rate-12m + * + * Apply a date range: + * bun run stripe:backfill-upgrade-rate-12m --apply --from=2024-01-01 --to=2026-07-21 + * + * Apply every stored global_stats row: + * bun run stripe:backfill-upgrade-rate-12m --apply --all + */ +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_LOOKBACK_DAYS = 30 +const DEFAULT_CONCURRENCY = 10 +const DEFAULT_PAGE_SIZE = 1000 +const DATE_ID_REGEX = /^\d{4}-\d{2}-\d{2}$/ + +type SupabaseClient = ReturnType +type GlobalStatsRow = Pick +type OrgRow = Pick +type StripeInfoRow = Pick + +export interface UpgradeRate12mBackfillRow { + changed: boolean + current_rate: number + date_id: string + next_rate: number + orgs: number + upgraded_orgs_12m: number +} + +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()) || getDateId(parsed) !== value) + throw new Error(`${label} must be a valid UTC calendar 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 getNextDateId(dateId: string) { + const date = new Date(`${dateId}T00:00:00.000Z`) + date.setUTCDate(date.getUTCDate() + 1) + return getDateId(date) +} + +function getTrailing12mStartMs(endExclusive: Date) { + const year = endExclusive.getUTCFullYear() - 1 + const month = endExclusive.getUTCMonth() + const day = endExclusive.getUTCDate() + const clampedDay = Math.min(day, new Date(Date.UTC(year, month + 1, 0)).getUTCDate()) + return Date.UTC(year, month, clampedDay) +} + +function toMetricNumber(value: number | string | null | undefined) { + const numberValue = Number(value ?? 0) + return Number.isFinite(numberValue) ? numberValue : 0 +} + +export function calculateUpgradeRate12m(upgradedOrgs: number | string | null | undefined, orgs: number | string | null | undefined) { + const upgradedCount = toMetricNumber(upgradedOrgs) + const orgCount = toMetricNumber(orgs) + if (orgCount <= 0) + return 0 + return Number(((upgradedCount * 100) / orgCount).toFixed(1)) +} + +function hasRateChanged(currentRate: number, nextRate: number) { + return Math.abs(currentRate - nextRate) > 0.0001 +} + +function buildOrgCreatedAtTimes(orgRows: OrgRow[]) { + return orgRows + .map(row => row.created_at ? Date.parse(row.created_at) : Number.NaN) + .filter(Number.isFinite) + .sort((left, right) => left - right) +} + +function buildUpgradeEvents(orgRows: OrgRow[], stripeRows: StripeInfoRow[]) { + const orgIdsByCustomerId = new Map() + for (const org of orgRows) { + if (!org.customer_id) + continue + const existing = orgIdsByCustomerId.get(org.customer_id) ?? [] + existing.push(org.id) + orgIdsByCustomerId.set(org.customer_id, existing) + } + + const events: Array<{ orgId: string, upgradedAt: number }> = [] + for (const row of stripeRows) { + if (!row.customer_id || !row.upgraded_at) + continue + const upgradedAt = Date.parse(row.upgraded_at) + if (!Number.isFinite(upgradedAt)) + continue + const orgIds = orgIdsByCustomerId.get(row.customer_id) + if (!orgIds?.length) + continue + for (const orgId of orgIds) + events.push({ orgId, upgradedAt }) + } + + return events.sort((left, right) => left.upgradedAt - right.upgradedAt) +} + +export function buildUpgradeRate12mBackfillRows( + rows: GlobalStatsRow[], + orgRows: OrgRow[], + stripeRows: StripeInfoRow[], +): UpgradeRate12mBackfillRow[] { + const orgCreatedAtTimes = buildOrgCreatedAtTimes(orgRows) + const upgradeEvents = buildUpgradeEvents(orgRows, stripeRows) + let orgIndex = 0 + + return [...rows] + .sort((left, right) => left.date_id.localeCompare(right.date_id)) + .map((row) => { + const endExclusiveDate = new Date(`${getNextDateId(row.date_id)}T00:00:00.000Z`) + const endExclusive = endExclusiveDate.getTime() + const startInclusive = getTrailing12mStartMs(endExclusiveDate) + + while (orgIndex < orgCreatedAtTimes.length && orgCreatedAtTimes[orgIndex]! < endExclusive) + orgIndex++ + + const upgradedOrgIds = new Set() + for (const event of upgradeEvents) { + if (event.upgradedAt < startInclusive) + continue + if (event.upgradedAt >= endExclusive) + break + upgradedOrgIds.add(event.orgId) + } + + const orgs = orgIndex + const upgraded_orgs_12m = upgradedOrgIds.size + const current_rate = toMetricNumber(row.upgrade_rate_12m) + const next_rate = calculateUpgradeRate12m(upgraded_orgs_12m, orgs) + + return { + date_id: row.date_id, + orgs, + upgraded_orgs_12m, + current_rate, + next_rate, + changed: row.upgrade_rate_12m == null || hasRateChanged(current_rate, next_rate), + } + }) +} + +async function fetchGlobalStatsRows(supabase: SupabaseClient, fromDateId: string | null, toDateId: string | null) { + const rows: GlobalStatsRow[] = [] + let offset = 0 + + while (true) { + let query = supabase + .from('global_stats') + .select('date_id, upgrade_rate_12m') + .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 fetchOrgRows(supabase: SupabaseClient, toDateId: string | null) { + const rows: OrgRow[] = [] + let lastId: string | null = null + + while (true) { + let query = supabase + .from('orgs') + .select('id, created_at, customer_id') + .order('id', { ascending: true }) + .limit(DEFAULT_PAGE_SIZE) + + if (toDateId) + query = query.lt('created_at', `${getNextDateId(toDateId)}T00:00:00.000Z`) + if (lastId) + query = query.gt('id', lastId) + + const { data, error } = await query + if (error) + throw error + if (!data?.length) + break + + rows.push(...data) + lastId = data[data.length - 1]!.id + if (data.length < DEFAULT_PAGE_SIZE) + break + } + + return rows +} + +async function fetchStripeInfoRows(supabase: SupabaseClient) { + const rows: StripeInfoRow[] = [] + let lastCustomerId: string | null = null + + while (true) { + let query = supabase + .from('stripe_info') + .select('customer_id, upgraded_at') + .not('upgraded_at', 'is', null) + .order('customer_id', { ascending: true }) + .limit(DEFAULT_PAGE_SIZE) + + if (lastCustomerId) + query = query.gt('customer_id', lastCustomerId) + + const { data, error } = await query + if (error) + throw error + if (!data?.length) + break + + rows.push(...data) + lastCustomerId = data[data.length - 1]!.customer_id + if (data.length < DEFAULT_PAGE_SIZE) + break + } + + return rows +} + +async function updateUpgradeRate(supabase: SupabaseClient, row: UpgradeRate12mBackfillRow) { + const { error } = await supabase + .from('global_stats') + .update({ upgrade_rate_12m: row.next_rate }) + .eq('date_id', row.date_id) + + if (error) + throw error +} + +async function main(args = process.argv.slice(2), runtimeEnv: Record = process.env) { + const apply = args.includes('--apply') + const all = args.includes('--all') + const envFile = getArgValue(args, '--env-file') ?? DEFAULT_ENV_FILE + const concurrency = parsePositiveInteger(getArgValue(args, '--concurrency'), '--concurrency', DEFAULT_CONCURRENCY) + const fromDateId = all + ? null + : assertDateId(getArgValue(args, '--from') ?? getDefaultFromDateId(), '--from') + const toDateId = all + ? null + : assertDateId(getArgValue(args, '--to') ?? getDateId(), '--to') + + if (fromDateId && toDateId && fromDateId > toDateId) + throw new Error('--from must be before or equal to --to') + + const fileEnv = await loadEnv(envFile) + const env = { + ...fileEnv, + ...runtimeEnv, + } + const supabase = createSupabaseServiceClient(env) + + const rows = await fetchGlobalStatsRows(supabase, fromDateId, toDateId) + const orgRows = await fetchOrgRows(supabase, toDateId) + const stripeRows = await fetchStripeInfoRows(supabase) + const backfillRows = buildUpgradeRate12mBackfillRows(rows, orgRows, stripeRows) + const changedRows = backfillRows.filter(row => row.changed) + + console.log(`Loaded ${rows.length} global_stats rows`) + console.log(`Loaded ${orgRows.length} org rows`) + console.log(`Loaded ${stripeRows.length} stripe_info rows with upgraded_at`) + console.log(`Env file: ${envFile}`) + if (all) + console.log('Scope: all global_stats rows') + else + console.log(`Scope: ${fromDateId} to ${toDateId}`) + console.log(`Rows needing update: ${changedRows.length}`) + + const sampleRows = changedRows.slice(0, 10) + if (sampleRows.length > 0) { + console.log('Sample updates:') + for (const row of sampleRows) + console.log(`${row.date_id}: ${row.current_rate}% -> ${row.next_rate}% (${row.upgraded_orgs_12m}/${row.orgs})`) + } + + if (!apply) { + console.log('Dry run only. Pass --apply to update global_stats.') + return + } + + if (changedRows.length === 0) { + console.log('Nothing to update.') + return + } + + let updated = 0 + await asyncPool(concurrency, changedRows, async (row) => { + await updateUpgradeRate(supabase, row) + updated++ + if (updated % 100 === 0 || updated === changedRows.length) + console.log(`Updated ${updated}/${changedRows.length}`) + }) + + console.log(`Done. Updated ${updated}/${changedRows.length} upgrade_rate_12m rows.`) +} + +if (import.meta.main) + await main() diff --git a/src/pages/admin/dashboard/revenue.vue b/src/pages/admin/dashboard/revenue.vue index 8ffcc7b165..e2f7b99512 100644 --- a/src/pages/admin/dashboard/revenue.vue +++ b/src/pages/admin/dashboard/revenue.vue @@ -60,6 +60,7 @@ const globalStatsTrendData = ref { ] }) +const upgradeRate12mSeries = computed(() => { + if (globalStatsTrendData.value.length === 0) + return [] + + return [ + { + label: t('upgrade-rate-12m'), + data: globalStatsTrendData.value.map(item => ({ + date: item.date, + value: item.upgrade_rate_12m || 0, + })), + color: '#10b981', // green + }, + ] +}) + const mrrSeries = computed(() => { if (globalStatsTrendData.value.length === 0) return [] @@ -893,6 +910,29 @@ displayStore.defaultBack = '/dashboard' + +
+
+
+ +
+
+
+

+ {{ t('upgrade-rate-12m') }} +

+

+ {{ formatNumberValue(latestGlobalStats.upgrade_rate_12m || 0, { minimumFractionDigits: 1, maximumFractionDigits: 1 }) }}% +

+

+ 0.0% +

+

+ {{ t('upgrade-rate-12m-description') }} +

+
+
+
@@ -1076,6 +1116,20 @@ displayStore.defaultBack = '/dashboard'
+
+ + + +
+
diff --git a/src/pages/settings/account/ChangePassword.vue b/src/pages/settings/account/ChangePassword.vue index e6ec45d591..6b55561d02 100644 --- a/src/pages/settings/account/ChangePassword.vue +++ b/src/pages/settings/account/ChangePassword.vue @@ -345,7 +345,7 @@ async function submit(form: { current_password?: string, password: string, passw

- {{ t('password-if-meets') }} {{ t('password-verify-it') }} + {{ t('password-if-meets') }} {{ t('auth-verify-access-hint') }}

{{ t('password-if-not-meets') }} {{ t('password-change-then-verify') }} diff --git a/src/types/supabase.types.ts b/src/types/supabase.types.ts index 3249cd7c4c..ad980e766a 100644 --- a/src/types/supabase.types.ts +++ b/src/types/supabase.types.ts @@ -1620,6 +1620,7 @@ export type Database = { updates: number updates_external: number | null updates_last_month: number | null + upgrade_rate_12m: number upgraded_orgs: number users: number | null users_active: number | null @@ -1717,6 +1718,7 @@ export type Database = { updates: number updates_external?: number | null updates_last_month?: number | null + upgrade_rate_12m?: number upgraded_orgs?: number users?: number | null users_active?: number | null @@ -1814,6 +1816,7 @@ export type Database = { updates?: number updates_external?: number | null updates_last_month?: number | null + upgrade_rate_12m?: number upgraded_orgs?: number users?: number | null users_active?: number | null diff --git a/supabase/functions/_backend/triggers/logsnag_insights.ts b/supabase/functions/_backend/triggers/logsnag_insights.ts index 776e9e42e3..c2a7964270 100644 --- a/supabase/functions/_backend/triggers/logsnag_insights.ts +++ b/supabase/functions/_backend/triggers/logsnag_insights.ts @@ -2778,6 +2778,57 @@ async function runUsageDemoAppsGlobalStatsShard(c: Context, window: DailyWindow) await updateUsageGlobalStatsSnapshot(c, window, 'Updated global stats usage demo apps shard', { demo_apps_created: demoAppsCreated }, { demoAppsCreated }) } + +function getTrailing12mStart(nextDayStart: Date): Date { + const year = nextDayStart.getUTCFullYear() - 1 + const month = nextDayStart.getUTCMonth() + const day = nextDayStart.getUTCDate() + const clampedDay = Math.min(day, new Date(Date.UTC(year, month + 1, 0)).getUTCDate()) + return new Date(Date.UTC( + year, + month, + clampedDay, + nextDayStart.getUTCHours(), + nextDayStart.getUTCMinutes(), + nextDayStart.getUTCSeconds(), + nextDayStart.getUTCMilliseconds(), + )) +} + +async function getUpgradeRate12m(c: Context, nextDayStart: Date): Promise { + const pgClient = getPgClient(c, false) + const drizzleClient = getDrizzleClient(pgClient) + const snapshotEndIso = nextDayStart.toISOString() + const trailing12mStartIso = getTrailing12mStart(nextDayStart).toISOString() + + try { + const result = await drizzleClient.execute(sql` + SELECT + ( + SELECT COUNT(*)::int + FROM public.orgs + WHERE created_at < ${snapshotEndIso}::timestamptz + ) AS orgs, + ( + SELECT COUNT(DISTINCT o.id)::int + FROM public.orgs o + INNER JOIN public.stripe_info si ON si.customer_id = o.customer_id + WHERE si.upgraded_at >= ${trailing12mStartIso}::timestamptz + AND si.upgraded_at < ${snapshotEndIso}::timestamptz + ) AS upgraded_orgs_12m + `) + const row = result.rows[0] as { orgs?: number | string | null, upgraded_orgs_12m?: number | string | null } | undefined + return calculateConversionRate(Number(row?.upgraded_orgs_12m) || 0, Number(row?.orgs) || 0) + } + catch (error) { + cloudlogErr({ requestId: c.get('requestId'), message: 'getUpgradeRate12m error', error }) + throw error + } + finally { + closeClient(c, pgClient) + } +} + async function runRevenueGlobalStatsShard(c: Context, window: DailyWindow): Promise { const supabase = supabaseAdmin(c) const metricWindow = getMetricWindowFromDailyWindow(window) @@ -2791,6 +2842,7 @@ async function runRevenueGlobalStatsShard(c: Context, window: DailyWindow): Prom new_paying_orgs, canceled_orgs, upgraded_orgs, + upgrade_rate_12m, trialExtensionStats, pastDueOrgStats, subscriptionAccessCounts, @@ -2850,6 +2902,7 @@ async function runRevenueGlobalStatsShard(c: Context, window: DailyWindow): Prom } return new Set((res.data || []).map(row => row.customer_id)).size }), + getUpgradeRate12m(c, nextDayStart), getTrialExtensionStats(c, metricWindow), refreshPastDueStats ? (async () => { @@ -2917,6 +2970,7 @@ async function runRevenueGlobalStatsShard(c: Context, window: DailyWindow): Prom trial_extended_orgs: trialExtensionStats.trial_extended_orgs, trial_extended_subscribed_orgs: trialExtensionStats.trial_extended_subscribed_orgs, total_revenue: revenue.total_revenue, + upgrade_rate_12m, upgraded_orgs, } @@ -3250,7 +3304,10 @@ export const logsnagInsightsTestUtils = { normalizeSubscriptionAccessSnapshotCounts, shouldRefreshMutablePastDueStats, calculateChurnRevenue, + calculateConversionRate, calculateNrr, + getUpgradeRate12m, + getTrailing12mStart, countUniqueCustomers, getCompletedDayWindowForDateId, getMetricWindowFromDailyWindow, diff --git a/supabase/functions/_backend/utils/pg.ts b/supabase/functions/_backend/utils/pg.ts index 2432ccdd33..36e952a603 100644 --- a/supabase/functions/_backend/utils/pg.ts +++ b/supabase/functions/_backend/utils/pg.ts @@ -1568,6 +1568,7 @@ export interface AdminGlobalStatsTrend { new_paying_orgs: number canceled_orgs: number upgraded_orgs: number + upgrade_rate_12m: number trial_extended_orgs: number trial_extended_subscribed_orgs: number past_due_orgs: number @@ -1697,6 +1698,7 @@ export async function getAdminGlobalStatsTrend( COALESCE(NULLIF(to_jsonb(gs) ->> 'active_past_due_orgs', '')::int, 0)::int AS active_past_due_orgs, gs.canceled_orgs::int AS canceled_orgs, COALESCE(gs.upgraded_orgs, 0)::int AS upgraded_orgs, + COALESCE(NULLIF(to_jsonb(gs) ->> 'upgrade_rate_12m', '')::float, 0)::float AS upgrade_rate_12m, COALESCE(NULLIF(to_jsonb(gs) ->> 'trial_extended_orgs', '')::int, 0)::int AS trial_extended_orgs, COALESCE(NULLIF(to_jsonb(gs) ->> 'trial_extended_subscribed_orgs', '')::int, 0)::int AS trial_extended_subscribed_orgs, gs.mrr::float AS mrr, @@ -1851,6 +1853,7 @@ export async function getAdminGlobalStatsTrend( new_paying_orgs: Number(row.new_paying_orgs) || 0, canceled_orgs: Number(row.canceled_orgs) || 0, upgraded_orgs: Number(row.upgraded_orgs) || 0, + upgrade_rate_12m: Number(row.upgrade_rate_12m) || 0, trial_extended_orgs: Number(row.trial_extended_orgs) || 0, trial_extended_subscribed_orgs: Number(row.trial_extended_subscribed_orgs) || 0, mrr: Number(row.mrr) || 0, diff --git a/supabase/functions/_backend/utils/supabase.types.ts b/supabase/functions/_backend/utils/supabase.types.ts index 3249cd7c4c..ad980e766a 100644 --- a/supabase/functions/_backend/utils/supabase.types.ts +++ b/supabase/functions/_backend/utils/supabase.types.ts @@ -1620,6 +1620,7 @@ export type Database = { updates: number updates_external: number | null updates_last_month: number | null + upgrade_rate_12m: number upgraded_orgs: number users: number | null users_active: number | null @@ -1717,6 +1718,7 @@ export type Database = { updates: number updates_external?: number | null updates_last_month?: number | null + upgrade_rate_12m?: number upgraded_orgs?: number users?: number | null users_active?: number | null @@ -1814,6 +1816,7 @@ export type Database = { updates?: number updates_external?: number | null updates_last_month?: number | null + upgrade_rate_12m?: number upgraded_orgs?: number users?: number | null users_active?: number | null diff --git a/supabase/migrations/20260721110632_admin_upgrade_rate_12m.sql b/supabase/migrations/20260721110632_admin_upgrade_rate_12m.sql new file mode 100644 index 0000000000..b6e752c3d6 --- /dev/null +++ b/supabase/migrations/20260721110632_admin_upgrade_rate_12m.sql @@ -0,0 +1,8 @@ +ALTER TABLE public.global_stats + ADD COLUMN IF NOT EXISTS upgrade_rate_12m double precision NOT NULL DEFAULT 0; + +COMMENT ON COLUMN public.global_stats.upgrade_rate_12m + IS 'Percentage of organizations whose last stripe_info.upgraded_at ' + 'falls within the trailing 12 calendar months ending at the UTC ' + 'snapshot day end (orgs with last stripe_info.upgraded_at in-window / ' + 'orgs created by day end * 100).'; diff --git a/tests/backfill-upgrade-rate-12m.unit.test.ts b/tests/backfill-upgrade-rate-12m.unit.test.ts new file mode 100644 index 0000000000..80be7c7e96 --- /dev/null +++ b/tests/backfill-upgrade-rate-12m.unit.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest' +import { buildUpgradeRate12mBackfillRows, calculateUpgradeRate12m } from '../scripts/backfill_upgrade_rate_12m.ts' + +describe('upgrade_rate_12m backfill helpers', () => { + it.concurrent('returns zero when there are no orgs', () => { + expect(calculateUpgradeRate12m(3, 0)).toBe(0) + }) + + it.concurrent('rounds to one decimal place', () => { + expect(calculateUpgradeRate12m(1, 3)).toBe(33.3) + }) + + it.concurrent('counts distinct upgraded orgs in the trailing 12 months', () => { + const rows = buildUpgradeRate12mBackfillRows( + [ + { date_id: '2026-07-20', upgrade_rate_12m: 0 }, + { date_id: '2025-07-20', upgrade_rate_12m: 0 }, + ], + [ + { id: 'org-1', created_at: '2024-01-01T00:00:00.000Z', customer_id: 'cus_1' }, + { id: 'org-2', created_at: '2024-06-01T00:00:00.000Z', customer_id: 'cus_2' }, + { id: 'org-3', created_at: '2026-07-21T00:00:00.000Z', customer_id: 'cus_3' }, + ], + [ + { customer_id: 'cus_1', upgraded_at: '2026-01-15T12:00:00.000Z' }, + { customer_id: 'cus_2', upgraded_at: '2024-08-01T00:00:00.000Z' }, + ], + ) + + expect(rows).toEqual([ + { + date_id: '2025-07-20', + orgs: 2, + upgraded_orgs_12m: 1, + current_rate: 0, + next_rate: 50, + changed: true, + }, + { + date_id: '2026-07-20', + orgs: 2, + upgraded_orgs_12m: 1, + current_rate: 0, + next_rate: 50, + changed: true, + }, + ]) + }) + + it.concurrent('keeps Feb 28 when the snapshot end is Feb 29 in a leap year', () => { + const rows = buildUpgradeRate12mBackfillRows( + [{ date_id: '2024-02-28', upgrade_rate_12m: 0 }], + [ + { id: 'org-1', created_at: '2022-01-01T00:00:00.000Z', customer_id: 'cus_1' }, + ], + [ + { customer_id: 'cus_1', upgraded_at: '2023-02-28T12:00:00.000Z' }, + ], + ) + + expect(rows[0]?.upgraded_orgs_12m).toBe(1) + expect(rows[0]?.next_rate).toBe(100) + }) +}) diff --git a/tests/logsnag-insights-revenue.unit.test.ts b/tests/logsnag-insights-revenue.unit.test.ts index 171db3e043..7e4681d4c0 100644 --- a/tests/logsnag-insights-revenue.unit.test.ts +++ b/tests/logsnag-insights-revenue.unit.test.ts @@ -51,6 +51,12 @@ describe('logsnag revenue metric helpers', () => { )).toBe(1) }) + + it.concurrent('clamps trailing 12-month start for leap-day ends', () => { + const start = logsnagInsightsTestUtils.getTrailing12mStart(new Date('2024-02-29T00:00:00.000Z')) + expect(start.toISOString()).toBe('2023-02-28T00:00:00.000Z') + }) + it.concurrent('builds UTC calendar-day bounds', () => { const { dayStart, nextDayStart, dayDateId } = logsnagInsightsTestUtils.getCurrentDayWindow(new Date('2026-03-24T18:45:12.000Z'))