diff --git a/docs/admin/plans-checkout-completion.md b/docs/admin/plans-checkout-completion.md index 2e6f87b78d..b4b6e34e54 100644 --- a/docs/admin/plans-checkout-completion.md +++ b/docs/admin/plans-checkout-completion.md @@ -1,9 +1,50 @@ # Plans Checkout Completion Analytics -The current Plans analytics page measures checkout intent only. Completion must remain deferred until Capgo emits a reliable server-side `Checkout Completed` event. +The Plans analytics page counts checkout completion from server-side billing evidence already used for historical billing classification. -The future event must contain `org_id`, a stable `checkout_attempt_id`, Stripe checkout session ID, product ID, recurrence, and completion timestamp. `Checkout Started` must carry the same `checkout_attempt_id` into Stripe metadata so completion is joined directly rather than inferred from a redirect. +## Attribution -The future full-width daily stacked chart uses the attributed Plans-opening UTC day. Each organization that started checkout that day appears once as Completed or Not completed. Recent attempts remain pending until the agreed observation window has elapsed; they must not be labeled abandoned prematurely. +Completion uses the same checkout cohort as **Checkout intent**: -Implementation requires a separate approved design for the observation window, late completions, retries, plan changes, and existing subscribers. +- A Plans opening is a `User visit` with `page = plans`. +- A checkout start is attributed to the latest preceding same-org opening within 24 hours (`CHECKOUT_ATTRIBUTION_MS`). +- Each organization is bucketed on the **attributed Plans-opening UTC day**, not the checkout or completion day. +- Same-day duplicate checkout starts dedupe to the earliest attributed checkout for that org/day. + +## Completion signal + +An attributed checkout is **completed** when the organization's billing timeline shows a `paid` transition strictly after the checkout timestamp and on or before the observation deadline. + +That timeline merges: + +- PostHog billing transitions (`User subscribe`, trusted `User update subscribe`, and related group updates) emitted from Stripe webhooks +- Postgres billing facts already loaded for Plans analytics (`stripe_info.paid_at`, revenue movements, and related history) + +This captures new subscriptions and plan upgrades without requiring a dedicated `Checkout Completed` PostHog event or Stripe `checkout_attempt_id` metadata. + +## Observation window + +The observation window is 24 hours after checkout start (`CHECKOUT_ATTRIBUTION_MS`, shared with checkout attribution). + +Until that deadline passes, an attributed checkout without a qualifying paid transition remains **pending** instead of **not completed**. + +## Billing evidence window + +Plans analytics loads billing transitions through the later of: + +- the selected range end, plus one checkout attribution window (for checkouts attributed near the range boundary), and +- the PostHog query helper's additional 24-hour buffer. + +That keeps paid transitions inside the observation deadline available when classifying range-edge checkouts. + +## Admin UI + +The Plans admin dashboard renders this cohort as a stacked daily chart (**Completed checkout**, **Not completed**, **Pending**) on the checkout completion card. + +## Limits + +- Completion is inferred from org-level billing transitions, not per Stripe checkout session. +- Multiple checkout attempts in one attributed day collapse to the earliest start; a later successful payment still counts if it falls inside the observation window for that earliest start. +- Credit-only top-ups and non-subscription Stripe checkout sessions are out of scope for this chart. + +A future `checkout_attempt_id` bridge in Stripe metadata would allow attempt-level joins but is not required for the current admin chart. diff --git a/messages/en.context.json b/messages/en.context.json index a3406926ab..d3adc670df 100644 --- a/messages/en.context.json +++ b/messages/en.context.json @@ -38,7 +38,7 @@ "7-days": "Used in Capgo web console areas: services. Role: filter or date-range option label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "90-days": "Used in Capgo web console areas: services. Role: filter or date-range option label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "Bandwidth": "Used in Capgo web console areas: components/dashboard, pages/settings/organization. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "Current": "Used in Capgo web console areas: pages/settings/organization. Role: filter or date-range option label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "Current": "Used in Capgo web console areas: pages/admin/dashboard, pages/settings/organization. Role: filter or date-range option label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "Filters": "Used in Capgo web console areas: components, components/tables. Role: filter or date-range option label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "MAU": "Used in Capgo web console areas: pages/settings/organization. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "Storage": "Used in Capgo web console areas: components/dashboard, pages/settings/organization. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -2456,12 +2456,14 @@ "plans": "Used in Capgo web console areas: components/dashboard, constants, pages/settings/organization, services, stores. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "plans-analytics-checkout-completion": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "plans-analytics-checkout-completion-description": "Used in Capgo web console areas: pages/admin/dashboard. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", - "plans-analytics-checkout-completion-link": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "plans-analytics-checkout-intent": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "plans-analytics-checkout-intent-description": "Used in Capgo web console areas: pages/admin/dashboard. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "plans-analytics-completed-checkout": "Used in Capgo web console areas: services. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "plans-analytics-did-not-start": "Used in Capgo web console areas: services. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "plans-analytics-empty": "Used in Capgo web console areas: pages/admin/dashboard. Role: empty state text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "plans-analytics-not-completed": "Used in Capgo web console areas: services. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "plans-analytics-partial-warning": "Used in Capgo web console areas: pages/admin/dashboard. Role: toast or status message. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "plans-analytics-pending-completion": "Used in Capgo web console areas: services. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "plans-analytics-posthog-timeout": "Used in Capgo web console areas: services. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "plans-analytics-posthog-unconfigured": "Used in Capgo web console areas: services. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "plans-analytics-range-too-large": "Used in Capgo web console areas: services. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", diff --git a/messages/en.json b/messages/en.json index 6c13c03cff..14d796589b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2250,8 +2250,10 @@ "plans-analytics-who-opened-checkout": "Who opened checkout?", "plans-analytics-who-opened-checkout-description": "Daily checkout starters by billing state at the attributed Plans opening", "plans-analytics-checkout-completion": "Checkout completion", - "plans-analytics-checkout-completion-description": "TODO — this graph will be implemented after reliable checkout-completion tracking is available.", - "plans-analytics-checkout-completion-link": "Read the implementation requirements", + "plans-analytics-checkout-completion-description": "Daily checkout starters attributed to each Plans-opening UTC day, counted once as completed (paid or upgraded), not completed, or still pending within the 24-hour observation window", + "plans-analytics-completed-checkout": "Completed checkout", + "plans-analytics-not-completed": "Not completed", + "plans-analytics-pending-completion": "Pending", "plans-category-paying": "Paying", "plans-category-active-trial": "Active trial", "plans-category-expired-trial": "Expired trial — never subscribed", diff --git a/src/pages/admin/dashboard/plans.vue b/src/pages/admin/dashboard/plans.vue index f8e4aad21c..10cf8ffd71 100644 --- a/src/pages/admin/dashboard/plans.vue +++ b/src/pages/admin/dashboard/plans.vue @@ -59,13 +59,14 @@ async function loadPlansAnalytics() { const series = computed(() => data.value ? buildPlansAnalyticsSeries(data.value, t) - : { traffic: [], visitors: [], checkoutIntent: [], checkoutVisitors: [] }) + : { traffic: [], visitors: [], checkoutIntent: [], checkoutCompletion: [], checkoutVisitors: [] }) const presentation = computed(() => buildPlansAnalyticsPresentationState(data.value, requestError.value, t)) const unavailableMessage = computed(() => presentation.value.unavailableMessage) const hasTraffic = computed(() => presentation.value.hasTraffic) const hasVisitors = computed(() => presentation.value.hasVisitors) const hasCheckoutIntent = computed(() => presentation.value.hasCheckoutIntent) +const hasCheckoutCompletion = computed(() => presentation.value.hasCheckoutCompletion) const hasCheckoutVisitors = computed(() => presentation.value.hasCheckoutVisitors) watch([ @@ -298,24 +299,45 @@ displayStore.defaultBack = '/dashboard' - + -
-

