From 9e4f72fc0b2fb568b92e4eeddd2527bdd03b692d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 12:00:40 +0000 Subject: [PATCH 1/6] feat(admin): add Plans checkout completion analytics chart Replace the deferred checkout completion placeholder with daily completed / not completed / pending counts for attributed checkout starters, inferred from existing server-side billing transitions. Co-authored-by: Martin DONADIEU --- docs/admin/plans-checkout-completion.md | 36 +++++++++-- messages/en.context.json | 6 +- messages/en.json | 6 +- src/pages/admin/dashboard/plans.vue | 50 ++++++++++----- src/services/adminPlansAnalytics.ts | 27 ++++++++ .../_backend/utils/plans_analytics.ts | 18 +++++- .../_backend/utils/plans_analytics_model.ts | 43 +++++++++++++ .../_backend/utils/plans_billing_history.ts | 15 +++++ ...min-plans-analytics-dashboard.unit.test.ts | 40 +++++++----- tests/plans-analytics-model.unit.test.ts | 63 +++++++++++++++++++ tests/plans-billing-history.unit.test.ts | 18 ++++++ 11 files changed, 283 insertions(+), 39 deletions(-) diff --git a/docs/admin/plans-checkout-completion.md b/docs/admin/plans-checkout-completion.md index 2e6f87b78d..7663b31ca9 100644 --- a/docs/admin/plans-checkout-completion.md +++ b/docs/admin/plans-checkout-completion.md @@ -1,9 +1,37 @@ # 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_COMPLETION_OBSERVATION_MS`, currently equal to the checkout attribution window). + +Until that deadline passes, an unattributed paid transition remains **pending** instead of **not completed**. + +## 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 e7d29efa0b..98c7d3517c 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..6c211acc6a 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 @@ -50,6 +57,7 @@ export interface PlansAnalyticsPresentationState { hasTraffic: boolean hasVisitors: boolean hasCheckoutIntent: boolean + hasCheckoutCompletion: boolean hasCheckoutVisitors: boolean showPartialBillingWarning: boolean } @@ -69,6 +77,7 @@ export interface PlansAnalyticsSeries { traffic: ChartSeries[] visitors: ChartSeries[] checkoutIntent: ChartSeries[] + checkoutCompletion: ChartSeries[] checkoutVisitors: ChartSeries[] } @@ -90,6 +99,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 +205,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 +242,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 +284,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..56bb0a7083 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' @@ -8,9 +8,11 @@ import { buildLogicalPlansOpenings, buildPlansChartData, CHECKOUT_ATTRIBUTION_MS, + CHECKOUT_COMPLETION_OBSERVATION_MS, } 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 +31,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 +177,9 @@ function emptyPlansAnalyticsResponse( attributedCheckouts: [], startMs, endMs, + nowMs: Date.now(), classifyAt: () => 'unknown', + isCheckoutCompleted: () => false, }) return { @@ -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_COMPLETION_OBSERVATION_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..ee89cd47f1 100644 --- a/supabase/functions/_backend/utils/plans_analytics_model.ts +++ b/supabase/functions/_backend/utils/plans_analytics_model.ts @@ -1,4 +1,5 @@ export const CHECKOUT_ATTRIBUTION_MS = 24 * 60 * 60 * 1000 +export const CHECKOUT_COMPLETION_OBSERVATION_MS = CHECKOUT_ATTRIBUTION_MS export type PlansBillingCategory = | 'paying' @@ -43,6 +44,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 +61,7 @@ export interface PlansChartData { } visitorBreakdown: DailyBillingPoint[] checkoutIntent: DailyCheckoutIntentPoint[] + checkoutCompletion: DailyCheckoutCompletionPoint[] checkoutVisitorBreakdown: DailyBillingPoint[] } @@ -167,12 +178,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_COMPLETION_OBSERVATION_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 +209,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 +271,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 +290,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..d5181aed8f 100644 --- a/tests/plans-analytics-model.unit.test.ts +++ b/tests/plans-analytics-model.unit.test.ts @@ -5,6 +5,8 @@ import { buildLogicalPlansOpenings, buildPlansChartData, CHECKOUT_ATTRIBUTION_MS, + CHECKOUT_COMPLETION_OBSERVATION_MS, + classifyCheckoutCompletion, } from '../supabase/functions/_backend/utils/plans_analytics_model.ts' const ms = (value: string) => Date.parse(value) @@ -105,7 +107,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 +133,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 +157,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 +197,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 +209,57 @@ 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', () => { + expect(CHECKOUT_COMPLETION_OBSERVATION_MS).toBe(CHECKOUT_ATTRIBUTION_MS) + + 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-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 From 9d8aca5f2979ccf5a28a650dac2ba9792b0b5792 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 12:07:24 +0000 Subject: [PATCH 2/6] fix(admin): add checkoutCompletion to frontend DTO and drop duplicate export Fix typecheck by exposing checkoutCompletion on PlansAnalyticsResponse and reuse CHECKOUT_ATTRIBUTION_MS for the completion observation window. Co-authored-by: Martin DONADIEU --- docs/admin/plans-checkout-completion.md | 2 +- src/services/adminPlansAnalytics.ts | 1 + supabase/functions/_backend/utils/plans_analytics.ts | 3 +-- supabase/functions/_backend/utils/plans_analytics_model.ts | 3 +-- tests/plans-analytics-model.unit.test.ts | 3 --- 5 files changed, 4 insertions(+), 8 deletions(-) diff --git a/docs/admin/plans-checkout-completion.md b/docs/admin/plans-checkout-completion.md index 7663b31ca9..10b82e5780 100644 --- a/docs/admin/plans-checkout-completion.md +++ b/docs/admin/plans-checkout-completion.md @@ -24,7 +24,7 @@ This captures new subscriptions and plan upgrades without requiring a dedicated ## Observation window -The observation window is 24 hours after checkout start (`CHECKOUT_COMPLETION_OBSERVATION_MS`, currently equal to the checkout attribution window). +The observation window is 24 hours after checkout start (`CHECKOUT_ATTRIBUTION_MS`, shared with checkout attribution). Until that deadline passes, an unattributed paid transition remains **pending** instead of **not completed**. diff --git a/src/services/adminPlansAnalytics.ts b/src/services/adminPlansAnalytics.ts index 6c211acc6a..71b28b01db 100644 --- a/src/services/adminPlansAnalytics.ts +++ b/src/services/adminPlansAnalytics.ts @@ -46,6 +46,7 @@ export interface PlansAnalyticsResponse { traffic: PlansAnalyticsTraffic visitorBreakdown: DailyBillingPoint[] checkoutIntent: DailyCheckoutIntentPoint[] + checkoutCompletion: DailyCheckoutCompletionPoint[] checkoutVisitorBreakdown: DailyBillingPoint[] dataQuality: PlansAnalyticsDataQuality } diff --git a/supabase/functions/_backend/utils/plans_analytics.ts b/supabase/functions/_backend/utils/plans_analytics.ts index 56bb0a7083..86ef696d72 100644 --- a/supabase/functions/_backend/utils/plans_analytics.ts +++ b/supabase/functions/_backend/utils/plans_analytics.ts @@ -8,7 +8,6 @@ import { buildLogicalPlansOpenings, buildPlansChartData, CHECKOUT_ATTRIBUTION_MS, - CHECKOUT_COMPLETION_OBSERVATION_MS, } from './plans_analytics_model.ts' import { classifyPlansBillingAt, @@ -439,7 +438,7 @@ export async function getAdminPlansAnalytics( return hasCheckoutPaidCompletion( history, checkoutTimestampMs, - checkoutTimestampMs + CHECKOUT_COMPLETION_OBSERVATION_MS, + checkoutTimestampMs + CHECKOUT_ATTRIBUTION_MS, ) }, }) diff --git a/supabase/functions/_backend/utils/plans_analytics_model.ts b/supabase/functions/_backend/utils/plans_analytics_model.ts index ee89cd47f1..0959191ef0 100644 --- a/supabase/functions/_backend/utils/plans_analytics_model.ts +++ b/supabase/functions/_backend/utils/plans_analytics_model.ts @@ -1,5 +1,4 @@ export const CHECKOUT_ATTRIBUTION_MS = 24 * 60 * 60 * 1000 -export const CHECKOUT_COMPLETION_OBSERVATION_MS = CHECKOUT_ATTRIBUTION_MS export type PlansBillingCategory = | 'paying' @@ -186,7 +185,7 @@ export function classifyCheckoutCompletion( if (!Number.isFinite(checkoutTimestampMs)) return 'not_completed' - const observationDeadlineMs = checkoutTimestampMs + CHECKOUT_COMPLETION_OBSERVATION_MS + const observationDeadlineMs = checkoutTimestampMs + CHECKOUT_ATTRIBUTION_MS if (isCompleted(checkoutTimestampMs)) return 'completed' if (Number.isFinite(nowMs) && nowMs >= observationDeadlineMs) diff --git a/tests/plans-analytics-model.unit.test.ts b/tests/plans-analytics-model.unit.test.ts index d5181aed8f..bc1f97f485 100644 --- a/tests/plans-analytics-model.unit.test.ts +++ b/tests/plans-analytics-model.unit.test.ts @@ -5,7 +5,6 @@ import { buildLogicalPlansOpenings, buildPlansChartData, CHECKOUT_ATTRIBUTION_MS, - CHECKOUT_COMPLETION_OBSERVATION_MS, classifyCheckoutCompletion, } from '../supabase/functions/_backend/utils/plans_analytics_model.ts' @@ -211,8 +210,6 @@ describe('plans analytics model', () => { }) it.concurrent('classifies checkout completion from billing evidence within the observation window', () => { - expect(CHECKOUT_COMPLETION_OBSERVATION_MS).toBe(CHECKOUT_ATTRIBUTION_MS) - const checkoutAt = ms('2026-08-01T08:05:00Z') const completed = classifyCheckoutCompletion( checkoutAt, From add2d31b6798f41034114a3f79a7a24652466eff Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 13:19:49 +0000 Subject: [PATCH 3/6] fix(ci): isolate Run tests concurrency per pull request number head_ref values like cursor/* must not share one workflow concurrency group across PRs, which was cancelling in-progress pull_request runs. Co-authored-by: Martin DONADIEU --- .github/workflows/tests.yml | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 61aeffb7d7..44575324b0 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -2,7 +2,9 @@ name: Run tests concurrency: # Include event_name so push and pull_request on the same branch do not cancel each other. - group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event_name == 'workflow_call' && github.sha || github.head_ref || github.ref_name || github.ref }} + # Use pull_request.number for PR runs: head_ref can contain slashes (cursor/*) and + # must not collide across concurrent PRs on different branches. + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event_name == 'workflow_call' && github.sha || github.event.pull_request.number || github.event.merge_group.head_ref || github.head_ref || github.ref_name || github.ref }} # Keep in-progress pull_request suites running; duplicate synchronize events were # cancelling shards mid-queue and leaving cancelled required checks on the PR. cancel-in-progress: ${{ github.event_name != 'pull_request' }} @@ -323,7 +325,7 @@ jobs: actions: write concurrency: # Isolate by event_name so push + pull_request on the same branch do not cancel pending jobs. - group: capgo-local-services-backend-${{ github.event_name }}-${{ github.repository }}-${{ matrix.shard }} + group: capgo-local-services-backend-${{ github.event_name }}-${{ github.repository }}-${{ github.event.pull_request.number || github.run_id }}-${{ matrix.shard }} cancel-in-progress: false env: SUPABASE_WORKTREE_INSTANCE: backend-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.shard_id }} @@ -489,7 +491,7 @@ jobs: contents: read actions: write concurrency: - group: capgo-local-services-backend-plugin-${{ github.event_name }}-${{ github.repository }} + group: capgo-local-services-backend-plugin-${{ github.event_name }}-${{ github.repository }}-${{ github.event.pull_request.number || github.run_id }} cancel-in-progress: false env: SUPABASE_WORKTREE_INSTANCE: backend-plugin-${{ github.run_id }}-${{ github.run_attempt }} @@ -599,7 +601,7 @@ jobs: contents: read concurrency: # Isolate by event_name so push + pull_request on the same branch do not cancel pending jobs. - group: capgo-local-services-backend-sql-${{ github.event_name }}-${{ github.repository }} + group: capgo-local-services-backend-sql-${{ github.event_name }}-${{ github.repository }}-${{ github.event.pull_request.number || github.run_id }} cancel-in-progress: false env: SUPABASE_WORKTREE_INSTANCE: backend-sql-${{ github.run_id }}-${{ github.run_attempt }} @@ -669,7 +671,7 @@ jobs: contents: read concurrency: # Isolate by event_name so push + pull_request on the same branch do not cancel pending jobs. - group: capgo-local-services-backend-sql-catalog-${{ github.event_name }}-${{ github.repository }} + group: capgo-local-services-backend-sql-catalog-${{ github.event_name }}-${{ github.repository }}-${{ github.event.pull_request.number || github.run_id }} cancel-in-progress: false env: SUPABASE_WORKTREE_INSTANCE: backend-sql-catalog-${{ github.run_id }}-${{ github.run_attempt }} @@ -741,7 +743,7 @@ jobs: contents: read concurrency: # Isolate by event_name so push + pull_request on the same branch do not cancel pending jobs. - group: capgo-local-services-cloudflare-${{ github.event_name }}-${{ github.repository }}-${{ matrix.shard }} + group: capgo-local-services-cloudflare-${{ github.event_name }}-${{ github.repository }}-${{ github.event.pull_request.number || github.run_id }}-${{ matrix.shard }} cancel-in-progress: false env: SUPABASE_WORKTREE_INSTANCE: cloudflare-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.shard_id }} @@ -883,7 +885,7 @@ jobs: permissions: contents: read concurrency: - group: capgo-local-services-cloudflare-plugin-${{ github.event_name }}-${{ github.repository }} + group: capgo-local-services-cloudflare-plugin-${{ github.event_name }}-${{ github.repository }}-${{ github.event.pull_request.number || github.run_id }} cancel-in-progress: false env: SUPABASE_WORKTREE_INSTANCE: cloudflare-plugin-${{ github.run_id }}-${{ github.run_attempt }} @@ -998,7 +1000,7 @@ jobs: contents: read concurrency: # Isolate by event_name so push + pull_request on the same branch do not cancel pending jobs. - group: capgo-local-services-playwright-${{ github.event_name }}-${{ github.repository }}-${{ matrix.shard }} + group: capgo-local-services-playwright-${{ github.event_name }}-${{ github.repository }}-${{ github.event.pull_request.number || github.run_id }}-${{ matrix.shard }} cancel-in-progress: false strategy: fail-fast: false @@ -1448,7 +1450,7 @@ jobs: actions: write concurrency: # Per-branch CLI stacks avoid cross-PR queue starvation on busy runner windows. - group: capgo-local-services-cli-${{ github.event_name }}-${{ github.repository }}-${{ github.head_ref || github.ref_name }} + group: capgo-local-services-cli-${{ github.event_name }}-${{ github.repository }}-${{ github.event.pull_request.number || github.run_id }} cancel-in-progress: false env: SUPABASE_WORKTREE_INSTANCE: cli-${{ github.run_id }}-${{ github.run_attempt }} From 9f2e06680d12edd401f7817ad68a80b5ca6c549d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 14:59:12 +0000 Subject: [PATCH 4/6] docs(admin): note checkout completion chart on Plans dashboard Co-authored-by: Martin DONADIEU --- docs/admin/plans-checkout-completion.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/admin/plans-checkout-completion.md b/docs/admin/plans-checkout-completion.md index 10b82e5780..d8c7aad146 100644 --- a/docs/admin/plans-checkout-completion.md +++ b/docs/admin/plans-checkout-completion.md @@ -28,6 +28,10 @@ The observation window is 24 hours after checkout start (`CHECKOUT_ATTRIBUTION_M Until that deadline passes, an unattributed paid transition remains **pending** instead of **not completed**. +## 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. From 5a56b3006d521a2389f4d7e6066eb822131769ec Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 16:05:15 +0000 Subject: [PATCH 5/6] fix(admin): extend billing transition window for range-edge checkout completion Co-authored-by: Martin DONADIEU --- docs/admin/plans-checkout-completion.md | 2 +- .../_backend/utils/plans_analytics.ts | 3 +- ...plans-analytics-orchestration.unit.test.ts | 40 +++++++++++++++++++ 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/docs/admin/plans-checkout-completion.md b/docs/admin/plans-checkout-completion.md index d8c7aad146..e72f094363 100644 --- a/docs/admin/plans-checkout-completion.md +++ b/docs/admin/plans-checkout-completion.md @@ -26,7 +26,7 @@ This captures new subscriptions and plan upgrades without requiring a dedicated The observation window is 24 hours after checkout start (`CHECKOUT_ATTRIBUTION_MS`, shared with checkout attribution). -Until that deadline passes, an unattributed paid transition remains **pending** instead of **not completed**. +Until that deadline passes, an attributed checkout without a qualifying paid transition remains **pending** instead of **not completed**. ## Admin UI diff --git a/supabase/functions/_backend/utils/plans_analytics.ts b/supabase/functions/_backend/utils/plans_analytics.ts index 86ef696d72..9a0ce64021 100644 --- a/supabase/functions/_backend/utils/plans_analytics.ts +++ b/supabase/functions/_backend/utils/plans_analytics.ts @@ -366,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) { 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'], From 5fa717d00e816299f10e395c249430f5a65b845b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 16:11:29 +0000 Subject: [PATCH 6/6] docs(admin): document billing evidence window for checkout completion Co-authored-by: Martin DONADIEU --- docs/admin/plans-checkout-completion.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/admin/plans-checkout-completion.md b/docs/admin/plans-checkout-completion.md index e72f094363..b4b6e34e54 100644 --- a/docs/admin/plans-checkout-completion.md +++ b/docs/admin/plans-checkout-completion.md @@ -28,6 +28,15 @@ The observation window is 24 hours after checkout start (`CHECKOUT_ATTRIBUTION_M 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.