Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 45 additions & 4 deletions docs/admin/plans-checkout-completion.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 4 additions & 2 deletions messages/en.context.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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.",
Expand Down
6 changes: 4 additions & 2 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
50 changes: 36 additions & 14 deletions src/pages/admin/dashboard/plans.vue
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,14 @@

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([
Expand Down Expand Up @@ -298,24 +299,45 @@
</div>
</ChartCard>

<ChartCard chart-id="checkout-completion" :title="t('plans-analytics-checkout-completion')" :has-data="true">
<ChartCard
chart-id="checkout-completion"
:title="t('plans-analytics-checkout-completion')"
:is-loading="isLoadingStats"
:has-data="hasCheckoutCompletion"
:error-message="unavailableMessage ?? undefined"
:no-data-message="t('plans-analytics-empty')"
>
<template #header>
<h2 class="text-xl font-semibold text-slate-900 dark:text-white sm:text-2xl">
<h2 id="plans-checkout-completion-title" class="text-xl font-semibold text-slate-900 dark:text-white sm:text-2xl">
{{ t('plans-analytics-checkout-completion') }}
</h2>
</template>
<div class="flex h-full flex-col items-center justify-center gap-4 px-6 text-center">
<p class="max-w-2xl text-sm text-slate-600 dark:text-slate-300">
<p id="plans-checkout-completion-description" class="mt-1 text-sm text-slate-500 dark:text-slate-400">
{{ t('plans-analytics-checkout-completion-description') }}
</p>
<a
href="https://github.com/Cap-go/capgo.app/blob/main/docs/admin/plans-checkout-completion.md"
target="_blank"
rel="noopener noreferrer"
class="font-medium text-primary underline underline-offset-4 hover:no-underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
>
{{ t('plans-analytics-checkout-completion-link') }}
</a>
</template>
<div role="group" aria-labelledby="plans-checkout-completion-title" aria-describedby="plans-checkout-completion-description" class="h-full">

Check warning on line 318 in src/pages/admin/dashboard/plans.vue

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use <address> or <details> or <fieldset> or <optgroup> instead of the group role to ensure accessibility across all devices.

See more on https://sonarcloud.io/project/issues?id=Cap-go_capgo&issues=AaA9-L-_zESqY4hYYacH&open=AaA9-L-_zESqY4hYYacH&pullRequest=3210
<AdminStackedBarChart :series="series.checkoutCompletion" :is-loading="isLoadingStats" accessible-borders />
<table class="sr-only">
<caption>{{ t('plans-analytics-checkout-completion-description') }}</caption>
<thead>
<tr>
<th scope="col">
{{ t('date') }}
</th><th v-for="item in series.checkoutCompletion" :key="item.label" scope="col">
{{ item.label }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="(point, index) in series.checkoutCompletion[0]?.data ?? []" :key="point.date">
<th scope="row">
{{ point.date }}
</th><td v-for="item in series.checkoutCompletion" :key="item.label">
{{ item.data[index]?.value ?? 0 }}
</td>
</tr>
</tbody>
</table>
</div>
</ChartCard>
</div>
Expand Down
28 changes: 28 additions & 0 deletions src/services/adminPlansAnalytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -39,6 +46,7 @@ export interface PlansAnalyticsResponse {
traffic: PlansAnalyticsTraffic
visitorBreakdown: DailyBillingPoint[]
checkoutIntent: DailyCheckoutIntentPoint[]
checkoutCompletion: DailyCheckoutCompletionPoint[]
checkoutVisitorBreakdown: DailyBillingPoint[]
dataQuality: PlansAnalyticsDataQuality
}
Expand All @@ -50,6 +58,7 @@ export interface PlansAnalyticsPresentationState {
hasTraffic: boolean
hasVisitors: boolean
hasCheckoutIntent: boolean
hasCheckoutCompletion: boolean
hasCheckoutVisitors: boolean
showPartialBillingWarning: boolean
}
Expand All @@ -69,6 +78,7 @@ export interface PlansAnalyticsSeries {
traffic: ChartSeries[]
visitors: ChartSeries[]
checkoutIntent: ChartSeries[]
checkoutCompletion: ChartSeries[]
checkoutVisitors: ChartSeries[]
}

Expand All @@ -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),
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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),
}
}
Loading
Loading