+

{{ t('plans-analytics-checkout-completion-description') }}

- - {{ t('plans-analytics-checkout-completion-link') }} - + +
+ + + + + + + + + + + + + +
{{ t('plans-analytics-checkout-completion-description') }}
+ {{ t('date') }} + + {{ item.label }} +
+ {{ point.date }} + + {{ item.data[index]?.value ?? 0 }} +
diff --git a/src/services/adminPlansAnalytics.ts b/src/services/adminPlansAnalytics.ts index 0c379b05f7..71b28b01db 100644 --- a/src/services/adminPlansAnalytics.ts +++ b/src/services/adminPlansAnalytics.ts @@ -24,6 +24,13 @@ export interface DailyCheckoutIntentPoint { didNotStart: number } +export interface DailyCheckoutCompletionPoint { + date: string + completed: number + notCompleted: number + pending: number +} + export interface PlansAnalyticsDataQuality { exactTrackingStartedAt: string | null exactLogicalOpens: number @@ -39,6 +46,7 @@ export interface PlansAnalyticsResponse { traffic: PlansAnalyticsTraffic visitorBreakdown: DailyBillingPoint[] checkoutIntent: DailyCheckoutIntentPoint[] + checkoutCompletion: DailyCheckoutCompletionPoint[] checkoutVisitorBreakdown: DailyBillingPoint[] dataQuality: PlansAnalyticsDataQuality } @@ -50,6 +58,7 @@ export interface PlansAnalyticsPresentationState { hasTraffic: boolean hasVisitors: boolean hasCheckoutIntent: boolean + hasCheckoutCompletion: boolean hasCheckoutVisitors: boolean showPartialBillingWarning: boolean } @@ -69,6 +78,7 @@ export interface PlansAnalyticsSeries { traffic: ChartSeries[] visitors: ChartSeries[] checkoutIntent: ChartSeries[] + checkoutCompletion: ChartSeries[] checkoutVisitors: ChartSeries[] } @@ -90,6 +100,7 @@ export function buildPlansAnalyticsPresentationState( hasTraffic: Boolean(data?.dataQuality.posthogConnected && data.traffic.totalOpens.some(value => value > 0)), hasVisitors: Boolean(data?.dataQuality.posthogConnected && data.visitorBreakdown.some(row => row.total > 0)), hasCheckoutIntent: Boolean(data?.dataQuality.posthogConnected && data.checkoutIntent.some(row => row.startedCheckout > 0 || row.didNotStart > 0)), + hasCheckoutCompletion: Boolean(data?.dataQuality.posthogConnected && data.checkoutCompletion.some(row => row.completed > 0 || row.notCompleted > 0 || row.pending > 0)), hasCheckoutVisitors: Boolean(data?.dataQuality.posthogConnected && data.checkoutVisitorBreakdown.some(row => row.total > 0)), showPartialBillingWarning: Boolean(data && data.dataQuality.unknownBillingOrganizations > 0), } @@ -195,6 +206,16 @@ function dailyBillingPoint(value: unknown, path: string): DailyBillingPoint { } } +function dailyCheckoutCompletionPoint(value: unknown, path: string): DailyCheckoutCompletionPoint { + const row = record(value, path) + return { + date: utcDate(row.date, `${path}.date`), + completed: count(row.completed, `${path}.completed`), + notCompleted: count(row.notCompleted, `${path}.notCompleted`), + pending: count(row.pending, `${path}.pending`), + } +} + function dailyCheckoutIntentPoint(value: unknown, path: string): DailyCheckoutIntentPoint { const row = record(value, path) return { @@ -222,6 +243,8 @@ export function parsePlansAnalyticsResponse(value: unknown): PlansAnalyticsRespo .map((row, index) => dailyBillingPoint(row, `response.visitorBreakdown[${index}]`)), checkoutIntent: array(response.checkoutIntent, 'response.checkoutIntent') .map((row, index) => dailyCheckoutIntentPoint(row, `response.checkoutIntent[${index}]`)), + checkoutCompletion: array(response.checkoutCompletion, 'response.checkoutCompletion') + .map((row, index) => dailyCheckoutCompletionPoint(row, `response.checkoutCompletion[${index}]`)), checkoutVisitorBreakdown: array(response.checkoutVisitorBreakdown, 'response.checkoutVisitorBreakdown') .map((row, index) => dailyBillingPoint(row, `response.checkoutVisitorBreakdown[${index}]`)), dataQuality: { @@ -262,6 +285,11 @@ export function buildPlansAnalyticsSeries(data: PlansAnalyticsResponse, t: Trans { label: t('plans-analytics-started-checkout'), color: '#10b981', data: data.checkoutIntent.map(row => ({ date: row.date, value: row.startedCheckout })) }, { label: t('plans-analytics-did-not-start'), color: '#94a3b8', data: data.checkoutIntent.map(row => ({ date: row.date, value: row.didNotStart })) }, ], + checkoutCompletion: [ + { label: t('plans-analytics-completed-checkout'), color: '#2563eb', data: data.checkoutCompletion.map(row => ({ date: row.date, value: row.completed })) }, + { label: t('plans-analytics-not-completed'), color: '#94a3b8', data: data.checkoutCompletion.map(row => ({ date: row.date, value: row.notCompleted })) }, + { label: t('plans-analytics-pending-completion'), color: '#f59e0b', data: data.checkoutCompletion.map(row => ({ date: row.date, value: row.pending })) }, + ], checkoutVisitors: billing(data.checkoutVisitorBreakdown), } } diff --git a/supabase/functions/_backend/utils/plans_analytics.ts b/supabase/functions/_backend/utils/plans_analytics.ts index 1cefc15e56..9a0ce64021 100644 --- a/supabase/functions/_backend/utils/plans_analytics.ts +++ b/supabase/functions/_backend/utils/plans_analytics.ts @@ -1,5 +1,5 @@ import type { Context } from 'hono' -import type { DailyBillingPoint, DailyCheckoutIntentPoint, PlansBehaviorEvent } from './plans_analytics_model.ts' +import type { DailyBillingPoint, DailyCheckoutCompletionPoint, DailyCheckoutIntentPoint, PlansBehaviorEvent } from './plans_analytics_model.ts' import type { BillingTransition } from './plans_billing_history.ts' import type { PosthogReadFailureReason, PosthogReadResult } from './posthog_read.ts' import { cloudlog } from './logging.ts' @@ -11,6 +11,7 @@ import { } from './plans_analytics_model.ts' import { classifyPlansBillingAt, + hasCheckoutPaidCompletion, loadPlansBillingHistories, } from './plans_billing_history.ts' import { MAX_POSTHOG_RESPONSE_BYTES, queryPosthogHogql } from './posthog_read.ts' @@ -29,6 +30,7 @@ export interface PlansAnalyticsResponse { traffic: { dates: string[], uniqueVisitorOrganizations: number[], totalOpens: number[] } visitorBreakdown: DailyBillingPoint[] checkoutIntent: DailyCheckoutIntentPoint[] + checkoutCompletion: DailyCheckoutCompletionPoint[] checkoutVisitorBreakdown: DailyBillingPoint[] dataQuality: { exactTrackingStartedAt: string | null @@ -174,7 +176,9 @@ function emptyPlansAnalyticsResponse( attributedCheckouts: [], startMs, endMs, + nowMs: Date.now(), classifyAt: () => 'unknown', + isCheckoutCompleted: () => false, }) return { @@ -362,10 +366,11 @@ export async function getAdminPlansAnalytics( for (let offset = 0; offset < orgIds.length; offset += TRANSITION_ORG_BATCH_SIZE) { transitionBatches.push(orgIds.slice(offset, offset + TRANSITION_ORG_BATCH_SIZE)) } + const billingTransitionsEndIso = safeIso(range.endMs + CHECKOUT_ATTRIBUTION_MS)! for (let offset = 0; offset < transitionBatches.length; offset += TRANSITION_QUERY_CONCURRENCY) { const wave = transitionBatches.slice(offset, offset + TRANSITION_QUERY_CONCURRENCY) const transitionResults = await Promise.all(wave.map(batch => ( - queryPosthogHogql(c, buildBillingTransitionsQuery(range.endIso, batch), { maxResponseBytes: MAX_TRANSITION_RESPONSE_BYTES }) + queryPosthogHogql(c, buildBillingTransitionsQuery(billingTransitionsEndIso, batch), { maxResponseBytes: MAX_TRANSITION_RESPONSE_BYTES }) ))) const transitionFailure = failedResult(transitionResults) if (transitionFailure) { @@ -419,6 +424,7 @@ export async function getAdminPlansAnalytics( attributedCheckouts, startMs: range.startMs, endMs: range.endMs, + nowMs: Date.now(), classifyAt: (orgId, timestampMs) => { const history = histories.get(orgId) const category = history ? classifyPlansBillingAt(history, timestampMs) : 'unknown' @@ -426,6 +432,16 @@ export async function getAdminPlansAnalytics( unknownOrganizations.add(orgId) return category }, + isCheckoutCompleted: (orgId, checkoutTimestampMs) => { + const history = histories.get(orgId) + if (!history) + return false + return hasCheckoutPaidCompletion( + history, + checkoutTimestampMs, + checkoutTimestampMs + CHECKOUT_ATTRIBUTION_MS, + ) + }, }) const response: PlansAnalyticsResponse = { diff --git a/supabase/functions/_backend/utils/plans_analytics_model.ts b/supabase/functions/_backend/utils/plans_analytics_model.ts index 1ec3c3cbe7..0959191ef0 100644 --- a/supabase/functions/_backend/utils/plans_analytics_model.ts +++ b/supabase/functions/_backend/utils/plans_analytics_model.ts @@ -43,6 +43,15 @@ export interface DailyCheckoutIntentPoint { didNotStart: number } +export interface DailyCheckoutCompletionPoint { + date: string + completed: number + notCompleted: number + pending: number +} + +export type CheckoutCompletionOutcome = 'completed' | 'not_completed' | 'pending' + export interface PlansChartData { traffic: { dates: string[] @@ -51,6 +60,7 @@ export interface PlansChartData { } visitorBreakdown: DailyBillingPoint[] checkoutIntent: DailyCheckoutIntentPoint[] + checkoutCompletion: DailyCheckoutCompletionPoint[] checkoutVisitorBreakdown: DailyBillingPoint[] } @@ -167,12 +177,30 @@ function utcDaysIntersecting(startMs: number, endMs: number): string[] { return dates } +export function classifyCheckoutCompletion( + checkoutTimestampMs: number, + nowMs: number, + isCompleted: (checkoutTimestampMs: number) => boolean, +): CheckoutCompletionOutcome { + if (!Number.isFinite(checkoutTimestampMs)) + return 'not_completed' + + const observationDeadlineMs = checkoutTimestampMs + CHECKOUT_ATTRIBUTION_MS + if (isCompleted(checkoutTimestampMs)) + return 'completed' + if (Number.isFinite(nowMs) && nowMs >= observationDeadlineMs) + return 'not_completed' + return 'pending' +} + export function buildPlansChartData(input: { openings: LogicalPlansOpening[] attributedCheckouts: AttributedCheckout[] startMs: number endMs: number + nowMs: number classifyAt: (orgId: string, timestampMs: number) => PlansBillingCategory + isCheckoutCompleted: (orgId: string, checkoutTimestampMs: number) => boolean }): PlansChartData { const dates = utcDaysIntersecting(input.startMs, input.endMs) const dateIndexes = new Map(dates.map((date, index) => [date, index])) @@ -180,6 +208,7 @@ export function buildPlansChartData(input: { const totalOpens = dates.map(() => 0) const visitorBreakdown = dates.map(createDailyBillingPoint) const checkoutIntent = dates.map(date => ({ date, startedCheckout: 0, didNotStart: 0 })) + const checkoutCompletion = dates.map(date => ({ date, completed: 0, notCompleted: 0, pending: 0 })) const checkoutVisitorBreakdown = dates.map(createDailyBillingPoint) const firstDailyOpening = new Map() const seenOrganizations = new Set() @@ -241,6 +270,18 @@ export function buildPlansChartData(input: { checkoutVisitorBreakdown[dateIndex], input.classifyAt(checkout.orgId, checkout.opening.timestampMs), ) + + const outcome = classifyCheckoutCompletion( + checkout.checkoutTimestampMs, + input.nowMs, + checkoutTimestampMs => input.isCheckoutCompleted(checkout.orgId, checkoutTimestampMs), + ) + if (outcome === 'completed') + checkoutCompletion[dateIndex].completed += 1 + else if (outcome === 'not_completed') + checkoutCompletion[dateIndex].notCompleted += 1 + else + checkoutCompletion[dateIndex].pending += 1 } } @@ -248,6 +289,7 @@ export function buildPlansChartData(input: { traffic: { dates, uniqueVisitorOrganizations, totalOpens }, visitorBreakdown, checkoutIntent, + checkoutCompletion, checkoutVisitorBreakdown, } } diff --git a/supabase/functions/_backend/utils/plans_billing_history.ts b/supabase/functions/_backend/utils/plans_billing_history.ts index d3d9952702..d624c51a16 100644 --- a/supabase/functions/_backend/utils/plans_billing_history.ts +++ b/supabase/functions/_backend/utils/plans_billing_history.ts @@ -295,6 +295,21 @@ function billingEvidenceAt(history: BillingHistoryEvidence, timestampMs: number) } } +export function hasCheckoutPaidCompletion( + history: BillingHistoryEvidence, + checkoutTimestampMs: number, + observationDeadlineMs: number, +): boolean { + if (!Number.isFinite(checkoutTimestampMs) || !Number.isFinite(observationDeadlineMs)) + return false + + return billingEvidenceTimeline(history).some(event => ( + event.kind === 'paid' + && event.timestampMs > checkoutTimestampMs + && event.timestampMs <= observationDeadlineMs + )) +} + export function classifyPlansBillingAt(history: BillingHistoryEvidence, timestampMs: number) { const evidence = billingEvidenceAt(history, timestampMs) diff --git a/tests/admin-plans-analytics-dashboard.unit.test.ts b/tests/admin-plans-analytics-dashboard.unit.test.ts index 12141502bc..58ac1d030f 100644 --- a/tests/admin-plans-analytics-dashboard.unit.test.ts +++ b/tests/admin-plans-analytics-dashboard.unit.test.ts @@ -8,6 +8,7 @@ const validResponse: BackendPlansAnalyticsResponse = { traffic: { dates: ['2026-08-01'], uniqueVisitorOrganizations: [2], totalOpens: [4] }, visitorBreakdown: [{ date: '2026-08-01', paying: 1, activeTrial: 1, expiredTrial: 0, canceled: 0, paymentProblem: 0, creditsOnly: 0, unknown: 0, total: 2 }], checkoutIntent: [{ date: '2026-08-01', startedCheckout: 1, didNotStart: 1 }], + checkoutCompletion: [{ date: '2026-08-01', completed: 1, notCompleted: 0, pending: 0 }], checkoutVisitorBreakdown: [{ date: '2026-08-01', paying: 1, activeTrial: 0, expiredTrial: 0, canceled: 0, paymentProblem: 0, creditsOnly: 0, unknown: 0, total: 1 }], dataQuality: { exactTrackingStartedAt: '2026-08-01T00:00:00Z', @@ -37,8 +38,10 @@ const requiredMessages = { 'plans-analytics-who-opened-checkout': 'Who opened checkout?', 'plans-analytics-who-opened-checkout-description': 'Daily checkout starters by billing state at the attributed Plans opening', 'plans-analytics-checkout-completion': 'Checkout completion', - 'plans-analytics-checkout-completion-description': 'TODO — this graph will be implemented after reliable checkout-completion tracking is available.', - 'plans-analytics-checkout-completion-link': 'Read the implementation requirements', + 'plans-analytics-checkout-completion-description': 'Daily checkout starters attributed to each Plans-opening UTC day, counted once as completed (paid or upgraded), not completed, or still pending within the 24-hour observation window', + 'plans-analytics-completed-checkout': 'Completed checkout', + 'plans-analytics-not-completed': 'Not completed', + 'plans-analytics-pending-completion': 'Pending', 'plans-category-paying': 'Paying', 'plans-category-active-trial': 'Active trial', 'plans-category-expired-trial': 'Expired trial — never subscribed', @@ -75,6 +78,7 @@ describe('admin Plans analytics dashboard', () => { traffic: { dates: [], uniqueVisitorOrganizations: [], totalOpens: [] }, visitorBreakdown: [], checkoutIntent: [], + checkoutCompletion: [], checkoutVisitorBreakdown: [], }], ])('parses a valid %s', (_name, value) => { @@ -151,6 +155,7 @@ describe('admin Plans analytics dashboard', () => { traffic: { dates: [], uniqueVisitorOrganizations: [], totalOpens: [] }, visitorBreakdown: [], checkoutIntent: [], + checkoutCompletion: [], checkoutVisitorBreakdown: [], dataQuality: { ...validResponse.dataQuality, @@ -163,6 +168,7 @@ describe('admin Plans analytics dashboard', () => { hasTraffic: false, hasVisitors: false, hasCheckoutIntent: false, + hasCheckoutCompletion: false, hasCheckoutVisitors: false, showPartialBillingWarning: true, }) @@ -211,6 +217,11 @@ describe('admin Plans analytics dashboard', () => { { label: 'plans-analytics-started-checkout', color: '#10b981', data: [{ date: '2026-08-01', value: 1 }] }, { label: 'plans-analytics-did-not-start', color: '#94a3b8', data: [{ date: '2026-08-01', value: 1 }] }, ]) + expect(series.checkoutCompletion.map(({ label, color, data }) => ({ label, color, data }))).toEqual([ + { label: 'plans-analytics-completed-checkout', color: '#2563eb', data: [{ date: '2026-08-01', value: 1 }] }, + { label: 'plans-analytics-not-completed', color: '#94a3b8', data: [{ date: '2026-08-01', value: 0 }] }, + { label: 'plans-analytics-pending-completion', color: '#f59e0b', data: [{ date: '2026-08-01', value: 0 }] }, + ]) expect(series.checkoutVisitors).toHaveLength(7) expect(series.checkoutVisitors.reduce((sum, item) => sum + item.data[0].value, 0)).toBe(1) }) @@ -243,14 +254,10 @@ describe('admin Plans analytics dashboard', () => { ]) expect(tabs).toContain(`label: 'plans-analytics-title'`) expect(tabs).toContain(`key: '/plans'`) - expect(completionDoc).toContain('server-side `Checkout Completed` event') - expect(completionDoc).toContain('stable `checkout_attempt_id`') - expect(completionDoc).toContain('Stripe metadata') - expect(completionDoc).toContain('Stripe checkout session ID, product ID, recurrence, and completion timestamp') + expect(completionDoc).toContain('server-side billing evidence') expect(completionDoc).toContain('attributed Plans-opening UTC day') - expect(completionDoc).toContain('Completed or Not completed') - expect(completionDoc).toContain('pending until the agreed observation window') - expect(completionDoc).toContain('separate approved design') + expect(completionDoc).toContain('observation window') + expect(completionDoc).toContain('paid transition') const messages = JSON.parse(messagesText) as Record for (const [key, expected] of Object.entries(requiredMessages)) { expect(messages).toHaveProperty(key) @@ -288,9 +295,9 @@ describe('admin Plans analytics dashboard', () => { expect(page.match(/ { expect(page).toContain('t(\'plans-analytics-who-opened-description\')') expect(page).toContain('t(\'plans-analytics-checkout-intent-description\')') expect(page).toContain('t(\'plans-analytics-who-opened-checkout-description\')') + expect(page).toContain('t(\'plans-analytics-checkout-completion-description\')') + expect(page).toContain('hasCheckoutCompletion') + expect(page).toContain(':series="series.checkoutCompletion"') const cardTitles = [ 'plans-analytics-traffic', @@ -328,10 +338,8 @@ describe('admin Plans analytics dashboard', () => { expect(page).not.toContain('setInterval') expect(page).not.toContain('setTimeout') - expect(page).toContain('t(\'plans-analytics-checkout-completion-description\')') - expect(page).toContain('https://github.com/Cap-go/capgo.app/blob/main/docs/admin/plans-checkout-completion.md') - expect(page).toContain('target="_blank"') - expect(page).toContain('rel="noopener noreferrer"') + expect(page).not.toContain('https://github.com/Cap-go/capgo.app/blob/main/docs/admin/plans-checkout-completion.md') + expect(page).not.toContain('plans-analytics-checkout-completion-link') expect(page).toContain('role="alert"') expect(page).toContain('role="status"') }) diff --git a/tests/plans-analytics-model.unit.test.ts b/tests/plans-analytics-model.unit.test.ts index a5f20fb20c..bc1f97f485 100644 --- a/tests/plans-analytics-model.unit.test.ts +++ b/tests/plans-analytics-model.unit.test.ts @@ -5,6 +5,7 @@ import { buildLogicalPlansOpenings, buildPlansChartData, CHECKOUT_ATTRIBUTION_MS, + classifyCheckoutCompletion, } from '../supabase/functions/_backend/utils/plans_analytics_model.ts' const ms = (value: string) => Date.parse(value) @@ -105,7 +106,9 @@ describe('plans analytics model', () => { attributedCheckouts: matches, startMs: ms('2026-08-01T00:00:00Z'), endMs: ms('2026-08-03T00:00:00Z'), + nowMs: ms('2026-08-10T00:00:00Z'), classifyAt: orgId => orgId === 'org-a' ? 'paying' : 'active_trial', + isCheckoutCompleted: () => false, }) expect(result.traffic.uniqueVisitorOrganizations).toEqual([1, 1]) @@ -129,7 +132,9 @@ describe('plans analytics model', () => { attributedCheckouts: matches, startMs: ms('2026-08-01T00:00:00Z'), endMs: ms('2026-08-02T00:00:00Z'), + nowMs: ms('2026-08-10T00:00:00Z'), classifyAt: (_orgId, timestampMs) => timestampMs < ms('2026-08-01T10:00:00Z') ? 'expired_trial' : 'credits_only', + isCheckoutCompleted: () => false, }) expect(result.checkoutIntent).toEqual([{ date: '2026-08-01', startedCheckout: 1, didNotStart: 0 }]) @@ -151,10 +156,12 @@ describe('plans analytics model', () => { attributedCheckouts: matches, startMs: ms('2026-08-01T12:00:00Z'), endMs: ms('2026-08-04T06:00:00Z'), + nowMs: ms('2026-08-10T00:00:00Z'), classifyAt: (_orgId, timestampMs) => { classifiedAt.push(timestampMs) return timestampMs < ms('2026-08-02T10:00:00Z') ? 'expired_trial' : 'credits_only' }, + isCheckoutCompleted: () => false, }) expect(result.traffic).toEqual({ @@ -189,7 +196,9 @@ describe('plans analytics model', () => { }], startMs: ms('2026-08-01T00:00:00Z'), endMs: ms('2026-08-02T00:00:00Z'), + nowMs: ms('2026-08-10T00:00:00Z'), classifyAt: () => 'unknown', + isCheckoutCompleted: () => false, }) expect(result.traffic).toEqual({ @@ -199,4 +208,55 @@ describe('plans analytics model', () => { }) expect(result.checkoutIntent).toEqual([{ date: '2026-08-01', startedCheckout: 0, didNotStart: 1 }]) }) + + it.concurrent('classifies checkout completion from billing evidence within the observation window', () => { + const checkoutAt = ms('2026-08-01T08:05:00Z') + const completed = classifyCheckoutCompletion( + checkoutAt, + ms('2026-08-02T09:00:00Z'), + timestampMs => timestampMs === checkoutAt && ms('2026-08-01T09:00:00Z') > checkoutAt, + ) + const pending = classifyCheckoutCompletion( + checkoutAt, + ms('2026-08-01T20:00:00Z'), + () => false, + ) + const notCompleted = classifyCheckoutCompletion( + checkoutAt, + ms('2026-08-03T00:00:00Z'), + () => false, + ) + + expect(completed).toBe('completed') + expect(pending).toBe('pending') + expect(notCompleted).toBe('not_completed') + }) + + it.concurrent('buckets checkout completion on the attributed Plans-opening UTC day', () => { + const openings = buildLogicalPlansOpenings([ + event({ timestampMs: ms('2026-08-01T08:00:00Z'), orgId: 'org-a', page: 'plans' }), + event({ timestampMs: ms('2026-08-01T09:00:00Z'), orgId: 'org-b', page: 'plans' }), + event({ timestampMs: ms('2026-08-02T08:00:00Z'), orgId: 'org-c', page: 'plans' }), + ], ms('2026-08-01T00:00:00Z'), ms('2026-08-03T00:00:00Z')) + const matches = attributeCheckoutStarts(openings, [ + event({ event: 'Checkout Started', timestampMs: ms('2026-08-01T08:05:00Z'), orgId: 'org-a' }), + event({ event: 'Checkout Started', timestampMs: ms('2026-08-01T09:05:00Z'), orgId: 'org-b' }), + event({ event: 'Checkout Started', timestampMs: ms('2026-08-02T08:05:00Z'), orgId: 'org-c' }), + ]) + const result = buildPlansChartData({ + openings, + attributedCheckouts: matches, + startMs: ms('2026-08-01T00:00:00Z'), + endMs: ms('2026-08-03T00:00:00Z'), + nowMs: ms('2026-08-10T00:00:00Z'), + classifyAt: () => 'active_trial', + isCheckoutCompleted: (_orgId, checkoutTimestampMs) => checkoutTimestampMs === ms('2026-08-01T08:05:00Z'), + }) + + expect(result.checkoutCompletion).toEqual([ + { date: '2026-08-01', completed: 1, notCompleted: 1, pending: 0 }, + { date: '2026-08-02', completed: 0, notCompleted: 1, pending: 0 }, + ]) + expect(result.checkoutCompletion.map(day => day.completed + day.notCompleted + day.pending)).toEqual([2, 1]) + }) }) diff --git a/tests/plans-analytics-orchestration.unit.test.ts b/tests/plans-analytics-orchestration.unit.test.ts index 248d71f631..005dcbb4f9 100644 --- a/tests/plans-analytics-orchestration.unit.test.ts +++ b/tests/plans-analytics-orchestration.unit.test.ts @@ -214,6 +214,7 @@ describe('plans analytics orchestration', () => { expect(queries[0]).toContain('event IN (\'User visit\', \'Checkout Started\')') expect(queries[1]).toContain(`properties.$group_key IN ('${ORG_A}', '${ORG_B}')`) + expect(queries[1]).toContain('2026-08-04T00:00:00.000Z') expect(queries[1]).not.toContain(ORG_X) expect(queries[2]).toContain('SELECT min(timestamp) AS exact_tracking_started_at') expect(loadPlansBillingHistories).toHaveBeenCalledWith(context, [ORG_A, ORG_B], '2026-08-01', '2026-08-02', new Map()) @@ -559,6 +560,45 @@ describe('plans analytics orchestration', () => { expect(result.dataQuality.unknownBillingOrganizations).toBe(1) }) + it('counts a final-range checkout as completed when the paid transition lands after range end but before the completion deadline', async () => { + const openingMs = Date.parse('2026-08-01T23:00:00.000Z') + const checkoutMs = Date.parse('2026-08-02T22:00:00.000Z') + const paidMs = Date.parse('2026-08-03T12:00:00.000Z') + const transitionQueries: string[] = [] + vi.mocked(queryPosthogHogql).mockImplementation(async (_context, query) => { + if (query.includes('event IN (\'User visit\', \'Checkout Started\')')) { + return connected([ + behavior({ timestamp_ms: openingMs, org_id: ORG_A }), + behavior({ timestamp_ms: checkoutMs, event: 'Checkout Started', org_id: ORG_A }), + ]) + } + if (query.includes('event IN (\'User subscribe\'')) { + transitionQueries.push(query) + return connected([ + { + timestamp_ms: paidMs, + event: 'User subscribe', + group_key: ORG_A, + group_type: 'organization', + grouped_org_id: '', + plan_status: 'succeeded', + event_plan_status: null, + canceled_at: null, + }, + ]) + } + return connected() + }) + vi.mocked(loadPlansBillingHistories).mockImplementation(async (_context, orgIds, _startDate, _endDate, transitions) => new Map( + orgIds.map(orgId => [orgId, history(orgId, { transitions: transitions.get(orgId) ?? [] })]), + )) + + const result = await getAdminPlansAnalytics(context, start, end) + + expect(transitionQueries[0]).toContain('2026-08-04T00:00:00.000Z') + expect(result.checkoutCompletion[0]).toMatchObject({ date: '2026-08-01', completed: 1, notCompleted: 0, pending: 0 }) + }) + it.each([ ['invalid start', 'not-a-date', end], ['invalid end', start, 'not-a-date'], diff --git a/tests/plans-billing-history.unit.test.ts b/tests/plans-billing-history.unit.test.ts index 2106c59f7b..3d54391d41 100644 --- a/tests/plans-billing-history.unit.test.ts +++ b/tests/plans-billing-history.unit.test.ts @@ -3,6 +3,7 @@ import type { OrganizationBillingHistory } from '../supabase/functions/_backend/ import { beforeEach, describe, expect, it, vi } from 'vitest' import { classifyPlansBillingAt, + hasCheckoutPaidCompletion, loadPlansBillingHistories, } from '../supabase/functions/_backend/utils/plans_billing_history.ts' @@ -394,6 +395,23 @@ describe('loadPlansBillingHistories', () => { expect(classifyPlansBillingAt(history, at)).toBe('unknown') }) + it('detects checkout completion from paid billing transitions after checkout start', () => { + const checkoutAt = Date.parse('2026-08-01T08:05:00Z') + const deadline = checkoutAt + 24 * 60 * 60 * 1000 + const history = { + ...base(), + transitions: [{ timestampMs: checkoutAt + 60_000, kind: 'paid' as const }], + } + + expect(hasCheckoutPaidCompletion(history, checkoutAt, deadline)).toBe(true) + expect(hasCheckoutPaidCompletion(history, checkoutAt + 120_000, deadline)).toBe(false) + expect(hasCheckoutPaidCompletion({ + ...history, + paidAtMs: checkoutAt + 30_000, + transitions: [], + }, checkoutAt, deadline)).toBe(true) + }) + it('does not open an unbounded database query for an empty organization set', async () => { await expect(loadPlansBillingHistories(context(), [], '2026-08-01', '2026-08-07', new Map())) .resolves