From 59e73c01086ae67a929fc7008b091b1e96d0c493 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Mon, 10 Aug 2026 06:48:07 +0200 Subject: [PATCH 01/19] docs: design plans analytics dashboard --- ...-08-10-plans-analytics-dashboard-design.md | 279 ++++++++++++++++++ 1 file changed, 279 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-10-plans-analytics-dashboard-design.md diff --git a/docs/superpowers/specs/2026-08-10-plans-analytics-dashboard-design.md b/docs/superpowers/specs/2026-08-10-plans-analytics-dashboard-design.md new file mode 100644 index 0000000000..72c95a6b3f --- /dev/null +++ b/docs/superpowers/specs/2026-08-10-plans-analytics-dashboard-design.md @@ -0,0 +1,279 @@ +# Plans Analytics Dashboard Design + +## Summary + +Add a read-only **Plans** page to the admin dashboard that explains how organizations use the customer-facing Plans page and whether those visits lead to checkout intent. The page combines historical PostHog behavior with Capgo billing records, preserves data from before exact Plans tracking existed, and makes incomplete historical classification visible instead of silently guessing. + +This feature does not change the customer-facing Plans page, ended-subscription behavior, payment banners, or checkout behavior. The exact Plans tracking fix is a prerequisite, and the stacked vertical bar component merged in PR #2963 is reused. + +## Goals + +- Measure Plans-page traffic by organization and by total logical openings. +- Show the billing state of daily Plans visitors at the time they visited. +- Measure whether daily Plans visitors started checkout within the agreed attribution window. +- Show the historical billing state of organizations that started checkout. +- Preserve legacy Plans visit data while repairing watcher-generated duplicate bursts. +- Make PostHog failures and uncertain historical billing classifications explicit. + +## Non-goals + +- Measuring checkout completion or abandonment in this release. +- Adding a new analytics warehouse, scheduled importer, or materialized reporting table. +- Changing subscription, trial, credit, or checkout behavior. +- Treating current billing state as a substitute for historical state. +- Adding an artificial maximum to the admin date-range picker. + +## Selected Architecture + +Use live hybrid aggregation: + +1. The frontend requests one `plans_analytics` metric from the existing `/private/admin_stats` endpoint. +2. A focused backend Plans analytics module queries PostHog for Plans visits, checkout starts, and timestamped billing-transition evidence. +3. The backend reads Capgo billing and credit history for only the organizations returned by PostHog. +4. Pure functions repair legacy events, attribute checkout, reconstruct billing state, and produce every chart series. +5. The existing five-minute admin-dashboard cache stores the complete response by selected range. + +PostHog remains the source of behavioral facts. Capgo's database remains the billing source of truth, while timestamped PostHog billing transitions may resolve exact intraday timing that daily database rollups cannot provide. + +No direct per-query PostHog charge is expected under the current event-ingestion pricing model. The additional read workload is controlled through one page request, the existing cache, bounded query timestamps, and no automatic refresh or retry. + +## Time Model + +- All query boundaries, buckets, labels, billing-state comparisons, and checkout attribution use UTC. +- The page labels the reporting timezone as UTC. +- The selected range is half-open: `[start, end)`. +- Custom ranges remain unrestricted. Very large ranges may time out; the page explains that the administrator should choose a shorter range. + +## Plans Visit Sources + +### Exact events + +An exact opening is a `User visit` event with `page = 'plans'`. Exact events are emitted once per Plans page activation by the tracking fix and do not receive legacy burst repair. + +### Legacy events + +A legacy candidate is a `User visit` event that: + +- does not contain `page = 'plans'`; and +- has a URL/path property that normalizes to `/settings/organization/plans`. + +Query strings, fragments, and a trailing slash do not change the normalized path. Legacy and exact branches are mutually exclusive. + +### Organization identity + +Events without a valid organization identifier cannot contribute to organization-based graphs. The response reports their count as excluded data-quality evidence. + +## Legacy Burst Repair + +Legacy watcher emissions must be converted into logical openings without removing genuine repeat visits: + +1. Sort legacy candidates chronologically within `organization + PostHog session`. +2. If a session identifier is absent, use `organization + distinct user`. +3. Start a new logical opening when no preceding candidate exists or the inactivity gap exceeds the configured burst threshold. +4. Collapse the other events in the burst into the first event and retain that timestamp. +5. Read 30 seconds before the selected range so a burst crossing the range boundary does not create a false opening. Events before `start` remain excluded from chart counts. + +The initial threshold is 30 seconds. Before implementation is finalized, query the historical same-organization/session inter-event gap distribution and confirm that 30 seconds separates watcher bursts from genuine navigation. The selected value is returned in `dataQuality.legacyDeduplicationSeconds` and covered by deterministic tests. + +This repair changes **Total opens** and visit-to-checkout attribution. Organization-unique graphs apply their own range or daily deduplication after logical openings have been constructed. + +## Checkout Attribution + +- A `Checkout Started` event matches the most recent preceding logical Plans opening for the same organization within 24 hours. +- The checkout is attributed to the Plans opening's UTC day, not the checkout event's UTC day. +- A checkout matches at most one Plans opening. +- A checkout without a preceding Plans opening inside 24 hours is excluded from checkout-intent graphs and reported in data-quality metadata. +- For one organization with multiple attributed checkouts on the same attributed day, checkout-intent counts the organization once. The checkout breakdown uses its earliest attributed checkout that day. + +Example: a Plans opening at August 1 23:55 UTC followed by checkout at August 2 00:05 UTC counts as `Started checkout` on August 1. + +The PostHog query therefore reads Plans visits from 30 seconds before the selected range through `end`, and checkout events from `start` through 24 hours after `end`. Events outside the selected range support repair or attribution only and never become visible Plans visits. + +## Billing Categories + +The categories are mutually exclusive: + +1. Paying +2. Active trial +3. Expired trial — never subscribed +4. Canceled — previously paid and voluntarily ended +5. Payment problem — past due or payment-failure churn +6. Credits only +7. Unknown — insufficient historical evidence + +Classification precedence is: + +1. Payment problem +2. Paying +3. Active trial +4. Credits only +5. Canceled +6. Expired trial — never subscribed +7. Unknown + +Trial-generated `canceled` rows without a `paid_at` timestamp classify as Expired trial, not Canceled. Payment-related churn takes precedence over ordinary cancellation. Credit state is reconstructed at the visit timestamp from grants and transactions, not from the current balance. + +### Paying reconstruction + +For each relevant Stripe customer, reconstruct paid entitlement over time from `daily_revenue_metrics`: + +```text +ending MRR = + opening MRR + + new business MRR + + expansion MRR + - contraction MRR + - churn MRR +``` + +Positive MRR means paid entitlement; zero MRR means no paid entitlement. Carry the resulting state forward until the next billing movement. + +On a UTC day where entitlement changes, daily MRR identifies the start and end state but may not identify the exact intraday transition. Resolve the transition time using, in order: + +- `paid_at` for the first paid conversion; +- `canceled_at` for the current or final cancellation; and +- timestamped PostHog `User subscribe`, `User update subscribe`, `User cancel`, and organization `$groupidentify` billing transitions for intermediate cycles. + +For periods predating reliable revenue history, the fallback is `paid_at <= visit timestamp` and either no `canceled_at` or a visit before `canceled_at`. This fallback is valid only for an unambiguous single paid interval. If daily MRR proves an intraday change but no trustworthy transition timestamp exists, or multiple lifecycle records conflict, classify that visit as Unknown. + +“Paying” therefore means there is positive evidence that paid entitlement existed at the exact Plans-opening timestamp. It never means that today's mutable `stripe_info.status` is currently successful. + +## Graph Definitions + +### 1. Plans page traffic + +Full-width line chart with two series: + +- **Unique visitor orgs:** each organization contributes once in the selected range, placed on the UTC day of its first logical opening inside that range. A visit before the selected range does not suppress the first visit inside the range. +- **Total opens:** every logical opening after legacy repair. + +If an organization opens Plans on August 1 and August 6, it adds one unique organization on August 1, zero unique organizations on August 6, and one total opening on both days. + +### 2. Who opened Plans? + +Full-width stacked vertical bars. Each organization contributes once per UTC day and may contribute again on later days. Its category is evaluated at its first logical Plans opening that day. + +If the same organization opens Plans on August 1 and August 2, it appears once on each day. + +### 3. Checkout intent + +Full-width stacked vertical bars. Each daily unique Plans visitor belongs to exactly one series: + +- Started checkout +- Did not start + +An organization is Started checkout when at least one checkout is attributed to one of its logical Plans openings for that day. Graph 3 totals equal Graph 2 totals for every day. + +### 4. Who opened checkout? + +Full-width stacked vertical bars using the exact Started checkout population from Graph 3. Classify each organization at the Plans opening attributed to its earliest checkout for that attributed day. Graph 4 totals equal Graph 3's Started checkout totals for every day. + +### 5. Checkout completion + +Full-width placeholder card only. Its title is **Checkout completion**. Its body intentionally uses user-facing TODO language and links to `docs/admin/plans-checkout-completion.md` on GitHub. That document explains the completion/abandonment graph that will be implemented after reliable completion tracking exists. + +No completion estimates are derived from missing events in this release. + +## API Contract + +Add `plans_analytics` to the admin `MetricCategory` union and `/private/admin_stats` validation/switch. The endpoint returns all graphs in one response: + +```ts +interface PlansAnalyticsResponse { + traffic: { + dates: string[] + uniqueVisitorOrganizations: number[] + totalOpens: number[] + } + visitorBreakdown: DailyBillingSeries[] + checkoutIntent: DailyCheckoutIntentSeries[] + checkoutVisitorBreakdown: DailyBillingSeries[] + dataQuality: { + exactTrackingStartedAt: string | null + legacyLogicalOpens: number + exactLogicalOpens: number + excludedMissingOrganization: number + unmatchedCheckoutStarts: number + unknownBillingOrganizations: number + posthogConfigured: boolean + posthogConnected: boolean + legacyDeduplicationSeconds: number + } +} +``` + +The concrete daily-series types use the existing admin chart input conventions and contain every UTC date in the selected range, including zero-value days. + +## Admin UI + +Add a **Plans** tab and a dedicated admin dashboard page. The page uses the existing `AdminFilterBar`, identifies UTC explicitly, and renders the five full-width cards in graph order. + +- Graph 1 reuses `AdminMultiLineChart`. +- Graphs 2–4 reuse `AdminStackedBarChart` from PR #2963. +- Graph 5 is the documentation placeholder. +- Loading uses chart-card skeletons. +- A valid empty result renders zero/empty chart states. +- PostHog being unconfigured, unavailable, or timed out renders a page-level unavailable state rather than zero-valued charts. +- Partial billing reconstruction renders the charts with Unknown segments and a visible data-quality warning. +- Refresh uses the existing manual refresh control and invalidates the five-minute cache. +- There is no automatic refresh or automatic retry. + +For an extreme custom range that times out, retain the selection and show: “This range was too large to process. Select a shorter period and try again.” + +## Error Handling and Observability + +- Keep PostHog credentials and HogQL on the backend. +- Preserve the existing platform-admin read-only authorization gate. +- Distinguish unconfigured, connection failure, timeout, and valid empty data in the response/error model. +- Log query duration, selected range duration, logical event counts, and classification coverage without logging organization IDs or credentials. +- Never silently fall back to current billing status. +- Never substitute zeros when either source fails. + +## File Boundaries + +The implementation plan should preserve these responsibilities: + +- A focused backend Plans analytics module owns PostHog queries and orchestration. +- Pure backend helpers own visit repair, checkout attribution, paid-timeline reconstruction, and billing-category precedence. +- `/private/admin_stats` owns validation, authorization, and dispatch only. +- The Pinia store owns the typed metric request and existing cache integration. +- The Plans admin page owns presentation and state rendering only. +- A dedicated Markdown file owns the deferred checkout-completion requirements. + +No database migration is required. + +## Verification + +Unit tests cover: + +- duplicate legacy bursts and genuine repeat visits; +- session fallback to distinct user; +- a burst crossing the selected-range boundary; +- separation of exact and legacy events; +- range-wide versus daily organization uniqueness; +- UTC date bucketing; +- checkout across midnight within 24 hours; +- exclusion of checkout outside 24 hours or without a visit; +- one checkout result per organization/day; +- Graph 2 classification at the first daily opening; +- Graph 4 classification at the opening attributed to the earliest checkout; +- billing-category precedence; +- first subscription, cancellation, payment failure, recovery, and resubscription timelines; +- credit balance at the historical timestamp; +- deliberate Unknown classification for ambiguous history; +- Graph 2 and Graph 3 daily-total equality; +- Graph 3 Started checkout and Graph 4 daily-total equality. + +Backend tests cover admin authorization, request validation, PostHog unconfigured/unavailable/timeout behavior, valid empty data, and the complete response contract using mocked PostHog responses. + +Frontend tests cover loading, populated graphs, valid empty data, partial-data warnings, large-range timeout messaging, unavailable state, UTC labeling, and the checkout-completion documentation link. + +Before handoff, run focused tests, lint, type checking, and the production build. Validate the 30-second legacy threshold against the real historical gap distribution before considering the analytics numerically trustworthy. + +## Dependencies and Rollout + +- PR #2963 supplies the stacked vertical bar component. +- The exact Plans `User visit` tracking fix must be merged before this dashboard is considered complete. +- Legacy history remains visible with explicit reconstruction metadata. +- Exact and legacy counts appear in data-quality metadata so the transition can be monitored. +- Checkout completion remains deferred until a reliable success signal and abandonment definition are implemented. From e7714cc48b89fc6337db7a460d22a7efc6d71ee7 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Mon, 10 Aug 2026 06:58:32 +0200 Subject: [PATCH 02/19] docs: clarify plans analytics failure states --- .../specs/2026-08-10-plans-analytics-dashboard-design.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/superpowers/specs/2026-08-10-plans-analytics-dashboard-design.md b/docs/superpowers/specs/2026-08-10-plans-analytics-dashboard-design.md index 72c95a6b3f..e95ceb1048 100644 --- a/docs/superpowers/specs/2026-08-10-plans-analytics-dashboard-design.md +++ b/docs/superpowers/specs/2026-08-10-plans-analytics-dashboard-design.md @@ -197,6 +197,7 @@ interface PlansAnalyticsResponse { unknownBillingOrganizations: number posthogConfigured: boolean posthogConnected: boolean + posthogFailureReason: 'unconfigured' | 'timeout' | 'unavailable' | 'too_large' | null legacyDeduplicationSeconds: number } } @@ -225,6 +226,7 @@ For an extreme custom range that times out, retain the selection and show: “Th - Keep PostHog credentials and HogQL on the backend. - Preserve the existing platform-admin read-only authorization gate. - Distinguish unconfigured, connection failure, timeout, and valid empty data in the response/error model. +- Treat an analytics result that reaches the bounded PostHog row ceiling as `too_large`; never render silently truncated charts. - Log query duration, selected range duration, logical event counts, and classification coverage without logging organization IDs or credentials. - Never silently fall back to current billing status. - Never substitute zeros when either source fails. From a1656e8d25c1ce6e0d9ccf35a0558d955846de54 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Mon, 10 Aug 2026 07:17:13 +0200 Subject: [PATCH 03/19] docs: add plans analytics implementation plan --- .../2026-08-10-plans-analytics-dashboard.md | 1493 +++++++++++++++++ ...-08-10-plans-analytics-dashboard-design.md | 4 + 2 files changed, 1497 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-10-plans-analytics-dashboard.md diff --git a/docs/superpowers/plans/2026-08-10-plans-analytics-dashboard.md b/docs/superpowers/plans/2026-08-10-plans-analytics-dashboard.md new file mode 100644 index 0000000000..88f2a92b20 --- /dev/null +++ b/docs/superpowers/plans/2026-08-10-plans-analytics-dashboard.md @@ -0,0 +1,1493 @@ +# Plans Analytics Dashboard Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a read-only admin Plans analytics page that reconstructs logical Plans openings, classifies daily visitor organizations by historical billing state, and attributes checkout intent across midnight. + +**Architecture:** `/private/admin_stats` dispatches one `plans_analytics` request to a focused backend orchestrator. PostHog supplies behavior and timestamped billing transitions, PostgreSQL supplies billing and credit history, pure functions build the reconciled UTC chart datasets, and the existing Pinia cache supplies five-minute frontend caching. Unavailable, ambiguous, and oversized data is reported explicitly rather than rendered as zero. + +**Tech Stack:** Vue 3, Pinia, TypeScript, Hono, PostgreSQL, PostHog HogQL, Chart.js, Vitest, Bun. + +**Design specification:** `docs/superpowers/specs/2026-08-10-plans-analytics-dashboard-design.md` + +--- + +## File Structure + +**Create:** + +- `supabase/functions/_backend/utils/posthog_read.ts` — reusable, bounded PostHog HogQL transport with structured failure reasons. +- `supabase/functions/_backend/utils/plans_analytics_model.ts` — pure visit repair, checkout attribution, UTC bucketing, and chart aggregation. +- `supabase/functions/_backend/utils/plans_billing_history.ts` — pure billing timeline reconstruction plus bounded PostgreSQL history loading. +- `supabase/functions/_backend/utils/plans_analytics.ts` — PostHog query builders and orchestration of behavior, billing, and response metadata. +- `src/services/adminPlansAnalytics.ts` — frontend response types and chart-series adapters. +- `src/pages/admin/dashboard/plans.vue` — the read-only admin page. +- `docs/admin/plans-checkout-completion.md` — deferred Graph 5 definition. +- `tests/posthog-read.unit.test.ts` — PostHog read transport tests. +- `tests/plans-analytics-model.unit.test.ts` — deduplication, attribution, UTC, and graph invariant tests. +- `tests/plans-billing-history.unit.test.ts` — paid/trial/canceled/payment/credits classification tests. +- `tests/plans-analytics-orchestration.unit.test.ts` — HogQL, PostgreSQL loader, response, and failure-state tests. +- `tests/admin-plans-analytics-dashboard.unit.test.ts` — frontend adapters, page wiring, translations, tab, and deferred-document tests. + +**Modify:** + +- `supabase/functions/_backend/utils/builder_analytics.ts` — consume the shared PostHog read transport. +- `supabase/functions/_backend/private/admin_stats.ts` — validate and dispatch `plans_analytics`. +- `src/stores/adminDashboard.ts` — add the metric category. +- `src/constants/adminTabs.ts` — add the Plans tab. +- `messages/en.json` — add all user-visible page, graph, category, and failure-state labels. + +No database migration or customer-facing Plans-page change belongs in this implementation. + +--- + +### Task 1: Verify the Historical PostHog Contract + +**Files:** + +- Read: `src/pages/settings/organization/Plans.vue` +- Read: `src/pages/settings/organization/Usage.vue` +- Read: `supabase/functions/_backend/utils/posthog.ts` +- Read: `supabase/functions/_backend/private/events.ts` +- Modify if evidence changes: `docs/superpowers/specs/2026-08-10-plans-analytics-dashboard-design.md` + +- [ ] **Step 1: Verify the exact tracking prerequisite without copying it into this branch** + +Run: + +```bash +rg -n "page: 'plans'|plansVisitTracking" src/pages/settings/organization/Plans.vue src/services +``` + +Expected after the tracking PR has merged: an exact `User visit` emission with event property `page: 'plans'`. If it is absent, merge the prerequisite through `main`; do not recreate or cherry-pick its implementation into this analytics PR. + +- [ ] **Step 2: Run the bounded PostHog schema/sample probe** + +Run this HogQL through the configured PostHog SQL reader or SQL editor: + +```sql +SELECT + timestamp, + properties.org_id AS org_id, + properties.page AS page, + properties.$current_url AS event_current_url, + properties.$pathname AS event_pathname, + properties.$session_id AS session_id, + properties.$groups.organization AS grouped_org_id, + distinct_id, + person.properties.$current_url AS person_current_url +FROM events +WHERE event = 'User visit' + AND timestamp >= parseDateTimeBestEffort('2026-02-23T00:00:00.000Z') + AND timestamp < now() +ORDER BY timestamp DESC +LIMIT 100 +``` + +Expected: exact events expose `page = 'plans'`; legacy rows expose a usable organization identifier. Record whether `event_current_url` or `event_pathname` contains an event-time Plans path. Only use `person_current_url` if the PostHog project metadata confirms person-on-events ingestion-time semantics. + +- [ ] **Step 3: Decide the legacy availability flag from evidence** + +Use this fixed rule: + +```text +event_current_url or event_pathname available on legacy rows + => legacyReconstructionAvailable = true + +only person_current_url available AND person-on-events is event-time + => legacyReconstructionAvailable = true + +only query-time person URL available + => legacyReconstructionAvailable = false + legacyUnavailableReason = 'missing_event_time_path' +``` + +Expected: the implementation never turns a current person URL into a historical Plans visit. + +- [ ] **Step 4: Validate the 30-second legacy burst threshold** + +Run: + +```sql +SELECT + multiIf( + gap_seconds <= 1, '00-01s', + gap_seconds <= 5, '02-05s', + gap_seconds <= 10, '06-10s', + gap_seconds <= 30, '11-30s', + gap_seconds <= 60, '31-60s', + gap_seconds <= 300, '01-05m', + 'over-05m' + ) AS gap_bucket, + count() AS events +FROM ( + SELECT dateDiff( + 'second', + lagInFrame(timestamp) OVER ( + PARTITION BY properties.org_id, distinct_id + ORDER BY timestamp + ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING + ), + timestamp + ) AS gap_seconds + FROM events + WHERE event = 'User visit' + AND timestamp >= parseDateTimeBestEffort('2026-02-23T00:00:00.000Z') + AND timestamp < now() +) +WHERE gap_seconds >= 0 +GROUP BY gap_bucket +ORDER BY gap_bucket +``` + +Expected: duplicate bursts concentrate at or below 30 seconds. Keep `LEGACY_BURST_SECONDS = 30`; if the distribution disproves the cutoff, update the constant, its tests, and the design document in the same commit before continuing. + +- [ ] **Step 5: Commit any evidence-driven specification correction** + +```bash +git add docs/superpowers/specs/2026-08-10-plans-analytics-dashboard-design.md +git commit -m "docs: record plans analytics data contract" +``` + +Expected: either a focused spec commit, or a clean worktree when the existing contract is confirmed unchanged. + +--- + +### Task 2: Extract a Structured PostHog Read Transport + +**Files:** + +- Create: `tests/posthog-read.unit.test.ts` +- Create: `supabase/functions/_backend/utils/posthog_read.ts` +- Modify: `supabase/functions/_backend/utils/builder_analytics.ts` + +- [ ] **Step 1: Write the failing transport tests** + +Create `tests/posthog-read.unit.test.ts`: + +```ts +import type { Context } from 'hono' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { queryPosthogHogql } from '../supabase/functions/_backend/utils/posthog_read.ts' + +vi.mock('hono/adapter', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + env: vi.fn((c: Context) => (c as Context & { env?: Record }).env ?? {}), + } +}) + +function context(env: Record = {}) { + return { + env, + get: vi.fn((key: string) => key === 'requestId' ? 'plans-test' : undefined), + } as unknown as Context +} + +afterEach(() => vi.unstubAllGlobals()) + +describe('PostHog read transport', () => { + it.concurrent('reports unconfigured without calling fetch', async () => { + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + await expect(queryPosthogHogql(context(), 'SELECT 1')).resolves.toEqual({ + configured: false, + connected: false, + failureReason: 'unconfigured', + rows: [], + }) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('maps columns to objects on success', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ + columns: ['org_id', 'opens'], + results: [['org-a', 3]], + }), { status: 200 }))) + await expect(queryPosthogHogql(context({ POSTHOG_READ_KEY: 'key' }), 'SELECT 1')).resolves.toEqual({ + configured: true, + connected: true, + failureReason: null, + rows: [{ org_id: 'org-a', opens: 3 }], + }) + }) + + it.each([ + ['HTTP failure', new Response('', { status: 503 }), 'unavailable'], + ['timeout', Object.assign(new Error('timed out'), { name: 'TimeoutError' }), 'timeout'], + ] as const)('reports %s', async (_label, outcome, failureReason) => { + const fetchMock = outcome instanceof Response + ? vi.fn().mockResolvedValue(outcome) + : vi.fn().mockRejectedValue(outcome) + vi.stubGlobal('fetch', fetchMock) + const result = await queryPosthogHogql(context({ POSTHOG_READ_KEY: 'key' }), 'SELECT 1') + expect(result).toMatchObject({ configured: true, connected: false, failureReason, rows: [] }) + }) +}) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: + +```bash +bunx vitest run tests/posthog-read.unit.test.ts +``` + +Expected: FAIL because `posthog_read.ts` does not exist. + +- [ ] **Step 3: Implement the shared transport** + +Create `supabase/functions/_backend/utils/posthog_read.ts` with this public contract: + +```ts +import type { Context } from 'hono' +import { cloudlogErr, serializeError } from './logging.ts' +import { getEnv } from './utils.ts' + +export type PosthogReadFailureReason = 'unconfigured' | 'timeout' | 'unavailable' + +export interface PosthogReadResult { + configured: boolean + connected: boolean + failureReason: PosthogReadFailureReason | null + rows: Record[] +} + +export async function queryPosthogHogql(c: Context, query: string): Promise { + const key = (getEnv(c, 'POSTHOG_READ_KEY') || '').trim() + if (!key) + return { configured: false, connected: false, failureReason: 'unconfigured', rows: [] } + + const host = ((getEnv(c, 'POSTHOG_READ_HOST') || 'https://eu.posthog.com').trim()).replace(/\/$/, '') + const project = (getEnv(c, 'POSTHOG_READ_PROJECT_ID') || '22029').trim() + try { + const response = await fetch(`${host}/api/projects/${project}/query/`, { + method: 'POST', + headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ query: { kind: 'HogQLQuery', query } }), + signal: AbortSignal.timeout(20_000), + }) + if (!response.ok) { + cloudlogErr({ requestId: c.get('requestId'), message: 'posthog_query_failed', status: response.status }) + return { configured: true, connected: false, failureReason: 'unavailable', rows: [] } + } + const body = await response.json() as { columns?: string[], results?: unknown[][] } + const columns = body.columns ?? [] + return { + configured: true, + connected: true, + failureReason: null, + rows: (body.results ?? []).map(row => Object.fromEntries(columns.map((column, index) => [column, row[index]]))), + } + } + catch (error) { + const timeout = error instanceof Error && (error.name === 'TimeoutError' || error.name === 'AbortError') + cloudlogErr({ requestId: c.get('requestId'), message: 'posthog_query_error', error: serializeError(error) }) + return { configured: true, connected: false, failureReason: timeout ? 'timeout' : 'unavailable', rows: [] } + } +} +``` + +- [ ] **Step 4: Refactor Builder analytics to use the transport** + +In `builder_analytics.ts`, remove its private `HogResult`/`hogql` implementation and import: + +```ts +import { queryPosthogHogql } from './posthog_read.ts' +``` + +Replace calls with: + +```ts +const { connected: ok, rows } = await queryPosthogHogql(c, q) +``` + +Keep Builder's existing `posthog_configured` and `posthog_connected` response semantics unchanged. + +- [ ] **Step 5: Run focused tests and commit** + +Run: + +```bash +bunx vitest run tests/posthog-read.unit.test.ts +bun run typecheck:backend +``` + +Expected: PASS. + +Commit: + +```bash +git add tests/posthog-read.unit.test.ts supabase/functions/_backend/utils/posthog_read.ts supabase/functions/_backend/utils/builder_analytics.ts +git commit -m "refactor(analytics): share PostHog read transport" +``` + +--- + +### Task 3: Implement Logical Openings, Checkout Attribution, and Graph Invariants + +**Files:** + +- Create: `tests/plans-analytics-model.unit.test.ts` +- Create: `supabase/functions/_backend/utils/plans_analytics_model.ts` + +- [ ] **Step 1: Write failing model tests** + +Create `tests/plans-analytics-model.unit.test.ts` using these fixtures and assertions: + +```ts +import { describe, expect, it } from 'vitest' +import { + attributeCheckoutStarts, + buildLogicalPlansOpenings, + buildPlansChartData, + type PlansBehaviorEvent, +} from '../supabase/functions/_backend/utils/plans_analytics_model.ts' + +const ms = (value: string) => Date.parse(value) +const event = (partial: Partial & Pick): PlansBehaviorEvent => ({ + actorId: 'user-a', + event: 'User visit', + page: '', + path: '/settings/organization/plans', + sessionId: '', + ...partial, +}) + +describe('Plans analytics model', () => { + it.concurrent('collapses only legacy bursts and preserves exact repeat openings', () => { + const events = [ + event({ timestampMs: ms('2026-08-01T10:00:00Z'), orgId: 'org-a' }), + event({ timestampMs: ms('2026-08-01T10:00:08Z'), orgId: 'org-a' }), + event({ timestampMs: ms('2026-08-01T10:05:00Z'), orgId: 'org-a' }), + event({ timestampMs: ms('2026-08-01T11:00:00Z'), orgId: 'org-a', page: 'plans', path: '' }), + event({ timestampMs: ms('2026-08-01T11:00:02Z'), orgId: 'org-a', page: 'plans', path: '' }), + ] + expect(buildLogicalPlansOpenings(events, ms('2026-08-01T00:00:00Z'), ms('2026-08-02T00:00:00Z'), 30)) + .toHaveLength(4) + }) + + it.concurrent('uses session then actor fallback and suppresses a boundary-crossing duplicate', () => { + const events = [ + event({ timestampMs: ms('2026-07-31T23:59:50Z'), orgId: 'org-a', actorId: 'user-a' }), + event({ timestampMs: ms('2026-08-01T00:00:05Z'), orgId: 'org-a', actorId: 'user-a' }), + event({ timestampMs: ms('2026-08-01T00:00:05Z'), orgId: 'org-a', actorId: 'user-b' }), + ] + const openings = buildLogicalPlansOpenings(events, ms('2026-08-01T00:00:00Z'), ms('2026-08-02T00:00:00Z'), 30) + expect(openings.map(item => item.actorId)).toEqual(['user-b']) + }) + + it.concurrent('attributes a post-midnight checkout to the latest preceding opening within 24 hours', () => { + const openings = buildLogicalPlansOpenings([ + event({ timestampMs: ms('2026-08-01T23:55:00Z'), orgId: 'org-a', page: 'plans', path: '' }), + ], ms('2026-08-01T00:00:00Z'), ms('2026-08-02T00:00:00Z'), 30) + const matches = attributeCheckoutStarts(openings, [ + event({ event: 'Checkout Started', timestampMs: ms('2026-08-02T00:05:00Z'), orgId: 'org-a', path: '' }), + event({ event: 'Checkout Started', timestampMs: ms('2026-08-03T00:06:00Z'), orgId: 'org-a', path: '' }), + ]) + expect(matches).toHaveLength(1) + expect(matches[0].attributedDate).toBe('2026-08-01') + }) + + it.concurrent('keeps range-wide uniques distinct from daily uniques and reconciles graph totals', () => { + const openings = buildLogicalPlansOpenings([ + event({ timestampMs: ms('2026-08-01T08:00:00Z'), orgId: 'org-a', page: 'plans', path: '' }), + event({ timestampMs: ms('2026-08-02T08:00:00Z'), orgId: 'org-a', page: 'plans', path: '' }), + event({ timestampMs: ms('2026-08-02T09:00:00Z'), orgId: 'org-b', page: 'plans', path: '' }), + ], ms('2026-08-01T00:00:00Z'), ms('2026-08-03T00:00:00Z'), 30) + const matches = attributeCheckoutStarts(openings, [ + event({ event: 'Checkout Started', timestampMs: ms('2026-08-02T08:10:00Z'), orgId: 'org-a', path: '' }), + ]) + const result = buildPlansChartData({ + openings, + attributedCheckouts: matches, + startMs: ms('2026-08-01T00:00:00Z'), + endMs: ms('2026-08-03T00:00:00Z'), + classifyAt: orgId => orgId === 'org-a' ? 'paying' : 'active_trial', + }) + expect(result.traffic.uniqueVisitorOrganizations).toEqual([1, 1]) + expect(result.traffic.totalOpens).toEqual([1, 2]) + expect(result.visitorBreakdown.map(day => day.total)).toEqual([1, 2]) + expect(result.checkoutIntent.map(day => day.startedCheckout + day.didNotStart)).toEqual([1, 2]) + expect(result.checkoutVisitorBreakdown.map(day => day.total)).toEqual([0, 1]) + }) +}) +``` + +- [ ] **Step 2: Run the model tests to verify they fail** + +```bash +bunx vitest run tests/plans-analytics-model.unit.test.ts +``` + +Expected: FAIL because the model module does not exist. + +- [ ] **Step 3: Define the pure model contract** + +Create `plans_analytics_model.ts` with these exported types and constants: + +```ts +export const LEGACY_BURST_SECONDS = 30 +export const CHECKOUT_ATTRIBUTION_MS = 24 * 60 * 60 * 1000 + +export type PlansBillingCategory = + | 'paying' + | 'active_trial' + | 'expired_trial' + | 'canceled' + | 'payment_problem' + | 'credits_only' + | 'unknown' + +export interface PlansBehaviorEvent { + event: 'User visit' | 'Checkout Started' + timestampMs: number + orgId: string + actorId: string + sessionId: string + page: string + path: string +} + +export interface LogicalPlansOpening extends PlansBehaviorEvent { + source: 'exact' | 'legacy' +} + +export interface AttributedCheckout { + checkoutTimestampMs: number + orgId: string + opening: LogicalPlansOpening + attributedDate: string +} +``` + +The output daily types must contain explicit keys rather than arbitrary records: + +```ts +export interface DailyBillingPoint { + date: string + paying: number + activeTrial: number + expiredTrial: number + canceled: number + paymentProblem: number + creditsOnly: number + unknown: number + total: number +} + +export interface DailyCheckoutIntentPoint { + date: string + startedCheckout: number + didNotStart: number +} +``` + +- [ ] **Step 4: Implement visit repair and checkout attribution** + +Implement these rules directly in the exported functions: + +```ts +export function buildLogicalPlansOpenings( + events: PlansBehaviorEvent[], + startMs: number, + endMs: number, + burstSeconds = LEGACY_BURST_SECONDS, +): LogicalPlansOpening[] + +export function attributeCheckoutStarts( + openings: LogicalPlansOpening[], + checkoutEvents: PlansBehaviorEvent[], +): AttributedCheckout[] +``` + +Implementation requirements: + +```text +exact candidate: event === 'User visit' && page === 'plans' +legacy candidate: event === 'User visit' && normalized path === '/settings/organization/plans' +legacy identity: orgId + (sessionId || actorId) +legacy new opening: first event or previous gap > burstSeconds +visible opening: timestampMs >= startMs && timestampMs < endMs +checkout match: maximum opening.timestampMs <= checkout.timestampMs with gap <= 24h +``` + +Normalize paths with `new URL(value, 'https://console.capgo.app').pathname`, then remove a trailing slash except for `/`. + +- [ ] **Step 5: Implement graph aggregation** + +Export: + +```ts +export function buildPlansChartData(input: { + openings: LogicalPlansOpening[] + attributedCheckouts: AttributedCheckout[] + startMs: number + endMs: number + classifyAt: (orgId: string, timestampMs: number) => PlansBillingCategory +}): { + traffic: { dates: string[], uniqueVisitorOrganizations: number[], totalOpens: number[] } + visitorBreakdown: DailyBillingPoint[] + checkoutIntent: DailyCheckoutIntentPoint[] + checkoutVisitorBreakdown: DailyBillingPoint[] +} +``` + +Use the first opening per organization in the selected range for Graph 1, the first opening per organization/day for Graph 2, any attributed checkout per organization/day for Graph 3, and the earliest attributed checkout per organization/day for Graph 4. Generate all UTC day keys intersecting `[startMs, endMs)`, including zero days. + +- [ ] **Step 6: Run tests and commit** + +```bash +bunx vitest run tests/plans-analytics-model.unit.test.ts +bun run typecheck:backend +git add tests/plans-analytics-model.unit.test.ts supabase/functions/_backend/utils/plans_analytics_model.ts +git commit -m "feat(admin): model plans visit analytics" +``` + +Expected: PASS. + +--- + +### Task 4: Reconstruct Historical Billing State + +**Files:** + +- Create: `tests/plans-billing-history.unit.test.ts` +- Create: `supabase/functions/_backend/utils/plans_billing_history.ts` + +- [ ] **Step 1: Write the failing classification tests** + +Create a table-driven test with the complete precedence set: + +```ts +import { describe, expect, it } from 'vitest' +import { classifyPlansBillingAt, type OrganizationBillingHistory } from '../supabase/functions/_backend/utils/plans_billing_history.ts' + +const at = Date.parse('2026-08-01T12:00:00Z') +const base = (): OrganizationBillingHistory => ({ + orgId: 'org-a', customerId: 'cus-a', trialEndsAtMs: Date.parse('2026-07-01T00:00:00Z'), + paidAtMs: null, canceledAtMs: null, currentPastDueAtMs: null, churnReason: null, + revenueMovements: [], transitions: [], creditGrants: [], creditConsumptions: [], +}) + +describe('Plans billing history', () => { + it.each([ + ['payment problem beats paying', { + ...base(), paidAtMs: Date.parse('2026-06-01T00:00:00Z'), currentPastDueAtMs: Date.parse('2026-07-20T00:00:00Z'), + }, 'payment_problem'], + ['positive carried MRR is paying', { + ...base(), paidAtMs: Date.parse('2026-06-01T00:00:00Z'), revenueMovements: [{ + date: '2026-06-01', openingMrr: 0, newBusinessMrr: 12, expansionMrr: 0, contractionMrr: 0, churnMrr: 0, churnReason: null, + }], + }, 'paying'], + ['future trial end is active trial', { + ...base(), trialEndsAtMs: Date.parse('2026-08-10T00:00:00Z'), + }, 'active_trial'], + ['historically positive credits are credits only', { + ...base(), creditGrants: [{ id: 'grant-a', grantedAtMs: Date.parse('2026-07-01T00:00:00Z'), expiresAtMs: Date.parse('2026-09-01T00:00:00Z'), creditsTotal: 10 }], + }, 'credits_only'], + ['previously paid voluntary end is canceled', { + ...base(), paidAtMs: Date.parse('2026-06-01T00:00:00Z'), canceledAtMs: Date.parse('2026-07-01T00:00:00Z'), + }, 'canceled'], + ['never-paid ended trial is expired trial', base(), 'expired_trial'], + ] as const)('%s', (_label, history, expected) => { + expect(classifyPlansBillingAt(history, at)).toBe(expected) + }) + + it.concurrent('uses exact intraday transitions and returns unknown for an unresolved movement day', () => { + const history = { + ...base(), + paidAtMs: Date.parse('2026-06-01T00:00:00Z'), + revenueMovements: [{ date: '2026-08-01', openingMrr: 12, newBusinessMrr: 0, expansionMrr: 0, contractionMrr: 0, churnMrr: 12, churnReason: null }], + } + expect(classifyPlansBillingAt(history, at)).toBe('unknown') + expect(classifyPlansBillingAt({ + ...history, + transitions: [{ timestampMs: Date.parse('2026-08-01T14:00:00Z'), kind: 'canceled' }], + }, Date.parse('2026-08-01T13:00:00Z'))).toBe('paying') + }) + + it.each([ + ['payment-failure churn', { + ...base(), + paidAtMs: Date.parse('2026-06-01T00:00:00Z'), + revenueMovements: [{ date: '2026-08-01', openingMrr: 12, newBusinessMrr: 0, expansionMrr: 0, contractionMrr: 0, churnMrr: 12, churnReason: 'past_due_unresolved' }], + transitions: [{ timestampMs: Date.parse('2026-08-01T10:00:00Z'), kind: 'payment_problem' as const }], + }, at, 'payment_problem'], + ['recovered past due', { + ...base(), + paidAtMs: Date.parse('2026-06-01T00:00:00Z'), + revenueMovements: [{ date: '2026-06-01', openingMrr: 0, newBusinessMrr: 12, expansionMrr: 0, contractionMrr: 0, churnMrr: 0, churnReason: null }], + transitions: [ + { timestampMs: Date.parse('2026-08-01T09:00:00Z'), kind: 'payment_problem' as const }, + { timestampMs: Date.parse('2026-08-01T10:00:00Z'), kind: 'recovered' as const }, + ], + }, at, 'paying'], + ['resubscribed after cancellation', { + ...base(), + paidAtMs: Date.parse('2026-06-01T00:00:00Z'), + canceledAtMs: Date.parse('2026-07-01T00:00:00Z'), + transitions: [ + { timestampMs: Date.parse('2026-07-01T00:00:00Z'), kind: 'canceled' as const }, + { timestampMs: Date.parse('2026-08-01T11:00:00Z'), kind: 'paid' as const }, + ], + }, at, 'paying'], + ['credits consumed before visit', { + ...base(), + creditGrants: [{ id: 'grant-a', grantedAtMs: Date.parse('2026-07-01T00:00:00Z'), expiresAtMs: Date.parse('2026-09-01T00:00:00Z'), creditsTotal: 10 }], + creditConsumptions: [{ grantId: 'grant-a', appliedAtMs: Date.parse('2026-07-20T00:00:00Z'), creditsUsed: 10 }], + }, at, 'expired_trial'], + ['credits expired before visit', { + ...base(), + creditGrants: [{ id: 'grant-a', grantedAtMs: Date.parse('2026-06-01T00:00:00Z'), expiresAtMs: Date.parse('2026-07-01T00:00:00Z'), creditsTotal: 10 }], + }, at, 'expired_trial'], + ] as const)('%s', (_label, history, timestamp, expected) => { + expect(classifyPlansBillingAt(history, timestamp)).toBe(expected) + }) + + it.concurrent('returns unknown for contradictory transitions at one instant', () => { + expect(classifyPlansBillingAt({ + ...base(), + paidAtMs: Date.parse('2026-06-01T00:00:00Z'), + transitions: [ + { timestampMs: Date.parse('2026-08-01T10:00:00Z'), kind: 'paid' }, + { timestampMs: Date.parse('2026-08-01T10:00:00Z'), kind: 'canceled' }, + ], + }, at)).toBe('unknown') + }) +}) +``` + +- [ ] **Step 2: Run the test to verify failure** + +```bash +bunx vitest run tests/plans-billing-history.unit.test.ts +``` + +Expected: FAIL because the module does not exist. + +- [ ] **Step 3: Define billing evidence types and calculation helpers** + +Create `plans_billing_history.ts` with explicit evidence types: + +```ts +export interface RevenueMovement { + date: string + openingMrr: number + newBusinessMrr: number + expansionMrr: number + contractionMrr: number + churnMrr: number + churnReason: string | null +} + +export interface BillingTransition { + timestampMs: number + kind: 'paid' | 'canceled' | 'payment_problem' | 'recovered' +} + +export interface OrganizationBillingHistory { + orgId: string + customerId: string | null + trialEndsAtMs: number | null + paidAtMs: number | null + canceledAtMs: number | null + currentPastDueAtMs: number | null + churnReason: string | null + revenueMovements: RevenueMovement[] + transitions: BillingTransition[] + creditGrants: Array<{ id: string, grantedAtMs: number, expiresAtMs: number, creditsTotal: number }> + creditConsumptions: Array<{ grantId: string, appliedAtMs: number, creditsUsed: number }> +} +``` + +Implement `endingMrr()`, `hasCreditsAt()`, `paidStateAt()`, and `classifyPlansBillingAt()`. Never read the mutable current Stripe status as historical truth. + +- [ ] **Step 4: Implement the exact precedence** + +`classifyPlansBillingAt(history, timestampMs)` must follow: + +```text +1. payment_problem: active past-due episode or payment-failure churn at timestamp +2. paying: positive reconstructed entitlement at timestamp +3. active_trial: timestamp < trialEndsAtMs and no paid entitlement +4. credits_only: positive unexpired historical credit balance +5. canceled: paidAtMs <= timestamp and a voluntary cancellation is active +6. expired_trial: timestamp >= trialEndsAtMs and no payment before timestamp +7. unknown +``` + +For a movement day, use `openingMrr` at 00:00 UTC and apply timestamped transitions in order. If opening and ending MRR disagree and no transition locates the change relative to the visit, return `unknown` for that visit. + +- [ ] **Step 5: Add the bounded PostgreSQL loader** + +Export: + +```ts +export async function loadPlansBillingHistories( + c: Context, + orgIds: string[], + startDate: string, + endDate: string, + transitions: Map, +): Promise> +``` + +Use one `getPgClient(c, true)` lifecycle and parameterized `ANY($1::uuid[])`/`ANY($1::text[])` queries. Load: + +```sql +SELECT o.id::text AS org_id, o.customer_id, si.trial_at, si.paid_at, + si.canceled_at, si.past_due_at, si.churn_reason +FROM public.orgs o +LEFT JOIN public.stripe_info si ON si.customer_id = o.customer_id +WHERE o.id = ANY($1::uuid[]) +``` + +For revenue, union the last row before `startDate` per relevant customer with every row in `[startDate, endDate]`; do not scan unrelated customers. For credits, load grants whose lifetime overlaps the selected range and their consumptions through `endDate`. + +- [ ] **Step 6: Run tests and commit** + +```bash +bunx vitest run tests/plans-billing-history.unit.test.ts +bun run typecheck:backend +git add tests/plans-billing-history.unit.test.ts supabase/functions/_backend/utils/plans_billing_history.ts +git commit -m "feat(admin): classify historical plans billing state" +``` + +Expected: PASS. + +--- + +### Task 5: Query PostHog and Assemble the Analytics Response + +**Files:** + +- Create: `tests/plans-analytics-orchestration.unit.test.ts` +- Create: `supabase/functions/_backend/utils/plans_analytics.ts` + +- [ ] **Step 1: Write failing query and response tests** + +Create `tests/plans-analytics-orchestration.unit.test.ts` with mocked transports and these assertions: + +```ts +import type { Context } from 'hono' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { loadPlansBillingHistories } from '../supabase/functions/_backend/utils/plans_billing_history.ts' +import { + buildBillingTransitionsQuery, + buildExactTrackingStartQuery, + buildPlansBehaviorQuery, + getAdminPlansAnalytics, + MAX_POSTHOG_ROWS, +} from '../supabase/functions/_backend/utils/plans_analytics.ts' +import { queryPosthogHogql } from '../supabase/functions/_backend/utils/posthog_read.ts' + +vi.mock('../supabase/functions/_backend/utils/posthog_read.ts', () => ({ queryPosthogHogql: vi.fn() })) +vi.mock('../supabase/functions/_backend/utils/plans_billing_history.ts', async (importOriginal) => ({ + ...await importOriginal(), + loadPlansBillingHistories: vi.fn(), +})) + +const start = '2026-08-01T00:00:00.000Z' +const end = '2026-08-02T00:00:00.000Z' +const context = { get: vi.fn(() => 'request-id') } as unknown as Context + +beforeEach(() => { + vi.clearAllMocks() + vi.mocked(loadPlansBillingHistories).mockResolvedValue(new Map()) +}) + +describe('Plans analytics orchestration', () => { + it.concurrent('builds bounded scalar-only queries', () => { + expect(buildPlansBehaviorQuery(start, end)).toContain("event IN ('User visit', 'Checkout Started')") + expect(buildPlansBehaviorQuery(start, end)).toContain('2026-07-31T23:59:30.000Z') + expect(buildPlansBehaviorQuery(start, end)).toContain('2026-08-03T00:00:00.000Z') + expect(buildPlansBehaviorQuery(start, end)).not.toContain('SELECT properties') + expect(buildBillingTransitionsQuery(end)).toContain("event IN ('User subscribe', 'User update subscribe', 'User cancel', '$groupidentify')") + expect(buildExactTrackingStartQuery()).toContain("properties.page = 'plans'") + expect(buildExactTrackingStartQuery()).toContain('2026-02-23T00:00:00.000Z') + }) + + it.each([ + ['unconfigured', { configured: false, connected: false, failureReason: 'unconfigured' as const, rows: [] }], + ['timeout', { configured: true, connected: false, failureReason: 'timeout' as const, rows: [] }], + ['unavailable', { configured: true, connected: false, failureReason: 'unavailable' as const, rows: [] }], + ])('returns a structured %s state', async (_label, failure) => { + vi.mocked(queryPosthogHogql).mockResolvedValue(failure) + const result = await getAdminPlansAnalytics(context, start, end) + expect(result.dataQuality.posthogFailureReason).toBe(failure.failureReason) + expect(result.traffic.totalOpens).toEqual([0]) + expect(loadPlansBillingHistories).not.toHaveBeenCalled() + }) + + it('rejects a row-ceiling result instead of returning partial charts', async () => { + vi.mocked(queryPosthogHogql) + .mockResolvedValueOnce({ configured: true, connected: true, failureReason: null, rows: Array.from({ length: MAX_POSTHOG_ROWS + 1 }, () => ({})) }) + .mockResolvedValue({ configured: true, connected: true, failureReason: null, rows: [] }) + const result = await getAdminPlansAnalytics(context, start, end) + expect(result.dataQuality.posthogFailureReason).toBe('too_large') + expect(result.traffic.totalOpens).toEqual([0]) + }) + + it('distinguishes connected empty data from unavailable data', async () => { + vi.mocked(queryPosthogHogql).mockResolvedValue({ configured: true, connected: true, failureReason: null, rows: [] }) + const result = await getAdminPlansAnalytics(context, start, end) + expect(result.dataQuality).toMatchObject({ posthogConnected: true, posthogFailureReason: null }) + expect(result.traffic).toEqual({ dates: ['2026-08-01'], uniqueVisitorOrganizations: [0], totalOpens: [0] }) + }) +}) +``` + +Add these fixture tests to the same file for exact/legacy quality accounting: + +```ts +it('retains exact rows while reporting unavailable legacy reconstruction and unmatched data', async () => { + vi.mocked(queryPosthogHogql) + .mockResolvedValueOnce({ + configured: true, + connected: true, + failureReason: null, + rows: [ + { timestamp_ms: Date.parse('2026-08-01T10:00:00Z'), event: 'User visit', org_id: 'org-a', grouped_org_id: '', page: 'plans', event_current_url: '', event_pathname: '', person_current_url: '', session_id: '', distinct_id: 'user-a' }, + { timestamp_ms: Date.parse('2026-08-01T10:01:00Z'), event: 'User visit', org_id: 'org-b', grouped_org_id: '', page: '', event_current_url: '', event_pathname: '', person_current_url: '/settings/organization/plans', session_id: '', distinct_id: 'user-b' }, + { timestamp_ms: Date.parse('2026-08-01T10:02:00Z'), event: 'User visit', org_id: '', grouped_org_id: '', page: 'plans', event_current_url: '', event_pathname: '', person_current_url: '', session_id: '', distinct_id: 'user-c' }, + { timestamp_ms: Date.parse('2026-08-01T10:05:00Z'), event: 'Checkout Started', org_id: 'org-a', grouped_org_id: '', page: '', event_current_url: '', event_pathname: '', person_current_url: '', session_id: '', distinct_id: 'user-a' }, + { timestamp_ms: Date.parse('2026-08-01T12:00:00Z'), event: 'Checkout Started', org_id: 'org-x', grouped_org_id: '', page: '', event_current_url: '', event_pathname: '', person_current_url: '', session_id: '', distinct_id: 'user-x' }, + ], + }) + .mockResolvedValueOnce({ configured: true, connected: true, failureReason: null, rows: [] }) + .mockResolvedValueOnce({ configured: true, connected: true, failureReason: null, rows: [{ exact_tracking_started_at: '2026-08-01T10:00:00Z' }] }) + vi.mocked(loadPlansBillingHistories).mockResolvedValue(new Map([['org-a', { + orgId: 'org-a', customerId: 'cus-a', trialEndsAtMs: Date.parse('2026-07-01T00:00:00Z'), + paidAtMs: null, canceledAtMs: null, currentPastDueAtMs: null, churnReason: null, + revenueMovements: [], transitions: [], creditGrants: [], creditConsumptions: [], + }]])) + + const result = await getAdminPlansAnalytics(context, start, end) + expect(result.dataQuality).toMatchObject({ + exactLogicalOpens: 1, + legacyLogicalOpens: 0, + legacyReconstructionAvailable: false, + legacyUnavailableReason: 'missing_event_time_path', + excludedMissingOrganization: 1, + unmatchedCheckoutStarts: 1, + unknownBillingOrganizations: 0, + }) + expect(result.checkoutIntent[0]).toMatchObject({ startedCheckout: 1, didNotStart: 0 }) +}) + +it('uses a verified event pathname for repaired legacy openings', async () => { + vi.mocked(queryPosthogHogql) + .mockResolvedValueOnce({ configured: true, connected: true, failureReason: null, rows: [ + { timestamp_ms: Date.parse('2026-08-01T10:00:00Z'), event: 'User visit', org_id: 'org-a', grouped_org_id: '', page: '', event_current_url: 'https://console.capgo.app/settings/organization/plans?from=dashboard', event_pathname: '', person_current_url: '', session_id: '', distinct_id: 'user-a' }, + { timestamp_ms: Date.parse('2026-08-01T10:00:05Z'), event: 'User visit', org_id: 'org-a', grouped_org_id: '', page: '', event_current_url: 'https://console.capgo.app/settings/organization/plans', event_pathname: '', person_current_url: '', session_id: '', distinct_id: 'user-a' }, + ] }) + .mockResolvedValueOnce({ configured: true, connected: true, failureReason: null, rows: [] }) + .mockResolvedValueOnce({ configured: true, connected: true, failureReason: null, rows: [] }) + const result = await getAdminPlansAnalytics(context, start, end) + expect(result.dataQuality).toMatchObject({ legacyReconstructionAvailable: true, legacyLogicalOpens: 1 }) +}) +``` + +- [ ] **Step 2: Run the test to verify failure** + +```bash +bunx vitest run tests/plans-analytics-orchestration.unit.test.ts +``` + +Expected: FAIL because `plans_analytics.ts` does not exist. + +- [ ] **Step 3: Define the response and failure contract** + +Create `plans_analytics.ts` with: + +```ts +export const MAX_POSTHOG_ROWS = 200_000 +export const TRACKING_HISTORY_START = '2026-02-23T00:00:00.000Z' +export const LEGACY_PATH_SOURCE = 'event' as const + +export interface PlansAnalyticsResponse { + traffic: { dates: string[], uniqueVisitorOrganizations: number[], totalOpens: number[] } + visitorBreakdown: DailyBillingPoint[] + checkoutIntent: DailyCheckoutIntentPoint[] + checkoutVisitorBreakdown: DailyBillingPoint[] + dataQuality: { + exactTrackingStartedAt: string | null + legacyLogicalOpens: number + exactLogicalOpens: number + legacyReconstructionAvailable: boolean + legacyUnavailableReason: 'missing_event_time_path' | null + excludedMissingOrganization: number + unmatchedCheckoutStarts: number + unknownBillingOrganizations: number + posthogConfigured: boolean + posthogConnected: boolean + posthogFailureReason: 'unconfigured' | 'timeout' | 'unavailable' | 'too_large' | null + legacyDeduplicationSeconds: number + } +} +``` + +Add `emptyPlansAnalyticsResponse(startMs, endMs, quality)` so every failure path shares one deterministic shape. + +- [ ] **Step 4: Implement safe HogQL builders** + +Use a local `sqlString(value)` that doubles apostrophes. The behavior query must select only required scalar fields: + +```sql +SELECT + toUnixTimestamp(timestamp) * 1000 AS timestamp_ms, + event, + properties.org_id AS org_id, + properties.$groups.organization AS grouped_org_id, + properties.page AS page, + properties.$current_url AS event_current_url, + properties.$pathname AS event_pathname, + person.properties.$current_url AS person_current_url, + properties.$session_id AS session_id, + distinct_id +FROM events +WHERE event IN ('User visit', 'Checkout Started') + AND timestamp >= parseDateTimeBestEffort('2026-07-31T23:59:30.000Z') + AND timestamp < parseDateTimeBestEffort('2026-08-03T00:00:00.000Z') +ORDER BY timestamp +LIMIT 200001 +``` + +The concrete timestamps above illustrate a request for `[2026-08-01, 2026-08-02)`. In the builder, calculate them with: + +```ts +const queryStart = new Date(Date.parse(startDate) - (LEGACY_BURST_SECONDS * 1000)).toISOString() +const queryEnd = new Date(Date.parse(endDate) + CHECKOUT_ATTRIBUTION_MS).toISOString() +``` + +Then insert them with `sqlString(queryStart)` and `sqlString(queryEnd)`. Restrict the checkout portion to timestamps at or after `startDate`; the extra pre-range window exists only for visit burst repair. `LEGACY_PATH_SOURCE = 'event'` means runtime reconstruction uses `event_current_url || event_pathname`, never `person_current_url`. If Task 1 proves that only an ingestion-time person-on-events URL is valid, change the constant and its fixture expectations before implementing the mapper. If Task 1 cannot prove either source, set the source to `unavailable` and return `missing_event_time_path`. Do not select the full `properties` object. + +The transition query begins at `TRACKING_HISTORY_START`, ends at `end + 24h`, and selects `$group_key`, `$group_type`, `$group_set.plan_status`, `$group_set.canceled_at`, organization group ID, and event name. + +Add a third, bounded aggregation query for the global exact-tracking boundary: + +```sql +SELECT min(timestamp) AS exact_tracking_started_at +FROM events +WHERE event = 'User visit' + AND properties.page = 'plans' + AND timestamp >= parseDateTimeBestEffort('2026-02-23T00:00:00.000Z') + AND timestamp < now() +``` + +- [ ] **Step 5: Implement orchestration** + +Export: + +```ts +export async function getAdminPlansAnalytics(c: Context, startDate: string, endDate: string): Promise +``` + +The function must: + +```text +1. parse and validate finite start/end with start < end +2. query behavior, billing transitions, and the exact-tracking boundary through queryPosthogHogql +3. return a structured empty response for any PostHog failure +4. reject rows.length > MAX_POSTHOG_ROWS as too_large +5. map only valid scalar rows into PlansBehaviorEvent/BillingTransition +6. repair logical openings before removing the 30-second lookback +7. attribute checkout through end + 24h +8. batch-load billing histories for unique opening org IDs +9. build all charts with classifyPlansBillingAt +10. compute data-quality counts and log only aggregate durations/counts +``` + +Do not log organization IDs, PostHog keys, URLs containing credentials, or raw event properties. + +- [ ] **Step 6: Run tests and commit** + +```bash +bunx vitest run tests/plans-analytics-model.unit.test.ts tests/plans-billing-history.unit.test.ts tests/plans-analytics-orchestration.unit.test.ts tests/posthog-read.unit.test.ts +bun run typecheck:backend +git add tests/plans-analytics-orchestration.unit.test.ts supabase/functions/_backend/utils/plans_analytics.ts +git commit -m "feat(admin): aggregate plans analytics" +``` + +Expected: PASS. + +--- + +### Task 6: Wire the Admin Endpoint and Store + +**Files:** + +- Modify: `tests/admin-stats.unit.test.ts` +- Modify: `supabase/functions/_backend/private/admin_stats.ts` +- Modify: `src/stores/adminDashboard.ts` + +- [ ] **Step 1: Add the failing validation assertion** + +In `tests/admin-stats.unit.test.ts` add: + +```ts +it.concurrent('accepts the plans analytics metric', () => { + const parsed = safeParseSchema(adminStatsBodySchema, { + ...baseBody, + metric_category: 'plans_analytics', + }) + expect(parsed.success).toBe(true) +}) +``` + +- [ ] **Step 2: Run the test to verify failure** + +```bash +bunx vitest run tests/admin-stats.unit.test.ts +``` + +Expected: FAIL because the enum rejects `plans_analytics`. + +- [ ] **Step 3: Add endpoint dispatch** + +In `admin_stats.ts`: + +```ts +import { getAdminPlansAnalytics } from '../utils/plans_analytics.ts' +``` + +Add `'plans_analytics'` to `metricCategories` and add: + +```ts +case 'plans_analytics': + result = await getAdminPlansAnalytics(c, start_date, end_date) + break +``` + +Keep the existing platform-admin authorization before dispatch. The analytics function returns structured PostHog failures, so these states remain HTTP 200 data; unexpected programming/database failures continue through the existing `admin_stats_error` path. + +- [ ] **Step 4: Add the Pinia metric category** + +Append `'plans_analytics'` to `MetricCategory` in `src/stores/adminDashboard.ts`. Do not add a new cache: `fetchStats()` already caches by category and exact selected range for five minutes and refresh already invalidates it. + +- [ ] **Step 5: Run tests and commit** + +```bash +bunx vitest run tests/admin-stats.unit.test.ts tests/plans-analytics-orchestration.unit.test.ts +bun run typecheck +git add tests/admin-stats.unit.test.ts supabase/functions/_backend/private/admin_stats.ts src/stores/adminDashboard.ts +git commit -m "feat(admin): expose plans analytics metric" +``` + +Expected: PASS. + +--- + +### Task 7: Add Frontend Types, Series Adapters, Navigation, Copy, and Deferred Documentation + +**Files:** + +- Create: `tests/admin-plans-analytics-dashboard.unit.test.ts` +- Create: `src/services/adminPlansAnalytics.ts` +- Create: `docs/admin/plans-checkout-completion.md` +- Modify: `src/constants/adminTabs.ts` +- Modify: `messages/en.json` + +- [ ] **Step 1: Write failing frontend adapter and wiring tests** + +Create `tests/admin-plans-analytics-dashboard.unit.test.ts`: + +```ts +import { readFile } from 'node:fs/promises' +import { describe, expect, it } from 'vitest' +import { buildPlansAnalyticsSeries } from '../src/services/adminPlansAnalytics.ts' + +describe('admin Plans analytics dashboard', () => { + it.concurrent('maps all API datasets into stable chart series', () => { + const series = buildPlansAnalyticsSeries({ + 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 }], + 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', legacyLogicalOpens: 3, exactLogicalOpens: 1, + legacyReconstructionAvailable: true, legacyUnavailableReason: null, + excludedMissingOrganization: 0, unmatchedCheckoutStarts: 0, unknownBillingOrganizations: 0, + posthogConfigured: true, posthogConnected: true, posthogFailureReason: null, legacyDeduplicationSeconds: 30, + }, + }, key => key) + expect(series.traffic.map(item => item.data[0].value)).toEqual([2, 4]) + expect(series.visitors).toHaveLength(7) + expect(series.checkoutIntent.map(item => item.data[0].value)).toEqual([1, 1]) + expect(series.checkoutVisitors.reduce((sum, item) => sum + item.data[0].value, 0)).toBe(1) + }) + + it.concurrent('wires a full-width Plans page and deferred documentation', async () => { + const [page, tabs, completionDoc, messagesText] = await Promise.all([ + readFile(new URL('../src/pages/admin/dashboard/plans.vue', import.meta.url), 'utf8'), + readFile(new URL('../src/constants/adminTabs.ts', import.meta.url), 'utf8'), + readFile(new URL('../docs/admin/plans-checkout-completion.md', import.meta.url), 'utf8'), + readFile(new URL('../messages/en.json', import.meta.url), 'utf8'), + ]) + expect(page).toContain("fetchStats('plans_analytics')") + expect(page.match(/AdminStackedBarChart/g)?.length).toBeGreaterThanOrEqual(4) + expect(page).toContain('AdminMultiLineChart') + expect(page).toContain('UTC') + expect(tabs).toContain("key: '/plans'") + expect(completionDoc).toContain('Checkout Completed') + const messages = JSON.parse(messagesText) as Record + expect(messages['plans-analytics-title']).toBe('Plans analytics') + expect(messages['plans-analytics-checkout-intent']).toBe('Checkout intent') + }) +}) +``` + +- [ ] **Step 2: Run the test to verify failure** + +```bash +bunx vitest run tests/admin-plans-analytics-dashboard.unit.test.ts +``` + +Expected: FAIL because the adapter, page, and document do not exist. + +- [ ] **Step 3: Implement typed series adapters** + +Create `src/services/adminPlansAnalytics.ts` with the response interfaces from Task 5 and: + +```ts +type Translate = (key: string) => string +type ChartSeries = { label: string, data: Array<{ date: string, value: number }>, color: string } + +export function buildPlansAnalyticsSeries(data: PlansAnalyticsResponse, t: Translate) { + const point = (dates: string[], values: number[]) => dates.map((date, index) => ({ date, value: values[index] ?? 0 })) + const billing = (rows: DailyBillingPoint[]): ChartSeries[] => [ + { label: t('plans-category-paying'), color: '#2563eb', data: rows.map(row => ({ date: row.date, value: row.paying })) }, + { label: t('plans-category-active-trial'), color: '#10b981', data: rows.map(row => ({ date: row.date, value: row.activeTrial })) }, + { label: t('plans-category-expired-trial'), color: '#f59e0b', data: rows.map(row => ({ date: row.date, value: row.expiredTrial })) }, + { label: t('plans-category-canceled'), color: '#64748b', data: rows.map(row => ({ date: row.date, value: row.canceled })) }, + { label: t('plans-category-payment-problem'), color: '#ef4444', data: rows.map(row => ({ date: row.date, value: row.paymentProblem })) }, + { label: t('plans-category-credits-only'), color: '#8b5cf6', data: rows.map(row => ({ date: row.date, value: row.creditsOnly })) }, + { label: t('plans-category-unknown'), color: '#94a3b8', data: rows.map(row => ({ date: row.date, value: row.unknown })) }, + ] + return { + traffic: [ + { label: t('plans-analytics-unique-visitor-orgs'), color: '#2563eb', data: point(data.traffic.dates, data.traffic.uniqueVisitorOrganizations) }, + { label: t('plans-analytics-total-opens'), color: '#8b5cf6', data: point(data.traffic.dates, data.traffic.totalOpens) }, + ], + visitors: billing(data.visitorBreakdown), + checkoutIntent: [ + { 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 })) }, + ], + checkoutVisitors: billing(data.checkoutVisitorBreakdown), + } +} +``` + +- [ ] **Step 4: Add the admin tab and English translations** + +Import a suitable chart icon such as `~icons/heroicons/chart-bar-square` and add: + +```ts +{ label: 'plans-analytics-title', icon: IconChartBar, key: '/plans' }, +``` + +Add English keys for the page title, four graph titles/descriptions, UTC label, seven categories, two checkout-intent series, partial-data warning, missing legacy path, unconfigured/unavailable/timeout/too-large messages, empty state, and checkout-completion card/link. Use translation keys only in Vue code. + +Use these exact English values: + +```json +{ + "plans-analytics-title": "Plans analytics", + "plans-analytics-timezone": "Reporting timezone: UTC", + "plans-analytics-traffic": "Plans page traffic", + "plans-analytics-traffic-description": "Organizations and logical openings of the Plans page", + "plans-analytics-unique-visitor-orgs": "Unique visitor orgs", + "plans-analytics-total-opens": "Total opens", + "plans-analytics-who-opened": "Who opened Plans?", + "plans-analytics-who-opened-description": "Daily unique organizations by billing state at their first Plans opening", + "plans-analytics-checkout-intent": "Checkout intent", + "plans-analytics-checkout-intent-description": "Daily Plans visitors who started checkout within the attribution window", + "plans-analytics-started-checkout": "Started checkout", + "plans-analytics-did-not-start": "Did not start", + "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-category-paying": "Paying", + "plans-category-active-trial": "Active trial", + "plans-category-expired-trial": "Expired trial — never subscribed", + "plans-category-canceled": "Canceled", + "plans-category-payment-problem": "Payment problem", + "plans-category-credits-only": "Credits only", + "plans-category-unknown": "Unknown", + "plans-analytics-partial-warning": "Some organizations could not be classified from historical billing records and appear as Unknown.", + "plans-analytics-legacy-unavailable": "Legacy Plans visits are unavailable because no event-time pathname could be verified.", + "plans-analytics-posthog-unconfigured": "PostHog analytics is not configured.", + "plans-analytics-posthog-timeout": "This range took too long to process. Select a shorter period and try again.", + "plans-analytics-range-too-large": "This range returned too much data to process. Select a shorter period and try again.", + "plans-analytics-unavailable": "Plans analytics is temporarily unavailable.", + "plans-analytics-empty": "No Plans visits were recorded in this period." +} +``` + +- [ ] **Step 5: Write the deferred completion document** + +Create `docs/admin/plans-checkout-completion.md` with these fixed requirements: + +```markdown +# 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 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. + +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. + +Implementation requires a separate approved design for the observation window, late completions, retries, plan changes, and existing subscribers. +``` + +The final sentence records genuinely deferred product decisions rather than pretending they are implemented. + +- [ ] **Step 6: Run adapter tests and commit the non-page pieces** + +The test still fails because `plans.vue` is not yet present; verify the adapter directly: + +```bash +bunx vitest run tests/admin-plans-analytics-dashboard.unit.test.ts -t "maps all API datasets" +``` + +Expected: PASS. + +Commit: + +```bash +git add src/services/adminPlansAnalytics.ts src/constants/adminTabs.ts messages/en.json docs/admin/plans-checkout-completion.md tests/admin-plans-analytics-dashboard.unit.test.ts +git commit -m "feat(admin): prepare plans analytics presentation" +``` + +--- + +### Task 8: Build the Plans Admin Page + +**Files:** + +- Create: `src/pages/admin/dashboard/plans.vue` +- Modify: `tests/admin-plans-analytics-dashboard.unit.test.ts` + +- [ ] **Step 1: Implement the page state and loading flow** + +Create `plans.vue` with `meta.layout = admin`, admin redirect protection, `AdminFilterBar`, and these state fields: + +```ts +const data = ref(null) +const isInitialLoading = ref(true) +const isLoadingStats = ref(false) +const requestError = ref(null) + +async function loadPlansAnalytics() { + isLoadingStats.value = true + requestError.value = null + try { + data.value = await adminStore.fetchStats('plans_analytics') as PlansAnalyticsResponse + } + catch (error) { + console.error('[Admin Dashboard Plans] Error loading Plans analytics:', error) + data.value = null + requestError.value = t('plans-analytics-unavailable') + } + finally { + isLoadingStats.value = false + } +} +``` + +Watch `adminStore.activeDateRange` and `adminStore.refreshTrigger`, matching existing admin pages. Do not add intervals, automatic retries, or a second cache. + +- [ ] **Step 2: Implement explicit availability messages** + +Map `dataQuality.posthogFailureReason` exactly: + +```ts +const unavailableMessage = computed(() => { + if (requestError.value) + return requestError.value + switch (data.value?.dataQuality.posthogFailureReason) { + case 'unconfigured': return t('plans-analytics-posthog-unconfigured') + case 'timeout': return t('plans-analytics-posthog-timeout') + case 'too_large': return t('plans-analytics-range-too-large') + case 'unavailable': return t('plans-analytics-unavailable') + default: return null + } +}) +``` + +Show a non-blocking warning when `unknownBillingOrganizations > 0` or legacy reconstruction is unavailable. A connected response with zero values is a valid empty result, not an error. + +- [ ] **Step 3: Render all five full-width cards** + +Render in this order inside `space-y-6`: + +```vue + + + + + + + + + + + + + + + +``` + +The fifth card is not a chart. Render a full-width card with title `Checkout completion`, the literal user-facing deferred copy approved in the design specification, and an external link to: + +```text +https://github.com/Cap-go/capgo.app/blob/main/docs/admin/plans-checkout-completion.md +``` + +Open the link in a new tab with `rel="noopener noreferrer"`. + +- [ ] **Step 4: Label UTC and preserve full-width layout** + +Place a small `UTC` reporting label next to the page heading/filter context. Do not place Graph 3 and Graph 4 side-by-side; each `ChartCard` remains `col-span-full` and occupies the available width. + +- [ ] **Step 5: Run frontend tests and type checking** + +```bash +bunx vitest run tests/admin-plans-analytics-dashboard.unit.test.ts tests/admin-stacked-bar-chart.unit.test.ts +bun run typecheck:frontend +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/pages/admin/dashboard/plans.vue tests/admin-plans-analytics-dashboard.unit.test.ts +git commit -m "feat(admin): add plans analytics dashboard" +``` + +--- + +### Task 9: Verify End-to-End Behavior and Documentation Consistency + +**Files:** + +- Verify: all files changed above +- Modify only if a check finds an issue: affected source, test, translation, or documentation file + +- [ ] **Step 1: Run formatting and lint first** + +```bash +bun run lint:fix +bun run lint:backend +``` + +Expected: PASS with only intentional formatting changes. + +- [ ] **Step 2: Run all focused unit tests** + +```bash +bunx vitest run \ + tests/posthog-read.unit.test.ts \ + tests/plans-analytics-model.unit.test.ts \ + tests/plans-billing-history.unit.test.ts \ + tests/plans-analytics-orchestration.unit.test.ts \ + tests/admin-stats.unit.test.ts \ + tests/admin-plans-analytics-dashboard.unit.test.ts \ + tests/admin-stacked-bar-chart.unit.test.ts +``` + +Expected: PASS. + +- [ ] **Step 3: Run complete type checking and production build** + +```bash +bun run typecheck +bun run build +``` + +Expected: PASS. + +- [ ] **Step 4: Perform local visual verification** + +Start the frontend: + +```bash +bun serve:dev +``` + +Expected: Vite prints its local URL. Sign in with the documented local admin account and inspect `/admin/dashboard/plans`. Verify: + +```text +- the filter remains at the top and says UTC +- all five cards are full width and in the approved order +- Graph 1 is a two-series line chart +- Graphs 2-4 are stacked vertical bars +- Graph 2 and Graph 3 daily totals match +- Graph 3 Started checkout and Graph 4 daily totals match +- Unknown is visible rather than absorbed into another category +- unconfigured, timeout, too-large, empty, and partial states are distinguishable +- the completion link opens the committed GitHub document +- no customer-facing Plans banner or subscription behavior changed +``` + +- [ ] **Step 5: Run the full unit suite** + +```bash +bun run test:unit +``` + +Expected: PASS. + +- [ ] **Step 6: Review the final diff and commit verification fixes** + +```bash +git diff origin/main...HEAD --check +git status --short +``` + +Expected: no whitespace errors and no generated or unrelated files staged. + +If verification required changes, stage the feature files that the checks changed: + +```bash +git add supabase/functions/_backend/utils/posthog_read.ts \ + supabase/functions/_backend/utils/plans_analytics_model.ts \ + supabase/functions/_backend/utils/plans_billing_history.ts \ + supabase/functions/_backend/utils/plans_analytics.ts \ + supabase/functions/_backend/private/admin_stats.ts \ + src/services/adminPlansAnalytics.ts \ + src/pages/admin/dashboard/plans.vue \ + src/stores/adminDashboard.ts \ + src/constants/adminTabs.ts \ + messages/en.json \ + docs/admin/plans-checkout-completion.md \ + tests/posthog-read.unit.test.ts \ + tests/plans-analytics-model.unit.test.ts \ + tests/plans-billing-history.unit.test.ts \ + tests/plans-analytics-orchestration.unit.test.ts \ + tests/admin-stats.unit.test.ts \ + tests/admin-plans-analytics-dashboard.unit.test.ts +git commit -m "fix(admin): harden plans analytics dashboard" +``` + +If no changes were required, do not create an empty commit. + +--- + +## Pull Request Gate + +Before creating or updating the pull request, invoke the repository-required `pr-ready` skill. The PR must remain blocked until: + +- the exact Plans tracking prerequisite is merged or otherwise present on `main`; +- the historical pathname probe has a recorded, trustworthy outcome; +- all focused tests, lint, type checking, build, and the full unit suite pass; +- no partial PostHog result can render as a complete zero-valued chart; +- the PR diff contains no changes to ended-subscription behavior or customer-facing Plans banners. diff --git a/docs/superpowers/specs/2026-08-10-plans-analytics-dashboard-design.md b/docs/superpowers/specs/2026-08-10-plans-analytics-dashboard-design.md index e95ceb1048..a3773340b8 100644 --- a/docs/superpowers/specs/2026-08-10-plans-analytics-dashboard-design.md +++ b/docs/superpowers/specs/2026-08-10-plans-analytics-dashboard-design.md @@ -59,6 +59,8 @@ A legacy candidate is a `User visit` event that: Query strings, fragments, and a trailing slash do not change the normalized path. Legacy and exact branches are mutually exclusive. +The pathname evidence must be stored on the event, or be a PostHog person-on-events property verified to represent ingestion-time state. A query-time person property is not valid historical evidence. If the schema probe cannot establish an event-time pathname, legacy reconstruction is disabled and the page reports the exact-tracking boundary rather than fabricating historical Plans visits. + ### Organization identity Events without a valid organization identifier cannot contribute to organization-based graphs. The response reports their count as excluded data-quality evidence. @@ -192,6 +194,8 @@ interface PlansAnalyticsResponse { exactTrackingStartedAt: string | null legacyLogicalOpens: number exactLogicalOpens: number + legacyReconstructionAvailable: boolean + legacyUnavailableReason: 'missing_event_time_path' | null excludedMissingOrganization: number unmatchedCheckoutStarts: number unknownBillingOrganizations: number From c58548dfecea50a4603b43e35fcdcda1800e0d66 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Mon, 10 Aug 2026 07:41:31 +0200 Subject: [PATCH 04/19] refactor(analytics): share PostHog read transport --- .../_backend/utils/builder_analytics.ts | 44 +--------- .../functions/_backend/utils/posthog_read.ts | 57 +++++++++++++ tests/posthog-read.unit.test.ts | 84 +++++++++++++++++++ 3 files changed, 145 insertions(+), 40 deletions(-) create mode 100644 supabase/functions/_backend/utils/posthog_read.ts create mode 100644 tests/posthog-read.unit.test.ts diff --git a/supabase/functions/_backend/utils/builder_analytics.ts b/supabase/functions/_backend/utils/builder_analytics.ts index 81e3a68087..3f7966a47e 100644 --- a/supabase/functions/_backend/utils/builder_analytics.ts +++ b/supabase/functions/_backend/utils/builder_analytics.ts @@ -1,6 +1,7 @@ import type { Context } from 'hono' -import { cloudlog, cloudlogErr } from './logging.ts' +import { cloudlog } from './logging.ts' import { closeClient, getPgClient } from './pg.ts' +import { queryPosthogHogql } from './posthog_read.ts' import { getEnv } from './utils.ts' // Builder analytics for the admin dashboard. Live (no cache): @@ -119,43 +120,6 @@ function sqlStr(v: string): string { return `'${v.replace(/'/g, '\'\'')}'` } -interface HogResult { ok: boolean, rows: Record[] } - -// Returns ok=false on any hard failure (not configured, non-2xx, network/abort/timeout, bad JSON) -// so callers can tell "PostHog unavailable" apart from "PostHog returned zero rows". -async function hogql(c: Context, query: string): Promise { - const key = (getEnv(c, 'POSTHOG_READ_KEY') || '').trim() - if (!key) - return { ok: false, rows: [] } - const host = ((getEnv(c, 'POSTHOG_READ_HOST') || 'https://eu.posthog.com').trim()).replace(/\/$/, '') - const project = (getEnv(c, 'POSTHOG_READ_PROJECT_ID') || '22029').trim() - try { - const res = await fetch(`${host}/api/projects/${project}/query/`, { - method: 'POST', - headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' }, - body: JSON.stringify({ query: { kind: 'HogQLQuery', query } }), - // Bound the request so a slow/unresponsive PostHog can't hang the Worker. - signal: AbortSignal.timeout(20_000), - }) - if (!res.ok) { - cloudlogErr({ requestId: c.get('requestId'), message: 'posthog_query_failed', status: res.status }) - return { ok: false, rows: [] } - } - const json = await res.json() as { columns?: string[], results?: unknown[][] } - const cols = json.columns ?? [] - const rows = (json.results ?? []).map((row) => { - const obj: Record = {} - cols.forEach((col, i) => { obj[col] = row[i] }) - return obj - }) - return { ok: true, rows } - } - catch (e) { - cloudlogErr({ requestId: c.get('requestId'), message: 'posthog_query_error', error: (e as Error).message }) - return { ok: false, rows: [] } - } -} - const num = (v: unknown): number => { const n = Number(v) return Number.isFinite(n) ? n : 0 @@ -184,7 +148,7 @@ async function loadOnboardingEvents(c: Context, start: string, end: string): Pro AND timestamp <= parseDateTimeBestEffort(${sqlStr(end)}) ORDER BY timestamp DESC LIMIT ${ONBOARDING_EVENT_LIMIT}` - const { ok, rows } = await hogql(c, q) + const { connected: ok, rows } = await queryPosthogHogql(c, q) if (rows.length >= ONBOARDING_EVENT_LIMIT) cloudlog({ requestId: c.get('requestId'), message: 'builder_analytics onboarding events truncated', limit: ONBOARDING_EVENT_LIMIT }) const events = rows @@ -209,7 +173,7 @@ async function loadAiChoiceCount(c: Context, start: string, end: string): Promis AND JSONExtractString(toString(properties), 'choice') IN ('capgo_ai', 'local_ai') AND timestamp >= parseDateTimeBestEffort(${sqlStr(start)}) AND timestamp <= parseDateTimeBestEffort(${sqlStr(end)})` - const { rows } = await hogql(c, q) + const { rows } = await queryPosthogHogql(c, q) return rows.length ? num(rows[0].orgs) : 0 } diff --git a/supabase/functions/_backend/utils/posthog_read.ts b/supabase/functions/_backend/utils/posthog_read.ts new file mode 100644 index 0000000000..43f68d0d61 --- /dev/null +++ b/supabase/functions/_backend/utils/posthog_read.ts @@ -0,0 +1,57 @@ +import type { Context } from 'hono' +import { cloudlogErr, serializeError } from './logging.ts' +import { getEnv } from './utils.ts' + +export type PosthogReadFailureReason = 'unconfigured' | 'timeout' | 'unavailable' + +export interface PosthogReadResult { + configured: boolean + connected: boolean + failureReason: PosthogReadFailureReason | null + rows: Record[] +} + +export async function queryPosthogHogql(c: Context, query: string): Promise { + const key = (getEnv(c, 'POSTHOG_READ_KEY') || '').trim() + if (!key) { + return { + configured: false, + connected: false, + failureReason: 'unconfigured', + rows: [], + } + } + + const host = ((getEnv(c, 'POSTHOG_READ_HOST') || '').trim() || 'https://eu.posthog.com').replace(/\/+$/, '') + const project = (getEnv(c, 'POSTHOG_READ_PROJECT_ID') || '').trim() || '22029' + + try { + const response = await fetch(`${host}/api/projects/${project}/query/`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${key}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ query: { kind: 'HogQLQuery', query } }), + signal: AbortSignal.timeout(20_000), + }) + if (!response.ok) { + cloudlogErr({ requestId: c.get('requestId'), message: 'posthog_query_failed', status: response.status }) + return { configured: true, connected: false, failureReason: 'unavailable', rows: [] } + } + + const json = await response.json() as { columns?: string[], results?: unknown[][] } + const columns = json.columns ?? [] + const rows = (json.results ?? []).map((result) => { + const row: Record = {} + columns.forEach((column, index) => { + row[column] = result[index] + }) + return row + }) + return { configured: true, connected: true, failureReason: null, rows } + } + catch (error) { + cloudlogErr({ requestId: c.get('requestId'), message: 'posthog_query_error', error: serializeError(error) }) + const name = error instanceof Error ? error.name : '' + const failureReason = name === 'TimeoutError' || name === 'AbortError' ? 'timeout' : 'unavailable' + return { configured: true, connected: false, failureReason, rows: [] } + } +} diff --git a/tests/posthog-read.unit.test.ts b/tests/posthog-read.unit.test.ts new file mode 100644 index 0000000000..45b505edd5 --- /dev/null +++ b/tests/posthog-read.unit.test.ts @@ -0,0 +1,84 @@ +import type { Context } from 'hono' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { queryPosthogHogql } from '../supabase/functions/_backend/utils/posthog_read.ts' + +vi.mock('hono/adapter', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + env: vi.fn((c: Context) => (c as Context & { env?: Record }).env ?? {}), + } +}) + +function context(environment: Record = {}): Context { + return { + env: environment, + get: vi.fn((key: string) => key === 'requestId' ? 'test-request' : undefined), + } as unknown as Context +} + +afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() +}) + +describe('postHog read transport', () => { + it('does not fetch when the read key is unconfigured', async () => { + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + + await expect(queryPosthogHogql(context(), 'SELECT 1')).resolves.toEqual({ + configured: false, + connected: false, + failureReason: 'unconfigured', + rows: [], + }) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('maps successful columns and results to row objects', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ + columns: ['org_id', 'opens'], + results: [['org-a', 3]], + }), { status: 200, headers: { 'content-type': 'application/json' } })) + vi.stubGlobal('fetch', fetchMock) + + await expect(queryPosthogHogql(context({ POSTHOG_READ_KEY: ' read-key ' }), 'SELECT org_id, opens')).resolves.toEqual({ + configured: true, + connected: true, + failureReason: null, + rows: [{ org_id: 'org-a', opens: 3 }], + }) + expect(fetchMock).toHaveBeenCalledWith('https://eu.posthog.com/api/projects/22029/query/', expect.objectContaining({ + method: 'POST', + headers: { 'Authorization': 'Bearer read-key', 'Content-Type': 'application/json' }, + body: JSON.stringify({ query: { kind: 'HogQLQuery', query: 'SELECT org_id, opens' } }), + signal: expect.any(AbortSignal), + })) + }) + + it('reports PostHog HTTP failures as unavailable', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 503 }))) + + await expect(queryPosthogHogql(context({ POSTHOG_READ_KEY: 'read-key' }), 'SELECT 1')).resolves.toEqual({ + configured: true, + connected: false, + failureReason: 'unavailable', + rows: [], + }) + }) + + it('reports TimeoutError failures as timeout', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const timeoutError = Object.assign(new Error('timed out'), { name: 'TimeoutError' }) + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(timeoutError)) + + await expect(queryPosthogHogql(context({ POSTHOG_READ_KEY: 'read-key' }), 'SELECT 1')).resolves.toEqual({ + configured: true, + connected: false, + failureReason: 'timeout', + rows: [], + }) + }) +}) From af79aaff98c1c0b23b659395a28a048c1e9e6505 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Mon, 10 Aug 2026 07:55:02 +0200 Subject: [PATCH 05/19] feat(admin): model plans visit analytics --- .../_backend/utils/plans_analytics_model.ts | 296 ++++++++++++++++++ tests/plans-analytics-model.unit.test.ts | 217 +++++++++++++ 2 files changed, 513 insertions(+) create mode 100644 supabase/functions/_backend/utils/plans_analytics_model.ts create mode 100644 tests/plans-analytics-model.unit.test.ts diff --git a/supabase/functions/_backend/utils/plans_analytics_model.ts b/supabase/functions/_backend/utils/plans_analytics_model.ts new file mode 100644 index 0000000000..1624173234 --- /dev/null +++ b/supabase/functions/_backend/utils/plans_analytics_model.ts @@ -0,0 +1,296 @@ +export const LEGACY_BURST_SECONDS = 30 +export const CHECKOUT_ATTRIBUTION_MS = 24 * 60 * 60 * 1000 + +export type PlansBillingCategory + = | 'paying' + | 'active_trial' + | 'expired_trial' + | 'canceled' + | 'payment_problem' + | 'credits_only' + | 'unknown' + +export interface PlansBehaviorEvent { + event: 'User visit' | 'Checkout Started' + timestampMs: number + orgId: string + actorId: string + sessionId: string + page: string + path: string +} + +export interface LogicalPlansOpening extends PlansBehaviorEvent { + source: 'exact' | 'legacy' +} + +export interface AttributedCheckout { + checkoutTimestampMs: number + orgId: string + opening: LogicalPlansOpening + attributedDate: string +} + +export interface DailyBillingPoint { + date: string + paying: number + activeTrial: number + expiredTrial: number + canceled: number + paymentProblem: number + creditsOnly: number + unknown: number + total: number +} + +export interface DailyCheckoutIntentPoint { + date: string + startedCheckout: number + didNotStart: number +} + +export interface PlansChartData { + traffic: { + dates: string[] + uniqueVisitorOrganizations: number[] + totalOpens: number[] + } + visitorBreakdown: DailyBillingPoint[] + checkoutIntent: DailyCheckoutIntentPoint[] + checkoutVisitorBreakdown: DailyBillingPoint[] +} + +const LEGACY_PLANS_PATH = '/settings/organization/plans' +const DAY_MS = 24 * 60 * 60 * 1000 + +function utcDate(timestampMs: number): string { + return new Date(timestampMs).toISOString().slice(0, 10) +} + +function normalizePath(value: string): string | null { + try { + const pathname = new URL(value, 'https://console.capgo.app').pathname + return pathname.length > 1 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname + } + catch { + return null + } +} + +function sortedWithInputOrder(items: T[]): T[] { + return items + .filter(item => Number.isFinite(item.timestampMs)) + .map((item, index) => ({ index, item })) + .sort((left, right) => left.item.timestampMs - right.item.timestampMs || left.index - right.index) + .map(({ item }) => item) +} + +export function buildLogicalPlansOpenings( + events: PlansBehaviorEvent[], + startMs: number, + endMs: number, + burstSeconds = LEGACY_BURST_SECONDS, +): LogicalPlansOpening[] { + const previousLegacyTimestamp = new Map() + const openings: LogicalPlansOpening[] = [] + const burstMs = burstSeconds * 1000 + + for (const behaviorEvent of sortedWithInputOrder(events)) { + if (behaviorEvent.event !== 'User visit' || behaviorEvent.timestampMs >= endMs) + continue + + if (behaviorEvent.page === 'plans') { + if (behaviorEvent.timestampMs >= startMs) + openings.push({ ...behaviorEvent, source: 'exact' }) + continue + } + + if (normalizePath(behaviorEvent.path) !== LEGACY_PLANS_PATH) + continue + + const identity = behaviorEvent.sessionId || behaviorEvent.actorId + const identityKey = `${behaviorEvent.orgId}\u0000${identity}` + const previousTimestamp = previousLegacyTimestamp.get(identityKey) + previousLegacyTimestamp.set(identityKey, behaviorEvent.timestampMs) + + if ( + behaviorEvent.timestampMs >= startMs + && (previousTimestamp === undefined || behaviorEvent.timestampMs - previousTimestamp > burstMs) + ) { + openings.push({ ...behaviorEvent, source: 'legacy' }) + } + } + + return openings +} + +export function attributeCheckoutStarts( + openings: LogicalPlansOpening[], + checkoutEvents: PlansBehaviorEvent[], +): AttributedCheckout[] { + const openingsByOrganization = new Map() + for (const opening of sortedWithInputOrder(openings)) { + const organizationOpenings = openingsByOrganization.get(opening.orgId) ?? [] + organizationOpenings.push(opening) + openingsByOrganization.set(opening.orgId, organizationOpenings) + } + + const attributed: AttributedCheckout[] = [] + for (const checkout of sortedWithInputOrder(checkoutEvents)) { + if (checkout.event !== 'Checkout Started') + continue + + const organizationOpenings = openingsByOrganization.get(checkout.orgId) ?? [] + let lower = 0 + let upper = organizationOpenings.length + while (lower < upper) { + const middle = Math.floor((lower + upper) / 2) + if (organizationOpenings[middle].timestampMs <= checkout.timestampMs) + lower = middle + 1 + else + upper = middle + } + const matchedOpening = organizationOpenings[lower - 1] + + if (!matchedOpening || checkout.timestampMs - matchedOpening.timestampMs > CHECKOUT_ATTRIBUTION_MS) + continue + + attributed.push({ + checkoutTimestampMs: checkout.timestampMs, + orgId: checkout.orgId, + opening: matchedOpening, + attributedDate: utcDate(matchedOpening.timestampMs), + }) + } + + return attributed +} + +function createDailyBillingPoint(date: string): DailyBillingPoint { + return { + date, + paying: 0, + activeTrial: 0, + expiredTrial: 0, + canceled: 0, + paymentProblem: 0, + creditsOnly: 0, + unknown: 0, + total: 0, + } +} + +function incrementCategory(point: DailyBillingPoint, category: PlansBillingCategory): void { + type BillingCountKey = Exclude + const categoryKeys: Record = { + paying: 'paying', + active_trial: 'activeTrial', + expired_trial: 'expiredTrial', + canceled: 'canceled', + payment_problem: 'paymentProblem', + credits_only: 'creditsOnly', + unknown: 'unknown', + } + const key = categoryKeys[category] + point[key] += 1 + point.total += 1 +} + +function utcDaysIntersecting(startMs: number, endMs: number): string[] { + if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs <= startMs) + return [] + + const start = new Date(startMs) + let cursor = Date.UTC(start.getUTCFullYear(), start.getUTCMonth(), start.getUTCDate()) + const dates: string[] = [] + while (cursor < endMs) { + dates.push(utcDate(cursor)) + cursor += DAY_MS + } + return dates +} + +export function buildPlansChartData(input: { + openings: LogicalPlansOpening[] + attributedCheckouts: AttributedCheckout[] + startMs: number + endMs: number + classifyAt: (orgId: string, timestampMs: number) => PlansBillingCategory +}): PlansChartData { + const dates = utcDaysIntersecting(input.startMs, input.endMs) + const dateIndexes = new Map(dates.map((date, index) => [date, index])) + const uniqueVisitorOrganizations = dates.map(() => 0) + const totalOpens = dates.map(() => 0) + const visitorBreakdown = dates.map(createDailyBillingPoint) + const checkoutIntent = dates.map(date => ({ date, startedCheckout: 0, didNotStart: 0 })) + const checkoutVisitorBreakdown = dates.map(createDailyBillingPoint) + const firstDailyOpening = new Map() + const seenOrganizations = new Set() + + for (const opening of sortedWithInputOrder(input.openings)) { + if (opening.timestampMs < input.startMs || opening.timestampMs >= input.endMs) + continue + + const date = utcDate(opening.timestampMs) + const dateIndex = dateIndexes.get(date) + if (dateIndex === undefined) + continue + + totalOpens[dateIndex] += 1 + if (!seenOrganizations.has(opening.orgId)) { + seenOrganizations.add(opening.orgId) + uniqueVisitorOrganizations[dateIndex] += 1 + } + + const visitorKey = `${date}\u0000${opening.orgId}` + if (!firstDailyOpening.has(visitorKey)) + firstDailyOpening.set(visitorKey, opening) + } + + for (const opening of firstDailyOpening.values()) { + const dateIndex = dateIndexes.get(utcDate(opening.timestampMs)) + if (dateIndex !== undefined) + incrementCategory(visitorBreakdown[dateIndex], input.classifyAt(opening.orgId, opening.timestampMs)) + } + + const earliestCheckoutByVisitor = new Map() + for (const checkout of input.attributedCheckouts) { + if (!Number.isFinite(checkout.checkoutTimestampMs) || !Number.isFinite(checkout.opening.timestampMs)) + continue + + const visitorKey = `${checkout.attributedDate}\u0000${checkout.orgId}` + if (!firstDailyOpening.has(visitorKey)) + continue + + const previous = earliestCheckoutByVisitor.get(visitorKey) + if (!previous || checkout.checkoutTimestampMs < previous.checkoutTimestampMs) + earliestCheckoutByVisitor.set(visitorKey, checkout) + } + + for (const [visitorKey, opening] of firstDailyOpening) { + const dateIndex = dateIndexes.get(utcDate(opening.timestampMs)) + if (dateIndex === undefined) + continue + if (earliestCheckoutByVisitor.has(visitorKey)) + checkoutIntent[dateIndex].startedCheckout += 1 + else + checkoutIntent[dateIndex].didNotStart += 1 + } + + for (const checkout of earliestCheckoutByVisitor.values()) { + const dateIndex = dateIndexes.get(checkout.attributedDate) + if (dateIndex !== undefined) { + incrementCategory( + checkoutVisitorBreakdown[dateIndex], + input.classifyAt(checkout.orgId, checkout.opening.timestampMs), + ) + } + } + + return { + traffic: { dates, uniqueVisitorOrganizations, totalOpens }, + visitorBreakdown, + checkoutIntent, + checkoutVisitorBreakdown, + } +} diff --git a/tests/plans-analytics-model.unit.test.ts b/tests/plans-analytics-model.unit.test.ts new file mode 100644 index 0000000000..6a6eacc2fd --- /dev/null +++ b/tests/plans-analytics-model.unit.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, it } from 'vitest' +import { + attributeCheckoutStarts, + buildLogicalPlansOpenings, + buildPlansChartData, + CHECKOUT_ATTRIBUTION_MS, + LEGACY_BURST_SECONDS, + type LogicalPlansOpening, + type PlansBehaviorEvent, +} from '../supabase/functions/_backend/utils/plans_analytics_model.ts' + +const ms = (value: string) => Date.parse(value) +const event = (partial: Partial & Pick): PlansBehaviorEvent => ({ + actorId: 'user-a', + event: 'User visit', + page: '', + path: '/settings/organization/plans', + sessionId: '', + ...partial, +}) + +describe('Plans analytics model', () => { + it.concurrent('collapses only legacy bursts and preserves exact repeat openings', () => { + const events = [ + event({ timestampMs: ms('2026-08-01T10:00:00Z'), orgId: 'org-a' }), + event({ timestampMs: ms('2026-08-01T10:00:08Z'), orgId: 'org-a' }), + event({ timestampMs: ms('2026-08-01T10:05:00Z'), orgId: 'org-a' }), + event({ timestampMs: ms('2026-08-01T11:00:00Z'), orgId: 'org-a', page: 'plans', path: '' }), + event({ timestampMs: ms('2026-08-01T11:00:02Z'), orgId: 'org-a', page: 'plans', path: '' }), + ] + + const openings = buildLogicalPlansOpenings(events, ms('2026-08-01T00:00:00Z'), ms('2026-08-02T00:00:00Z')) + + expect(LEGACY_BURST_SECONDS).toBe(30) + expect(openings.map(opening => [opening.timestampMs, opening.source])).toEqual([ + [ms('2026-08-01T10:00:00Z'), 'legacy'], + [ms('2026-08-01T10:05:00Z'), 'legacy'], + [ms('2026-08-01T11:00:00Z'), 'exact'], + [ms('2026-08-01T11:00:02Z'), 'exact'], + ]) + }) + + it.concurrent('repairs boundary bursts and prefers session identity over actor fallback', () => { + const events = [ + event({ timestampMs: ms('2026-07-31T23:59:50Z'), orgId: 'org-a', actorId: 'user-a' }), + event({ timestampMs: ms('2026-08-01T00:00:05Z'), orgId: 'org-a', actorId: 'user-a' }), + event({ timestampMs: ms('2026-08-01T00:00:05Z'), orgId: 'org-a', actorId: 'user-b' }), + event({ timestampMs: ms('2026-08-01T01:00:00Z'), orgId: 'org-a', actorId: 'shared', sessionId: 'session-a' }), + event({ timestampMs: ms('2026-08-01T01:00:08Z'), orgId: 'org-a', actorId: 'shared', sessionId: 'session-b' }), + event({ timestampMs: ms('2026-08-01T01:00:10Z'), orgId: 'org-a', actorId: 'different', sessionId: 'session-a' }), + ] + + const openings = buildLogicalPlansOpenings(events, ms('2026-08-01T00:00:00Z'), ms('2026-08-02T00:00:00Z')) + + expect(openings.map(opening => [opening.timestampMs, opening.actorId, opening.sessionId])).toEqual([ + [ms('2026-08-01T00:00:05Z'), 'user-b', ''], + [ms('2026-08-01T01:00:00Z'), 'shared', 'session-a'], + [ms('2026-08-01T01:00:08Z'), 'shared', 'session-b'], + ]) + }) + + it.concurrent('normalizes only the legacy Plans path and ignores unrelated events', () => { + const events = [ + event({ timestampMs: ms('2026-08-01T10:00:00Z'), orgId: 'org-a', path: 'https://console.capgo.app/settings/organization/plans/?tab=billing#top' }), + event({ timestampMs: ms('2026-08-01T10:01:00Z'), orgId: 'org-a', path: '/settings/organization/plans-extra' }), + event({ timestampMs: ms('2026-08-01T10:02:00Z'), orgId: 'org-a', path: 'http://[::1' }), + event({ event: 'Checkout Started', timestampMs: ms('2026-08-01T10:03:00Z'), orgId: 'org-a' }), + event({ timestampMs: ms('2026-08-01T10:04:00Z'), orgId: 'org-a', page: 'Plans', path: '' }), + ] + + expect(buildLogicalPlansOpenings(events, ms('2026-08-01T00:00:00Z'), ms('2026-08-02T00:00:00Z'))) + .toMatchObject([{ timestampMs: ms('2026-08-01T10:00:00Z'), source: 'legacy' }]) + }) + + it.concurrent('skips non-finite behavior timestamps before repair and attribution', () => { + const openings = buildLogicalPlansOpenings([ + event({ timestampMs: Number.NaN, orgId: 'org-a' }), + event({ timestampMs: ms('2026-08-01T10:00:00Z'), orgId: 'org-a' }), + ], ms('2026-08-01T00:00:00Z'), ms('2026-08-02T00:00:00Z')) + const invalidOpening: LogicalPlansOpening = { + ...event({ timestampMs: Number.NaN, orgId: 'org-a', page: 'plans', path: '' }), + source: 'exact', + } + + expect(openings).toHaveLength(1) + expect(openings[0].timestampMs).toBe(ms('2026-08-01T10:00:00Z')) + expect(attributeCheckoutStarts([invalidOpening, ...openings], [ + event({ event: 'Checkout Started', timestampMs: Number.NaN, orgId: 'org-a', path: '' }), + event({ event: 'Checkout Started', timestampMs: ms('2026-08-01T10:05:00Z'), orgId: 'org-a', path: '' }), + ])).toMatchObject([{ + checkoutTimestampMs: ms('2026-08-01T10:05:00Z'), + opening: { timestampMs: ms('2026-08-01T10:00:00Z') }, + }]) + }) + + it.concurrent('attributes each checkout to the latest preceding same-org opening within 24 hours', () => { + const openings = buildLogicalPlansOpenings([ + event({ timestampMs: ms('2026-08-01T22:00:00Z'), orgId: 'org-a', page: 'plans', path: '' }), + event({ timestampMs: ms('2026-08-01T23:55:00Z'), orgId: 'org-a', page: 'plans', path: '' }), + event({ timestampMs: ms('2026-08-02T00:00:00Z'), orgId: 'org-b', page: 'plans', path: '' }), + ], ms('2026-08-01T00:00:00Z'), ms('2026-08-03T00:00:00Z')) + const matches = attributeCheckoutStarts(openings, [ + event({ event: 'Checkout Started', timestampMs: ms('2026-08-02T00:05:00Z'), orgId: 'org-a', path: '' }), + event({ event: 'Checkout Started', timestampMs: ms('2026-08-03T00:00:00Z'), orgId: 'org-b', path: '' }), + event({ event: 'Checkout Started', timestampMs: ms('2026-08-03T00:06:00Z'), orgId: 'org-a', path: '' }), + event({ timestampMs: ms('2026-08-02T00:06:00Z'), orgId: 'org-a', page: 'plans', path: '' }), + ]) + + expect(CHECKOUT_ATTRIBUTION_MS).toBe(24 * 60 * 60 * 1000) + expect(matches.map(match => ({ + attributedDate: match.attributedDate, + checkoutTimestampMs: match.checkoutTimestampMs, + openingTimestampMs: match.opening.timestampMs, + orgId: match.orgId, + }))).toEqual([ + { + attributedDate: '2026-08-01', + checkoutTimestampMs: ms('2026-08-02T00:05:00Z'), + openingTimestampMs: ms('2026-08-01T23:55:00Z'), + orgId: 'org-a', + }, + { + attributedDate: '2026-08-02', + checkoutTimestampMs: ms('2026-08-03T00:00:00Z'), + openingTimestampMs: ms('2026-08-02T00:00:00Z'), + orgId: 'org-b', + }, + ]) + }) + + it.concurrent('keeps range-wide uniques distinct from daily uniques and reconciles graph totals', () => { + const openings = buildLogicalPlansOpenings([ + event({ timestampMs: ms('2026-08-01T08:00:00Z'), orgId: 'org-a', page: 'plans', path: '' }), + event({ timestampMs: ms('2026-08-02T08:00:00Z'), orgId: 'org-a', page: 'plans', path: '' }), + event({ timestampMs: ms('2026-08-02T09:00:00Z'), orgId: 'org-b', page: 'plans', path: '' }), + ], ms('2026-08-01T00:00:00Z'), ms('2026-08-03T00:00:00Z')) + const matches = attributeCheckoutStarts(openings, [ + event({ event: 'Checkout Started', timestampMs: ms('2026-08-02T08:10:00Z'), orgId: 'org-a', path: '' }), + ]) + const result = buildPlansChartData({ + openings, + attributedCheckouts: matches, + startMs: ms('2026-08-01T00:00:00Z'), + endMs: ms('2026-08-03T00:00:00Z'), + classifyAt: orgId => orgId === 'org-a' ? 'paying' : 'active_trial', + }) + + expect(result.traffic.uniqueVisitorOrganizations).toEqual([1, 1]) + expect(result.traffic.totalOpens).toEqual([1, 2]) + expect(result.visitorBreakdown.map(day => day.total)).toEqual([1, 2]) + expect(result.checkoutIntent.map(day => day.startedCheckout + day.didNotStart)).toEqual([1, 2]) + expect(result.checkoutVisitorBreakdown.map(day => day.total)).toEqual([0, 1]) + }) + + it.concurrent('zero-fills intersecting UTC days and uses each graph category timestamp', () => { + const openings = buildLogicalPlansOpenings([ + event({ timestampMs: ms('2026-08-02T08:00:00Z'), orgId: 'org-a', page: 'plans', path: '' }), + event({ timestampMs: ms('2026-08-02T12:00:00Z'), orgId: 'org-a', page: 'plans', path: '' }), + ], ms('2026-08-01T12:00:00Z'), ms('2026-08-04T06:00:00Z')) + const matches = attributeCheckoutStarts(openings, [ + event({ event: 'Checkout Started', timestampMs: ms('2026-08-02T12:05:00Z'), orgId: 'org-a', path: '' }), + event({ event: 'Checkout Started', timestampMs: ms('2026-08-02T12:10:00Z'), orgId: 'org-a', path: '' }), + ]) + const classifiedAt: number[] = [] + const result = buildPlansChartData({ + openings, + attributedCheckouts: matches, + startMs: ms('2026-08-01T12:00:00Z'), + endMs: ms('2026-08-04T06:00:00Z'), + classifyAt: (_orgId, timestampMs) => { + classifiedAt.push(timestampMs) + return timestampMs < ms('2026-08-02T10:00:00Z') ? 'expired_trial' : 'credits_only' + }, + }) + + expect(result.traffic).toEqual({ + dates: ['2026-08-01', '2026-08-02', '2026-08-03', '2026-08-04'], + uniqueVisitorOrganizations: [0, 1, 0, 0], + totalOpens: [0, 2, 0, 0], + }) + expect(result.visitorBreakdown[1]).toMatchObject({ expiredTrial: 1, total: 1 }) + expect(result.checkoutIntent[1]).toEqual({ date: '2026-08-02', startedCheckout: 1, didNotStart: 0 }) + expect(result.checkoutVisitorBreakdown[1]).toMatchObject({ creditsOnly: 1, total: 1 }) + expect(classifiedAt).toEqual([ + ms('2026-08-02T08:00:00Z'), + ms('2026-08-02T12:00:00Z'), + ]) + }) + + it.concurrent('skips non-finite chart timestamps before bucketing checkout intent', () => { + const validOpening: LogicalPlansOpening = { + ...event({ timestampMs: ms('2026-08-01T10:00:00Z'), orgId: 'org-a', page: 'plans', path: '' }), + source: 'exact', + } + const invalidOpening: LogicalPlansOpening = { ...validOpening, timestampMs: Number.NaN } + + const result = buildPlansChartData({ + openings: [invalidOpening, validOpening], + attributedCheckouts: [{ + attributedDate: '2026-08-01', + checkoutTimestampMs: Number.NaN, + opening: validOpening, + orgId: 'org-a', + }], + startMs: ms('2026-08-01T00:00:00Z'), + endMs: ms('2026-08-02T00:00:00Z'), + classifyAt: () => 'unknown', + }) + + expect(result.traffic).toEqual({ + dates: ['2026-08-01'], + uniqueVisitorOrganizations: [1], + totalOpens: [1], + }) + expect(result.checkoutIntent).toEqual([{ date: '2026-08-01', startedCheckout: 0, didNotStart: 1 }]) + }) +}) From da615f6e03d118c5079c96ac87b2fbc6e8b1d51c Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Mon, 10 Aug 2026 08:19:21 +0200 Subject: [PATCH 06/19] feat(admin): classify historical plans billing state --- .../_backend/utils/plans_billing_history.ts | 506 ++++++++++++++++++ tests/plans-billing-history.unit.test.ts | 386 +++++++++++++ 2 files changed, 892 insertions(+) create mode 100644 supabase/functions/_backend/utils/plans_billing_history.ts create mode 100644 tests/plans-billing-history.unit.test.ts diff --git a/supabase/functions/_backend/utils/plans_billing_history.ts b/supabase/functions/_backend/utils/plans_billing_history.ts new file mode 100644 index 0000000000..201d2c7780 --- /dev/null +++ b/supabase/functions/_backend/utils/plans_billing_history.ts @@ -0,0 +1,506 @@ +import type { Context } from 'hono' +import type { PoolClient } from 'pg' +import { closeClient, getPgClient } from './pg.ts' + +export interface RevenueMovement { + date: string + openingMrr: number + newBusinessMrr: number + expansionMrr: number + contractionMrr: number + churnMrr: number + churnReason: string | null +} + +export interface BillingTransition { + timestampMs: number + kind: 'paid' | 'canceled' | 'payment_problem' | 'recovered' +} + +export interface OrganizationBillingHistory { + orgId: string + customerId: string | null + trialEndsAtMs: number | null + paidAtMs: number | null + canceledAtMs: number | null + currentPastDueAtMs: number | null + churnReason: string | null + revenueMovements: RevenueMovement[] + transitions: BillingTransition[] + creditGrants: Array<{ id: string, grantedAtMs: number, expiresAtMs: number, creditsTotal: number }> + creditConsumptions: Array<{ grantId: string, appliedAtMs: number, creditsUsed: number }> +} + +export type HistoricalPaidState = 'paying' | 'not_paying' | 'payment_problem' | 'unknown' + +type BillingHistoryEvidence = Omit & { + readonly revenueMovements: readonly RevenueMovement[] + readonly transitions: readonly BillingTransition[] + readonly creditGrants: ReadonlyArray<{ id: string, grantedAtMs: number, expiresAtMs: number, creditsTotal: number }> + readonly creditConsumptions: ReadonlyArray<{ grantId: string, appliedAtMs: number, creditsUsed: number }> +} + +interface BillingEvidenceAt { + paidState: HistoricalPaidState + voluntaryCancellationActive: boolean + hasPaidBefore: boolean +} + +const DAY_MS = 24 * 60 * 60 * 1000 + +export function endingMrr(movement: RevenueMovement): number { + return Math.max(0, movement.openingMrr + movement.newBusinessMrr + movement.expansionMrr - movement.contractionMrr - movement.churnMrr) +} + +export function hasCreditsAt(history: BillingHistoryEvidence, timestampMs: number): boolean { + const consumedByGrant = new Map() + for (const consumption of history.creditConsumptions) { + if (consumption.appliedAtMs <= timestampMs) { + consumedByGrant.set( + consumption.grantId, + (consumedByGrant.get(consumption.grantId) ?? 0) + consumption.creditsUsed, + ) + } + } + + return history.creditGrants.some(grant => ( + grant.grantedAtMs <= timestampMs + && grant.expiresAtMs >= timestampMs + && grant.creditsTotal - (consumedByGrant.get(grant.id) ?? 0) > 0 + )) +} + +function utcDate(timestampMs: number): string { + return new Date(timestampMs).toISOString().slice(0, 10) +} + +function utcDayStart(date: string): number { + return Date.parse(`${date}T00:00:00Z`) +} + +function isValidRevenueDate(date: string): boolean { + if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) + return false + const timestampMs = utcDayStart(date) + return Number.isFinite(timestampMs) && utcDate(timestampMs) === date +} + +function hasValidBillingEvidence(history: BillingHistoryEvidence, timestampMs: number): boolean { + if (!Number.isFinite(timestampMs)) + return false + + const scalarTimestamps = [history.trialEndsAtMs, history.paidAtMs, history.canceledAtMs, history.currentPastDueAtMs] + if (scalarTimestamps.some(value => value !== null && !Number.isFinite(value))) + return false + + if (history.transitions.some(transition => !Number.isFinite(transition.timestampMs))) + return false + + if (history.revenueMovements.some(movement => ( + !isValidRevenueDate(movement.date) + || ![ + movement.openingMrr, + movement.newBusinessMrr, + movement.expansionMrr, + movement.contractionMrr, + movement.churnMrr, + ].every(value => Number.isFinite(value) && value >= 0) + ))) { + return false + } + + if (history.creditGrants.some(grant => ( + !Number.isFinite(grant.grantedAtMs) + || !Number.isFinite(grant.expiresAtMs) + || !Number.isFinite(grant.creditsTotal) + || grant.creditsTotal < 0 + || grant.expiresAtMs < grant.grantedAtMs + ))) { + return false + } + + return !history.creditConsumptions.some(consumption => ( + !Number.isFinite(consumption.appliedAtMs) + || !Number.isFinite(consumption.creditsUsed) + || consumption.creditsUsed <= 0 + )) +} + +interface BillingEvidenceEvent extends BillingTransition { + order: number +} + +function isPaymentFailureChurnReason(reason: string | null): boolean { + // This is the only internal payment-failure churn variant written by the Stripe event pipeline. + return reason === 'past_due_unresolved' +} + +function isPaymentFailureCancellation(history: BillingHistoryEvidence): boolean { + if (isPaymentFailureChurnReason(history.churnReason)) + return true + if (history.canceledAtMs === null) + return false + + const cancellationDate = utcDate(history.canceledAtMs) + return history.revenueMovements.some(movement => ( + movement.date === cancellationDate + && movement.churnMrr > 0 + && isPaymentFailureChurnReason(movement.churnReason) + )) +} + +function billingEvidenceTimeline(history: BillingHistoryEvidence): BillingEvidenceEvent[] { + const timeline: BillingEvidenceEvent[] = history.transitions + .filter(transition => Number.isFinite(transition.timestampMs)) + .map((transition, order) => ({ ...transition, order })) + let order = timeline.length + + if (history.paidAtMs !== null) + timeline.push({ timestampMs: history.paidAtMs, kind: 'paid', order: order++ }) + if (history.currentPastDueAtMs !== null) + timeline.push({ timestampMs: history.currentPastDueAtMs, kind: 'payment_problem', order: order++ }) + if (history.canceledAtMs !== null) { + timeline.push({ + timestampMs: history.canceledAtMs, + kind: isPaymentFailureCancellation(history) ? 'payment_problem' : 'canceled', + order, + }) + } + + return timeline.sort((left, right) => left.timestampMs - right.timestampMs || left.order - right.order) +} + +function hasContradictoryEventAtOrBefore(timeline: BillingEvidenceEvent[], timestampMs: number): boolean { + let latestTimestamp = Number.NEGATIVE_INFINITY + let latestKinds = new Set() + for (const event of timeline) { + if (event.timestampMs > timestampMs) + break + if (event.timestampMs !== latestTimestamp) { + latestTimestamp = event.timestampMs + latestKinds = new Set() + } + latestKinds.add(event.kind) + } + return latestKinds.size > 1 +} + +function billingEvidenceAt(history: BillingHistoryEvidence, timestampMs: number): BillingEvidenceAt { + if (!hasValidBillingEvidence(history, timestampMs)) { + return { + paidState: 'unknown', + voluntaryCancellationActive: false, + hasPaidBefore: false, + } + } + + const timeline = billingEvidenceTimeline(history) + if (hasContradictoryEventAtOrBefore(timeline, timestampMs)) { + return { + paidState: 'unknown', + voluntaryCancellationActive: false, + hasPaidBefore: false, + } + } + + const visitDate = utcDate(timestampMs) + const movement = history.revenueMovements + .filter(candidate => candidate.date <= visitDate) + .sort((left, right) => left.date.localeCompare(right.date)) + .at(-1) + + let entitled = movement ? endingMrr(movement) > 0 : false + let evidenceStartMs = Number.NEGATIVE_INFINITY + + if (movement) { + evidenceStartMs = utcDayStart(movement.date) + if (movement.date === visitDate) { + entitled = movement.openingMrr > 0 + const dayEndMs = evidenceStartMs + DAY_MS + const dayEvents = timeline.filter(event => event.timestampMs >= evidenceStartMs && event.timestampMs < dayEndMs) + const changesEntitlement = movement.openingMrr > 0 !== (endingMrr(movement) > 0) + + if (changesEntitlement) { + let endingEntitled = movement.openingMrr > 0 + for (const event of dayEvents) { + if (event.kind === 'paid' || event.kind === 'recovered') + endingEntitled = true + else if (event.kind === 'canceled' || event.kind === 'payment_problem') + endingEntitled = false + } + const unresolvedMovementDay = hasContradictoryEventAtOrBefore(dayEvents, dayEndMs - 1) + || dayEvents.length === 0 + || endingEntitled !== (endingMrr(movement) > 0) + const laterEvidence = timeline.filter(event => event.timestampMs >= dayEndMs && event.timestampMs <= timestampMs) + const supersededByLaterEvidence = laterEvidence.length > 0 + && !hasContradictoryEventAtOrBefore(laterEvidence, timestampMs) + if (unresolvedMovementDay && !supersededByLaterEvidence) { + return { + paidState: 'unknown', + voluntaryCancellationActive: false, + hasPaidBefore: false, + } + } + } + } + else { + evidenceStartMs += DAY_MS + } + } + + let paymentProblemActive = false + let voluntaryCancellationActive = false + let hasPaidBefore = false + + for (const event of timeline) { + if (event.timestampMs > timestampMs) + break + + if (event.kind === 'paid') { + hasPaidBefore = true + paymentProblemActive = false + voluntaryCancellationActive = false + } + else if (event.kind === 'recovered') { + paymentProblemActive = false + } + else if (event.kind === 'payment_problem') { + paymentProblemActive = true + } + else { + paymentProblemActive = false + voluntaryCancellationActive = true + } + + if (event.timestampMs >= evidenceStartMs) { + if (event.kind === 'paid' || event.kind === 'recovered') + entitled = true + else if (event.kind === 'canceled' || event.kind === 'payment_problem') + entitled = false + } + } + + if (paymentProblemActive) { + return { + paidState: 'payment_problem', + voluntaryCancellationActive, + hasPaidBefore, + } + } + + return { + paidState: entitled ? 'paying' : 'not_paying', + voluntaryCancellationActive, + hasPaidBefore, + } +} + +export function paidStateAt(history: BillingHistoryEvidence, timestampMs: number): HistoricalPaidState { + return billingEvidenceAt(history, timestampMs).paidState +} + +export function classifyPlansBillingAt(history: BillingHistoryEvidence, timestampMs: number) { + const evidence = billingEvidenceAt(history, timestampMs) + + if (evidence.paidState === 'unknown') + return 'unknown' as const + if (evidence.paidState === 'payment_problem') + return 'payment_problem' as const + if (evidence.paidState === 'paying') + return 'paying' as const + if (history.trialEndsAtMs !== null && timestampMs < history.trialEndsAtMs) + return 'active_trial' as const + if (hasCreditsAt(history, timestampMs)) + return 'credits_only' as const + if (evidence.hasPaidBefore && evidence.voluntaryCancellationActive) + return 'canceled' as const + if (history.trialEndsAtMs !== null && timestampMs >= history.trialEndsAtMs && !evidence.hasPaidBefore) + return 'expired_trial' as const + return 'unknown' as const +} + +interface OrganizationRow { + org_id: string + customer_id: string | null + trial_at: string | Date | null + paid_at: string | Date | null + canceled_at: string | Date | null + past_due_at: string | Date | null + churn_reason: string | null +} + +interface RevenueRow { + customer_id: string + date_id: string + opening_mrr: number | string + new_business_mrr: number | string + expansion_mrr: number | string + contraction_mrr: number | string + churn_mrr: number | string + churn_reason: string | null +} + +interface CreditGrantRow { + id: string + org_id: string + granted_at: string | Date + expires_at: string | Date + credits_total: number | string +} + +interface CreditConsumptionRow { + grant_id: string + org_id: string + applied_at: string | Date + credits_used: number | string +} + +function timestamp(value: string | Date | null): number | null { + return value === null ? null : new Date(value).getTime() +} + +export async function loadPlansBillingHistories( + c: Context, + orgIds: string[], + startDate: string, + endDate: string, + transitions: Map, +): Promise> { + if (orgIds.length === 0) + return new Map() + + const pool = getPgClient(c, true) + let client: PoolClient | undefined + + try { + client = await pool.connect() + const organizations = await client.query(` + SELECT o.id::text AS org_id, o.customer_id, si.trial_at, si.paid_at, + si.canceled_at, si.past_due_at, si.churn_reason + FROM public.orgs o + LEFT JOIN public.stripe_info si ON si.customer_id = o.customer_id + WHERE o.id = ANY($1::uuid[]) + `, [orgIds]) + + const histories = new Map() + const orgIdByCustomer = new Map() + for (const row of organizations.rows) { + histories.set(row.org_id, { + orgId: row.org_id, + customerId: row.customer_id, + trialEndsAtMs: timestamp(row.trial_at), + paidAtMs: timestamp(row.paid_at), + canceledAtMs: timestamp(row.canceled_at), + currentPastDueAtMs: timestamp(row.past_due_at), + churnReason: row.churn_reason, + revenueMovements: [], + transitions: [...(transitions.get(row.org_id) ?? [])], + creditGrants: [], + creditConsumptions: [], + }) + if (row.customer_id) + orgIdByCustomer.set(row.customer_id, row.org_id) + } + + const customerIds = [...orgIdByCustomer.keys()] + if (customerIds.length > 0) { + const revenue = await client.query(` + WITH relevant_customers AS ( + SELECT customer_id + FROM unnest($1::text[]) AS customer_id + ), carry_in AS ( + SELECT latest.* + FROM relevant_customers rc + CROSS JOIN LATERAL ( + SELECT drm.customer_id, drm.date_id, drm.opening_mrr, drm.new_business_mrr, + drm.expansion_mrr, drm.contraction_mrr, drm.churn_mrr, drm.churn_reason + FROM public.daily_revenue_metrics drm + WHERE drm.date_id = ( + SELECT pse.date_id + FROM public.processed_stripe_events pse + WHERE pse.customer_id = rc.customer_id + AND pse.date_id < $2::text + ORDER BY pse.date_id DESC + LIMIT 1 + ) + AND drm.customer_id = rc.customer_id + ) latest + ), in_range AS ( + SELECT drm.customer_id, drm.date_id, drm.opening_mrr, drm.new_business_mrr, + drm.expansion_mrr, drm.contraction_mrr, drm.churn_mrr, drm.churn_reason + FROM relevant_customers rc + CROSS JOIN LATERAL ( + SELECT DISTINCT pse.date_id + FROM public.processed_stripe_events pse + WHERE pse.customer_id = rc.customer_id + AND pse.date_id BETWEEN $2::text AND $3::text + ) movement_dates + JOIN public.daily_revenue_metrics drm + ON drm.date_id = movement_dates.date_id + AND drm.customer_id = rc.customer_id + ) + SELECT * FROM carry_in + UNION ALL + SELECT * FROM in_range + ORDER BY customer_id, date_id + `, [customerIds, startDate, endDate]) + + for (const row of revenue.rows) { + const history = histories.get(orgIdByCustomer.get(row.customer_id) ?? '') + history?.revenueMovements.push({ + date: row.date_id, + openingMrr: Number(row.opening_mrr), + newBusinessMrr: Number(row.new_business_mrr), + expansionMrr: Number(row.expansion_mrr), + contractionMrr: Number(row.contraction_mrr), + churnMrr: Number(row.churn_mrr), + churnReason: row.churn_reason, + }) + } + } + + const grants = await client.query(` + SELECT g.id::text, g.org_id::text, g.granted_at, g.expires_at, g.credits_total + FROM public.usage_credit_grants g + WHERE g.org_id = ANY($1::uuid[]) + AND g.granted_at < ($3::date + INTERVAL '1 day') + AND g.expires_at >= $2::date + ORDER BY g.org_id, g.granted_at, g.id + `, [orgIds, startDate, endDate]) + + const grantIds: string[] = [] + for (const row of grants.rows) { + grantIds.push(row.id) + histories.get(row.org_id)?.creditGrants.push({ + id: row.id, + grantedAtMs: timestamp(row.granted_at)!, + expiresAtMs: timestamp(row.expires_at)!, + creditsTotal: Number(row.credits_total), + }) + } + + if (grantIds.length > 0) { + const consumptions = await client.query(` + SELECT c.grant_id::text, c.org_id::text, c.applied_at, c.credits_used + FROM public.usage_credit_consumptions c + WHERE c.grant_id = ANY($1::uuid[]) + AND c.applied_at < ($2::date + INTERVAL '1 day') + ORDER BY c.org_id, c.applied_at, c.id + `, [grantIds, endDate]) + + for (const row of consumptions.rows) { + histories.get(row.org_id)?.creditConsumptions.push({ + grantId: row.grant_id, + appliedAtMs: timestamp(row.applied_at)!, + creditsUsed: Number(row.credits_used), + }) + } + } + + return histories + } + finally { + client?.release() + await closeClient(c, pool) + } +} diff --git a/tests/plans-billing-history.unit.test.ts b/tests/plans-billing-history.unit.test.ts new file mode 100644 index 0000000000..7bcc7fdcbb --- /dev/null +++ b/tests/plans-billing-history.unit.test.ts @@ -0,0 +1,386 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + closeClientMock, + getPgClientMock, + pgConnectMock, + pgQueryMock, + pgReleaseMock, +} = vi.hoisted(() => { + const pgQueryMock = vi.fn() + const pgReleaseMock = vi.fn() + const pgConnectMock = vi.fn(async () => ({ query: pgQueryMock, release: pgReleaseMock })) + return { + closeClientMock: vi.fn(), + getPgClientMock: vi.fn(() => ({ connect: pgConnectMock })), + pgConnectMock, + pgQueryMock, + pgReleaseMock, + } +}) + +vi.mock('../supabase/functions/_backend/utils/pg.ts', () => ({ + closeClient: closeClientMock, + getPgClient: getPgClientMock, +})) + +import { + classifyPlansBillingAt, + loadPlansBillingHistories, + type OrganizationBillingHistory, +} from '../supabase/functions/_backend/utils/plans_billing_history.ts' + +const at = Date.parse('2026-08-01T12:00:00Z') +const base = (): OrganizationBillingHistory => ({ + orgId: 'org-a', + customerId: 'cus-a', + trialEndsAtMs: Date.parse('2026-07-01T00:00:00Z'), + paidAtMs: null, + canceledAtMs: null, + currentPastDueAtMs: null, + churnReason: null, + revenueMovements: [], + transitions: [], + creditGrants: [], + creditConsumptions: [], +}) + +function normalizedQuery(query: unknown) { + return String(query).replace(/\s+/g, ' ').trim() +} + +function context() { + return { get: vi.fn(() => 'request-id') } as never +} + +describe('Plans billing history classification', () => { + it.each([ + ['active payment problem beats paying', { + ...base(), paidAtMs: Date.parse('2026-06-01T00:00:00Z'), currentPastDueAtMs: Date.parse('2026-07-20T00:00:00Z'), + revenueMovements: [{ date: '2026-06-01', openingMrr: 0, newBusinessMrr: 12, expansionMrr: 0, contractionMrr: 0, churnMrr: 0, churnReason: null }], + }, 'payment_problem'], + ['carried positive MRR is paying', { + ...base(), paidAtMs: Date.parse('2026-06-01T00:00:00Z'), revenueMovements: [{ + date: '2026-06-01', openingMrr: 0, newBusinessMrr: 12, expansionMrr: 0, contractionMrr: 0, churnMrr: 0, churnReason: null, + }], + }, 'paying'], + ['future trial end is active trial', { + ...base(), trialEndsAtMs: Date.parse('2026-08-10T00:00:00Z'), + }, 'active_trial'], + ['positive unexpired credits are credits only', { + ...base(), creditGrants: [{ id: 'grant-a', grantedAtMs: Date.parse('2026-07-01T00:00:00Z'), expiresAtMs: Date.parse('2026-09-01T00:00:00Z'), creditsTotal: 10 }], + }, 'credits_only'], + ['previously paid voluntary ended entitlement is canceled', { + ...base(), paidAtMs: Date.parse('2026-06-01T00:00:00Z'), canceledAtMs: Date.parse('2026-07-01T00:00:00Z'), + }, 'canceled'], + ['never-paid ended trial is expired trial', base(), 'expired_trial'], + ] as const)('%s', (_label, history, expected) => { + expect(classifyPlansBillingAt(history, at)).toBe(expected) + }) + + it.concurrent('returns unknown for a movement-day mismatch without a locating transition', () => { + const history = { + ...base(), + paidAtMs: Date.parse('2026-06-01T00:00:00Z'), + revenueMovements: [{ date: '2026-08-01', openingMrr: 12, newBusinessMrr: 0, expansionMrr: 0, contractionMrr: 0, churnMrr: 12, churnReason: null }], + } + expect(classifyPlansBillingAt(history, at)).toBe('unknown') + }) + + it.concurrent('keeps a visit before a later same-day cancel paying', () => { + expect(classifyPlansBillingAt({ + ...base(), + paidAtMs: Date.parse('2026-06-01T00:00:00Z'), + revenueMovements: [{ date: '2026-08-01', openingMrr: 12, newBusinessMrr: 0, expansionMrr: 0, contractionMrr: 0, churnMrr: 12, churnReason: null }], + transitions: [{ timestampMs: Date.parse('2026-08-01T14:00:00Z'), kind: 'canceled' }], + }, Date.parse('2026-08-01T13:00:00Z'))).toBe('paying') + }) + + it.each([ + ['payment-failure churn with a transition', { + ...base(), + paidAtMs: Date.parse('2026-06-01T00:00:00Z'), + revenueMovements: [{ date: '2026-08-01', openingMrr: 12, newBusinessMrr: 0, expansionMrr: 0, contractionMrr: 0, churnMrr: 12, churnReason: 'past_due_unresolved' }], + transitions: [{ timestampMs: Date.parse('2026-08-01T10:00:00Z'), kind: 'payment_problem' as const }], + }, at, 'payment_problem'], + ['recovered past due is paying', { + ...base(), + paidAtMs: Date.parse('2026-06-01T00:00:00Z'), + revenueMovements: [{ date: '2026-06-01', openingMrr: 0, newBusinessMrr: 12, expansionMrr: 0, contractionMrr: 0, churnMrr: 0, churnReason: null }], + transitions: [ + { timestampMs: Date.parse('2026-08-01T09:00:00Z'), kind: 'payment_problem' as const }, + { timestampMs: Date.parse('2026-08-01T10:00:00Z'), kind: 'recovered' as const }, + ], + }, at, 'paying'], + ['resubscribed after cancel is paying', { + ...base(), + paidAtMs: Date.parse('2026-06-01T00:00:00Z'), + canceledAtMs: Date.parse('2026-07-01T00:00:00Z'), + transitions: [ + { timestampMs: Date.parse('2026-07-01T00:00:00Z'), kind: 'canceled' as const }, + { timestampMs: Date.parse('2026-08-01T11:00:00Z'), kind: 'paid' as const }, + ], + }, at, 'paying'], + ['fully consumed credits do not count', { + ...base(), + creditGrants: [{ id: 'grant-a', grantedAtMs: Date.parse('2026-07-01T00:00:00Z'), expiresAtMs: Date.parse('2026-09-01T00:00:00Z'), creditsTotal: 10 }], + creditConsumptions: [{ grantId: 'grant-a', appliedAtMs: Date.parse('2026-07-20T00:00:00Z'), creditsUsed: 10 }], + }, at, 'expired_trial'], + ['expired credits do not count', { + ...base(), + creditGrants: [{ id: 'grant-a', grantedAtMs: Date.parse('2026-06-01T00:00:00Z'), expiresAtMs: Date.parse('2026-07-01T00:00:00Z'), creditsTotal: 10 }], + }, at, 'expired_trial'], + ] as const)('%s', (_label, history, timestamp, expected) => { + expect(classifyPlansBillingAt(history, timestamp)).toBe(expected) + }) + + it.concurrent('returns unknown for contradictory transitions at one instant', () => { + expect(classifyPlansBillingAt({ + ...base(), + paidAtMs: Date.parse('2026-06-01T00:00:00Z'), + transitions: [ + { timestampMs: Date.parse('2026-08-01T10:00:00Z'), kind: 'paid' }, + { timestampMs: Date.parse('2026-08-01T10:00:00Z'), kind: 'canceled' }, + ], + }, at)).toBe('unknown') + }) + + it.concurrent('allows later definitive evidence to supersede a contradictory instant', () => { + const history = { + ...base(), + paidAtMs: Date.parse('2026-06-01T00:00:00Z'), + transitions: [ + { timestampMs: Date.parse('2026-08-01T10:00:00Z'), kind: 'paid' as const }, + { timestampMs: Date.parse('2026-08-01T10:00:00Z'), kind: 'canceled' as const }, + { timestampMs: Date.parse('2026-08-02T10:00:00Z'), kind: 'paid' as const }, + ], + } + + expect(classifyPlansBillingAt(history, Date.parse('2026-08-01T12:00:00Z'))).toBe('unknown') + expect(classifyPlansBillingAt(history, Date.parse('2026-08-02T12:00:00Z'))).toBe('paying') + }) + + it.concurrent('applies scalar cancellation evidence after older paid evidence', () => { + expect(classifyPlansBillingAt({ + ...base(), + paidAtMs: Date.parse('2026-06-01T00:00:00Z'), + canceledAtMs: Date.parse('2026-07-01T00:00:00Z'), + revenueMovements: [{ date: '2026-06-01', openingMrr: 0, newBusinessMrr: 12, expansionMrr: 0, contractionMrr: 0, churnMrr: 0, churnReason: null }], + transitions: [{ timestampMs: Date.parse('2026-06-01T01:00:00Z'), kind: 'paid' }], + }, at)).toBe('canceled') + }) + + it.concurrent('uses paidAt as paying evidence before a future cancellation and future trial end', () => { + expect(classifyPlansBillingAt({ + ...base(), + trialEndsAtMs: Date.parse('2026-08-10T00:00:00Z'), + paidAtMs: Date.parse('2026-08-01T10:00:00Z'), + canceledAtMs: Date.parse('2026-08-01T14:00:00Z'), + }, at)).toBe('paying') + }) + + it.each([ + ['paidAt locates a zero-to-positive movement', { + ...base(), + paidAtMs: Date.parse('2026-08-01T10:00:00Z'), + revenueMovements: [{ date: '2026-08-01', openingMrr: 0, newBusinessMrr: 12, expansionMrr: 0, contractionMrr: 0, churnMrr: 0, churnReason: null }], + }, 'paying'], + ['canceledAt locates a positive-to-zero movement', { + ...base(), + paidAtMs: Date.parse('2026-06-01T00:00:00Z'), + canceledAtMs: Date.parse('2026-08-01T10:00:00Z'), + revenueMovements: [{ date: '2026-08-01', openingMrr: 12, newBusinessMrr: 0, expansionMrr: 0, contractionMrr: 0, churnMrr: 12, churnReason: null }], + }, 'canceled'], + ] as const)('%s', (_label, history, expected) => { + expect(classifyPlansBillingAt(history, at)).toBe(expected) + }) + + it.concurrent('returns unknown for contradictory scalar and explicit transitions at one instant', () => { + expect(classifyPlansBillingAt({ + ...base(), + paidAtMs: Date.parse('2026-08-01T10:00:00Z'), + transitions: [{ timestampMs: Date.parse('2026-08-01T10:00:00Z'), kind: 'canceled' }], + }, at)).toBe('unknown') + }) + + it.each([ + ['movement churn reason', { + ...base(), + paidAtMs: Date.parse('2026-06-01T00:00:00Z'), + canceledAtMs: Date.parse('2026-08-01T10:00:00Z'), + revenueMovements: [{ date: '2026-08-01', openingMrr: 12, newBusinessMrr: 0, expansionMrr: 0, contractionMrr: 0, churnMrr: 12, churnReason: 'past_due_unresolved' }], + }], + ['organization churn reason', { + ...base(), + paidAtMs: Date.parse('2026-06-01T00:00:00Z'), + canceledAtMs: Date.parse('2026-08-01T10:00:00Z'), + churnReason: 'past_due_unresolved', + revenueMovements: [{ date: '2026-08-01', openingMrr: 12, newBusinessMrr: 0, expansionMrr: 0, contractionMrr: 0, churnMrr: 12, churnReason: null }], + }], + ] as const)('classifies payment-failure churn from %s without an explicit transition', (_label, history) => { + expect(classifyPlansBillingAt(history, at)).toBe('payment_problem') + }) + + it.each([ + ['non-finite visit timestamp', base(), Number.NaN], + ['non-finite cancellation timestamp', { ...base(), canceledAtMs: Number.NaN }, at], + ['non-finite transition timestamp', { ...base(), transitions: [{ timestampMs: Number.NaN, kind: 'paid' as const }] }, at], + ['invalid revenue date', { ...base(), revenueMovements: [{ date: 'not-a-date', openingMrr: 12, newBusinessMrr: 0, expansionMrr: 0, contractionMrr: 0, churnMrr: 0, churnReason: null }] }, at], + ['non-finite MRR', { ...base(), revenueMovements: [{ date: '2026-07-01', openingMrr: Number.NaN, newBusinessMrr: 0, expansionMrr: 0, contractionMrr: 0, churnMrr: 0, churnReason: null }] }, at], + ['non-finite grant timestamp', { ...base(), creditGrants: [{ id: 'grant-a', grantedAtMs: Number.NaN, expiresAtMs: at + 1000, creditsTotal: 10 }] }, at], + ['non-finite credit quantity', { ...base(), creditGrants: [{ id: 'grant-a', grantedAtMs: at - 1000, expiresAtMs: at + 1000, creditsTotal: Number.POSITIVE_INFINITY }] }, at], + ['non-finite consumption evidence', { + ...base(), + creditGrants: [{ id: 'grant-a', grantedAtMs: at - 1000, expiresAtMs: at + 1000, creditsTotal: 10 }], + creditConsumptions: [{ grantId: 'grant-a', appliedAtMs: at - 500, creditsUsed: Number.NaN }], + }, at], + ] as const)('returns unknown without throwing for %s', (_label, history, timestamp) => { + expect(() => classifyPlansBillingAt(history, timestamp)).not.toThrow() + expect(classifyPlansBillingAt(history, timestamp)).toBe('unknown') + }) + + it.each([ + ['negative opening MRR', { + ...base(), + revenueMovements: [{ date: '2026-07-01', openingMrr: -1, newBusinessMrr: 0, expansionMrr: 0, contractionMrr: 0, churnMrr: 0, churnReason: null }], + }], + ['negative credit total', { + ...base(), + creditGrants: [{ id: 'grant-a', grantedAtMs: at - 1000, expiresAtMs: at + 1000, creditsTotal: -1 }], + }], + ['zero credit consumption', { + ...base(), + creditGrants: [{ id: 'grant-a', grantedAtMs: at - 1000, expiresAtMs: at + 1000, creditsTotal: 10 }], + creditConsumptions: [{ grantId: 'grant-a', appliedAtMs: at - 500, creditsUsed: 0 }], + }], + ['negative credit consumption', { + ...base(), + creditGrants: [{ id: 'grant-a', grantedAtMs: at - 1000, expiresAtMs: at + 1000, creditsTotal: 10 }], + creditConsumptions: [{ grantId: 'grant-a', appliedAtMs: at - 500, creditsUsed: -1 }], + }], + ['reversed grant interval', { + ...base(), + creditGrants: [{ id: 'grant-a', grantedAtMs: at + 1000, expiresAtMs: at - 1000, creditsTotal: 10 }], + }], + ] as const)('returns unknown for impossible finite evidence: %s', (_label, history) => { + expect(classifyPlansBillingAt(history, at)).toBe('unknown') + }) + + it.each([ + ['zero MRR', { + ...base(), + revenueMovements: [{ date: '2026-07-01', openingMrr: 0, newBusinessMrr: 0, expansionMrr: 0, contractionMrr: 0, churnMrr: 0, churnReason: null }], + }], + ['zero credit grant', { + ...base(), + creditGrants: [{ id: 'grant-a', grantedAtMs: at, expiresAtMs: at, creditsTotal: 0 }], + }], + ] as const)('keeps legitimate boundary evidence valid: %s', (_label, history) => { + expect(classifyPlansBillingAt(history, at)).toBe('expired_trial') + }) +}) + +describe('loadPlansBillingHistories', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('uses bounded parameterized queries and reconstructs relevant histories', async () => { + pgQueryMock + .mockResolvedValueOnce({ rows: [ + { org_id: 'org-a', customer_id: 'cus-a', trial_at: '2026-08-10T00:00:00Z', paid_at: '2026-07-01T00:00:00Z', canceled_at: null, past_due_at: null, churn_reason: null }, + { org_id: 'org-b', customer_id: null, trial_at: '2026-07-01T00:00:00Z', paid_at: null, canceled_at: null, past_due_at: null, churn_reason: null }, + ] }) + .mockResolvedValueOnce({ rows: [ + { customer_id: 'cus-a', date_id: '2026-07-31', opening_mrr: 10, new_business_mrr: 0, expansion_mrr: 2, contraction_mrr: 0, churn_mrr: 0, churn_reason: null }, + { customer_id: 'cus-a', date_id: '2026-08-01', opening_mrr: 12, new_business_mrr: 0, expansion_mrr: 0, contraction_mrr: 0, churn_mrr: 0, churn_reason: null }, + ] }) + .mockResolvedValueOnce({ rows: [ + { id: 'grant-a', org_id: 'org-a', granted_at: '2026-07-20T00:00:00Z', expires_at: '2026-09-01T00:00:00Z', credits_total: '10' }, + { id: 'grant-b', org_id: 'org-b', granted_at: '2026-08-02T00:00:00Z', expires_at: '2026-08-31T00:00:00Z', credits_total: '4' }, + ] }) + .mockResolvedValueOnce({ rows: [ + { grant_id: 'grant-a', org_id: 'org-a', applied_at: '2026-08-03T00:00:00Z', credits_used: '3' }, + ] }) + + const transitions = new Map([['org-a', [{ timestampMs: at, kind: 'paid' as const }]]]) + const result = await loadPlansBillingHistories(context(), ['org-a', 'org-b'], '2026-08-01', '2026-08-07', transitions) + + expect(getPgClientMock).toHaveBeenCalledOnce() + expect(getPgClientMock).toHaveBeenCalledWith(expect.anything(), true) + expect(pgConnectMock).toHaveBeenCalledOnce() + expect(pgQueryMock).toHaveBeenCalledTimes(4) + + const calls = pgQueryMock.mock.calls.map(([query, params]) => ({ sql: normalizedQuery(query), params })) + expect(calls[0]).toMatchObject({ params: [['org-a', 'org-b']] }) + expect(calls[0]!.sql).toContain('WHERE o.id = ANY($1::uuid[])') + expect(calls[1]).toMatchObject({ params: [['cus-a'], '2026-08-01', '2026-08-07'] }) + expect(calls[1]!.sql).toContain('FROM unnest($1::text[]) AS customer_id') + expect(calls[1]!.sql).toContain('CROSS JOIN LATERAL') + expect(calls[1]!.sql).toContain('FROM public.processed_stripe_events pse') + expect(calls[1]!.sql).toContain('pse.customer_id = rc.customer_id') + expect(calls[1]!.sql).toContain('drm.date_id = ( SELECT pse.date_id') + expect(calls[1]!.sql).toContain('drm.customer_id = rc.customer_id') + expect(calls[1]!.sql).toContain('pse.date_id < $2::text') + expect(calls[1]!.sql).toContain('SELECT DISTINCT pse.date_id') + expect(calls[1]!.sql).toContain('pse.date_id BETWEEN $2::text AND $3::text') + expect(calls[1]!.sql).toContain('drm.date_id = movement_dates.date_id') + expect(calls[1]!.sql).toContain('drm.customer_id = rc.customer_id') + expect(calls[1]!.sql).not.toContain('drm.customer_id = ANY($1::text[])') + expect(calls[1]!.sql).not.toContain('drm.date_id BETWEEN $2::text AND $3::text') + expect(calls[2]).toMatchObject({ params: [['org-a', 'org-b'], '2026-08-01', '2026-08-07'] }) + expect(calls[2]!.sql).toContain('g.org_id = ANY($1::uuid[])') + expect(calls[2]!.sql).toContain("g.granted_at < ($3::date + INTERVAL '1 day')") + expect(calls[2]!.sql).toContain('g.expires_at >= $2::date') + expect(calls[3]).toMatchObject({ params: [['grant-a', 'grant-b'], '2026-08-07'] }) + expect(calls[3]!.sql).toContain('c.grant_id = ANY($1::uuid[])') + expect(calls[3]!.sql).toContain("c.applied_at < ($2::date + INTERVAL '1 day')") + + expect(result.get('org-a')).toMatchObject({ + orgId: 'org-a', + customerId: 'cus-a', + revenueMovements: [{ date: '2026-07-31' }, { date: '2026-08-01' }], + transitions: [{ timestampMs: at, kind: 'paid' }], + creditGrants: [{ id: 'grant-a', creditsTotal: 10 }], + creditConsumptions: [{ grantId: 'grant-a', creditsUsed: 3 }], + }) + expect(result.get('org-b')).toMatchObject({ customerId: null, creditGrants: [{ id: 'grant-b', creditsTotal: 4 }] }) + expect(pgReleaseMock).toHaveBeenCalledOnce() + expect(closeClientMock).toHaveBeenCalledWith(expect.anything(), getPgClientMock.mock.results[0]!.value) + }) + + it('releases the client and closes the pool when a bounded query fails', async () => { + pgQueryMock.mockRejectedValueOnce(new Error('database unavailable')) + + await expect(loadPlansBillingHistories(context(), ['org-a'], '2026-08-01', '2026-08-07', new Map())) + .rejects.toThrow('database unavailable') + + expect(pgReleaseMock).toHaveBeenCalledOnce() + expect(closeClientMock).toHaveBeenCalledOnce() + }) + + it('preserves malformed loaded numerics so classification remains unknown', async () => { + pgQueryMock + .mockResolvedValueOnce({ rows: [ + { org_id: 'org-a', customer_id: 'cus-a', trial_at: '2026-07-01T00:00:00Z', paid_at: null, canceled_at: null, past_due_at: null, churn_reason: null }, + ] }) + .mockResolvedValueOnce({ rows: [ + { customer_id: 'cus-a', date_id: '2026-07-31', opening_mrr: 'invalid', new_business_mrr: 0, expansion_mrr: 0, contraction_mrr: 0, churn_mrr: 0, churn_reason: null }, + ] }) + .mockResolvedValueOnce({ rows: [] }) + + const histories = await loadPlansBillingHistories(context(), ['org-a'], '2026-08-01', '2026-08-07', new Map()) + const history = histories.get('org-a')! + + expect(Number.isNaN(history.revenueMovements[0]!.openingMrr)).toBe(true) + expect(classifyPlansBillingAt(history, at)).toBe('unknown') + }) + + 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.toEqual(new Map()) + + expect(getPgClientMock).not.toHaveBeenCalled() + expect(pgQueryMock).not.toHaveBeenCalled() + }) +}) From 375c31f33a9303a4232e36b4bafca7a9f9c23ef1 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Mon, 10 Aug 2026 08:59:21 +0200 Subject: [PATCH 07/19] feat(admin): aggregate plans analytics --- .../_backend/utils/plans_analytics.ts | 460 +++++++++++++++++ ...plans-analytics-orchestration.unit.test.ts | 464 ++++++++++++++++++ 2 files changed, 924 insertions(+) create mode 100644 supabase/functions/_backend/utils/plans_analytics.ts create mode 100644 tests/plans-analytics-orchestration.unit.test.ts diff --git a/supabase/functions/_backend/utils/plans_analytics.ts b/supabase/functions/_backend/utils/plans_analytics.ts new file mode 100644 index 0000000000..a84f30af96 --- /dev/null +++ b/supabase/functions/_backend/utils/plans_analytics.ts @@ -0,0 +1,460 @@ +import type { Context } from 'hono' +import type { DailyBillingPoint, 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' +import { + attributeCheckoutStarts, + buildLogicalPlansOpenings, + buildPlansChartData, + CHECKOUT_ATTRIBUTION_MS, + LEGACY_BURST_SECONDS, +} from './plans_analytics_model.ts' +import { + classifyPlansBillingAt, + loadPlansBillingHistories, +} from './plans_billing_history.ts' +import { queryPosthogHogql } from './posthog_read.ts' + +export const MAX_POSTHOG_ROWS = 200_000 +export const TRACKING_HISTORY_START = '2026-02-23T00:00:00.000Z' +export const LEGACY_PATH_SOURCE = 'unavailable' as const +const TRANSITION_ORG_BATCH_SIZE = 1_000 +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + +type PlansAnalyticsFailureReason = PosthogReadFailureReason | 'too_large' + +export interface PlansAnalyticsResponse { + traffic: { dates: string[], uniqueVisitorOrganizations: number[], totalOpens: number[] } + visitorBreakdown: DailyBillingPoint[] + checkoutIntent: DailyCheckoutIntentPoint[] + checkoutVisitorBreakdown: DailyBillingPoint[] + dataQuality: { + exactTrackingStartedAt: string | null + legacyLogicalOpens: number + exactLogicalOpens: number + legacyReconstructionAvailable: boolean + legacyUnavailableReason: 'missing_event_time_path' | null + excludedMissingOrganization: number + unmatchedCheckoutStarts: number + unknownBillingOrganizations: number + posthogConfigured: boolean + posthogConnected: boolean + posthogFailureReason: PlansAnalyticsFailureReason | null + legacyDeduplicationSeconds: number + } +} + +interface QualityOverrides { + exactTrackingStartedAt?: string | null + exactLogicalOpens?: number + excludedMissingOrganization?: number + unmatchedCheckoutStarts?: number + unknownBillingOrganizations?: number + posthogConfigured?: boolean + posthogConnected?: boolean + posthogFailureReason?: PlansAnalyticsFailureReason | null +} + +interface ParsedRange { + startMs: number + endMs: number + startIso: string + endIso: string +} + +function safeIso(timestampMs: number): string | null { + if (!Number.isFinite(timestampMs)) + return null + const date = new Date(timestampMs) + return Number.isFinite(date.getTime()) ? date.toISOString() : null +} + +function parseRange(startDate: string, endDate: string): ParsedRange | null { + if (typeof startDate !== 'string' || typeof endDate !== 'string') + return null + + const startMs = Date.parse(startDate) + const endMs = Date.parse(endDate) + if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || startMs >= endMs) + return null + + const startIso = safeIso(startMs) + const endIso = safeIso(endMs) + if (!startIso || !endIso || !safeIso(startMs - LEGACY_BURST_SECONDS * 1000) || !safeIso(endMs + CHECKOUT_ATTRIBUTION_MS)) + return null + + return { + startMs, + endMs, + startIso, + endIso, + } +} + +function sqlString(value: string): string { + return `'${value.replaceAll('\'', '\'\'')}'` +} + +export function buildPlansBehaviorQuery(startDate: string, endDate: string): string { + const range = parseRange(startDate, endDate) + if (!range) + return '' + + const queryStart = safeIso(range.startMs - LEGACY_BURST_SECONDS * 1000)! + const queryEnd = safeIso(range.endMs + CHECKOUT_ATTRIBUTION_MS)! + return ` +SELECT + toUnixTimestamp64Milli(timestamp) AS timestamp_ms, + event, + properties.org_id AS org_id, + properties.$groups.organization AS grouped_org_id, + properties.page AS page, + properties.$session_id AS session_id, + distinct_id +FROM events +WHERE event IN ('User visit', 'Checkout Started') + AND ( + (event = 'User visit' + AND timestamp >= parseDateTimeBestEffort(${sqlString(queryStart)}) + AND timestamp < parseDateTimeBestEffort(${sqlString(range.endIso)})) + OR + (event = 'Checkout Started' + AND timestamp >= parseDateTimeBestEffort(${sqlString(range.startIso)}) + AND timestamp < parseDateTimeBestEffort(${sqlString(queryEnd)})) + ) +ORDER BY timestamp +LIMIT ${MAX_POSTHOG_ROWS + 1}`.trim() +} + +export function buildBillingTransitionsQuery(endDate: string, orgIds: string[]): string { + const endMs = Date.parse(endDate) + if (!Number.isFinite(endMs)) + return '' + + const queryEnd = safeIso(endMs + CHECKOUT_ATTRIBUTION_MS) + if (!queryEnd) + return '' + const organizationValues = orgIds.map(orgId => sqlString(orgId.trim())).join(', ') + const organizationFilter = organizationValues + ? `AND (properties.$group_key IN (${organizationValues}) OR properties.$groups.organization IN (${organizationValues}))` + : 'AND 0 = 1' + return ` +SELECT + toUnixTimestamp64Milli(timestamp) AS timestamp_ms, + event, + properties.$group_key AS group_key, + properties.$group_type AS group_type, + properties.$groups.organization AS grouped_org_id, + properties.$group_set.plan_status AS plan_status, + properties.plan_status AS event_plan_status, + properties.$group_set.canceled_at AS canceled_at +FROM events +WHERE event IN ('User subscribe', 'User update subscribe', 'User cancel', '$groupidentify') + AND timestamp >= parseDateTimeBestEffort(${sqlString(TRACKING_HISTORY_START)}) + AND timestamp < parseDateTimeBestEffort(${sqlString(queryEnd)}) + ${organizationFilter} +ORDER BY timestamp +LIMIT ${MAX_POSTHOG_ROWS + 1}`.trim() +} + +export function buildExactTrackingStartQuery(): string { + return ` +SELECT min(timestamp) AS exact_tracking_started_at +FROM events +WHERE event = 'User visit' + AND properties.page = 'plans' + AND timestamp >= parseDateTimeBestEffort(${sqlString(TRACKING_HISTORY_START)}) + AND timestamp < now() +LIMIT ${MAX_POSTHOG_ROWS + 1}`.trim() +} + +function emptyPlansAnalyticsResponse( + startMs: number, + endMs: number, + quality: QualityOverrides = {}, +): PlansAnalyticsResponse { + const charts = buildPlansChartData({ + openings: [], + attributedCheckouts: [], + startMs, + endMs, + classifyAt: () => 'unknown', + }) + + return { + ...charts, + dataQuality: { + exactTrackingStartedAt: quality.exactTrackingStartedAt ?? null, + legacyLogicalOpens: 0, + exactLogicalOpens: quality.exactLogicalOpens ?? 0, + legacyReconstructionAvailable: false, + legacyUnavailableReason: 'missing_event_time_path', + excludedMissingOrganization: quality.excludedMissingOrganization ?? 0, + unmatchedCheckoutStarts: quality.unmatchedCheckoutStarts ?? 0, + unknownBillingOrganizations: quality.unknownBillingOrganizations ?? 0, + posthogConfigured: quality.posthogConfigured ?? false, + posthogConnected: quality.posthogConnected ?? false, + posthogFailureReason: quality.posthogFailureReason ?? null, + legacyDeduplicationSeconds: LEGACY_BURST_SECONDS, + }, + } +} + +function isOptionalString(value: unknown): value is string | null | undefined { + return value === undefined || value === null || typeof value === 'string' +} + +function normalizedUuid(value: unknown): string | null { + if (typeof value !== 'string') + return null + const normalized = value.trim().toLowerCase() + return UUID_PATTERN.test(normalized) ? normalized : null +} + +function organizationId(row: Record): string | null { + return normalizedUuid(row.org_id) ?? normalizedUuid(row.grouped_org_id) +} + +function mapBehaviorRow(row: Record): PlansBehaviorEvent | null { + if (!Number.isFinite(row.timestamp_ms) || (row.event !== 'User visit' && row.event !== 'Checkout Started')) + return null + if ( + !isOptionalString(row.page) + || !isOptionalString(row.session_id) + || !isOptionalString(row.distinct_id) + ) { + return null + } + + const orgId = organizationId(row) + if (!orgId) + return null + + return { + event: row.event, + timestampMs: row.timestamp_ms as number, + orgId, + actorId: row.distinct_id ?? '', + sessionId: row.session_id ?? '', + page: row.page ?? '', + // Legacy reconstruction is unavailable. Event-time and person-current paths are intentionally ignored. + path: '', + } +} + +function unresolvedOrganizationCount(rows: Record[], range: ParsedRange): number { + return rows.filter(row => ( + Number.isFinite(row.timestamp_ms) + && ( + ( + row.event === 'User visit' + && row.page === 'plans' + && (row.timestamp_ms as number) >= range.startMs + && (row.timestamp_ms as number) < range.endMs + ) + || ( + row.event === 'Checkout Started' + && (row.timestamp_ms as number) >= range.startMs + && (row.timestamp_ms as number) < range.endMs + CHECKOUT_ATTRIBUTION_MS + ) + ) + && !organizationId(row) + )).length +} + +function explicitTransitionKind(value: unknown): BillingTransition['kind'] | null { + if (typeof value !== 'string') + return null + const status = value.trim().toLowerCase() + if (status === 'past_due' || status === 'unpaid') + return 'payment_problem' + if (status === 'canceled' || status === 'cancelled' || status === 'deleted') + return 'canceled' + if (status === 'succeeded' || status === 'created' || status === 'updated' || status === 'active') + return 'paid' + return null +} + +function transitionKind(row: Record): BillingTransition['kind'] | null { + if (row.event === 'User cancel') + return 'canceled' + if (row.event === 'User subscribe') + return 'paid' + if (row.event === 'User update subscribe') + return explicitTransitionKind(row.event_plan_status) + if (row.event !== '$groupidentify') + return null + + if (typeof row.canceled_at === 'string' && Number.isFinite(Date.parse(row.canceled_at))) + return 'canceled' + return explicitTransitionKind(row.plan_status) +} + +function transitionOrganizationId(row: Record): string | null { + const groupKey = row.group_type === 'organization' ? normalizedUuid(row.group_key) : null + return groupKey ?? normalizedUuid(row.grouped_org_id) +} + +function mapBillingTransitions( + rows: Record[], + relevantOrganizations: ReadonlySet, +): Map { + const transitions = new Map() + for (const row of rows) { + if (!Number.isFinite(row.timestamp_ms)) + continue + + const orgId = transitionOrganizationId(row) + const kind = transitionKind(row) + if (!orgId || !relevantOrganizations.has(orgId) || !kind) + continue + + const organizationTransitions = transitions.get(orgId) ?? [] + organizationTransitions.push({ timestampMs: row.timestamp_ms as number, kind }) + transitions.set(orgId, organizationTransitions) + } + return transitions +} + +function exactTrackingStartedAt(rows: Record[]): string | null { + const value = rows[0]?.exact_tracking_started_at + if (typeof value !== 'string') + return null + const timestampMs = Date.parse(value) + return Number.isFinite(timestampMs) ? new Date(timestampMs).toISOString() : null +} + +function failedResult(results: PosthogReadResult[]): PosthogReadResult | null { + return results.find(result => result.failureReason !== null || !result.connected) ?? null +} + +export async function getAdminPlansAnalytics( + c: Context, + startDate: string, + endDate: string, +): Promise { + const startedAt = Date.now() + const range = parseRange(startDate, endDate) + if (!range) + return emptyPlansAnalyticsResponse(0, 0) + + const behaviorResult = await queryPosthogHogql(c, buildPlansBehaviorQuery(range.startIso, range.endIso)) + const behaviorFailure = failedResult([behaviorResult]) + if (behaviorFailure) { + return emptyPlansAnalyticsResponse(range.startMs, range.endMs, { + posthogConfigured: behaviorFailure.configured, + posthogConnected: behaviorFailure.connected, + posthogFailureReason: behaviorFailure.failureReason ?? 'unavailable', + }) + } + if (behaviorResult.rows.length > MAX_POSTHOG_ROWS) { + return emptyPlansAnalyticsResponse(range.startMs, range.endMs, { + posthogConfigured: true, + posthogConnected: true, + posthogFailureReason: 'too_large', + }) + } + + const excludedMissingOrganization = unresolvedOrganizationCount(behaviorResult.rows, range) + const behaviorEvents = behaviorResult.rows.map(mapBehaviorRow).filter(event => event !== null) + const exactEvents = behaviorEvents.filter(event => event.event !== 'User visit' || event.page === 'plans') + const openings = buildLogicalPlansOpenings(exactEvents, range.startMs, range.endMs) + const checkoutEvents = exactEvents.filter(event => ( + event.event === 'Checkout Started' + && event.timestampMs >= range.startMs + && event.timestampMs < range.endMs + CHECKOUT_ATTRIBUTION_MS + )) + const attributedCheckouts = attributeCheckoutStarts(openings, checkoutEvents) + const orgIds = [...new Set(openings.map(opening => opening.orgId))] + const relevantOrganizations = new Set(orgIds) + const transitionRows: Record[] = [] + for (let offset = 0; offset < orgIds.length; offset += TRANSITION_ORG_BATCH_SIZE) { + const batch = orgIds.slice(offset, offset + TRANSITION_ORG_BATCH_SIZE) + const transitionResult = await queryPosthogHogql(c, buildBillingTransitionsQuery(range.endIso, batch)) + const transitionFailure = failedResult([transitionResult]) + if (transitionFailure) { + return emptyPlansAnalyticsResponse(range.startMs, range.endMs, { + posthogConfigured: transitionFailure.configured, + posthogConnected: transitionFailure.connected, + posthogFailureReason: transitionFailure.failureReason ?? 'unavailable', + }) + } + if (transitionResult.rows.length > MAX_POSTHOG_ROWS || transitionRows.length + transitionResult.rows.length > MAX_POSTHOG_ROWS) { + return emptyPlansAnalyticsResponse(range.startMs, range.endMs, { + posthogConfigured: true, + posthogConnected: true, + posthogFailureReason: 'too_large', + }) + } + transitionRows.push(...transitionResult.rows) + } + + const boundaryResult = await queryPosthogHogql(c, buildExactTrackingStartQuery()) + const boundaryFailure = failedResult([boundaryResult]) + if (boundaryFailure) { + return emptyPlansAnalyticsResponse(range.startMs, range.endMs, { + posthogConfigured: boundaryFailure.configured, + posthogConnected: boundaryFailure.connected, + posthogFailureReason: boundaryFailure.failureReason ?? 'unavailable', + }) + } + if (boundaryResult.rows.length > MAX_POSTHOG_ROWS) { + return emptyPlansAnalyticsResponse(range.startMs, range.endMs, { + posthogConfigured: true, + posthogConnected: true, + posthogFailureReason: 'too_large', + }) + } + + const transitions = mapBillingTransitions(transitionRows, relevantOrganizations) + const histories = await loadPlansBillingHistories( + c, + orgIds, + range.startIso.slice(0, 10), + range.endIso.slice(0, 10), + transitions, + ) + const unknownOrganizations = new Set() + const charts = buildPlansChartData({ + openings, + attributedCheckouts, + startMs: range.startMs, + endMs: range.endMs, + classifyAt: (orgId, timestampMs) => { + const history = histories.get(orgId) + const category = history ? classifyPlansBillingAt(history, timestampMs) : 'unknown' + if (category === 'unknown') + unknownOrganizations.add(orgId) + return category + }, + }) + + const response: PlansAnalyticsResponse = { + ...charts, + dataQuality: { + exactTrackingStartedAt: exactTrackingStartedAt(boundaryResult.rows), + legacyLogicalOpens: 0, + exactLogicalOpens: openings.length, + legacyReconstructionAvailable: false, + legacyUnavailableReason: 'missing_event_time_path', + excludedMissingOrganization, + unmatchedCheckoutStarts: checkoutEvents.length - attributedCheckouts.length, + unknownBillingOrganizations: unknownOrganizations.size, + posthogConfigured: true, + posthogConnected: true, + posthogFailureReason: null, + legacyDeduplicationSeconds: LEGACY_BURST_SECONDS, + }, + } + cloudlog({ + requestId: c.get('requestId'), + message: 'plans_analytics_aggregated', + durationMs: Date.now() - startedAt, + behaviorRows: behaviorResult.rows.length, + transitionRows: transitionRows.length, + logicalOpenings: openings.length, + attributedCheckouts: attributedCheckouts.length, + }) + return response +} diff --git a/tests/plans-analytics-orchestration.unit.test.ts b/tests/plans-analytics-orchestration.unit.test.ts new file mode 100644 index 0000000000..9abd07f7b3 --- /dev/null +++ b/tests/plans-analytics-orchestration.unit.test.ts @@ -0,0 +1,464 @@ +import type { Context } from 'hono' +import type { OrganizationBillingHistory } from '../supabase/functions/_backend/utils/plans_billing_history.ts' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + buildBillingTransitionsQuery, + buildExactTrackingStartQuery, + buildPlansBehaviorQuery, + getAdminPlansAnalytics, + LEGACY_PATH_SOURCE, + MAX_POSTHOG_ROWS, + TRACKING_HISTORY_START, +} from '../supabase/functions/_backend/utils/plans_analytics.ts' +import { loadPlansBillingHistories } from '../supabase/functions/_backend/utils/plans_billing_history.ts' +import { queryPosthogHogql } from '../supabase/functions/_backend/utils/posthog_read.ts' + +vi.mock('../supabase/functions/_backend/utils/posthog_read.ts', () => ({ queryPosthogHogql: vi.fn() })) +vi.mock('../supabase/functions/_backend/utils/plans_billing_history.ts', async importOriginal => ({ + ...await importOriginal(), + loadPlansBillingHistories: vi.fn(), +})) + +const start = '2026-08-01T00:00:00.000Z' +const end = '2026-08-02T00:00:00.000Z' +const startMs = Date.parse(start) +const context = { get: vi.fn(() => 'request-id') } as unknown as Context +const ORG_A = '00000000-0000-4000-8000-00000000000a' +const ORG_B = '00000000-0000-4000-8000-00000000000b' +const ORG_C = '00000000-0000-4000-8000-00000000000c' +const ORG_D = '00000000-0000-4000-8000-00000000000d' +const ORG_E = '00000000-0000-4000-8000-00000000000e' +const ORG_F = '00000000-0000-4000-8000-00000000000f' +const ORG_X = '00000000-0000-4000-8000-000000000010' +const ORG_GROUPED = '00000000-0000-4000-8000-000000000011' +const ORG_PAID = '00000000-0000-4000-8000-000000000012' +const ORG_CANCELED = '00000000-0000-4000-8000-000000000013' +const ORG_KNOWN = '00000000-0000-4000-8000-000000000014' +const ORG_UNKNOWN = '00000000-0000-4000-8000-000000000015' + +function connected(rows: Record[] = []) { + return { + configured: true, + connected: true, + failureReason: null, + rows, + } as const +} + +function rowsWithLength(length: number): Record[] { + const rows: Record[] = [] + rows.length = length + return rows +} + +function behavior(overrides: Record = {}) { + return { + timestamp_ms: startMs + 60_000, + event: 'User visit', + org_id: ORG_A, + grouped_org_id: '', + page: 'plans', + session_id: 'session-a', + distinct_id: 'user-a', + ...overrides, + } +} + +function history(orgId: string, overrides: Partial = {}): OrganizationBillingHistory { + return { + orgId, + customerId: null, + trialEndsAtMs: Date.parse('2026-08-10T00:00:00Z'), + paidAtMs: null, + canceledAtMs: null, + currentPastDueAtMs: null, + churnReason: null, + revenueMovements: [], + transitions: [], + creditGrants: [], + creditConsumptions: [], + ...overrides, + } +} + +beforeEach(() => { + vi.clearAllMocks() + vi.mocked(loadPlansBillingHistories).mockResolvedValue(new Map()) +}) + +describe('plans analytics query construction', () => { + it.concurrent('builds bounded scalar-only behavior, transition, and exact-boundary queries', () => { + const behaviorQuery = buildPlansBehaviorQuery(start, end) + expect(behaviorQuery).toContain('toUnixTimestamp64Milli(timestamp) AS timestamp_ms') + expect(behaviorQuery).toContain('event IN (\'User visit\', \'Checkout Started\')') + expect(behaviorQuery).toContain('2026-07-31T23:59:30.000Z') + expect(behaviorQuery).toContain('2026-08-03T00:00:00.000Z') + expect(behaviorQuery).toContain('event = \'User visit\'\n AND timestamp >= parseDateTimeBestEffort(\'2026-07-31T23:59:30.000Z\')\n AND timestamp < parseDateTimeBestEffort(\'2026-08-02T00:00:00.000Z\')') + expect(behaviorQuery).toContain('event = \'Checkout Started\'\n AND timestamp >= parseDateTimeBestEffort(\'2026-08-01T00:00:00.000Z\')\n AND timestamp < parseDateTimeBestEffort(\'2026-08-03T00:00:00.000Z\')') + expect(behaviorQuery).toContain('LIMIT 200001') + expect(behaviorQuery).not.toMatch(/SELECT\s+properties\b/i) + expect(behaviorQuery).not.toContain('current_url') + expect(behaviorQuery).not.toContain('pathname') + + const transitionQuery = buildBillingTransitionsQuery(end, [ORG_A, ORG_B]) + expect(transitionQuery).toContain('toUnixTimestamp64Milli(timestamp) AS timestamp_ms') + expect(transitionQuery).toContain('event IN (\'User subscribe\', \'User update subscribe\', \'User cancel\', \'$groupidentify\')') + expect(transitionQuery).toContain(TRACKING_HISTORY_START) + expect(transitionQuery).toContain('2026-08-03T00:00:00.000Z') + expect(transitionQuery).toContain('properties.plan_status AS event_plan_status') + expect(transitionQuery).toContain(`properties.$group_key IN ('${ORG_A}', '${ORG_B}')`) + expect(transitionQuery).toContain(`properties.$groups.organization IN ('${ORG_A}', '${ORG_B}')`) + expect(transitionQuery).toContain('LIMIT 200001') + expect(transitionQuery).not.toMatch(/SELECT\s+properties\b/i) + + const exactQuery = buildExactTrackingStartQuery() + expect(exactQuery).toContain('properties.page = \'plans\'') + expect(exactQuery).toContain(TRACKING_HISTORY_START) + expect(exactQuery).toContain('timestamp < now()') + expect(exactQuery).toContain('LIMIT 200001') + }) + + it.concurrent('escapes date scalar literals and validates dates before constructing queries', () => { + expect(buildPlansBehaviorQuery('2026-08-01T00:00:00.000Z\' OR 1=1', end)).toBe('') + expect(buildBillingTransitionsQuery('not-a-date', [ORG_A])).toBe('') + expect(buildBillingTransitionsQuery('+275760-09-12T23:59:59.999Z', [ORG_A])).toBe('') + expect(buildBillingTransitionsQuery(end, ['bad\'id'])).toContain('\'bad\'\'id\'') + expect(() => buildPlansBehaviorQuery('not-a-date', end)).not.toThrow() + }) +}) + +describe('plans analytics orchestration', () => { + it.each([ + ['unconfigured', { configured: false, connected: false, failureReason: 'unconfigured' as const, rows: [] }], + ['timeout', { configured: true, connected: false, failureReason: 'timeout' as const, rows: [] }], + ['unavailable', { configured: true, connected: false, failureReason: 'unavailable' as const, rows: [] }], + ])('returns a structured %s state', async (_label, failure) => { + vi.mocked(queryPosthogHogql).mockResolvedValue(failure) + + const result = await getAdminPlansAnalytics(context, start, end) + + expect(result.dataQuality).toMatchObject({ + posthogConfigured: failure.configured, + posthogConnected: failure.connected, + posthogFailureReason: failure.failureReason, + legacyReconstructionAvailable: false, + legacyUnavailableReason: 'missing_event_time_path', + legacyLogicalOpens: 0, + }) + expect(result.traffic.totalOpens).toEqual([0]) + expect(result.visitorBreakdown).toHaveLength(1) + expect(loadPlansBillingHistories).not.toHaveBeenCalled() + }) + + it('rejects a row-ceiling result instead of returning partial charts', async () => { + vi.mocked(queryPosthogHogql) + .mockResolvedValueOnce(connected(rowsWithLength(MAX_POSTHOG_ROWS + 1))) + .mockResolvedValue(connected()) + + const result = await getAdminPlansAnalytics(context, start, end) + + expect(result.dataQuality).toMatchObject({ + posthogConfigured: true, + posthogConnected: true, + posthogFailureReason: 'too_large', + }) + expect(result.traffic.totalOpens).toEqual([0]) + expect(loadPlansBillingHistories).not.toHaveBeenCalled() + }) + + it('distinguishes connected empty data from unavailable data', async () => { + vi.mocked(queryPosthogHogql).mockResolvedValue(connected()) + + const result = await getAdminPlansAnalytics(context, start, end) + + expect(result.dataQuality).toMatchObject({ posthogConfigured: true, posthogConnected: true, posthogFailureReason: null }) + expect(result.traffic).toEqual({ dates: ['2026-08-01'], uniqueVisitorOrganizations: [0], totalOpens: [0] }) + expect(queryPosthogHogql).toHaveBeenCalledTimes(2) + expect(vi.mocked(queryPosthogHogql).mock.calls[1]?.[1]).toContain('SELECT min(timestamp) AS exact_tracking_started_at') + }) + + it('runs behavior first and scopes transitions to the relevant opening organizations', async () => { + const queries: string[] = [] + vi.mocked(queryPosthogHogql).mockImplementation(async (_context, query) => { + queries.push(query) + if (queries.length === 1) + return connected([behavior(), behavior({ org_id: ORG_B, distinct_id: 'user-b' })]) + return connected() + }) + + await getAdminPlansAnalytics(context, start, end) + + 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]).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()) + }) + + it('batches large relevant organization sets deterministically', async () => { + const orgIds = Array.from({ length: 1_001 }, (_, index) => `00000000-0000-4000-8000-${index.toString(16).padStart(12, '0')}`) + vi.mocked(queryPosthogHogql) + .mockResolvedValueOnce(connected(orgIds.map(orgId => behavior({ org_id: orgId })))) + .mockResolvedValueOnce(connected()) + .mockResolvedValueOnce(connected()) + .mockResolvedValueOnce(connected()) + + await getAdminPlansAnalytics(context, start, end) + + const firstTransitionQuery = vi.mocked(queryPosthogHogql).mock.calls[1]?.[1] ?? '' + const secondTransitionQuery = vi.mocked(queryPosthogHogql).mock.calls[2]?.[1] ?? '' + expect(firstTransitionQuery).toContain(orgIds[0]) + expect(firstTransitionQuery).toContain(orgIds[999]) + expect(firstTransitionQuery).not.toContain(orgIds[1_000]) + expect(secondTransitionQuery).toContain(orgIds[1_000]) + expect(secondTransitionQuery).not.toContain(orgIds[0]) + expect(loadPlansBillingHistories).toHaveBeenCalledWith(context, orgIds, '2026-08-01', '2026-08-02', new Map()) + }) + + it('prevents unrelated global transitions from consuming the row ceiling', async () => { + vi.mocked(queryPosthogHogql).mockImplementation(async (_context, query) => { + if (query.includes('event IN (\'User visit\', \'Checkout Started\')')) + return connected([behavior()]) + if (query.includes(`properties.$group_key IN ('${ORG_A}')`)) + return connected() + if (query.includes('SELECT min(timestamp) AS exact_tracking_started_at')) + return connected() + return connected(rowsWithLength(MAX_POSTHOG_ROWS + 1)) + }) + + const result = await getAdminPlansAnalytics(context, start, end) + + expect(result.dataQuality.posthogFailureReason).toBeNull() + expect(result.traffic.totalOpens).toEqual([1]) + }) + + it('returns a structured transition failure after successful behavior mapping', async () => { + vi.mocked(queryPosthogHogql) + .mockResolvedValueOnce(connected([behavior()])) + .mockResolvedValueOnce({ configured: true, connected: false, failureReason: 'timeout', rows: [] }) + + const result = await getAdminPlansAnalytics(context, start, end) + + expect(result.dataQuality).toMatchObject({ posthogConfigured: true, posthogConnected: false, posthogFailureReason: 'timeout' }) + expect(queryPosthogHogql).toHaveBeenCalledTimes(2) + expect(loadPlansBillingHistories).not.toHaveBeenCalled() + }) + + it('fails closed when a transport is disconnected without a failure reason', async () => { + vi.mocked(queryPosthogHogql).mockResolvedValue({ configured: true, connected: false, failureReason: null, rows: [] }) + + const result = await getAdminPlansAnalytics(context, start, end) + + expect(result.dataQuality).toMatchObject({ + posthogConfigured: true, + posthogConnected: false, + posthogFailureReason: 'unavailable', + }) + }) + + it('retains exact rows while failing closed on URL-looking legacy rows and reporting unmatched data', async () => { + expect(LEGACY_PATH_SOURCE).toBe('unavailable') + vi.mocked(queryPosthogHogql) + .mockResolvedValueOnce(connected([ + behavior(), + behavior({ timestamp_ms: startMs + 120_000, org_id: ORG_B, page: '', distinct_id: 'user-b', event_current_url: 42, event_pathname: {}, person_current_url: [] }), + behavior({ timestamp_ms: startMs + 180_000, org_id: '', grouped_org_id: '', distinct_id: 'user-c' }), + behavior({ timestamp_ms: startMs + 300_000, event: 'Checkout Started' }), + behavior({ timestamp_ms: startMs + 360_000, event: 'Checkout Started', org_id: 'not-a-uuid', grouped_org_id: '', distinct_id: 'user-invalid-checkout' }), + behavior({ timestamp_ms: startMs + 420_000, event: 'Checkout Started', org_id: 42, grouped_org_id: null, distinct_id: 'user-wrong-type-checkout' }), + behavior({ timestamp_ms: startMs + 480_000, event: 'Checkout Started', org_id: undefined, grouped_org_id: '', distinct_id: 'user-missing-checkout' }), + behavior({ timestamp_ms: startMs + 7_200_000, event: 'Checkout Started', org_id: ORG_X, distinct_id: 'user-x' }), + behavior({ timestamp_ms: startMs - 1, event: 'Checkout Started', org_id: '', grouped_org_id: '', distinct_id: 'user-before-window' }), + behavior({ timestamp_ms: Date.parse(end) + 24 * 60 * 60 * 1000, event: 'Checkout Started', org_id: '', grouped_org_id: '', distinct_id: 'user-after-window' }), + ])) + .mockResolvedValueOnce(connected()) + .mockResolvedValueOnce(connected([{ exact_tracking_started_at: '2026-08-01T10:00:00Z' }])) + vi.mocked(loadPlansBillingHistories).mockResolvedValue(new Map([[ORG_A, history(ORG_A)]])) + + const result = await getAdminPlansAnalytics(context, start, end) + + expect(result.dataQuality).toMatchObject({ + exactTrackingStartedAt: '2026-08-01T10:00:00.000Z', + exactLogicalOpens: 1, + legacyLogicalOpens: 0, + legacyReconstructionAvailable: false, + legacyUnavailableReason: 'missing_event_time_path', + excludedMissingOrganization: 4, + unmatchedCheckoutStarts: 1, + unknownBillingOrganizations: 0, + legacyDeduplicationSeconds: 30, + }) + expect(result.traffic.totalOpens).toEqual([1]) + expect(result.checkoutIntent[0]).toMatchObject({ startedCheckout: 1, didNotStart: 0 }) + }) + + it('uses grouped organization fallback, exact range boundaries, and excludes invalid scalar rows safely', async () => { + vi.mocked(queryPosthogHogql) + .mockResolvedValueOnce(connected([ + behavior({ timestamp_ms: startMs, org_id: 'not-a-uuid', grouped_org_id: ` ${ORG_GROUPED.toUpperCase()} ` }), + behavior({ timestamp_ms: Date.parse(end), org_id: ORG_X }), + behavior({ timestamp_ms: Number.NaN, org_id: ORG_X }), + behavior({ timestamp_ms: startMs + 1, event: ['User visit'], org_id: ORG_X }), + behavior({ timestamp_ms: startMs + 2, org_id: 12 }), + behavior({ timestamp_ms: startMs + 3, org_id: ' ', grouped_org_id: '', page: 'plans' }), + behavior({ timestamp_ms: startMs + 4, org_id: 'not-a-uuid', grouped_org_id: null, page: 'plans' }), + behavior({ timestamp_ms: startMs + 5, org_id: undefined, grouped_org_id: {}, page: 'plans' }), + ])) + .mockResolvedValueOnce(connected([ + { timestamp_ms: startMs, event: 'User subscribe', group_key: ORG_GROUPED, group_type: 'organization', grouped_org_id: '', plan_status: 'succeeded', canceled_at: null }, + { timestamp_ms: 'invalid', event: 'User cancel', group_key: ORG_GROUPED, group_type: 'organization', grouped_org_id: '', plan_status: 'canceled', canceled_at: '2026-08-01T01:00:00Z' }, + ])) + .mockResolvedValueOnce(connected([{ exact_tracking_started_at: 42 }])) + vi.mocked(loadPlansBillingHistories).mockResolvedValue(new Map([[ORG_GROUPED, history(ORG_GROUPED)]])) + + const result = await getAdminPlansAnalytics(context, start, end) + + expect(result.traffic).toEqual({ dates: ['2026-08-01'], uniqueVisitorOrganizations: [1], totalOpens: [1] }) + expect(result.dataQuality).toMatchObject({ exactTrackingStartedAt: null, exactLogicalOpens: 1, excludedMissingOrganization: 4 }) + expect(loadPlansBillingHistories).toHaveBeenCalledWith( + context, + [ORG_GROUPED], + '2026-08-01', + '2026-08-02', + new Map([[ORG_GROUPED, [{ timestampMs: startMs, kind: 'paid' }]]]), + ) + }) + + it('integrates transition mapping, billing loading, and historical classification', async () => { + vi.mocked(queryPosthogHogql) + .mockResolvedValueOnce(connected([ + behavior({ org_id: ORG_PAID }), + behavior({ timestamp_ms: startMs + 120_000, org_id: ORG_CANCELED, distinct_id: 'user-b' }), + ])) + .mockResolvedValueOnce(connected([ + { timestamp_ms: startMs - 5_000, event: 'User subscribe', group_key: ORG_PAID, group_type: 'organization', grouped_org_id: '', plan_status: 'succeeded', canceled_at: null }, + { timestamp_ms: startMs - 5_000, event: 'User cancel', group_key: '', group_type: '', grouped_org_id: ORG_CANCELED, plan_status: 'canceled', canceled_at: '2026-07-31T23:59:55Z' }, + ])) + .mockResolvedValueOnce(connected()) + vi.mocked(loadPlansBillingHistories).mockResolvedValue(new Map([ + [ORG_PAID, history(ORG_PAID, { paidAtMs: startMs - 5_000 })], + [ORG_CANCELED, history(ORG_CANCELED, { + trialEndsAtMs: startMs - 200_000, + paidAtMs: startMs - 100_000, + canceledAtMs: startMs - 5_000, + })], + ])) + + const result = await getAdminPlansAnalytics(context, start, end) + + expect(loadPlansBillingHistories).toHaveBeenCalledWith( + context, + [ORG_PAID, ORG_CANCELED], + '2026-08-01', + '2026-08-02', + new Map([ + [ORG_PAID, [{ timestampMs: startMs - 5_000, kind: 'paid' }]], + [ORG_CANCELED, [{ timestampMs: startMs - 5_000, kind: 'canceled' }]], + ]), + ) + expect(result.visitorBreakdown[0]).toMatchObject({ paying: 1, canceled: 1, total: 2 }) + }) + + it('ignores bare updates and maps only trustworthy explicit billing states', async () => { + vi.mocked(queryPosthogHogql) + .mockResolvedValueOnce(connected([ + behavior(), + behavior({ org_id: ORG_B, distinct_id: 'user-b' }), + behavior({ org_id: ORG_C, distinct_id: 'user-c' }), + behavior({ org_id: ORG_D, distinct_id: 'user-d' }), + behavior({ org_id: ORG_E, distinct_id: 'user-e' }), + behavior({ org_id: ORG_F, distinct_id: 'user-f' }), + ])) + .mockResolvedValueOnce(connected([ + { timestamp_ms: startMs - 6_000, event: 'User update subscribe', group_key: '', group_type: '', grouped_org_id: ORG_A, plan_status: 'past_due', event_plan_status: null, canceled_at: null }, + { timestamp_ms: startMs - 5_000, event: '$groupidentify', group_key: ORG_A, group_type: 'organization', grouped_org_id: '', plan_status: 'past_due', event_plan_status: null, canceled_at: null }, + { timestamp_ms: startMs - 4_000, event: 'User update subscribe', group_key: '', group_type: '', grouped_org_id: ORG_B, plan_status: null, event_plan_status: null, canceled_at: null }, + { timestamp_ms: startMs - 3_000, event: 'User update subscribe', group_key: '', group_type: '', grouped_org_id: ORG_C, plan_status: null, event_plan_status: 'succeeded', canceled_at: null }, + { timestamp_ms: startMs - 2_000, event: 'User update subscribe', group_key: '', group_type: '', grouped_org_id: ORG_D, plan_status: null, event_plan_status: 'mystery', canceled_at: null }, + { timestamp_ms: startMs - 1_000, event: '$groupidentify', group_key: ORG_E, group_type: 'organization', grouped_org_id: '', plan_status: null, event_plan_status: null, canceled_at: '2026-07-31T23:59:59Z' }, + { timestamp_ms: startMs - 500, event: '$groupidentify', group_key: ORG_F, group_type: 'organization', grouped_org_id: '', plan_status: 'succeeded', event_plan_status: null, canceled_at: null }, + ])) + .mockResolvedValueOnce(connected()) + + await getAdminPlansAnalytics(context, start, end) + + expect(vi.mocked(loadPlansBillingHistories).mock.calls[0]?.[4]).toEqual(new Map([ + [ORG_A, [{ timestampMs: startMs - 5_000, kind: 'payment_problem' }]], + [ORG_C, [{ timestampMs: startMs - 3_000, kind: 'paid' }]], + [ORG_E, [{ timestampMs: startMs - 1_000, kind: 'canceled' }]], + [ORG_F, [{ timestampMs: startMs - 500, kind: 'paid' }]], + ])) + }) + + it('keeps a same-second future cancellation from applying retroactively', async () => { + vi.mocked(queryPosthogHogql) + .mockResolvedValueOnce(connected([behavior({ timestamp_ms: startMs + 100 })])) + .mockResolvedValueOnce(connected([ + { timestamp_ms: startMs + 200, event: 'User cancel', group_key: '', group_type: '', grouped_org_id: ORG_A, plan_status: null, event_plan_status: null, canceled_at: null }, + ])) + .mockResolvedValueOnce(connected()) + vi.mocked(loadPlansBillingHistories).mockImplementation(async (_context, orgIds, _startDate, _endDate, transitions) => new Map([ + [ORG_A, history(ORG_A, { + trialEndsAtMs: startMs - 10_000, + paidAtMs: startMs - 1_000, + transitions: transitions.get(ORG_A) ?? [], + })], + ].filter(([orgId]) => orgIds.includes(orgId as string)) as Array<[string, OrganizationBillingHistory]>)) + + const result = await getAdminPlansAnalytics(context, start, end) + + expect(result.visitorBreakdown[0]).toMatchObject({ paying: 1, canceled: 0, total: 1 }) + }) + + it('maps a representative invalid-row payload without returning partial charts', async () => { + const invalidRows = Array.from({ length: 5_000 }, (_, index) => behavior({ + timestamp_ms: startMs + index, + org_id: index % 2 === 0 ? `invalid-${index}` : index, + grouped_org_id: index % 3 === 0 ? {} : '', + event_current_url: index, + })) + vi.mocked(queryPosthogHogql) + .mockResolvedValueOnce(connected([...invalidRows, behavior({ timestamp_ms: startMs + 10_000 })])) + .mockResolvedValueOnce(connected()) + .mockResolvedValueOnce(connected()) + + const result = await getAdminPlansAnalytics(context, start, end) + + expect(result.traffic.totalOpens).toEqual([1]) + expect(result.dataQuality).toMatchObject({ exactLogicalOpens: 1, excludedMissingOrganization: 5_000, posthogFailureReason: null }) + }) + + it('counts unknown billing organizations uniquely across visitor and checkout chart populations', async () => { + vi.mocked(queryPosthogHogql) + .mockResolvedValueOnce(connected([ + behavior({ org_id: ORG_KNOWN }), + behavior({ timestamp_ms: startMs + 120_000, org_id: ORG_UNKNOWN, distinct_id: 'user-b' }), + behavior({ timestamp_ms: startMs + 180_000, event: 'Checkout Started', org_id: ORG_UNKNOWN, distinct_id: 'user-b' }), + ])) + .mockResolvedValueOnce(connected()) + .mockResolvedValueOnce(connected()) + vi.mocked(loadPlansBillingHistories).mockResolvedValue(new Map([[ORG_KNOWN, history(ORG_KNOWN)]])) + + const result = await getAdminPlansAnalytics(context, start, end) + + expect(result.visitorBreakdown[0]).toMatchObject({ activeTrial: 1, unknown: 1, total: 2 }) + expect(result.checkoutVisitorBreakdown[0]).toMatchObject({ unknown: 1, total: 1 }) + expect(result.dataQuality.unknownBillingOrganizations).toBe(1) + }) + + it.each([ + ['invalid start', 'not-a-date', end], + ['invalid end', start, 'not-a-date'], + ['equal dates', start, start], + ['reversed dates', end, start], + ['end outside the safe attribution range', start, '+275760-09-12T23:59:59.999Z'], + ])('returns a deterministic empty response for %s without querying', async (_label, invalidStart, invalidEnd) => { + const first = await getAdminPlansAnalytics(context, invalidStart, invalidEnd) + const second = await getAdminPlansAnalytics(context, invalidStart, invalidEnd) + + expect(first).toEqual(second) + expect(first.traffic).toEqual({ dates: [], uniqueVisitorOrganizations: [], totalOpens: [] }) + expect(queryPosthogHogql).not.toHaveBeenCalled() + expect(loadPlansBillingHistories).not.toHaveBeenCalled() + }) +}) From b394fc86bbea72621811461a2c7359f1102fddf3 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Mon, 10 Aug 2026 09:34:17 +0200 Subject: [PATCH 08/19] feat(admin): expose plans analytics metric --- src/stores/adminDashboard.ts | 2 +- supabase/functions/_backend/private/admin_stats.ts | 6 ++++++ tests/admin-stats.unit.test.ts | 9 +++++++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/stores/adminDashboard.ts b/src/stores/adminDashboard.ts index d6a864475c..ff7ee0d532 100644 --- a/src/stores/adminDashboard.ts +++ b/src/stores/adminDashboard.ts @@ -6,7 +6,7 @@ import { } from '~/services/dateRange' import { defaultApiHost, useSupabase } from '~/services/supabase' -export type MetricCategory = 'uploads' | 'distribution' | 'failures' | 'success_rate' | 'platform_overview' | 'org_metrics' | 'mau_trend' | 'success_rate_trend' | 'apps_trend' | 'bundles_trend' | 'deployments_trend' | 'storage_trend' | 'bandwidth_trend' | 'global_stats_trend' | 'plugin_breakdown' | 'trial_organizations' | 'trial_plan_breakdown' | 'onboarding_funnel' | 'cancelled_users' | 'email_type_breakdown' | 'customer_country_breakdown' | 'organization_insights' | 'builder_analytics' | 'builder_capacity' | 'cli_usage' +export type MetricCategory = 'uploads' | 'distribution' | 'failures' | 'success_rate' | 'platform_overview' | 'org_metrics' | 'mau_trend' | 'success_rate_trend' | 'apps_trend' | 'bundles_trend' | 'deployments_trend' | 'storage_trend' | 'bandwidth_trend' | 'global_stats_trend' | 'plugin_breakdown' | 'trial_organizations' | 'trial_plan_breakdown' | 'onboarding_funnel' | 'cancelled_users' | 'email_type_breakdown' | 'customer_country_breakdown' | 'organization_insights' | 'builder_analytics' | 'builder_capacity' | 'cli_usage' | 'plans_analytics' export type DateRangeMode = DateRangePreset export const DEFAULT_DATE_RANGE_MODE = '30day' as const satisfies DateRangeMode diff --git a/supabase/functions/_backend/private/admin_stats.ts b/supabase/functions/_backend/private/admin_stats.ts index 3824931c55..e31cedf2eb 100644 --- a/supabase/functions/_backend/private/admin_stats.ts +++ b/supabase/functions/_backend/private/admin_stats.ts @@ -10,6 +10,7 @@ import { parseBody, simpleError, useCors } from '../utils/hono.ts' import { middlewareAuth } from '../utils/hono_jwt.ts' import { cloudlog } from '../utils/logging.ts' import { getAdminCancelledOrganizations, getAdminCustomerCountryBreakdown, getAdminDeploymentsTrend, getAdminEmailTypeBreakdown, getAdminGlobalStatsTrend, getAdminOnboardingFunnel, getAdminOrganizationInsights, getAdminPluginBreakdown, getAdminTrialOrganizations, getAdminTrialPlanBreakdown } from '../utils/pg.ts' +import { getAdminPlansAnalytics } from '../utils/plans_analytics.ts' import { safeParseSchema } from '../utils/schema_validation.ts' import { getCancellationDetails } from '../utils/stripe.ts' import { supabaseClient as useSupabaseClient } from '../utils/supabase.ts' @@ -45,6 +46,7 @@ const metricCategories = [ 'builder_analytics', 'builder_capacity', 'cli_usage', + 'plans_analytics', ] as const const isoUtcDatetimeSchema = z.string().refine( @@ -325,6 +327,10 @@ app.post('/', middlewareAuth, async (c) => { result = await getAdminCliUsage(c, start_date, end_date) break + case 'plans_analytics': + result = await getAdminPlansAnalytics(c, start_date, end_date) + break + default: throw simpleError('invalid_metric_category', 'Invalid metric category', { metric_category }) } diff --git a/tests/admin-stats.unit.test.ts b/tests/admin-stats.unit.test.ts index 944b28ef0a..5198ec5a36 100644 --- a/tests/admin-stats.unit.test.ts +++ b/tests/admin-stats.unit.test.ts @@ -69,6 +69,15 @@ describe('admin stats validation', () => { expect(parsed.success).toBe(true) }) + it.concurrent('accepts the plans analytics metric', () => { + const parsed = safeParseSchema(adminStatsBodySchema, { + ...baseBody, + metric_category: 'plans_analytics', + }) + + expect(parsed.success).toBe(true) + }) + it.concurrent('accepts the trial plan breakdown metric', () => { const parsed = safeParseSchema(adminStatsBodySchema, { ...baseBody, From d84d29b264f31eb8b05bc5481f8dcb6af3fb8e7f Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Mon, 10 Aug 2026 09:45:55 +0200 Subject: [PATCH 09/19] feat(admin): prepare plans analytics presentation --- docs/admin/plans-checkout-completion.md | 9 + messages/en.json | 31 +++ src/constants/adminTabs.ts | 2 + src/services/adminPlansAnalytics.ts | 227 ++++++++++++++++++ ...min-plans-analytics-dashboard.unit.test.ts | 192 +++++++++++++++ 5 files changed, 461 insertions(+) create mode 100644 docs/admin/plans-checkout-completion.md create mode 100644 src/services/adminPlansAnalytics.ts create mode 100644 tests/admin-plans-analytics-dashboard.unit.test.ts diff --git a/docs/admin/plans-checkout-completion.md b/docs/admin/plans-checkout-completion.md new file mode 100644 index 0000000000..2e6f87b78d --- /dev/null +++ b/docs/admin/plans-checkout-completion.md @@ -0,0 +1,9 @@ +# 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 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. + +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. + +Implementation requires a separate approved design for the observation window, late completions, retries, plan changes, and existing subscribers. diff --git a/messages/en.json b/messages/en.json index d46da84fd4..f2367d0337 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1982,6 +1982,37 @@ "plan-upgrade": "Subscribe", "plan-upgrade-v2": "Upgrade", "plans": "plans", + "plans-analytics-title": "Plans analytics", + "plans-analytics-timezone": "Reporting timezone: UTC", + "plans-analytics-traffic": "Plans page traffic", + "plans-analytics-traffic-description": "Organizations and logical openings of the Plans page", + "plans-analytics-unique-visitor-orgs": "Unique visitor orgs", + "plans-analytics-total-opens": "Total opens", + "plans-analytics-who-opened": "Who opened Plans?", + "plans-analytics-who-opened-description": "Daily unique organizations by billing state at their first Plans opening", + "plans-analytics-checkout-intent": "Checkout intent", + "plans-analytics-checkout-intent-description": "Daily Plans visitors who started checkout within the attribution window", + "plans-analytics-started-checkout": "Started checkout", + "plans-analytics-did-not-start": "Did not start", + "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-category-paying": "Paying", + "plans-category-active-trial": "Active trial", + "plans-category-expired-trial": "Expired trial — never subscribed", + "plans-category-canceled": "Canceled", + "plans-category-payment-problem": "Payment problem", + "plans-category-credits-only": "Credits only", + "plans-category-unknown": "Unknown", + "plans-analytics-partial-warning": "Some organizations could not be classified from historical billing records and appear as Unknown.", + "plans-analytics-legacy-unavailable": "Legacy Plans visits are unavailable because no event-time pathname could be verified.", + "plans-analytics-posthog-unconfigured": "PostHog analytics is not configured.", + "plans-analytics-posthog-timeout": "This range took too long to process. Select a shorter period and try again.", + "plans-analytics-range-too-large": "This range returned too much data to process. Select a shorter period and try again.", + "plans-analytics-unavailable": "Plans analytics is temporarily unavailable.", + "plans-analytics-empty": "No Plans visits were recorded in this period.", "plans-super-only": "Only super admins are allowed to view plans and billing", "platform": "Platform", "platform-android": "Android", diff --git a/src/constants/adminTabs.ts b/src/constants/adminTabs.ts index 787d1ff87d..3265291a52 100644 --- a/src/constants/adminTabs.ts +++ b/src/constants/adminTabs.ts @@ -3,6 +3,7 @@ import IconArrowPath from '~icons/heroicons/arrow-path' import IconBanknotes from '~icons/heroicons/banknotes' import IconBell from '~icons/heroicons/bell' import IconBuildingOffice from '~icons/heroicons/building-office-2' +import IconChartBar from '~icons/heroicons/chart-bar-square' import IconCircleStack from '~icons/heroicons/circle-stack' import IconCommandLine from '~icons/heroicons/command-line' import IconCurrencyDollar from '~icons/heroicons/currency-dollar' @@ -19,6 +20,7 @@ export const adminTabs: Tab[] = [ { label: 'users', icon: IconUsers, key: '/users' }, { label: 'admin-organizations', icon: IconBuildingOffice, key: '/organizations' }, { label: 'revenue', icon: IconBanknotes, key: '/revenue' }, + { label: 'plans-analytics-title', icon: IconChartBar, key: '/plans' }, { label: 'credits', icon: IconCurrencyDollar, key: '/credits' }, { label: 'notifications', icon: IconBell, key: '/notifications' }, ] diff --git a/src/services/adminPlansAnalytics.ts b/src/services/adminPlansAnalytics.ts new file mode 100644 index 0000000000..fee5123bbe --- /dev/null +++ b/src/services/adminPlansAnalytics.ts @@ -0,0 +1,227 @@ +export type PlansAnalyticsFailureReason = 'unconfigured' | 'timeout' | 'unavailable' | 'too_large' + +export interface PlansAnalyticsTraffic { + dates: string[] + uniqueVisitorOrganizations: number[] + totalOpens: number[] +} + +export interface DailyBillingPoint { + date: string + paying: number + activeTrial: number + expiredTrial: number + canceled: number + paymentProblem: number + creditsOnly: number + unknown: number + total: number +} + +export interface DailyCheckoutIntentPoint { + date: string + startedCheckout: number + didNotStart: number +} + +export interface PlansAnalyticsDataQuality { + exactTrackingStartedAt: string | null + legacyLogicalOpens: number + exactLogicalOpens: number + legacyReconstructionAvailable: boolean + legacyUnavailableReason: 'missing_event_time_path' | null + excludedMissingOrganization: number + unmatchedCheckoutStarts: number + unknownBillingOrganizations: number + posthogConfigured: boolean + posthogConnected: boolean + posthogFailureReason: PlansAnalyticsFailureReason | null + legacyDeduplicationSeconds: number +} + +export interface PlansAnalyticsResponse { + traffic: PlansAnalyticsTraffic + visitorBreakdown: DailyBillingPoint[] + checkoutIntent: DailyCheckoutIntentPoint[] + checkoutVisitorBreakdown: DailyBillingPoint[] + dataQuality: PlansAnalyticsDataQuality +} + +export type Translate = (key: string) => string + +export interface ChartDataPoint { + date: string + value: number +} + +export interface ChartSeries { + label: string + data: ChartDataPoint[] + color: string +} + +export interface PlansAnalyticsSeries { + traffic: ChartSeries[] + visitors: ChartSeries[] + checkoutIntent: ChartSeries[] + checkoutVisitors: ChartSeries[] +} + +function invalidResponse(path: string): never { + throw new TypeError(`Invalid Plans analytics response at ${path}`) +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function record(value: unknown, path: string): Record { + if (!isRecord(value)) + return invalidResponse(path) + return value +} + +function array(value: unknown, path: string): unknown[] { + if (!Array.isArray(value)) + return invalidResponse(path) + return value +} + +function count(value: unknown, path: string): number { + if (typeof value !== 'number' || !Number.isFinite(value) || !Number.isInteger(value) || value < 0) + return invalidResponse(path) + return value +} + +function boolean(value: unknown, path: string): boolean { + if (typeof value !== 'boolean') + return invalidResponse(path) + return value +} + +function utcDate(value: unknown, path: string): string { + if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) + return invalidResponse(path) + const timestamp = Date.parse(`${value}T00:00:00.000Z`) + if (!Number.isFinite(timestamp) || new Date(timestamp).toISOString().slice(0, 10) !== value) + return invalidResponse(path) + return value +} + +function nullableTimestamp(value: unknown, path: string): string | null { + if (value === null) + return null + if (typeof value !== 'string' || !Number.isFinite(Date.parse(value))) + return invalidResponse(path) + return value +} + +function nullableLegacyReason(value: unknown, path: string): 'missing_event_time_path' | null { + if (value === null || value === 'missing_event_time_path') + return value + return invalidResponse(path) +} + +function nullableFailureReason(value: unknown, path: string): PlansAnalyticsFailureReason | null { + if (value === null || value === 'unconfigured' || value === 'timeout' || value === 'unavailable' || value === 'too_large') + return value + return invalidResponse(path) +} + +function counts(value: unknown, path: string): number[] { + return array(value, path).map((item, index) => count(item, `${path}[${index}]`)) +} + +function dates(value: unknown, path: string): string[] { + return array(value, path).map((item, index) => utcDate(item, `${path}[${index}]`)) +} + +function dailyBillingPoint(value: unknown, path: string): DailyBillingPoint { + const row = record(value, path) + return { + date: utcDate(row.date, `${path}.date`), + paying: count(row.paying, `${path}.paying`), + activeTrial: count(row.activeTrial, `${path}.activeTrial`), + expiredTrial: count(row.expiredTrial, `${path}.expiredTrial`), + canceled: count(row.canceled, `${path}.canceled`), + paymentProblem: count(row.paymentProblem, `${path}.paymentProblem`), + creditsOnly: count(row.creditsOnly, `${path}.creditsOnly`), + unknown: count(row.unknown, `${path}.unknown`), + total: count(row.total, `${path}.total`), + } +} + +function dailyCheckoutIntentPoint(value: unknown, path: string): DailyCheckoutIntentPoint { + const row = record(value, path) + return { + date: utcDate(row.date, `${path}.date`), + startedCheckout: count(row.startedCheckout, `${path}.startedCheckout`), + didNotStart: count(row.didNotStart, `${path}.didNotStart`), + } +} + +export function parsePlansAnalyticsResponse(value: unknown): PlansAnalyticsResponse { + const response = record(value, 'response') + const trafficValue = record(response.traffic, 'response.traffic') + const traffic: PlansAnalyticsTraffic = { + dates: dates(trafficValue.dates, 'response.traffic.dates'), + uniqueVisitorOrganizations: counts(trafficValue.uniqueVisitorOrganizations, 'response.traffic.uniqueVisitorOrganizations'), + totalOpens: counts(trafficValue.totalOpens, 'response.traffic.totalOpens'), + } + if (traffic.uniqueVisitorOrganizations.length !== traffic.dates.length || traffic.totalOpens.length !== traffic.dates.length) + return invalidResponse('response.traffic') + + const qualityValue = record(response.dataQuality, 'response.dataQuality') + return { + traffic, + visitorBreakdown: array(response.visitorBreakdown, 'response.visitorBreakdown') + .map((row, index) => dailyBillingPoint(row, `response.visitorBreakdown[${index}]`)), + checkoutIntent: array(response.checkoutIntent, 'response.checkoutIntent') + .map((row, index) => dailyCheckoutIntentPoint(row, `response.checkoutIntent[${index}]`)), + checkoutVisitorBreakdown: array(response.checkoutVisitorBreakdown, 'response.checkoutVisitorBreakdown') + .map((row, index) => dailyBillingPoint(row, `response.checkoutVisitorBreakdown[${index}]`)), + dataQuality: { + exactTrackingStartedAt: nullableTimestamp(qualityValue.exactTrackingStartedAt, 'response.dataQuality.exactTrackingStartedAt'), + legacyLogicalOpens: count(qualityValue.legacyLogicalOpens, 'response.dataQuality.legacyLogicalOpens'), + exactLogicalOpens: count(qualityValue.exactLogicalOpens, 'response.dataQuality.exactLogicalOpens'), + legacyReconstructionAvailable: boolean(qualityValue.legacyReconstructionAvailable, 'response.dataQuality.legacyReconstructionAvailable'), + legacyUnavailableReason: nullableLegacyReason(qualityValue.legacyUnavailableReason, 'response.dataQuality.legacyUnavailableReason'), + excludedMissingOrganization: count(qualityValue.excludedMissingOrganization, 'response.dataQuality.excludedMissingOrganization'), + unmatchedCheckoutStarts: count(qualityValue.unmatchedCheckoutStarts, 'response.dataQuality.unmatchedCheckoutStarts'), + unknownBillingOrganizations: count(qualityValue.unknownBillingOrganizations, 'response.dataQuality.unknownBillingOrganizations'), + posthogConfigured: boolean(qualityValue.posthogConfigured, 'response.dataQuality.posthogConfigured'), + posthogConnected: boolean(qualityValue.posthogConnected, 'response.dataQuality.posthogConnected'), + posthogFailureReason: nullableFailureReason(qualityValue.posthogFailureReason, 'response.dataQuality.posthogFailureReason'), + legacyDeduplicationSeconds: count(qualityValue.legacyDeduplicationSeconds, 'response.dataQuality.legacyDeduplicationSeconds'), + }, + } +} + +export function buildPlansAnalyticsSeries(data: PlansAnalyticsResponse, t: Translate): PlansAnalyticsSeries { + const point = (dates: string[], values: number[]): ChartDataPoint[] => dates.map((date, index) => ({ + date, + value: values[index] ?? 0, + })) + const billing = (rows: DailyBillingPoint[]): ChartSeries[] => [ + { label: t('plans-category-paying'), color: '#2563eb', data: rows.map(row => ({ date: row.date, value: row.paying })) }, + { label: t('plans-category-active-trial'), color: '#10b981', data: rows.map(row => ({ date: row.date, value: row.activeTrial })) }, + { label: t('plans-category-expired-trial'), color: '#f59e0b', data: rows.map(row => ({ date: row.date, value: row.expiredTrial })) }, + { label: t('plans-category-canceled'), color: '#64748b', data: rows.map(row => ({ date: row.date, value: row.canceled })) }, + { label: t('plans-category-payment-problem'), color: '#ef4444', data: rows.map(row => ({ date: row.date, value: row.paymentProblem })) }, + { label: t('plans-category-credits-only'), color: '#8b5cf6', data: rows.map(row => ({ date: row.date, value: row.creditsOnly })) }, + { label: t('plans-category-unknown'), color: '#94a3b8', data: rows.map(row => ({ date: row.date, value: row.unknown })) }, + ] + + return { + traffic: [ + { label: t('plans-analytics-unique-visitor-orgs'), color: '#2563eb', data: point(data.traffic.dates, data.traffic.uniqueVisitorOrganizations) }, + { label: t('plans-analytics-total-opens'), color: '#8b5cf6', data: point(data.traffic.dates, data.traffic.totalOpens) }, + ], + visitors: billing(data.visitorBreakdown), + checkoutIntent: [ + { 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 })) }, + ], + checkoutVisitors: billing(data.checkoutVisitorBreakdown), + } +} diff --git a/tests/admin-plans-analytics-dashboard.unit.test.ts b/tests/admin-plans-analytics-dashboard.unit.test.ts new file mode 100644 index 0000000000..ad9e382189 --- /dev/null +++ b/tests/admin-plans-analytics-dashboard.unit.test.ts @@ -0,0 +1,192 @@ +import type { PlansAnalyticsResponse as FrontendPlansAnalyticsResponse } from '../src/services/adminPlansAnalytics.ts' +import type { PlansAnalyticsResponse as BackendPlansAnalyticsResponse } from '../supabase/functions/_backend/utils/plans_analytics.ts' +import { readFile } from 'node:fs/promises' +import { describe, expect, expectTypeOf, it } from 'vitest' +import { buildPlansAnalyticsSeries, parsePlansAnalyticsResponse } from '../src/services/adminPlansAnalytics.ts' + +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 }], + 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', + legacyLogicalOpens: 3, + exactLogicalOpens: 1, + legacyReconstructionAvailable: true, + legacyUnavailableReason: null, + excludedMissingOrganization: 0, + unmatchedCheckoutStarts: 0, + unknownBillingOrganizations: 0, + posthogConfigured: true, + posthogConnected: true, + posthogFailureReason: null, + legacyDeduplicationSeconds: 30, + }, +} + +const requiredMessages = { + 'plans-analytics-title': 'Plans analytics', + 'plans-analytics-timezone': 'Reporting timezone: UTC', + 'plans-analytics-traffic': 'Plans page traffic', + 'plans-analytics-traffic-description': 'Organizations and logical openings of the Plans page', + 'plans-analytics-unique-visitor-orgs': 'Unique visitor orgs', + 'plans-analytics-total-opens': 'Total opens', + 'plans-analytics-who-opened': 'Who opened Plans?', + 'plans-analytics-who-opened-description': 'Daily unique organizations by billing state at their first Plans opening', + 'plans-analytics-checkout-intent': 'Checkout intent', + 'plans-analytics-checkout-intent-description': 'Daily Plans visitors who started checkout within the attribution window', + 'plans-analytics-started-checkout': 'Started checkout', + 'plans-analytics-did-not-start': 'Did not start', + '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-category-paying': 'Paying', + 'plans-category-active-trial': 'Active trial', + 'plans-category-expired-trial': 'Expired trial — never subscribed', + 'plans-category-canceled': 'Canceled', + 'plans-category-payment-problem': 'Payment problem', + 'plans-category-credits-only': 'Credits only', + 'plans-category-unknown': 'Unknown', + 'plans-analytics-partial-warning': 'Some organizations could not be classified from historical billing records and appear as Unknown.', + 'plans-analytics-legacy-unavailable': 'Legacy Plans visits are unavailable because no event-time pathname could be verified.', + 'plans-analytics-posthog-unconfigured': 'PostHog analytics is not configured.', + 'plans-analytics-posthog-timeout': 'This range took too long to process. Select a shorter period and try again.', + 'plans-analytics-range-too-large': 'This range returned too much data to process. Select a shorter period and try again.', + 'plans-analytics-unavailable': 'Plans analytics is temporarily unavailable.', + 'plans-analytics-empty': 'No Plans visits were recorded in this period.', +} as const + +describe('admin Plans analytics dashboard', () => { + it('keeps the frontend response DTO identical to the backend wire contract', () => { + expectTypeOf().toMatchTypeOf() + expectTypeOf().toMatchTypeOf() + expectTypeOf().toEqualTypeOf() + }) + + it.each([ + ['complete response', validResponse], + ['nullable quality fields', { + ...validResponse, + dataQuality: { + ...validResponse.dataQuality, + exactTrackingStartedAt: null, + legacyReconstructionAvailable: false, + legacyUnavailableReason: 'missing_event_time_path', + posthogConnected: false, + posthogFailureReason: 'timeout', + }, + }], + ['empty daily datasets', { + ...validResponse, + traffic: { dates: [], uniqueVisitorOrganizations: [], totalOpens: [] }, + visitorBreakdown: [], + checkoutIntent: [], + checkoutVisitorBreakdown: [], + }], + ])('parses a valid %s', (_name, value) => { + expect(parsePlansAnalyticsResponse(value)).toEqual(value) + }) + + it.each([ + ['non-object response', null], + ['missing required fields', { traffic: validResponse.traffic }], + ['invalid failure reason', { + ...validResponse, + dataQuality: { ...validResponse.dataQuality, posthogFailureReason: 'rate_limited' }, + }], + ['non-finite count', { + ...validResponse, + traffic: { ...validResponse.traffic, totalOpens: [Number.POSITIVE_INFINITY] }, + }], + ['mismatched traffic arrays', { + ...validResponse, + traffic: { ...validResponse.traffic, dates: ['2026-08-01', '2026-08-02'] }, + }], + ['invalid daily date', { + ...validResponse, + checkoutIntent: [{ date: '2026-02-30', startedCheckout: 1, didNotStart: 1 }], + }], + ['invalid daily count', { + ...validResponse, + visitorBreakdown: [{ ...validResponse.visitorBreakdown[0], paying: '1' }], + }], + ['invalid quality boolean', { + ...validResponse, + dataQuality: { ...validResponse.dataQuality, posthogConfigured: 1 }, + }], + ['invalid tracking timestamp', { + ...validResponse, + dataQuality: { ...validResponse.dataQuality, exactTrackingStartedAt: 'not-a-timestamp' }, + }], + ])('rejects a malformed response with %s', (_name, value) => { + expect(() => parsePlansAnalyticsResponse(value)).toThrowError('Invalid Plans analytics response') + }) + + it.concurrent('maps all API datasets into stable chart series', () => { + const series = buildPlansAnalyticsSeries({ + ...validResponse, + traffic: { + dates: ['2026-08-01', '2026-08-02'], + uniqueVisitorOrganizations: [2], + totalOpens: [4, 3, 99], + }, + }, key => key) + + expect(series.traffic).toEqual([ + { + label: 'plans-analytics-unique-visitor-orgs', + color: '#2563eb', + data: [{ date: '2026-08-01', value: 2 }, { date: '2026-08-02', value: 0 }], + }, + { + label: 'plans-analytics-total-opens', + color: '#8b5cf6', + data: [{ date: '2026-08-01', value: 4 }, { date: '2026-08-02', value: 3 }], + }, + ]) + expect(series.visitors.map(({ label, color }) => ({ label, color }))).toEqual([ + { label: 'plans-category-paying', color: '#2563eb' }, + { label: 'plans-category-active-trial', color: '#10b981' }, + { label: 'plans-category-expired-trial', color: '#f59e0b' }, + { label: 'plans-category-canceled', color: '#64748b' }, + { label: 'plans-category-payment-problem', color: '#ef4444' }, + { label: 'plans-category-credits-only', color: '#8b5cf6' }, + { label: 'plans-category-unknown', color: '#94a3b8' }, + ]) + expect(series.visitors.every(item => item.data[0]?.date === '2026-08-01')).toBe(true) + expect(series.checkoutIntent.map(({ label, color, data }) => ({ label, color, data }))).toEqual([ + { 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.checkoutVisitors).toHaveLength(7) + expect(series.checkoutVisitors.reduce((sum, item) => sum + item.data[0].value, 0)).toBe(1) + }) + + it.concurrent('wires a full-width Plans page and deferred documentation', async () => { + const [tabs, completionDoc, messagesText] = await Promise.all([ + readFile(new URL('../src/constants/adminTabs.ts', import.meta.url), 'utf8'), + readFile(new URL('../docs/admin/plans-checkout-completion.md', import.meta.url), 'utf8'), + readFile(new URL('../messages/en.json', import.meta.url), 'utf8'), + ]) + 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('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(JSON.parse(messagesText)).toMatchObject(requiredMessages) + + const page = await readFile(new URL('../src/pages/admin/dashboard/plans.vue', import.meta.url), 'utf8') + expect(page).toContain(`fetchStats('plans_analytics')`) + expect(page.match(/AdminStackedBarChart/g)?.length).toBeGreaterThanOrEqual(4) + expect(page).toContain('AdminMultiLineChart') + expect(page).toContain('UTC') + }) +}) From cd607ece293aaea8014192eaa5bb8817448cd6ea Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Mon, 10 Aug 2026 10:07:52 +0200 Subject: [PATCH 10/19] feat(admin): add plans analytics dashboard --- src/components/admin/AdminStackedBarChart.vue | 17 +- src/pages/admin/dashboard/plans.vue | 356 ++++++++++++++++++ src/services/adminPlansAnalytics.ts | 22 ++ ...min-plans-analytics-dashboard.unit.test.ts | 98 ++++- tests/admin-stacked-bar-chart.unit.test.ts | 9 + 5 files changed, 497 insertions(+), 5 deletions(-) create mode 100644 src/pages/admin/dashboard/plans.vue diff --git a/src/components/admin/AdminStackedBarChart.vue b/src/components/admin/AdminStackedBarChart.vue index d4d997b802..fe074dc2ed 100644 --- a/src/components/admin/AdminStackedBarChart.vue +++ b/src/components/admin/AdminStackedBarChart.vue @@ -30,6 +30,10 @@ const props = defineProps({ type: Boolean, default: false, }, + accessibleBorders: { + type: Boolean, + default: false, + }, }) const isDark = useDark() @@ -54,7 +58,18 @@ const chartData = computed(() => { color: item.color, })) - return buildAdminStackedBarChartData(labels, datasets) + const data = buildAdminStackedBarChartData(labels, datasets) + if (!props.accessibleBorders) + return data + + return { + ...data, + datasets: data.datasets.map(dataset => ({ + ...dataset, + borderColor: isDark.value ? '#f8fafc' : '#0f172a', + borderWidth: 1, + })), + } }) const chartOptions = computed(() => buildAdminStackedBarChartOptions(isDark.value)) diff --git a/src/pages/admin/dashboard/plans.vue b/src/pages/admin/dashboard/plans.vue new file mode 100644 index 0000000000..0d13cb9b75 --- /dev/null +++ b/src/pages/admin/dashboard/plans.vue @@ -0,0 +1,356 @@ + +meta: + layout: admin + + + + + diff --git a/src/services/adminPlansAnalytics.ts b/src/services/adminPlansAnalytics.ts index fee5123bbe..21612d05ce 100644 --- a/src/services/adminPlansAnalytics.ts +++ b/src/services/adminPlansAnalytics.ts @@ -67,6 +67,28 @@ export interface PlansAnalyticsSeries { checkoutVisitors: ChartSeries[] } +export function createLatestRequestCoordinator() { + let latestRequestId = 0 + const pendingRequestIds = new Set() + + return { + begin() { + latestRequestId += 1 + pendingRequestIds.add(latestRequestId) + return latestRequestId + }, + isLatest(requestId: number) { + return requestId === latestRequestId + }, + finish(requestId: number) { + pendingRequestIds.delete(requestId) + }, + get pendingCount() { + return pendingRequestIds.size + }, + } +} + function invalidResponse(path: string): never { throw new TypeError(`Invalid Plans analytics response at ${path}`) } diff --git a/tests/admin-plans-analytics-dashboard.unit.test.ts b/tests/admin-plans-analytics-dashboard.unit.test.ts index ad9e382189..66c0abd67d 100644 --- a/tests/admin-plans-analytics-dashboard.unit.test.ts +++ b/tests/admin-plans-analytics-dashboard.unit.test.ts @@ -2,7 +2,7 @@ import type { PlansAnalyticsResponse as FrontendPlansAnalyticsResponse } from '. import type { PlansAnalyticsResponse as BackendPlansAnalyticsResponse } from '../supabase/functions/_backend/utils/plans_analytics.ts' import { readFile } from 'node:fs/promises' import { describe, expect, expectTypeOf, it } from 'vitest' -import { buildPlansAnalyticsSeries, parsePlansAnalyticsResponse } from '../src/services/adminPlansAnalytics.ts' +import { buildPlansAnalyticsSeries, createLatestRequestCoordinator, parsePlansAnalyticsResponse } from '../src/services/adminPlansAnalytics.ts' const validResponse: BackendPlansAnalyticsResponse = { traffic: { dates: ['2026-08-01'], uniqueVisitorOrganizations: [2], totalOpens: [4] }, @@ -165,6 +165,23 @@ describe('admin Plans analytics dashboard', () => { expect(series.checkoutVisitors.reduce((sum, item) => sum + item.data[0].value, 0)).toBe(1) }) + it.concurrent('coordinates overlapping requests with latest-wins and pending-count semantics', () => { + const coordinator = createLatestRequestCoordinator() + const olderRequest = coordinator.begin() + const latestRequest = coordinator.begin() + + expect(coordinator.pendingCount).toBe(2) + expect(coordinator.isLatest(olderRequest)).toBe(false) + expect(coordinator.isLatest(latestRequest)).toBe(true) + + coordinator.finish(latestRequest) + expect(coordinator.pendingCount).toBe(1) + expect(coordinator.isLatest(olderRequest)).toBe(false) + + coordinator.finish(olderRequest) + expect(coordinator.pendingCount).toBe(0) + }) + it.concurrent('wires a full-width Plans page and deferred documentation', async () => { const [tabs, completionDoc, messagesText] = await Promise.all([ readFile(new URL('../src/constants/adminTabs.ts', import.meta.url), 'utf8'), @@ -184,9 +201,82 @@ describe('admin Plans analytics dashboard', () => { expect(JSON.parse(messagesText)).toMatchObject(requiredMessages) const page = await readFile(new URL('../src/pages/admin/dashboard/plans.vue', import.meta.url), 'utf8') + expect(page).toContain('layout: admin') + expect(page).toContain('if (!mainStore.isAdmin)') + expect(page).toContain('router.push(\'/dashboard\')') + expect(page).toContain('') expect(page).toContain(`fetchStats('plans_analytics')`) - expect(page.match(/AdminStackedBarChart/g)?.length).toBeGreaterThanOrEqual(4) - expect(page).toContain('AdminMultiLineChart') - expect(page).toContain('UTC') + expect(page).toContain('parsePlansAnalyticsResponse(response)') + expect(page).not.toContain('as PlansAnalyticsResponse') + expect(page).toContain('const data = ref(null)') + expect(page).toContain('const isInitialLoading = ref(true)') + expect(page).toContain('const isLoadingStats = ref(false)') + expect(page).toContain('const requestError = ref(null)') + + expect(page).toContain('case \'unconfigured\':') + expect(page).toContain('case \'timeout\':') + expect(page).toContain('case \'too_large\':') + expect(page).toContain('case \'unavailable\':') + expect(page).toContain('t(\'plans-analytics-posthog-unconfigured\')') + expect(page).toContain('t(\'plans-analytics-posthog-timeout\')') + expect(page).toContain('t(\'plans-analytics-range-too-large\')') + expect(page).toContain('t(\'plans-analytics-unavailable\')') + expect(page).toContain('unknownBillingOrganizations > 0') + expect(page).toContain('!data.dataQuality.legacyReconstructionAvailable') + expect(page).toContain('t(\'plans-analytics-partial-warning\')') + expect(page).toContain('t(\'plans-analytics-legacy-unavailable\')') + expect(page).toContain('t(\'plans-analytics-empty\')') + + expect(page.match(/ page.indexOf(`t('${key}')`)) + expect(titlePositions.every(position => position >= 0)).toBe(true) + expect(titlePositions).toEqual([...titlePositions].sort((a, b) => a - b)) + expect(page).toContain('class="space-y-6"') + expect(page).not.toContain('lg:grid-cols-2') + + expect(page).toContain('t(\'plans-analytics-timezone\')') + expect(page.match(/watch\(/g)).toHaveLength(1) + expect(page).toContain('watch([') + expect(page).toContain('() => adminStore.activeDateRange') + expect(page).toContain('() => adminStore.refreshTrigger') + expect(page).toContain('{ deep: true }') + expect(page).toContain('const authorized = ref(false)') + expect(page).toContain('if (!authorized.value)') + expect(page).toContain('authorized.value = true') + expect(page.indexOf('authorized.value = true')).toBeGreaterThan(page.indexOf('if (!mainStore.isAdmin)')) + expect(page).toContain('createLatestRequestCoordinator()') + expect(page).toContain('requestCoordinator.begin()') + expect(page.match(/if \(requestCoordinator\.isLatest\(requestId\)\)/g)).toHaveLength(2) + expect(page).toContain('requestCoordinator.finish(requestId)') + expect(page).toContain('requestCoordinator.pendingCount > 0') + 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).toContain('role="alert"') + expect(page).toContain('role="status"') }) }) diff --git a/tests/admin-stacked-bar-chart.unit.test.ts b/tests/admin-stacked-bar-chart.unit.test.ts index eadbf1bc24..43d937d99d 100644 --- a/tests/admin-stacked-bar-chart.unit.test.ts +++ b/tests/admin-stacked-bar-chart.unit.test.ts @@ -47,4 +47,13 @@ describe('admin stacked bar chart', () => { expect(source).toContain('class="d-loading d-loading-spinner d-loading-lg text-primary"') expect(source).not.toContain('class="loading loading-spinner loading-lg text-primary"') }) + + it.concurrent('offers opt-in contrasting segment boundaries without changing every chart', async () => { + const source = await readFile(new URL('../src/components/admin/AdminStackedBarChart.vue', import.meta.url), 'utf8') + + expect(source).toContain('accessibleBorders') + expect(source).toContain('default: false') + expect(source).toContain('isDark.value ? \'#f8fafc\' : \'#0f172a\'') + expect(source).toContain('borderWidth: 1') + }) }) From 877312a2ad3713fa333cb7908c6f15426fc46e62 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Mon, 10 Aug 2026 10:44:14 +0200 Subject: [PATCH 11/19] fix(admin): mark legacy deduplication unverified --- .../2026-08-10-plans-analytics-dashboard.md | 10 +++++++++- ...-08-10-plans-analytics-dashboard-design.md | 12 ++++++++++-- src/services/adminPlansAnalytics.ts | 8 ++++++-- .../_backend/utils/plans_analytics.ts | 10 +++++++--- ...min-plans-analytics-dashboard.unit.test.ts | 19 ++++++++++++++++++- ...plans-analytics-orchestration.unit.test.ts | 3 ++- 6 files changed, 52 insertions(+), 10 deletions(-) diff --git a/docs/superpowers/plans/2026-08-10-plans-analytics-dashboard.md b/docs/superpowers/plans/2026-08-10-plans-analytics-dashboard.md index 88f2a92b20..1d187dabe4 100644 --- a/docs/superpowers/plans/2026-08-10-plans-analytics-dashboard.md +++ b/docs/superpowers/plans/2026-08-10-plans-analytics-dashboard.md @@ -10,6 +10,14 @@ **Design specification:** `docs/superpowers/specs/2026-08-10-plans-analytics-dashboard-design.md` +## Evidence Record — 2026-08-10 + +The exact tracking prerequisite was merged via `main`: commit `918f7dc15` (`fix(analytics): deduplicate plans page visit tracking (#2964)`) emits one Plans `User visit` per activation with `tags: { page: 'plans' }`; merge commit `060a4abaa` includes it on this branch. + +The Task 1 PostHog project lookup, schema lookup, bounded event sample, and legacy gap-histogram calls were attempted, and every call returned MCP error `-32603 Internal error`. Therefore no production event-time pathname or inter-event gap distribution was proven. The implementation must keep `LEGACY_PATH_SOURCE = 'unavailable'`, return `legacyUnavailableReason = 'missing_event_time_path'`, and return `legacyDeduplicationSeconds = null`; 30 seconds remains an unvalidated candidate and must not be interpreted as active or numerically trustworthy. + +Legacy reconstruction may be re-enabled only after a successful event-time pathname proof, validation of a real same-organization/session gap histogram, and tests for the enabled path, threshold boundaries, and DTO metadata. The failed calls support no claims about production data. + --- ## File Structure @@ -925,7 +933,7 @@ export interface PlansAnalyticsResponse { posthogConfigured: boolean posthogConnected: boolean posthogFailureReason: 'unconfigured' | 'timeout' | 'unavailable' | 'too_large' | null - legacyDeduplicationSeconds: number + legacyDeduplicationSeconds: number | null } } ``` diff --git a/docs/superpowers/specs/2026-08-10-plans-analytics-dashboard-design.md b/docs/superpowers/specs/2026-08-10-plans-analytics-dashboard-design.md index a3773340b8..c150dac825 100644 --- a/docs/superpowers/specs/2026-08-10-plans-analytics-dashboard-design.md +++ b/docs/superpowers/specs/2026-08-10-plans-analytics-dashboard-design.md @@ -75,7 +75,7 @@ Legacy watcher emissions must be converted into logical openings without removin 4. Collapse the other events in the burst into the first event and retain that timestamp. 5. Read 30 seconds before the selected range so a burst crossing the range boundary does not create a false opening. Events before `start` remain excluded from chart counts. -The initial threshold is 30 seconds. Before implementation is finalized, query the historical same-organization/session inter-event gap distribution and confirm that 30 seconds separates watcher bursts from genuine navigation. The selected value is returned in `dataQuality.legacyDeduplicationSeconds` and covered by deterministic tests. +Thirty seconds is only a candidate threshold. It must not be treated as active or numerically trustworthy until a historical same-organization/session inter-event gap distribution proves that it separates watcher bursts from genuine navigation. `dataQuality.legacyDeduplicationSeconds` is `null` while legacy reconstruction is unavailable; a selected numeric value may be returned only after the legacy path and threshold are validated and covered by deterministic tests. This repair changes **Total opens** and visit-to-checkout attribution. Organization-unique graphs apply their own range or daily deduplication after logical openings have been constructed. @@ -202,11 +202,19 @@ interface PlansAnalyticsResponse { posthogConfigured: boolean posthogConnected: boolean posthogFailureReason: 'unconfigured' | 'timeout' | 'unavailable' | 'too_large' | null - legacyDeduplicationSeconds: number + legacyDeduplicationSeconds: number | null } } ``` +## Evidence Record — 2026-08-10 + +The exact tracking prerequisite is present through `main`: commit `918f7dc15` (`fix(analytics): deduplicate plans page visit tracking (#2964)`) emits one `User visit` per Plans-page activation with `tags: { page: 'plans' }`, and merge commit `060a4abaa` brought that prerequisite into this branch. + +The Task 1 PostHog project lookup, schema lookup, bounded event sample, and legacy inter-event gap histogram were each attempted. Every call returned MCP error `-32603 Internal error`. These failures provide no production event sample, schema result, event-time pathname proof, or gap distribution. Consequently, legacy reconstruction remains disabled with `legacyUnavailableReason: 'missing_event_time_path'`, and the 30-second candidate is unvalidated: it is neither active nor numerically trustworthy and is reported as `legacyDeduplicationSeconds: null`. + +Re-enabling legacy reconstruction requires a successful proof that the chosen pathname is event-time data, a real same-organization/session gap histogram that validates the selected threshold, and tests covering the enabled mapper, burst boundaries, and wire metadata. No production-data conclusion is inferred from the failed calls. + The concrete daily-series types use the existing admin chart input conventions and contain every UTC date in the selected range, including zero-value days. ## Admin UI diff --git a/src/services/adminPlansAnalytics.ts b/src/services/adminPlansAnalytics.ts index 21612d05ce..cd13dd8625 100644 --- a/src/services/adminPlansAnalytics.ts +++ b/src/services/adminPlansAnalytics.ts @@ -36,7 +36,7 @@ export interface PlansAnalyticsDataQuality { posthogConfigured: boolean posthogConnected: boolean posthogFailureReason: PlansAnalyticsFailureReason | null - legacyDeduplicationSeconds: number + legacyDeduplicationSeconds: number | null } export interface PlansAnalyticsResponse { @@ -150,6 +150,10 @@ function nullableFailureReason(value: unknown, path: string): PlansAnalyticsFail return invalidResponse(path) } +function nullableCount(value: unknown, path: string): number | null { + return value === null ? null : count(value, path) +} + function counts(value: unknown, path: string): number[] { return array(value, path).map((item, index) => count(item, `${path}[${index}]`)) } @@ -214,7 +218,7 @@ export function parsePlansAnalyticsResponse(value: unknown): PlansAnalyticsRespo posthogConfigured: boolean(qualityValue.posthogConfigured, 'response.dataQuality.posthogConfigured'), posthogConnected: boolean(qualityValue.posthogConnected, 'response.dataQuality.posthogConnected'), posthogFailureReason: nullableFailureReason(qualityValue.posthogFailureReason, 'response.dataQuality.posthogFailureReason'), - legacyDeduplicationSeconds: count(qualityValue.legacyDeduplicationSeconds, 'response.dataQuality.legacyDeduplicationSeconds'), + legacyDeduplicationSeconds: nullableCount(qualityValue.legacyDeduplicationSeconds, 'response.dataQuality.legacyDeduplicationSeconds'), }, } } diff --git a/supabase/functions/_backend/utils/plans_analytics.ts b/supabase/functions/_backend/utils/plans_analytics.ts index a84f30af96..dad6e599df 100644 --- a/supabase/functions/_backend/utils/plans_analytics.ts +++ b/supabase/functions/_backend/utils/plans_analytics.ts @@ -41,7 +41,7 @@ export interface PlansAnalyticsResponse { posthogConfigured: boolean posthogConnected: boolean posthogFailureReason: PlansAnalyticsFailureReason | null - legacyDeduplicationSeconds: number + legacyDeduplicationSeconds: number | null } } @@ -63,6 +63,10 @@ interface ParsedRange { endIso: string } +function legacyDeduplicationSeconds(reconstructionAvailable: boolean): number | null { + return reconstructionAvailable ? LEGACY_BURST_SECONDS : null +} + function safeIso(timestampMs: number): string | null { if (!Number.isFinite(timestampMs)) return null @@ -196,7 +200,7 @@ function emptyPlansAnalyticsResponse( posthogConfigured: quality.posthogConfigured ?? false, posthogConnected: quality.posthogConnected ?? false, posthogFailureReason: quality.posthogFailureReason ?? null, - legacyDeduplicationSeconds: LEGACY_BURST_SECONDS, + legacyDeduplicationSeconds: legacyDeduplicationSeconds(false), }, } } @@ -444,7 +448,7 @@ export async function getAdminPlansAnalytics( posthogConfigured: true, posthogConnected: true, posthogFailureReason: null, - legacyDeduplicationSeconds: LEGACY_BURST_SECONDS, + legacyDeduplicationSeconds: legacyDeduplicationSeconds(false), }, } cloudlog({ diff --git a/tests/admin-plans-analytics-dashboard.unit.test.ts b/tests/admin-plans-analytics-dashboard.unit.test.ts index 66c0abd67d..c934b26e3a 100644 --- a/tests/admin-plans-analytics-dashboard.unit.test.ts +++ b/tests/admin-plans-analytics-dashboard.unit.test.ts @@ -21,7 +21,7 @@ const validResponse: BackendPlansAnalyticsResponse = { posthogConfigured: true, posthogConnected: true, posthogFailureReason: null, - legacyDeduplicationSeconds: 30, + legacyDeduplicationSeconds: null, }, } @@ -90,6 +90,15 @@ describe('admin Plans analytics dashboard', () => { expect(parsePlansAnalyticsResponse(value)).toEqual(value) }) + it('accepts a validated nonnegative integer legacy threshold for a future enabled path', () => { + const value = { + ...validResponse, + dataQuality: { ...validResponse.dataQuality, legacyDeduplicationSeconds: 30 }, + } + + expect(parsePlansAnalyticsResponse(value)).toEqual(value) + }) + it.each([ ['non-object response', null], ['missing required fields', { traffic: validResponse.traffic }], @@ -121,6 +130,14 @@ describe('admin Plans analytics dashboard', () => { ...validResponse, dataQuality: { ...validResponse.dataQuality, exactTrackingStartedAt: 'not-a-timestamp' }, }], + ['negative legacy deduplication threshold', { + ...validResponse, + dataQuality: { ...validResponse.dataQuality, legacyDeduplicationSeconds: -1 }, + }], + ['fractional legacy deduplication threshold', { + ...validResponse, + dataQuality: { ...validResponse.dataQuality, legacyDeduplicationSeconds: 0.5 }, + }], ])('rejects a malformed response with %s', (_name, value) => { expect(() => parsePlansAnalyticsResponse(value)).toThrowError('Invalid Plans analytics response') }) diff --git a/tests/plans-analytics-orchestration.unit.test.ts b/tests/plans-analytics-orchestration.unit.test.ts index 9abd07f7b3..dd857484be 100644 --- a/tests/plans-analytics-orchestration.unit.test.ts +++ b/tests/plans-analytics-orchestration.unit.test.ts @@ -144,6 +144,7 @@ describe('plans analytics orchestration', () => { legacyReconstructionAvailable: false, legacyUnavailableReason: 'missing_event_time_path', legacyLogicalOpens: 0, + legacyDeduplicationSeconds: null, }) expect(result.traffic.totalOpens).toEqual([0]) expect(result.visitorBreakdown).toHaveLength(1) @@ -286,7 +287,7 @@ describe('plans analytics orchestration', () => { excludedMissingOrganization: 4, unmatchedCheckoutStarts: 1, unknownBillingOrganizations: 0, - legacyDeduplicationSeconds: 30, + legacyDeduplicationSeconds: null, }) expect(result.traffic.totalOpens).toEqual([1]) expect(result.checkoutIntent[0]).toMatchObject({ startedCheckout: 1, didNotStart: 0 }) From 3d67ab17f85cb1624b524dd05e65ea62dcd10fb5 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Mon, 10 Aug 2026 10:53:23 +0200 Subject: [PATCH 12/19] fix(admin): avoid console grep false positive --- supabase/functions/_backend/utils/plans_analytics_model.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supabase/functions/_backend/utils/plans_analytics_model.ts b/supabase/functions/_backend/utils/plans_analytics_model.ts index 1624173234..f7cd647918 100644 --- a/supabase/functions/_backend/utils/plans_analytics_model.ts +++ b/supabase/functions/_backend/utils/plans_analytics_model.ts @@ -69,7 +69,7 @@ function utcDate(timestampMs: number): string { function normalizePath(value: string): string | null { try { - const pathname = new URL(value, 'https://console.capgo.app').pathname + const pathname = new URL(value, 'https://capgo.app').pathname return pathname.length > 1 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname } catch { From 77862fe8a76a5ae8733ebaff5d57d0db75e03e4b Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Mon, 10 Aug 2026 11:20:06 +0200 Subject: [PATCH 13/19] fix(admin): address plans analytics review --- .../2026-08-10-plans-analytics-dashboard.md | 77 +++++++++-------- ...-08-10-plans-analytics-dashboard-design.md | 6 +- messages/en.json | 4 +- src/components/admin/AdminStackedBarChart.vue | 19 ++--- src/components/admin/adminStackedBarChart.ts | 18 ++++ src/pages/admin/dashboard/plans.vue | 45 ++-------- src/services/adminPlansAnalytics.ts | 34 ++++++++ .../_backend/utils/plans_billing_history.ts | 18 ++-- ...min-plans-analytics-dashboard.unit.test.ts | 74 ++++++++++++----- tests/admin-stacked-bar-chart.unit.test.ts | 15 ++-- tests/plans-analytics-model.unit.test.ts | 47 ++++++++--- tests/plans-billing-history.unit.test.ts | 83 ++++++++++++------- 12 files changed, 271 insertions(+), 169 deletions(-) diff --git a/docs/superpowers/plans/2026-08-10-plans-analytics-dashboard.md b/docs/superpowers/plans/2026-08-10-plans-analytics-dashboard.md index 1d187dabe4..77b595810a 100644 --- a/docs/superpowers/plans/2026-08-10-plans-analytics-dashboard.md +++ b/docs/superpowers/plans/2026-08-10-plans-analytics-dashboard.md @@ -132,7 +132,9 @@ FROM ( SELECT dateDiff( 'second', lagInFrame(timestamp) OVER ( - PARTITION BY properties.org_id, distinct_id + PARTITION BY + properties.org_id, + coalesce(nullIf(toString(properties.$session_id), ''), toString(distinct_id)) ORDER BY timestamp ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING ), @@ -148,7 +150,7 @@ GROUP BY gap_bucket ORDER BY gap_bucket ``` -Expected: duplicate bursts concentrate at or below 30 seconds. Keep `LEGACY_BURST_SECONDS = 30`; if the distribution disproves the cutoff, update the constant, its tests, and the design document in the same commit before continuing. +Expected before enabling legacy repair: duplicate bursts concentrate at or below the selected cutoff when partitioned by the same organization plus session-or-actor identity used at runtime. Thirty seconds remains only a candidate until this query succeeds. If the distribution validates a cutoff, update the constant, enabled-path tests, response metadata, and design document together; otherwise keep legacy reconstruction unavailable and its reported threshold null. - [ ] **Step 5: Commit any evidence-driven specification correction** @@ -265,11 +267,10 @@ export interface PosthogReadResult { export async function queryPosthogHogql(c: Context, query: string): Promise { const key = (getEnv(c, 'POSTHOG_READ_KEY') || '').trim() - if (!key) + const host = (getEnv(c, 'POSTHOG_READ_HOST') || '').trim().replace(/\/$/, '') + const project = (getEnv(c, 'POSTHOG_READ_PROJECT_ID') || '').trim() + if (!key || !host || !project) return { configured: false, connected: false, failureReason: 'unconfigured', rows: [] } - - const host = ((getEnv(c, 'POSTHOG_READ_HOST') || 'https://eu.posthog.com').trim()).replace(/\/$/, '') - const project = (getEnv(c, 'POSTHOG_READ_PROJECT_ID') || '22029').trim() try { const response = await fetch(`${host}/api/projects/${project}/query/`, { method: 'POST', @@ -403,23 +404,29 @@ describe('Plans analytics model', () => { const openings = buildLogicalPlansOpenings([ event({ timestampMs: ms('2026-08-01T08:00:00Z'), orgId: 'org-a', page: 'plans', path: '' }), event({ timestampMs: ms('2026-08-02T08:00:00Z'), orgId: 'org-a', page: 'plans', path: '' }), + event({ timestampMs: ms('2026-08-02T12:00:00Z'), orgId: 'org-a', page: 'plans', path: '' }), event({ timestampMs: ms('2026-08-02T09:00:00Z'), orgId: 'org-b', page: 'plans', path: '' }), ], ms('2026-08-01T00:00:00Z'), ms('2026-08-03T00:00:00Z'), 30) const matches = attributeCheckoutStarts(openings, [ event({ event: 'Checkout Started', timestampMs: ms('2026-08-02T08:10:00Z'), orgId: 'org-a', path: '' }), + event({ event: 'Checkout Started', timestampMs: ms('2026-08-02T12:10:00Z'), orgId: 'org-a', path: '' }), ]) const result = buildPlansChartData({ openings, attributedCheckouts: matches, startMs: ms('2026-08-01T00:00:00Z'), endMs: ms('2026-08-03T00:00:00Z'), - classifyAt: orgId => orgId === 'org-a' ? 'paying' : 'active_trial', + classifyAt: (orgId, timestampMs) => orgId === 'org-b' + ? 'active_trial' + : timestampMs < ms('2026-08-02T10:00:00Z') ? 'paying' : 'credits_only', }) expect(result.traffic.uniqueVisitorOrganizations).toEqual([1, 1]) expect(result.traffic.totalOpens).toEqual([1, 2]) expect(result.visitorBreakdown.map(day => day.total)).toEqual([1, 2]) expect(result.checkoutIntent.map(day => day.startedCheckout + day.didNotStart)).toEqual([1, 2]) expect(result.checkoutVisitorBreakdown.map(day => day.total)).toEqual([0, 1]) + expect(result.checkoutIntent[1].startedCheckout).toBe(1) + expect(result.checkoutVisitorBreakdown[1]).toMatchObject({ paying: 1, creditsOnly: 0 }) }) }) ``` @@ -515,14 +522,14 @@ Implementation requirements: ```text exact candidate: event === 'User visit' && page === 'plans' -legacy candidate: event === 'User visit' && normalized path === '/settings/organization/plans' +legacy candidate: event === 'User visit' && page !== 'plans' && normalized path === '/settings/organization/plans' legacy identity: orgId + (sessionId || actorId) legacy new opening: first event or previous gap > burstSeconds visible opening: timestampMs >= startMs && timestampMs < endMs checkout match: maximum opening.timestampMs <= checkout.timestampMs with gap <= 24h ``` -Normalize paths with `new URL(value, 'https://console.capgo.app').pathname`, then remove a trailing slash except for `/`. +Normalize paths with `new URL(value, 'https://capgo.app').pathname`, then remove a trailing slash except for `/`. - [ ] **Step 5: Implement graph aggregation** @@ -743,7 +750,7 @@ export async function loadPlansBillingHistories( ): Promise> ``` -Use one `getPgClient(c, true)` lifecycle and parameterized `ANY($1::uuid[])`/`ANY($1::text[])` queries. Load: +Before calling the loader, normalize and validate every PostHog organization identifier as a UUID. Count invalid or missing identifiers in `excludedMissingOrganization` and pass only validated UUIDs to the parameterized database queries. Use one `getPgClient(c, true)` lifecycle and parameterized `ANY($1::uuid[])`/`ANY($1::text[])` queries. Load: ```sql SELECT o.id::text AS org_id, o.customer_id, si.trial_at, si.paid_at, @@ -914,7 +921,7 @@ Create `plans_analytics.ts` with: ```ts export const MAX_POSTHOG_ROWS = 200_000 export const TRACKING_HISTORY_START = '2026-02-23T00:00:00.000Z' -export const LEGACY_PATH_SOURCE = 'event' as const +export const LEGACY_PATH_SOURCE = 'unavailable' as const export interface PlansAnalyticsResponse { traffic: { dates: string[], uniqueVisitorOrganizations: number[], totalOpens: number[] } @@ -951,15 +958,19 @@ SELECT properties.org_id AS org_id, properties.$groups.organization AS grouped_org_id, properties.page AS page, - properties.$current_url AS event_current_url, - properties.$pathname AS event_pathname, - person.properties.$current_url AS person_current_url, properties.$session_id AS session_id, distinct_id FROM events WHERE event IN ('User visit', 'Checkout Started') - AND timestamp >= parseDateTimeBestEffort('2026-07-31T23:59:30.000Z') - AND timestamp < parseDateTimeBestEffort('2026-08-03T00:00:00.000Z') + AND ( + (event = 'User visit' + AND timestamp >= parseDateTimeBestEffort('2026-07-31T23:59:30.000Z') + AND timestamp < parseDateTimeBestEffort('2026-08-02T00:00:00.000Z')) + OR + (event = 'Checkout Started' + AND timestamp >= parseDateTimeBestEffort('2026-08-01T00:00:00.000Z') + AND timestamp < parseDateTimeBestEffort('2026-08-03T00:00:00.000Z')) + ) ORDER BY timestamp LIMIT 200001 ``` @@ -971,7 +982,7 @@ const queryStart = new Date(Date.parse(startDate) - (LEGACY_BURST_SECONDS * 1000 const queryEnd = new Date(Date.parse(endDate) + CHECKOUT_ATTRIBUTION_MS).toISOString() ``` -Then insert them with `sqlString(queryStart)` and `sqlString(queryEnd)`. Restrict the checkout portion to timestamps at or after `startDate`; the extra pre-range window exists only for visit burst repair. `LEGACY_PATH_SOURCE = 'event'` means runtime reconstruction uses `event_current_url || event_pathname`, never `person_current_url`. If Task 1 proves that only an ingestion-time person-on-events URL is valid, change the constant and its fixture expectations before implementing the mapper. If Task 1 cannot prove either source, set the source to `unavailable` and return `missing_event_time_path`. Do not select the full `properties` object. +Then insert them with `sqlString(queryStart)` and `sqlString(queryEnd)`. Restrict checkout rows to `[startDate, queryEnd)` and visit rows to `[queryStart, endDate)`; the extra pre-range window exists only for visit burst repair. Keep `LEGACY_PATH_SOURCE = 'unavailable'`, return `missing_event_time_path`, and report a null legacy threshold. Only switch to an event-time source after Task 1 proves it and the enabled mapper, burst boundaries, and wire metadata are covered by tests. Never use `person_current_url`, and do not select the full `properties` object. The transition query begins at `TRACKING_HISTORY_START`, ends at `end + 24h`, and selects `$group_key`, `$group_type`, `$group_set.plan_status`, `$group_set.canceled_at`, organization group ID, and event name. @@ -1148,6 +1159,8 @@ describe('admin Plans analytics dashboard', () => { }) ``` +Keep raw-source checks limited to stable wiring contracts: the admin guard, `fetchStats('plans_analytics')`, response parsing, the UTC key, and the secured documentation link. Test response validation and presentation behavior through exported pure helpers. Cover initial/pending request coordination, valid empty data, partial-billing and unavailable-legacy warnings, each of `unconfigured`, `timeout`, `too_large`, and `unavailable`, plus request-error precedence. Assert required translation-key presence rather than exact copy, except where the design explicitly fixes the wording. + - [ ] **Step 2: Run the test to verify failure** ```bash @@ -1207,7 +1220,7 @@ Use these exact English values: "plans-analytics-title": "Plans analytics", "plans-analytics-timezone": "Reporting timezone: UTC", "plans-analytics-traffic": "Plans page traffic", - "plans-analytics-traffic-description": "Organizations and logical openings of the Plans page", + "plans-analytics-traffic-description": "Unique organizations on their first Plans opening in the selected range, alongside total logical openings per UTC day", "plans-analytics-unique-visitor-orgs": "Unique visitor orgs", "plans-analytics-total-opens": "Total opens", "plans-analytics-who-opened": "Who opened Plans?", @@ -1231,7 +1244,7 @@ Use these exact English values: "plans-analytics-partial-warning": "Some organizations could not be classified from historical billing records and appear as Unknown.", "plans-analytics-legacy-unavailable": "Legacy Plans visits are unavailable because no event-time pathname could be verified.", "plans-analytics-posthog-unconfigured": "PostHog analytics is not configured.", - "plans-analytics-posthog-timeout": "This range took too long to process. Select a shorter period and try again.", + "plans-analytics-posthog-timeout": "This range was too large to process. Select a shorter period and try again.", "plans-analytics-range-too-large": "This range returned too much data to process. Select a shorter period and try again.", "plans-analytics-unavailable": "Plans analytics is temporarily unavailable.", "plans-analytics-empty": "No Plans visits were recorded in this period." @@ -1296,7 +1309,8 @@ async function loadPlansAnalytics() { isLoadingStats.value = true requestError.value = null try { - data.value = await adminStore.fetchStats('plans_analytics') as PlansAnalyticsResponse + const response: unknown = await adminStore.fetchStats('plans_analytics') + data.value = parsePlansAnalyticsResponse(response) } catch (error) { console.error('[Admin Dashboard Plans] Error loading Plans analytics:', error) @@ -1313,23 +1327,14 @@ Watch `adminStore.activeDateRange` and `adminStore.refreshTrigger`, matching exi - [ ] **Step 2: Implement explicit availability messages** -Map `dataQuality.posthogFailureReason` exactly: +Map `dataQuality.posthogFailureReason` through the behavior-tested presentation helper: ```ts -const unavailableMessage = computed(() => { - if (requestError.value) - return requestError.value - switch (data.value?.dataQuality.posthogFailureReason) { - case 'unconfigured': return t('plans-analytics-posthog-unconfigured') - case 'timeout': return t('plans-analytics-posthog-timeout') - case 'too_large': return t('plans-analytics-range-too-large') - case 'unavailable': return t('plans-analytics-unavailable') - default: return null - } -}) +const presentation = computed(() => buildPlansAnalyticsPresentationState(data.value, requestError.value, t)) +const unavailableMessage = computed(() => presentation.value.unavailableMessage) ``` -Show a non-blocking warning when `unknownBillingOrganizations > 0` or legacy reconstruction is unavailable. A connected response with zero values is a valid empty result, not an error. +The helper maps `unconfigured`, `timeout`, `too_large`, and `unavailable` to their translation keys, gives a request failure precedence, and derives chart availability. Show a non-blocking warning when its partial-billing or unavailable-legacy flag is true. A connected response with zero values is a valid empty result, not an error. - [ ] **Step 3: Render all five full-width cards** @@ -1353,7 +1358,7 @@ Render in this order inside `space-y-6`: ``` -The fifth card is not a chart. Render a full-width card with title `Checkout completion`, the literal user-facing deferred copy approved in the design specification, and an external link to: +The fifth card is not a chart. Render a full-width card using the `plans-analytics-checkout-completion`, `plans-analytics-checkout-completion-description`, and `plans-analytics-checkout-completion-link` translation keys. Keep only the external URL literal: ```text https://github.com/Cap-go/capgo.app/blob/main/docs/admin/plans-checkout-completion.md @@ -1393,8 +1398,8 @@ git commit -m "feat(admin): add plans analytics dashboard" - [ ] **Step 1: Run formatting and lint first** ```bash -bun run lint:fix -bun run lint:backend +bun lint:fix +bun lint:backend ``` Expected: PASS with only intentional formatting changes. diff --git a/docs/superpowers/specs/2026-08-10-plans-analytics-dashboard-design.md b/docs/superpowers/specs/2026-08-10-plans-analytics-dashboard-design.md index c150dac825..4401c89c0a 100644 --- a/docs/superpowers/specs/2026-08-10-plans-analytics-dashboard-design.md +++ b/docs/superpowers/specs/2026-08-10-plans-analytics-dashboard-design.md @@ -187,9 +187,9 @@ interface PlansAnalyticsResponse { uniqueVisitorOrganizations: number[] totalOpens: number[] } - visitorBreakdown: DailyBillingSeries[] - checkoutIntent: DailyCheckoutIntentSeries[] - checkoutVisitorBreakdown: DailyBillingSeries[] + visitorBreakdown: DailyBillingPoint[] + checkoutIntent: DailyCheckoutIntentPoint[] + checkoutVisitorBreakdown: DailyBillingPoint[] dataQuality: { exactTrackingStartedAt: string | null legacyLogicalOpens: number diff --git a/messages/en.json b/messages/en.json index f2367d0337..95b5944a7d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1985,7 +1985,7 @@ "plans-analytics-title": "Plans analytics", "plans-analytics-timezone": "Reporting timezone: UTC", "plans-analytics-traffic": "Plans page traffic", - "plans-analytics-traffic-description": "Organizations and logical openings of the Plans page", + "plans-analytics-traffic-description": "Unique organizations on their first Plans opening in the selected range, alongside total logical openings per UTC day", "plans-analytics-unique-visitor-orgs": "Unique visitor orgs", "plans-analytics-total-opens": "Total opens", "plans-analytics-who-opened": "Who opened Plans?", @@ -2009,7 +2009,7 @@ "plans-analytics-partial-warning": "Some organizations could not be classified from historical billing records and appear as Unknown.", "plans-analytics-legacy-unavailable": "Legacy Plans visits are unavailable because no event-time pathname could be verified.", "plans-analytics-posthog-unconfigured": "PostHog analytics is not configured.", - "plans-analytics-posthog-timeout": "This range took too long to process. Select a shorter period and try again.", + "plans-analytics-posthog-timeout": "This range was too large to process. Select a shorter period and try again.", "plans-analytics-range-too-large": "This range returned too much data to process. Select a shorter period and try again.", "plans-analytics-unavailable": "Plans analytics is temporarily unavailable.", "plans-analytics-empty": "No Plans visits were recorded in this period.", diff --git a/src/components/admin/AdminStackedBarChart.vue b/src/components/admin/AdminStackedBarChart.vue index fe074dc2ed..ad69bc54f2 100644 --- a/src/components/admin/AdminStackedBarChart.vue +++ b/src/components/admin/AdminStackedBarChart.vue @@ -12,7 +12,7 @@ import { } from 'chart.js' import { computed } from 'vue' import { Bar } from 'vue-chartjs' -import { buildAdminStackedBarChartData, buildAdminStackedBarChartOptions } from '~/components/admin/adminStackedBarChart' +import { applyAdminStackedBarAccessibleBorders, buildAdminStackedBarChartData, buildAdminStackedBarChartOptions } from '~/components/admin/adminStackedBarChart' import { formatLocalDate } from '~/services/date' interface DataSeries { @@ -58,18 +58,11 @@ const chartData = computed(() => { color: item.color, })) - const data = buildAdminStackedBarChartData(labels, datasets) - if (!props.accessibleBorders) - return data - - return { - ...data, - datasets: data.datasets.map(dataset => ({ - ...dataset, - borderColor: isDark.value ? '#f8fafc' : '#0f172a', - borderWidth: 1, - })), - } + return applyAdminStackedBarAccessibleBorders( + buildAdminStackedBarChartData(labels, datasets), + props.accessibleBorders, + isDark.value, + ) }) const chartOptions = computed(() => buildAdminStackedBarChartOptions(isDark.value)) diff --git a/src/components/admin/adminStackedBarChart.ts b/src/components/admin/adminStackedBarChart.ts index 9e7f8e9a03..5fa17d5ec2 100644 --- a/src/components/admin/adminStackedBarChart.ts +++ b/src/components/admin/adminStackedBarChart.ts @@ -25,6 +25,24 @@ export function buildAdminStackedBarChartData( } } +export function applyAdminStackedBarAccessibleBorders( + data: ChartData<'bar'>, + accessibleBorders: boolean, + isDark: boolean, +): ChartData<'bar'> { + if (!accessibleBorders) + return data + + return { + ...data, + datasets: data.datasets.map(dataset => ({ + ...dataset, + borderColor: isDark ? '#f8fafc' : '#0f172a', + borderWidth: 1, + })), + } +} + export function formatAdminStackedBarTooltip(label: string, value: number, total: number) { const percentage = total > 0 ? (value / total) * 100 : 0 return `${label}: ${formatNumberValue(value)} (${formatNumberValue(percentage, { maximumFractionDigits: 1 })}%)` diff --git a/src/pages/admin/dashboard/plans.vue b/src/pages/admin/dashboard/plans.vue index 0d13cb9b75..d148dc8f0f 100644 --- a/src/pages/admin/dashboard/plans.vue +++ b/src/pages/admin/dashboard/plans.vue @@ -13,7 +13,7 @@ import AdminMultiLineChart from '~/components/admin/AdminMultiLineChart.vue' import AdminStackedBarChart from '~/components/admin/AdminStackedBarChart.vue' import ChartCard from '~/components/dashboard/ChartCard.vue' import PageLoader from '~/components/PageLoader.vue' -import { buildPlansAnalyticsSeries, createLatestRequestCoordinator, parsePlansAnalyticsResponse } from '~/services/adminPlansAnalytics' +import { buildPlansAnalyticsPresentationState, buildPlansAnalyticsSeries, createLatestRequestCoordinator, parsePlansAnalyticsResponse } from '~/services/adminPlansAnalytics' import { useAdminDashboardStore } from '~/stores/adminDashboard' import { useDisplayStore } from '~/stores/display' import { useMainStore } from '~/stores/main' @@ -61,39 +61,12 @@ const series = computed(() => data.value ? buildPlansAnalyticsSeries(data.value, t) : { traffic: [], visitors: [], checkoutIntent: [], checkoutVisitors: [] }) -const unavailableMessage = computed(() => { - if (requestError.value) - return requestError.value - switch (data.value?.dataQuality.posthogFailureReason) { - case 'unconfigured': - return t('plans-analytics-posthog-unconfigured') - case 'timeout': - return t('plans-analytics-posthog-timeout') - case 'too_large': - return t('plans-analytics-range-too-large') - case 'unavailable': - return t('plans-analytics-unavailable') - default: - return null - } -}) - -const hasTraffic = computed(() => Boolean( - data.value?.dataQuality.posthogConnected - && data.value.traffic.totalOpens.some(value => value > 0), -)) -const hasVisitors = computed(() => Boolean( - data.value?.dataQuality.posthogConnected - && data.value.visitorBreakdown.some(row => row.total > 0), -)) -const hasCheckoutIntent = computed(() => Boolean( - data.value?.dataQuality.posthogConnected - && data.value.checkoutIntent.some(row => row.startedCheckout > 0 || row.didNotStart > 0), -)) -const hasCheckoutVisitors = computed(() => Boolean( - data.value?.dataQuality.posthogConnected - && data.value.checkoutVisitorBreakdown.some(row => row.total > 0), -)) +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 hasCheckoutVisitors = computed(() => presentation.value.hasCheckoutVisitors) watch([ () => adminStore.activeDateRange, @@ -150,7 +123,7 @@ displayStore.defaultBack = '/dashboard'
@@ -158,7 +131,7 @@ displayStore.defaultBack = '/dashboard'
diff --git a/src/services/adminPlansAnalytics.ts b/src/services/adminPlansAnalytics.ts index cd13dd8625..5fb65e00d7 100644 --- a/src/services/adminPlansAnalytics.ts +++ b/src/services/adminPlansAnalytics.ts @@ -49,6 +49,16 @@ export interface PlansAnalyticsResponse { export type Translate = (key: string) => string +export interface PlansAnalyticsPresentationState { + unavailableMessage: string | null + hasTraffic: boolean + hasVisitors: boolean + hasCheckoutIntent: boolean + hasCheckoutVisitors: boolean + showPartialBillingWarning: boolean + showLegacyUnavailableWarning: boolean +} + export interface ChartDataPoint { date: string value: number @@ -67,6 +77,30 @@ export interface PlansAnalyticsSeries { checkoutVisitors: ChartSeries[] } +export function buildPlansAnalyticsPresentationState( + data: PlansAnalyticsResponse | null, + requestError: string | null, + translate: Translate, +): PlansAnalyticsPresentationState { + const failureMessageKeys: Record = { + unconfigured: 'plans-analytics-posthog-unconfigured', + timeout: 'plans-analytics-posthog-timeout', + too_large: 'plans-analytics-range-too-large', + unavailable: 'plans-analytics-unavailable', + } + const failureReason = data?.dataQuality.posthogFailureReason + + return { + unavailableMessage: requestError ?? (failureReason ? translate(failureMessageKeys[failureReason]) : null), + 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)), + hasCheckoutVisitors: Boolean(data?.dataQuality.posthogConnected && data.checkoutVisitorBreakdown.some(row => row.total > 0)), + showPartialBillingWarning: Boolean(data && data.dataQuality.unknownBillingOrganizations > 0), + showLegacyUnavailableWarning: Boolean(data && !data.dataQuality.legacyReconstructionAvailable), + } +} + export function createLatestRequestCoordinator() { let latestRequestId = 0 const pendingRequestIds = new Set() diff --git a/supabase/functions/_backend/utils/plans_billing_history.ts b/supabase/functions/_backend/utils/plans_billing_history.ts index 201d2c7780..6c8b1abcb3 100644 --- a/supabase/functions/_backend/utils/plans_billing_history.ts +++ b/supabase/functions/_backend/utils/plans_billing_history.ts @@ -414,16 +414,14 @@ export async function loadPlansBillingHistories( CROSS JOIN LATERAL ( SELECT drm.customer_id, drm.date_id, drm.opening_mrr, drm.new_business_mrr, drm.expansion_mrr, drm.contraction_mrr, drm.churn_mrr, drm.churn_reason - FROM public.daily_revenue_metrics drm - WHERE drm.date_id = ( - SELECT pse.date_id - FROM public.processed_stripe_events pse - WHERE pse.customer_id = rc.customer_id - AND pse.date_id < $2::text - ORDER BY pse.date_id DESC - LIMIT 1 - ) - AND drm.customer_id = rc.customer_id + FROM public.processed_stripe_events pse + JOIN public.daily_revenue_metrics drm + ON drm.date_id = pse.date_id + AND drm.customer_id = pse.customer_id + WHERE pse.customer_id = rc.customer_id + AND pse.date_id < $2::text + ORDER BY pse.date_id DESC + LIMIT 1 ) latest ), in_range AS ( SELECT drm.customer_id, drm.date_id, drm.opening_mrr, drm.new_business_mrr, diff --git a/tests/admin-plans-analytics-dashboard.unit.test.ts b/tests/admin-plans-analytics-dashboard.unit.test.ts index c934b26e3a..f5027bde40 100644 --- a/tests/admin-plans-analytics-dashboard.unit.test.ts +++ b/tests/admin-plans-analytics-dashboard.unit.test.ts @@ -2,7 +2,7 @@ import type { PlansAnalyticsResponse as FrontendPlansAnalyticsResponse } from '. import type { PlansAnalyticsResponse as BackendPlansAnalyticsResponse } from '../supabase/functions/_backend/utils/plans_analytics.ts' import { readFile } from 'node:fs/promises' import { describe, expect, expectTypeOf, it } from 'vitest' -import { buildPlansAnalyticsSeries, createLatestRequestCoordinator, parsePlansAnalyticsResponse } from '../src/services/adminPlansAnalytics.ts' +import { buildPlansAnalyticsPresentationState, buildPlansAnalyticsSeries, createLatestRequestCoordinator, parsePlansAnalyticsResponse } from '../src/services/adminPlansAnalytics.ts' const validResponse: BackendPlansAnalyticsResponse = { traffic: { dates: ['2026-08-01'], uniqueVisitorOrganizations: [2], totalOpens: [4] }, @@ -29,7 +29,7 @@ const requiredMessages = { 'plans-analytics-title': 'Plans analytics', 'plans-analytics-timezone': 'Reporting timezone: UTC', 'plans-analytics-traffic': 'Plans page traffic', - 'plans-analytics-traffic-description': 'Organizations and logical openings of the Plans page', + 'plans-analytics-traffic-description': 'Unique organizations on their first Plans opening in the selected range, alongside total logical openings per UTC day', 'plans-analytics-unique-visitor-orgs': 'Unique visitor orgs', 'plans-analytics-total-opens': 'Total opens', 'plans-analytics-who-opened': 'Who opened Plans?', @@ -53,7 +53,7 @@ const requiredMessages = { 'plans-analytics-partial-warning': 'Some organizations could not be classified from historical billing records and appear as Unknown.', 'plans-analytics-legacy-unavailable': 'Legacy Plans visits are unavailable because no event-time pathname could be verified.', 'plans-analytics-posthog-unconfigured': 'PostHog analytics is not configured.', - 'plans-analytics-posthog-timeout': 'This range took too long to process. Select a shorter period and try again.', + 'plans-analytics-posthog-timeout': 'This range was too large to process. Select a shorter period and try again.', 'plans-analytics-range-too-large': 'This range returned too much data to process. Select a shorter period and try again.', 'plans-analytics-unavailable': 'Plans analytics is temporarily unavailable.', 'plans-analytics-empty': 'No Plans visits were recorded in this period.', @@ -61,8 +61,6 @@ const requiredMessages = { describe('admin Plans analytics dashboard', () => { it('keeps the frontend response DTO identical to the backend wire contract', () => { - expectTypeOf().toMatchTypeOf() - expectTypeOf().toMatchTypeOf() expectTypeOf().toEqualTypeOf() }) @@ -142,6 +140,51 @@ describe('admin Plans analytics dashboard', () => { expect(() => parsePlansAnalyticsResponse(value)).toThrowError('Invalid Plans analytics response') }) + it.each([ + ['unconfigured', 'plans-analytics-posthog-unconfigured'], + ['timeout', 'plans-analytics-posthog-timeout'], + ['too_large', 'plans-analytics-range-too-large'], + ['unavailable', 'plans-analytics-unavailable'], + ] as const)('maps the %s backend failure to its visible message', (failureReason, expected) => { + const state = buildPlansAnalyticsPresentationState({ + ...validResponse, + dataQuality: { ...validResponse.dataQuality, posthogFailureReason: failureReason }, + }, null, key => key) + + expect(state.unavailableMessage).toBe(expected) + }) + + it('distinguishes valid empty data, partial billing, and unavailable legacy history', () => { + const state = buildPlansAnalyticsPresentationState({ + ...validResponse, + traffic: { dates: [], uniqueVisitorOrganizations: [], totalOpens: [] }, + visitorBreakdown: [], + checkoutIntent: [], + checkoutVisitorBreakdown: [], + dataQuality: { + ...validResponse.dataQuality, + unknownBillingOrganizations: 2, + legacyReconstructionAvailable: false, + }, + }, null, key => key) + + expect(state).toMatchObject({ + unavailableMessage: null, + hasTraffic: false, + hasVisitors: false, + hasCheckoutIntent: false, + hasCheckoutVisitors: false, + showPartialBillingWarning: true, + showLegacyUnavailableWarning: true, + }) + }) + + it('gives a request failure precedence over backend presentation state', () => { + const state = buildPlansAnalyticsPresentationState(validResponse, 'request failed', key => key) + + expect(state.unavailableMessage).toBe('request failed') + }) + it.concurrent('maps all API datasets into stable chart series', () => { const series = buildPlansAnalyticsSeries({ ...validResponse, @@ -215,7 +258,12 @@ describe('admin Plans analytics dashboard', () => { expect(completionDoc).toContain('Completed or Not completed') expect(completionDoc).toContain('pending until the agreed observation window') expect(completionDoc).toContain('separate approved design') - expect(JSON.parse(messagesText)).toMatchObject(requiredMessages) + const messages = JSON.parse(messagesText) as Record + for (const key of Object.keys(requiredMessages)) { + expect(messages).toHaveProperty(key) + expect(messages[key]).toEqual(expect.any(String)) + } + expect(messages['plans-analytics-posthog-timeout']).toBe(requiredMessages['plans-analytics-posthog-timeout']) const page = await readFile(new URL('../src/pages/admin/dashboard/plans.vue', import.meta.url), 'utf8') expect(page).toContain('layout: admin') @@ -230,16 +278,7 @@ describe('admin Plans analytics dashboard', () => { expect(page).toContain('const isLoadingStats = ref(false)') expect(page).toContain('const requestError = ref(null)') - expect(page).toContain('case \'unconfigured\':') - expect(page).toContain('case \'timeout\':') - expect(page).toContain('case \'too_large\':') - expect(page).toContain('case \'unavailable\':') - expect(page).toContain('t(\'plans-analytics-posthog-unconfigured\')') - expect(page).toContain('t(\'plans-analytics-posthog-timeout\')') - expect(page).toContain('t(\'plans-analytics-range-too-large\')') - expect(page).toContain('t(\'plans-analytics-unavailable\')') - expect(page).toContain('unknownBillingOrganizations > 0') - expect(page).toContain('!data.dataQuality.legacyReconstructionAvailable') + expect(page).toContain('buildPlansAnalyticsPresentationState(data.value, requestError.value, t)') expect(page).toContain('t(\'plans-analytics-partial-warning\')') expect(page).toContain('t(\'plans-analytics-legacy-unavailable\')') expect(page).toContain('t(\'plans-analytics-empty\')') @@ -268,9 +307,6 @@ describe('admin Plans analytics dashboard', () => { const titlePositions = cardTitles.map(key => page.indexOf(`t('${key}')`)) expect(titlePositions.every(position => position >= 0)).toBe(true) expect(titlePositions).toEqual([...titlePositions].sort((a, b) => a - b)) - expect(page).toContain('class="space-y-6"') - expect(page).not.toContain('lg:grid-cols-2') - expect(page).toContain('t(\'plans-analytics-timezone\')') expect(page.match(/watch\(/g)).toHaveLength(1) expect(page).toContain('watch([') diff --git a/tests/admin-stacked-bar-chart.unit.test.ts b/tests/admin-stacked-bar-chart.unit.test.ts index 43d937d99d..88b23952c8 100644 --- a/tests/admin-stacked-bar-chart.unit.test.ts +++ b/tests/admin-stacked-bar-chart.unit.test.ts @@ -1,6 +1,7 @@ import { readFile } from 'node:fs/promises' import { describe, expect, it } from 'vitest' import { + applyAdminStackedBarAccessibleBorders, buildAdminStackedBarChartData, buildAdminStackedBarChartOptions, formatAdminStackedBarTooltip, @@ -48,12 +49,14 @@ describe('admin stacked bar chart', () => { expect(source).not.toContain('class="loading loading-spinner loading-lg text-primary"') }) - it.concurrent('offers opt-in contrasting segment boundaries without changing every chart', async () => { - const source = await readFile(new URL('../src/components/admin/AdminStackedBarChart.vue', import.meta.url), 'utf8') + it.concurrent('offers opt-in contrasting segment boundaries without changing every chart', () => { + const data = buildAdminStackedBarChartData(['Aug 1', 'Aug 2'], series) + const unchanged = applyAdminStackedBarAccessibleBorders(data, false, false) + const light = applyAdminStackedBarAccessibleBorders(data, true, false) + const dark = applyAdminStackedBarAccessibleBorders(data, true, true) - expect(source).toContain('accessibleBorders') - expect(source).toContain('default: false') - expect(source).toContain('isDark.value ? \'#f8fafc\' : \'#0f172a\'') - expect(source).toContain('borderWidth: 1') + expect(unchanged).toBe(data) + expect(light.datasets.every(dataset => dataset.borderColor === '#0f172a' && dataset.borderWidth === 1)).toBe(true) + expect(dark.datasets.every(dataset => dataset.borderColor === '#f8fafc' && dataset.borderWidth === 1)).toBe(true) }) }) diff --git a/tests/plans-analytics-model.unit.test.ts b/tests/plans-analytics-model.unit.test.ts index 6a6eacc2fd..fb936f40de 100644 --- a/tests/plans-analytics-model.unit.test.ts +++ b/tests/plans-analytics-model.unit.test.ts @@ -1,3 +1,4 @@ +import type { LogicalPlansOpening, PlansBehaviorEvent } from '../supabase/functions/_backend/utils/plans_analytics_model.ts' import { describe, expect, it } from 'vitest' import { attributeCheckoutStarts, @@ -5,21 +6,22 @@ import { buildPlansChartData, CHECKOUT_ATTRIBUTION_MS, LEGACY_BURST_SECONDS, - type LogicalPlansOpening, - type PlansBehaviorEvent, + } from '../supabase/functions/_backend/utils/plans_analytics_model.ts' const ms = (value: string) => Date.parse(value) -const event = (partial: Partial & Pick): PlansBehaviorEvent => ({ - actorId: 'user-a', - event: 'User visit', - page: '', - path: '/settings/organization/plans', - sessionId: '', - ...partial, -}) - -describe('Plans analytics model', () => { +function event(partial: Partial & Pick): PlansBehaviorEvent { + return { + actorId: 'user-a', + event: 'User visit', + page: '', + path: '/settings/organization/plans', + sessionId: '', + ...partial, + } +} + +describe('plans analytics model', () => { it.concurrent('collapses only legacy bursts and preserves exact repeat openings', () => { const events = [ event({ timestampMs: ms('2026-08-01T10:00:00Z'), orgId: 'org-a' }), @@ -152,6 +154,27 @@ describe('Plans analytics model', () => { expect(result.checkoutVisitorBreakdown.map(day => day.total)).toEqual([0, 1]) }) + it.concurrent('deduplicates same-day checkout starts and classifies the earliest attributed opening', () => { + const openings = buildLogicalPlansOpenings([ + event({ timestampMs: ms('2026-08-01T08:00:00Z'), orgId: 'org-a', page: 'plans', path: '' }), + event({ timestampMs: ms('2026-08-01T12:00:00Z'), orgId: 'org-a', page: 'plans', path: '' }), + ], ms('2026-08-01T00:00:00Z'), ms('2026-08-02T00:00:00Z')) + const matches = attributeCheckoutStarts(openings, [ + event({ event: 'Checkout Started', timestampMs: ms('2026-08-01T08:05:00Z'), orgId: 'org-a', path: '' }), + event({ event: 'Checkout Started', timestampMs: ms('2026-08-01T12:05:00Z'), orgId: 'org-a', path: '' }), + ]) + const result = buildPlansChartData({ + openings, + attributedCheckouts: matches, + startMs: ms('2026-08-01T00:00:00Z'), + endMs: ms('2026-08-02T00:00:00Z'), + classifyAt: (_orgId, timestampMs) => timestampMs < ms('2026-08-01T10:00:00Z') ? 'expired_trial' : 'credits_only', + }) + + expect(result.checkoutIntent).toEqual([{ date: '2026-08-01', startedCheckout: 1, didNotStart: 0 }]) + expect(result.checkoutVisitorBreakdown[0]).toMatchObject({ expiredTrial: 1, creditsOnly: 0, total: 1 }) + }) + it.concurrent('zero-fills intersecting UTC days and uses each graph category timestamp', () => { const openings = buildLogicalPlansOpenings([ event({ timestampMs: ms('2026-08-02T08:00:00Z'), orgId: 'org-a', page: 'plans', path: '' }), diff --git a/tests/plans-billing-history.unit.test.ts b/tests/plans-billing-history.unit.test.ts index 7bcc7fdcbb..2106c59f7b 100644 --- a/tests/plans-billing-history.unit.test.ts +++ b/tests/plans-billing-history.unit.test.ts @@ -1,4 +1,11 @@ +import type { OrganizationBillingHistory } from '../supabase/functions/_backend/utils/plans_billing_history.ts' + import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + classifyPlansBillingAt, + loadPlansBillingHistories, + +} from '../supabase/functions/_backend/utils/plans_billing_history.ts' const { closeClientMock, @@ -24,26 +31,22 @@ vi.mock('../supabase/functions/_backend/utils/pg.ts', () => ({ getPgClient: getPgClientMock, })) -import { - classifyPlansBillingAt, - loadPlansBillingHistories, - type OrganizationBillingHistory, -} from '../supabase/functions/_backend/utils/plans_billing_history.ts' - const at = Date.parse('2026-08-01T12:00:00Z') -const base = (): OrganizationBillingHistory => ({ - orgId: 'org-a', - customerId: 'cus-a', - trialEndsAtMs: Date.parse('2026-07-01T00:00:00Z'), - paidAtMs: null, - canceledAtMs: null, - currentPastDueAtMs: null, - churnReason: null, - revenueMovements: [], - transitions: [], - creditGrants: [], - creditConsumptions: [], -}) +function base(): OrganizationBillingHistory { + return { + orgId: 'org-a', + customerId: 'cus-a', + trialEndsAtMs: Date.parse('2026-07-01T00:00:00Z'), + paidAtMs: null, + canceledAtMs: null, + currentPastDueAtMs: null, + churnReason: null, + revenueMovements: [], + transitions: [], + creditGrants: [], + creditConsumptions: [], + } +} function normalizedQuery(query: unknown) { return String(query).replace(/\s+/g, ' ').trim() @@ -53,25 +56,39 @@ function context() { return { get: vi.fn(() => 'request-id') } as never } -describe('Plans billing history classification', () => { +describe('plans billing history classification', () => { it.each([ ['active payment problem beats paying', { - ...base(), paidAtMs: Date.parse('2026-06-01T00:00:00Z'), currentPastDueAtMs: Date.parse('2026-07-20T00:00:00Z'), + ...base(), + paidAtMs: Date.parse('2026-06-01T00:00:00Z'), + currentPastDueAtMs: Date.parse('2026-07-20T00:00:00Z'), revenueMovements: [{ date: '2026-06-01', openingMrr: 0, newBusinessMrr: 12, expansionMrr: 0, contractionMrr: 0, churnMrr: 0, churnReason: null }], }, 'payment_problem'], ['carried positive MRR is paying', { - ...base(), paidAtMs: Date.parse('2026-06-01T00:00:00Z'), revenueMovements: [{ - date: '2026-06-01', openingMrr: 0, newBusinessMrr: 12, expansionMrr: 0, contractionMrr: 0, churnMrr: 0, churnReason: null, + ...base(), + paidAtMs: Date.parse('2026-06-01T00:00:00Z'), + revenueMovements: [{ + date: '2026-06-01', + openingMrr: 0, + newBusinessMrr: 12, + expansionMrr: 0, + contractionMrr: 0, + churnMrr: 0, + churnReason: null, }], }, 'paying'], ['future trial end is active trial', { - ...base(), trialEndsAtMs: Date.parse('2026-08-10T00:00:00Z'), + ...base(), + trialEndsAtMs: Date.parse('2026-08-10T00:00:00Z'), }, 'active_trial'], ['positive unexpired credits are credits only', { - ...base(), creditGrants: [{ id: 'grant-a', grantedAtMs: Date.parse('2026-07-01T00:00:00Z'), expiresAtMs: Date.parse('2026-09-01T00:00:00Z'), creditsTotal: 10 }], + ...base(), + creditGrants: [{ id: 'grant-a', grantedAtMs: Date.parse('2026-07-01T00:00:00Z'), expiresAtMs: Date.parse('2026-09-01T00:00:00Z'), creditsTotal: 10 }], }, 'credits_only'], ['previously paid voluntary ended entitlement is canceled', { - ...base(), paidAtMs: Date.parse('2026-06-01T00:00:00Z'), canceledAtMs: Date.parse('2026-07-01T00:00:00Z'), + ...base(), + paidAtMs: Date.parse('2026-06-01T00:00:00Z'), + canceledAtMs: Date.parse('2026-07-01T00:00:00Z'), }, 'canceled'], ['never-paid ended trial is expired trial', base(), 'expired_trial'], ] as const)('%s', (_label, history, expected) => { @@ -319,9 +336,9 @@ describe('loadPlansBillingHistories', () => { expect(calls[1]!.sql).toContain('CROSS JOIN LATERAL') expect(calls[1]!.sql).toContain('FROM public.processed_stripe_events pse') expect(calls[1]!.sql).toContain('pse.customer_id = rc.customer_id') - expect(calls[1]!.sql).toContain('drm.date_id = ( SELECT pse.date_id') - expect(calls[1]!.sql).toContain('drm.customer_id = rc.customer_id') + expect(calls[1]!.sql).toContain('JOIN public.daily_revenue_metrics drm ON drm.date_id = pse.date_id AND drm.customer_id = pse.customer_id') expect(calls[1]!.sql).toContain('pse.date_id < $2::text') + expect(calls[1]!.sql).toContain('ORDER BY pse.date_id DESC LIMIT 1') expect(calls[1]!.sql).toContain('SELECT DISTINCT pse.date_id') expect(calls[1]!.sql).toContain('pse.date_id BETWEEN $2::text AND $3::text') expect(calls[1]!.sql).toContain('drm.date_id = movement_dates.date_id') @@ -330,11 +347,11 @@ describe('loadPlansBillingHistories', () => { expect(calls[1]!.sql).not.toContain('drm.date_id BETWEEN $2::text AND $3::text') expect(calls[2]).toMatchObject({ params: [['org-a', 'org-b'], '2026-08-01', '2026-08-07'] }) expect(calls[2]!.sql).toContain('g.org_id = ANY($1::uuid[])') - expect(calls[2]!.sql).toContain("g.granted_at < ($3::date + INTERVAL '1 day')") + expect(calls[2]!.sql).toContain('g.granted_at < ($3::date + INTERVAL \'1 day\')') expect(calls[2]!.sql).toContain('g.expires_at >= $2::date') expect(calls[3]).toMatchObject({ params: [['grant-a', 'grant-b'], '2026-08-07'] }) expect(calls[3]!.sql).toContain('c.grant_id = ANY($1::uuid[])') - expect(calls[3]!.sql).toContain("c.applied_at < ($2::date + INTERVAL '1 day')") + expect(calls[3]!.sql).toContain('c.applied_at < ($2::date + INTERVAL \'1 day\')') expect(result.get('org-a')).toMatchObject({ orgId: 'org-a', @@ -353,7 +370,8 @@ describe('loadPlansBillingHistories', () => { pgQueryMock.mockRejectedValueOnce(new Error('database unavailable')) await expect(loadPlansBillingHistories(context(), ['org-a'], '2026-08-01', '2026-08-07', new Map())) - .rejects.toThrow('database unavailable') + .rejects + .toThrow('database unavailable') expect(pgReleaseMock).toHaveBeenCalledOnce() expect(closeClientMock).toHaveBeenCalledOnce() @@ -378,7 +396,8 @@ describe('loadPlansBillingHistories', () => { 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.toEqual(new Map()) + .resolves + .toEqual(new Map()) expect(getPgClientMock).not.toHaveBeenCalled() expect(pgQueryMock).not.toHaveBeenCalled() From 98fc5e3572f06d539d12234f064fc94d43f28af1 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Mon, 10 Aug 2026 11:21:05 +0200 Subject: [PATCH 14/19] fix(admin): bound plans analytics queries --- .../2026-08-10-plans-analytics-dashboard.md | 33 ++-- ...-08-10-plans-analytics-dashboard-design.md | 2 +- .../_backend/utils/builder_analytics.ts | 9 +- .../_backend/utils/plans_analytics.ts | 41 +++-- .../functions/_backend/utils/posthog_read.ts | 90 +++++++++-- ...plans-analytics-orchestration.unit.test.ts | 114 +++++++++++++- tests/posthog-read.unit.test.ts | 144 +++++++++++++++++- 7 files changed, 388 insertions(+), 45 deletions(-) diff --git a/docs/superpowers/plans/2026-08-10-plans-analytics-dashboard.md b/docs/superpowers/plans/2026-08-10-plans-analytics-dashboard.md index 77b595810a..07ff93ae20 100644 --- a/docs/superpowers/plans/2026-08-10-plans-analytics-dashboard.md +++ b/docs/superpowers/plans/2026-08-10-plans-analytics-dashboard.md @@ -256,7 +256,9 @@ import type { Context } from 'hono' import { cloudlogErr, serializeError } from './logging.ts' import { getEnv } from './utils.ts' -export type PosthogReadFailureReason = 'unconfigured' | 'timeout' | 'unavailable' +export const MAX_POSTHOG_RESPONSE_BYTES = 8 * 1024 * 1024 + +export type PosthogReadFailureReason = 'too_large' | 'unconfigured' | 'timeout' | 'unavailable' export interface PosthogReadResult { configured: boolean @@ -265,12 +267,14 @@ export interface PosthogReadResult { rows: Record[] } -export async function queryPosthogHogql(c: Context, query: string): Promise { +export async function queryPosthogHogql(c: Context, query: string, options: { maxResponseBytes?: number } = {}): Promise { const key = (getEnv(c, 'POSTHOG_READ_KEY') || '').trim() - const host = (getEnv(c, 'POSTHOG_READ_HOST') || '').trim().replace(/\/$/, '') - const project = (getEnv(c, 'POSTHOG_READ_PROJECT_ID') || '').trim() - if (!key || !host || !project) + const hostOverride = (getEnv(c, 'POSTHOG_READ_HOST') || '').trim() + const projectOverride = (getEnv(c, 'POSTHOG_READ_PROJECT_ID') || '').trim() + if (!key || Boolean(hostOverride) !== Boolean(projectOverride)) return { configured: false, connected: false, failureReason: 'unconfigured', rows: [] } + const host = (hostOverride || 'https://eu.posthog.com').replace(/\/+$/, '') + const project = projectOverride || '22029' try { const response = await fetch(`${host}/api/projects/${project}/query/`, { method: 'POST', @@ -282,13 +286,16 @@ export async function queryPosthogHogql(c: Context, query: string): Promise Object.fromEntries(columns.map((column, index) => [column, row[index]]))), + rows: (json.results ?? []).map(row => Object.fromEntries(columns.map((column, index) => [column, row[index]]))), } } catch (error) { @@ -299,6 +306,8 @@ export async function queryPosthogHogql(c: Context, query: string): Promise= ONBOARDING_EVENT_LIMIT) cloudlog({ requestId: c.get('requestId'), message: 'builder_analytics onboarding events truncated', limit: ONBOARDING_EVENT_LIMIT }) const events = rows @@ -162,7 +161,7 @@ async function loadOnboardingEvents(c: Context, start: string, end: string): Pro errorCategory: str(r.error_category), })) .filter(e => e.appId || e.journeyId) - return { ok, events } + return { ok: connected && failureReason === null, events } } async function loadAiChoiceCount(c: Context, start: string, end: string): Promise { @@ -210,7 +209,7 @@ export async function getAdminBuilderAnalytics(c: Context, startDate: string, en const nowMs = Date.now() const startMs = Date.parse(startDate) const endMs = Date.parse(endDate) - const posthogConfigured = Boolean((getEnv(c, 'POSTHOG_READ_KEY') || '').trim()) + const posthogConfigured = isPosthogReadConfigured(c) // --- PostHog onboarding (parallel, each bounded by a fetch timeout) --- const [onboarding, aiOrgs] = await Promise.all([ diff --git a/supabase/functions/_backend/utils/plans_analytics.ts b/supabase/functions/_backend/utils/plans_analytics.ts index dad6e599df..72c71540cb 100644 --- a/supabase/functions/_backend/utils/plans_analytics.ts +++ b/supabase/functions/_backend/utils/plans_analytics.ts @@ -14,12 +14,15 @@ import { classifyPlansBillingAt, loadPlansBillingHistories, } from './plans_billing_history.ts' -import { queryPosthogHogql } from './posthog_read.ts' +import { MAX_POSTHOG_RESPONSE_BYTES, queryPosthogHogql } from './posthog_read.ts' export const MAX_POSTHOG_ROWS = 200_000 export const TRACKING_HISTORY_START = '2026-02-23T00:00:00.000Z' export const LEGACY_PATH_SOURCE = 'unavailable' as const const TRANSITION_ORG_BATCH_SIZE = 1_000 +export const TRANSITION_QUERY_CONCURRENCY = 4 +export const MAX_PLANS_ORGANIZATIONS = TRANSITION_ORG_BATCH_SIZE * TRANSITION_QUERY_CONCURRENCY +export const MAX_TRANSITION_RESPONSE_BYTES = Math.floor(MAX_POSTHOG_RESPONSE_BYTES / TRANSITION_QUERY_CONCURRENCY) const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i type PlansAnalyticsFailureReason = PosthogReadFailureReason | 'too_large' @@ -371,12 +374,25 @@ export async function getAdminPlansAnalytics( )) const attributedCheckouts = attributeCheckoutStarts(openings, checkoutEvents) const orgIds = [...new Set(openings.map(opening => opening.orgId))] + if (orgIds.length > MAX_PLANS_ORGANIZATIONS) { + return emptyPlansAnalyticsResponse(range.startMs, range.endMs, { + posthogConfigured: true, + posthogConnected: true, + posthogFailureReason: 'too_large', + }) + } const relevantOrganizations = new Set(orgIds) const transitionRows: Record[] = [] + const transitionBatches: string[][] = [] for (let offset = 0; offset < orgIds.length; offset += TRANSITION_ORG_BATCH_SIZE) { - const batch = orgIds.slice(offset, offset + TRANSITION_ORG_BATCH_SIZE) - const transitionResult = await queryPosthogHogql(c, buildBillingTransitionsQuery(range.endIso, batch)) - const transitionFailure = failedResult([transitionResult]) + transitionBatches.push(orgIds.slice(offset, offset + TRANSITION_ORG_BATCH_SIZE)) + } + 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 }) + ))) + const transitionFailure = failedResult(transitionResults) if (transitionFailure) { return emptyPlansAnalyticsResponse(range.startMs, range.endMs, { posthogConfigured: transitionFailure.configured, @@ -384,14 +400,17 @@ export async function getAdminPlansAnalytics( posthogFailureReason: transitionFailure.failureReason ?? 'unavailable', }) } - if (transitionResult.rows.length > MAX_POSTHOG_ROWS || transitionRows.length + transitionResult.rows.length > MAX_POSTHOG_ROWS) { - return emptyPlansAnalyticsResponse(range.startMs, range.endMs, { - posthogConfigured: true, - posthogConnected: true, - posthogFailureReason: 'too_large', - }) + for (const transitionResult of transitionResults) { + if (transitionResult.rows.length > MAX_POSTHOG_ROWS || transitionRows.length + transitionResult.rows.length > MAX_POSTHOG_ROWS) { + return emptyPlansAnalyticsResponse(range.startMs, range.endMs, { + posthogConfigured: true, + posthogConnected: true, + posthogFailureReason: 'too_large', + }) + } + for (const row of transitionResult.rows) + transitionRows.push(row) } - transitionRows.push(...transitionResult.rows) } const boundaryResult = await queryPosthogHogql(c, buildExactTrackingStartQuery()) diff --git a/supabase/functions/_backend/utils/posthog_read.ts b/supabase/functions/_backend/utils/posthog_read.ts index 43f68d0d61..f66f4fa255 100644 --- a/supabase/functions/_backend/utils/posthog_read.ts +++ b/supabase/functions/_backend/utils/posthog_read.ts @@ -2,7 +2,11 @@ import type { Context } from 'hono' import { cloudlogErr, serializeError } from './logging.ts' import { getEnv } from './utils.ts' -export type PosthogReadFailureReason = 'unconfigured' | 'timeout' | 'unavailable' +export const MAX_POSTHOG_RESPONSE_BYTES = 8 * 1024 * 1024 +const DEFAULT_POSTHOG_READ_HOST = 'https://eu.posthog.com' +const DEFAULT_POSTHOG_READ_PROJECT_ID = '22029' + +export type PosthogReadFailureReason = 'too_large' | 'unconfigured' | 'timeout' | 'unavailable' export interface PosthogReadResult { configured: boolean @@ -11,9 +15,70 @@ export interface PosthogReadResult { rows: Record[] } -export async function queryPosthogHogql(c: Context, query: string): Promise { - const key = (getEnv(c, 'POSTHOG_READ_KEY') || '').trim() - if (!key) { +export interface PosthogReadOptions { + maxResponseBytes?: number +} + +interface PosthogReadConfig { + key: string + host: string + project: string +} + +function posthogReadConfig(c: Context): PosthogReadConfig | null { + const key = getEnv(c, 'POSTHOG_READ_KEY').trim() + const hostOverride = getEnv(c, 'POSTHOG_READ_HOST').trim() + const projectOverride = getEnv(c, 'POSTHOG_READ_PROJECT_ID').trim() + if (!key || Boolean(hostOverride) !== Boolean(projectOverride)) + return null + + const host = (hostOverride || DEFAULT_POSTHOG_READ_HOST).replace(/\/+$/, '') + const project = projectOverride || DEFAULT_POSTHOG_READ_PROJECT_ID + if (!host || !project) + return null + + return { key, host, project } +} + +export function isPosthogReadConfigured(c: Context): boolean { + return posthogReadConfig(c) !== null +} + +async function readBoundedResponse(response: Response, maxResponseBytes: number): Promise { + const declaredLength = Number(response.headers.get('content-length')) + if (Number.isFinite(declaredLength) && declaredLength > maxResponseBytes) + return null + + if (!response.body) + return '' + + const reader = response.body.getReader() + const chunks: Uint8Array[] = [] + let totalBytes = 0 + while (true) { + const { done, value } = await reader.read() + if (done) + break + totalBytes += value.byteLength + if (totalBytes > maxResponseBytes) { + await reader.cancel() + return null + } + chunks.push(value) + } + + const bytes = new Uint8Array(totalBytes) + let offset = 0 + for (const chunk of chunks) { + bytes.set(chunk, offset) + offset += chunk.byteLength + } + return new TextDecoder().decode(bytes) +} + +export async function queryPosthogHogql(c: Context, query: string, options: PosthogReadOptions = {}): Promise { + const config = posthogReadConfig(c) + if (!config) { return { configured: false, connected: false, @@ -22,13 +87,10 @@ export async function queryPosthogHogql(c: Context, query: string): Promise 0 + ? Math.min(requestedMax, MAX_POSTHOG_RESPONSE_BYTES) + : 0 + const responseBody = await readBoundedResponse(response, maxResponseBytes) + if (responseBody === null) + return { configured: true, connected: true, failureReason: 'too_large', rows: [] } + + const json = JSON.parse(responseBody) as { columns?: string[], results?: unknown[][] } const columns = json.columns ?? [] const rows = (json.results ?? []).map((result) => { const row: Record = {} diff --git a/tests/plans-analytics-orchestration.unit.test.ts b/tests/plans-analytics-orchestration.unit.test.ts index dd857484be..4da85c0996 100644 --- a/tests/plans-analytics-orchestration.unit.test.ts +++ b/tests/plans-analytics-orchestration.unit.test.ts @@ -7,13 +7,19 @@ import { buildPlansBehaviorQuery, getAdminPlansAnalytics, LEGACY_PATH_SOURCE, + MAX_PLANS_ORGANIZATIONS, MAX_POSTHOG_ROWS, + MAX_TRANSITION_RESPONSE_BYTES, TRACKING_HISTORY_START, + TRANSITION_QUERY_CONCURRENCY, } from '../supabase/functions/_backend/utils/plans_analytics.ts' import { loadPlansBillingHistories } from '../supabase/functions/_backend/utils/plans_billing_history.ts' import { queryPosthogHogql } from '../supabase/functions/_backend/utils/posthog_read.ts' -vi.mock('../supabase/functions/_backend/utils/posthog_read.ts', () => ({ queryPosthogHogql: vi.fn() })) +vi.mock('../supabase/functions/_backend/utils/posthog_read.ts', async importOriginal => ({ + ...await importOriginal(), + queryPosthogHogql: vi.fn(), +})) vi.mock('../supabase/functions/_backend/utils/plans_billing_history.ts', async importOriginal => ({ ...await importOriginal(), loadPlansBillingHistories: vi.fn(), @@ -35,6 +41,9 @@ const ORG_PAID = '00000000-0000-4000-8000-000000000012' const ORG_CANCELED = '00000000-0000-4000-8000-000000000013' const ORG_KNOWN = '00000000-0000-4000-8000-000000000014' const ORG_UNKNOWN = '00000000-0000-4000-8000-000000000015' +const EXPECTED_TRANSITION_QUERY_CONCURRENCY = 4 +const EXPECTED_MAX_PLANS_ORGANIZATIONS = 4_000 +const EXPECTED_MAX_TRANSITION_RESPONSE_BYTES = 2 * 1024 * 1024 function connected(rows: Record[] = []) { return { @@ -51,6 +60,10 @@ function rowsWithLength(length: number): Record[] { return rows } +function organizationIds(length: number): string[] { + return Array.from({ length }, (_, index) => `00000000-0000-4000-8000-${index.toString(16).padStart(12, '0')}`) +} + function behavior(overrides: Record = {}) { return { timestamp_ms: startMs + 60_000, @@ -82,7 +95,7 @@ function history(orgId: string, overrides: Partial = } beforeEach(() => { - vi.clearAllMocks() + vi.resetAllMocks() vi.mocked(loadPlansBillingHistories).mockResolvedValue(new Map()) }) @@ -132,6 +145,7 @@ describe('plans analytics orchestration', () => { ['unconfigured', { configured: false, connected: false, failureReason: 'unconfigured' as const, rows: [] }], ['timeout', { configured: true, connected: false, failureReason: 'timeout' as const, rows: [] }], ['unavailable', { configured: true, connected: false, failureReason: 'unavailable' as const, rows: [] }], + ['too large', { configured: true, connected: true, failureReason: 'too_large' as const, rows: [] }], ])('returns a structured %s state', async (_label, failure) => { vi.mocked(queryPosthogHogql).mockResolvedValue(failure) @@ -197,7 +211,7 @@ describe('plans analytics orchestration', () => { }) it('batches large relevant organization sets deterministically', async () => { - const orgIds = Array.from({ length: 1_001 }, (_, index) => `00000000-0000-4000-8000-${index.toString(16).padStart(12, '0')}`) + const orgIds = organizationIds(1_001) vi.mocked(queryPosthogHogql) .mockResolvedValueOnce(connected(orgIds.map(orgId => behavior({ org_id: orgId })))) .mockResolvedValueOnce(connected()) @@ -216,6 +230,100 @@ describe('plans analytics orchestration', () => { expect(loadPlansBillingHistories).toHaveBeenCalledWith(context, orgIds, '2026-08-01', '2026-08-02', new Map()) }) + it('rejects organization cardinality above the single-wave ceiling before transition or billing work', async () => { + const orgIds = organizationIds(EXPECTED_MAX_PLANS_ORGANIZATIONS + 1) + vi.mocked(queryPosthogHogql).mockResolvedValueOnce(connected(orgIds.map(orgId => behavior({ org_id: orgId })))) + + expect(MAX_PLANS_ORGANIZATIONS).toBe(EXPECTED_MAX_PLANS_ORGANIZATIONS) + const result = await getAdminPlansAnalytics(context, start, end) + + expect(result.dataQuality.posthogFailureReason).toBe('too_large') + expect(queryPosthogHogql).toHaveBeenCalledTimes(1) + expect(loadPlansBillingHistories).not.toHaveBeenCalled() + }) + + it('runs the maximum organization set in one bounded transition-query wave', async () => { + const orgIds = organizationIds(EXPECTED_MAX_PLANS_ORGANIZATIONS) + let activeTransitions = 0 + let maxActiveTransitions = 0 + vi.mocked(queryPosthogHogql).mockImplementation(async (_context, query) => { + if (query.includes('event IN (\'User visit\', \'Checkout Started\')')) + return connected(orgIds.map(orgId => behavior({ org_id: orgId }))) + if (query.includes('SELECT min(timestamp) AS exact_tracking_started_at')) + return connected() + + activeTransitions += 1 + maxActiveTransitions = Math.max(maxActiveTransitions, activeTransitions) + await new Promise(resolve => setTimeout(resolve, 1)) + activeTransitions -= 1 + return connected() + }) + + const result = await getAdminPlansAnalytics(context, start, end) + + expect(TRANSITION_QUERY_CONCURRENCY).toBe(EXPECTED_TRANSITION_QUERY_CONCURRENCY) + expect(MAX_TRANSITION_RESPONSE_BYTES).toBe(EXPECTED_MAX_TRANSITION_RESPONSE_BYTES) + expect(maxActiveTransitions).toBe(EXPECTED_TRANSITION_QUERY_CONCURRENCY) + const transitionCalls = vi.mocked(queryPosthogHogql).mock.calls.filter(([, query]) => query.includes('event IN (\'User subscribe\'')) + expect(transitionCalls).toHaveLength(EXPECTED_TRANSITION_QUERY_CONCURRENCY) + expect(transitionCalls.every(([, , options]) => options?.maxResponseBytes === EXPECTED_MAX_TRANSITION_RESPONSE_BYTES)).toBe(true) + const behaviorCall = vi.mocked(queryPosthogHogql).mock.calls.find(([, query]) => query.includes('event IN (\'User visit\'')) + const boundaryCall = vi.mocked(queryPosthogHogql).mock.calls.find(([, query]) => query.includes('SELECT min(timestamp)')) + expect(behaviorCall?.[2]).toBeUndefined() + expect(boundaryCall?.[2]).toBeUndefined() + expect(result.dataQuality.posthogFailureReason).toBeNull() + expect(loadPlansBillingHistories).toHaveBeenCalledWith(context, orgIds, '2026-08-01', '2026-08-02', new Map()) + }) + + it('fails closed after a concurrent transition batch fails', async () => { + const orgIds = organizationIds(EXPECTED_MAX_PLANS_ORGANIZATIONS) + vi.mocked(queryPosthogHogql).mockImplementation(async (_context, query) => { + if (query.includes('event IN (\'User visit\', \'Checkout Started\')')) + return connected(orgIds.map(orgId => behavior({ org_id: orgId }))) + if (query.includes(orgIds[1_000])) + return { configured: true, connected: false, failureReason: 'timeout', rows: [] } + return connected() + }) + + const result = await getAdminPlansAnalytics(context, start, end) + + expect(result.dataQuality).toMatchObject({ posthogConnected: false, posthogFailureReason: 'timeout' }) + expect(queryPosthogHogql).toHaveBeenCalledTimes(1 + EXPECTED_TRANSITION_QUERY_CONCURRENCY) + expect(loadPlansBillingHistories).not.toHaveBeenCalled() + }) + + it('fails closed when a transition response exceeds its share of the wave budget', async () => { + vi.mocked(queryPosthogHogql) + .mockResolvedValueOnce(connected([behavior()])) + .mockResolvedValueOnce({ configured: true, connected: true, failureReason: 'too_large', rows: [] }) + + const result = await getAdminPlansAnalytics(context, start, end) + + expect(result.dataQuality).toMatchObject({ posthogConnected: true, posthogFailureReason: 'too_large' }) + expect(queryPosthogHogql).toHaveBeenCalledTimes(2) + expect(vi.mocked(queryPosthogHogql).mock.calls[1]?.[2]).toEqual({ maxResponseBytes: EXPECTED_MAX_TRANSITION_RESPONSE_BYTES }) + expect(loadPlansBillingHistories).not.toHaveBeenCalled() + }) + + it('applies the global transition row ceiling across concurrent batches', async () => { + const orgIds = organizationIds(2_000) + let transitionBatch = 0 + vi.mocked(queryPosthogHogql).mockImplementation(async (_context, query) => { + if (query.includes('event IN (\'User visit\', \'Checkout Started\')')) + return connected(orgIds.map(orgId => behavior({ org_id: orgId }))) + if (query.includes('SELECT min(timestamp) AS exact_tracking_started_at')) + return connected() + transitionBatch += 1 + return connected(rowsWithLength(transitionBatch === 1 ? 100_001 : 100_000)) + }) + + const result = await getAdminPlansAnalytics(context, start, end) + + expect(result.dataQuality.posthogFailureReason).toBe('too_large') + expect(queryPosthogHogql).toHaveBeenCalledTimes(3) + expect(loadPlansBillingHistories).not.toHaveBeenCalled() + }) + it('prevents unrelated global transitions from consuming the row ceiling', async () => { vi.mocked(queryPosthogHogql).mockImplementation(async (_context, query) => { if (query.includes('event IN (\'User visit\', \'Checkout Started\')')) diff --git a/tests/posthog-read.unit.test.ts b/tests/posthog-read.unit.test.ts index 45b505edd5..f7e3554b36 100644 --- a/tests/posthog-read.unit.test.ts +++ b/tests/posthog-read.unit.test.ts @@ -1,6 +1,8 @@ import type { Context } from 'hono' import { afterEach, describe, expect, it, vi } from 'vitest' -import { queryPosthogHogql } from '../supabase/functions/_backend/utils/posthog_read.ts' +import { isPosthogReadConfigured, MAX_POSTHOG_RESPONSE_BYTES, queryPosthogHogql } from '../supabase/functions/_backend/utils/posthog_read.ts' + +const EXPECTED_MAX_POSTHOG_RESPONSE_BYTES = 8 * 1024 * 1024 vi.mock('hono/adapter', async (importOriginal) => { const actual = await importOriginal() @@ -17,6 +19,15 @@ function context(environment: Record = {}): Context } as unknown as Context } +function posthogEnv(overrides: Record = {}): Record { + return { + POSTHOG_READ_KEY: 'read-key', + POSTHOG_READ_HOST: 'https://eu.posthog.com', + POSTHOG_READ_PROJECT_ID: '22029', + ...overrides, + } +} + afterEach(() => { vi.unstubAllGlobals() vi.restoreAllMocks() @@ -36,6 +47,38 @@ describe('postHog read transport', () => { expect(fetchMock).not.toHaveBeenCalled() }) + it.each([ + ['key', posthogEnv({ POSTHOG_READ_KEY: '' })], + ['host override without project', posthogEnv({ POSTHOG_READ_PROJECT_ID: '' })], + ['project override without host', posthogEnv({ POSTHOG_READ_HOST: '' })], + ])('does not fetch when the %s part of the read configuration is missing', async (_missing, environment) => { + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + + await expect(queryPosthogHogql(context(environment), 'SELECT 1')).resolves.toEqual({ + configured: false, + connected: false, + failureReason: 'unconfigured', + rows: [], + }) + expect(isPosthogReadConfigured(context(environment))).toBe(false) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('preserves the established production defaults for a key-only configuration', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ columns: [], results: [] }), { status: 200 })) + vi.stubGlobal('fetch', fetchMock) + const environment = { POSTHOG_READ_KEY: ' read-key ' } + + expect(isPosthogReadConfigured(context(environment))).toBe(true) + await expect(queryPosthogHogql(context(environment), 'SELECT 1')).resolves.toMatchObject({ + configured: true, + connected: true, + failureReason: null, + }) + expect(fetchMock).toHaveBeenCalledWith('https://eu.posthog.com/api/projects/22029/query/', expect.anything()) + }) + it('maps successful columns and results to row objects', async () => { const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ columns: ['org_id', 'opens'], @@ -43,7 +86,12 @@ describe('postHog read transport', () => { }), { status: 200, headers: { 'content-type': 'application/json' } })) vi.stubGlobal('fetch', fetchMock) - await expect(queryPosthogHogql(context({ POSTHOG_READ_KEY: ' read-key ' }), 'SELECT org_id, opens')).resolves.toEqual({ + expect(isPosthogReadConfigured(context(posthogEnv()))).toBe(true) + await expect(queryPosthogHogql(context(posthogEnv({ + POSTHOG_READ_KEY: ' read-key ', + POSTHOG_READ_HOST: ' https://eu.posthog.com/ ', + POSTHOG_READ_PROJECT_ID: ' 22029 ', + })), 'SELECT org_id, opens')).resolves.toEqual({ configured: true, connected: true, failureReason: null, @@ -57,11 +105,99 @@ describe('postHog read transport', () => { })) }) + it('accepts a response body exactly at the byte ceiling', async () => { + const payload = JSON.stringify({ columns: ['value'], results: [['ok']] }) + const body = `${payload}${' '.repeat(EXPECTED_MAX_POSTHOG_RESPONSE_BYTES - payload.length)}` + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(body, { status: 200 }))) + + expect(MAX_POSTHOG_RESPONSE_BYTES).toBe(EXPECTED_MAX_POSTHOG_RESPONSE_BYTES) + await expect(queryPosthogHogql(context(posthogEnv()), 'SELECT 1')).resolves.toEqual({ + configured: true, + connected: true, + failureReason: null, + rows: [{ value: 'ok' }], + }) + }) + + it('enforces a smaller caller response budget without reducing the default budget', async () => { + const callerBudget = MAX_POSTHOG_RESPONSE_BYTES / 4 + const payload = JSON.stringify({ columns: [], results: [] }) + const body = `${payload}${' '.repeat(callerBudget + 1 - payload.length)}` + const fetchMock = vi.fn() + .mockResolvedValueOnce(new Response(body, { status: 200 })) + .mockResolvedValueOnce(new Response(body, { status: 200 })) + vi.stubGlobal('fetch', fetchMock) + + await expect(queryPosthogHogql(context(posthogEnv()), 'SELECT 1', { maxResponseBytes: callerBudget })).resolves.toMatchObject({ + connected: true, + failureReason: 'too_large', + rows: [], + }) + await expect(queryPosthogHogql(context(posthogEnv()), 'SELECT 1')).resolves.toMatchObject({ + connected: true, + failureReason: null, + rows: [], + }) + }) + + it('rejects an oversized declared response before reading its body', async () => { + let bodyRead = false + const response = { + ok: true, + status: 200, + headers: new Headers({ 'content-length': String(EXPECTED_MAX_POSTHOG_RESPONSE_BYTES + 1) }), + get body() { + bodyRead = true + throw new Error('oversized body should not be read') + }, + json: async () => { + bodyRead = true + return { columns: [], results: [] } + }, + } as unknown as Response + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response)) + + await expect(queryPosthogHogql(context(posthogEnv()), 'SELECT 1')).resolves.toEqual({ + configured: true, + connected: true, + failureReason: 'too_large', + rows: [], + }) + expect(bodyRead).toBe(false) + }) + + it('cancels a chunked response as soon as it crosses the byte ceiling', async () => { + const chunks = [ + new Uint8Array(EXPECTED_MAX_POSTHOG_RESPONSE_BYTES / 2), + new Uint8Array(EXPECTED_MAX_POSTHOG_RESPONSE_BYTES / 2 + 1), + ] + let cancelled = false + const body = new ReadableStream({ + pull(controller) { + const chunk = chunks.shift() + if (chunk) + controller.enqueue(chunk) + }, + cancel() { + cancelled = true + }, + }) + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(body, { status: 200 }))) + + await expect(queryPosthogHogql(context(posthogEnv()), 'SELECT 1')).resolves.toEqual({ + configured: true, + connected: true, + failureReason: 'too_large', + rows: [], + }) + expect(cancelled).toBe(true) + }) + it('reports PostHog HTTP failures as unavailable', async () => { vi.spyOn(console, 'error').mockImplementation(() => {}) vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 503 }))) - await expect(queryPosthogHogql(context({ POSTHOG_READ_KEY: 'read-key' }), 'SELECT 1')).resolves.toEqual({ + await expect(queryPosthogHogql(context(posthogEnv()), 'SELECT 1')).resolves.toEqual({ configured: true, connected: false, failureReason: 'unavailable', @@ -74,7 +210,7 @@ describe('postHog read transport', () => { const timeoutError = Object.assign(new Error('timed out'), { name: 'TimeoutError' }) vi.stubGlobal('fetch', vi.fn().mockRejectedValue(timeoutError)) - await expect(queryPosthogHogql(context({ POSTHOG_READ_KEY: 'read-key' }), 'SELECT 1')).resolves.toEqual({ + await expect(queryPosthogHogql(context(posthogEnv()), 'SELECT 1')).resolves.toEqual({ configured: true, connected: false, failureReason: 'timeout', From 458e588ad9d9d41baf84f071e108858913fe2f35 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Mon, 10 Aug 2026 11:39:24 +0200 Subject: [PATCH 15/19] fix(admin): scope plans visit query --- .../plans/2026-08-10-plans-analytics-dashboard.md | 3 ++- .../functions/_backend/utils/plans_analytics.ts | 1 + .../_backend/utils/plans_billing_history.ts | 4 ---- tests/plans-analytics-orchestration.unit.test.ts | 15 ++++++++++++++- 4 files changed, 17 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/plans/2026-08-10-plans-analytics-dashboard.md b/docs/superpowers/plans/2026-08-10-plans-analytics-dashboard.md index 07ff93ae20..0287e66449 100644 --- a/docs/superpowers/plans/2026-08-10-plans-analytics-dashboard.md +++ b/docs/superpowers/plans/2026-08-10-plans-analytics-dashboard.md @@ -727,7 +727,7 @@ export interface OrganizationBillingHistory { } ``` -Implement `endingMrr()`, `hasCreditsAt()`, `paidStateAt()`, and `classifyPlansBillingAt()`. Never read the mutable current Stripe status as historical truth. +Implement `endingMrr()`, `hasCreditsAt()`, an internal `billingEvidenceAt()`, and the exported `classifyPlansBillingAt()`. Never read the mutable current Stripe status as historical truth; do not expose a redundant paid-state wrapper. - [ ] **Step 4: Implement the exact precedence** @@ -973,6 +973,7 @@ FROM events WHERE event IN ('User visit', 'Checkout Started') AND ( (event = 'User visit' + AND properties.page = 'plans' AND timestamp >= parseDateTimeBestEffort('2026-07-31T23:59:30.000Z') AND timestamp < parseDateTimeBestEffort('2026-08-02T00:00:00.000Z')) OR diff --git a/supabase/functions/_backend/utils/plans_analytics.ts b/supabase/functions/_backend/utils/plans_analytics.ts index 72c71540cb..a6d71ec2bb 100644 --- a/supabase/functions/_backend/utils/plans_analytics.ts +++ b/supabase/functions/_backend/utils/plans_analytics.ts @@ -123,6 +123,7 @@ FROM events WHERE event IN ('User visit', 'Checkout Started') AND ( (event = 'User visit' + AND properties.page = 'plans' AND timestamp >= parseDateTimeBestEffort(${sqlString(queryStart)}) AND timestamp < parseDateTimeBestEffort(${sqlString(range.endIso)})) OR diff --git a/supabase/functions/_backend/utils/plans_billing_history.ts b/supabase/functions/_backend/utils/plans_billing_history.ts index 6c8b1abcb3..d3d9952702 100644 --- a/supabase/functions/_backend/utils/plans_billing_history.ts +++ b/supabase/functions/_backend/utils/plans_billing_history.ts @@ -295,10 +295,6 @@ function billingEvidenceAt(history: BillingHistoryEvidence, timestampMs: number) } } -export function paidStateAt(history: BillingHistoryEvidence, timestampMs: number): HistoricalPaidState { - return billingEvidenceAt(history, timestampMs).paidState -} - export function classifyPlansBillingAt(history: BillingHistoryEvidence, timestampMs: number) { const evidence = billingEvidenceAt(history, timestampMs) diff --git a/tests/plans-analytics-orchestration.unit.test.ts b/tests/plans-analytics-orchestration.unit.test.ts index 4da85c0996..854526ced2 100644 --- a/tests/plans-analytics-orchestration.unit.test.ts +++ b/tests/plans-analytics-orchestration.unit.test.ts @@ -106,7 +106,7 @@ describe('plans analytics query construction', () => { expect(behaviorQuery).toContain('event IN (\'User visit\', \'Checkout Started\')') expect(behaviorQuery).toContain('2026-07-31T23:59:30.000Z') expect(behaviorQuery).toContain('2026-08-03T00:00:00.000Z') - expect(behaviorQuery).toContain('event = \'User visit\'\n AND timestamp >= parseDateTimeBestEffort(\'2026-07-31T23:59:30.000Z\')\n AND timestamp < parseDateTimeBestEffort(\'2026-08-02T00:00:00.000Z\')') + expect(behaviorQuery).toContain('event = \'User visit\'\n AND properties.page = \'plans\'\n AND timestamp >= parseDateTimeBestEffort(\'2026-07-31T23:59:30.000Z\')\n AND timestamp < parseDateTimeBestEffort(\'2026-08-02T00:00:00.000Z\')') expect(behaviorQuery).toContain('event = \'Checkout Started\'\n AND timestamp >= parseDateTimeBestEffort(\'2026-08-01T00:00:00.000Z\')\n AND timestamp < parseDateTimeBestEffort(\'2026-08-03T00:00:00.000Z\')') expect(behaviorQuery).toContain('LIMIT 200001') expect(behaviorQuery).not.toMatch(/SELECT\s+properties\b/i) @@ -131,6 +131,19 @@ describe('plans analytics query construction', () => { expect(exactQuery).toContain('LIMIT 200001') }) + it.concurrent('filters non-Plans visit noise before it can consume the PostHog row ceiling', () => { + const behaviorQuery = buildPlansBehaviorQuery(start, end) + const visitBranchStart = behaviorQuery.indexOf('(event = \'User visit\'') + const checkoutBranchStart = behaviorQuery.indexOf('(event = \'Checkout Started\'') + const limit = behaviorQuery.indexOf(`LIMIT ${MAX_POSTHOG_ROWS + 1}`) + const visitBranch = behaviorQuery.slice(visitBranchStart, checkoutBranchStart) + + expect(visitBranchStart).toBeGreaterThan(-1) + expect(checkoutBranchStart).toBeGreaterThan(visitBranchStart) + expect(limit).toBeGreaterThan(checkoutBranchStart) + expect(visitBranch).toContain("properties.page = 'plans'") + }) + it.concurrent('escapes date scalar literals and validates dates before constructing queries', () => { expect(buildPlansBehaviorQuery('2026-08-01T00:00:00.000Z\' OR 1=1', end)).toBe('') expect(buildBillingTransitionsQuery('not-a-date', [ORG_A])).toBe('') From 86bb39da500c78a1f82e2873ae9d1d895e12c15b Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Mon, 10 Aug 2026 11:43:20 +0200 Subject: [PATCH 16/19] fix(admin): clarify plans analytics timeout --- .../superpowers/plans/2026-08-10-plans-analytics-dashboard.md | 2 +- .../specs/2026-08-10-plans-analytics-dashboard-design.md | 4 ++-- messages/en.json | 2 +- tests/admin-plans-analytics-dashboard.unit.test.ts | 3 ++- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/plans/2026-08-10-plans-analytics-dashboard.md b/docs/superpowers/plans/2026-08-10-plans-analytics-dashboard.md index 0287e66449..d669c75c6f 100644 --- a/docs/superpowers/plans/2026-08-10-plans-analytics-dashboard.md +++ b/docs/superpowers/plans/2026-08-10-plans-analytics-dashboard.md @@ -1256,7 +1256,7 @@ Use these exact English values: "plans-analytics-partial-warning": "Some organizations could not be classified from historical billing records and appear as Unknown.", "plans-analytics-legacy-unavailable": "Legacy Plans visits are unavailable because no event-time pathname could be verified.", "plans-analytics-posthog-unconfigured": "PostHog analytics is not configured.", - "plans-analytics-posthog-timeout": "This range was too large to process. Select a shorter period and try again.", + "plans-analytics-posthog-timeout": "Plans analytics timed out. Try again, or select a shorter period.", "plans-analytics-range-too-large": "This range returned too much data to process. Select a shorter period and try again.", "plans-analytics-unavailable": "Plans analytics is temporarily unavailable.", "plans-analytics-empty": "No Plans visits were recorded in this period." diff --git a/docs/superpowers/specs/2026-08-10-plans-analytics-dashboard-design.md b/docs/superpowers/specs/2026-08-10-plans-analytics-dashboard-design.md index 6bed12fe30..32d064e2dd 100644 --- a/docs/superpowers/specs/2026-08-10-plans-analytics-dashboard-design.md +++ b/docs/superpowers/specs/2026-08-10-plans-analytics-dashboard-design.md @@ -231,7 +231,7 @@ Add a **Plans** tab and a dedicated admin dashboard page. The page uses the exis - Refresh uses the existing manual refresh control and invalidates the five-minute cache. - There is no automatic refresh or automatic retry. -For an extreme custom range that times out, retain the selection and show: “This range was too large to process. Select a shorter period and try again.” +For a timed-out request, retain the selection and show: “Plans analytics timed out. Try again, or select a shorter period.” Keep this distinct from the `too_large` response, which tells the admin that the result exceeded a bounded row or byte ceiling. ## Error Handling and Observability @@ -280,7 +280,7 @@ Unit tests cover: Backend tests cover admin authorization, request validation, PostHog unconfigured/unavailable/timeout behavior, valid empty data, and the complete response contract using mocked PostHog responses. -Frontend tests cover loading, populated graphs, valid empty data, partial-data warnings, large-range timeout messaging, unavailable state, UTC labeling, and the checkout-completion documentation link. +Frontend tests cover loading, populated graphs, valid empty data, partial-data warnings, timeout recovery messaging distinct from `too_large`, unavailable state, UTC labeling, and the checkout-completion documentation link. Before handoff, run focused tests, lint, type checking, and the production build. Validate the 30-second legacy threshold against the real historical gap distribution before considering the analytics numerically trustworthy. diff --git a/messages/en.json b/messages/en.json index 95b5944a7d..2570404acb 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2009,7 +2009,7 @@ "plans-analytics-partial-warning": "Some organizations could not be classified from historical billing records and appear as Unknown.", "plans-analytics-legacy-unavailable": "Legacy Plans visits are unavailable because no event-time pathname could be verified.", "plans-analytics-posthog-unconfigured": "PostHog analytics is not configured.", - "plans-analytics-posthog-timeout": "This range was too large to process. Select a shorter period and try again.", + "plans-analytics-posthog-timeout": "Plans analytics timed out. Try again, or select a shorter period.", "plans-analytics-range-too-large": "This range returned too much data to process. Select a shorter period and try again.", "plans-analytics-unavailable": "Plans analytics is temporarily unavailable.", "plans-analytics-empty": "No Plans visits were recorded in this period.", diff --git a/tests/admin-plans-analytics-dashboard.unit.test.ts b/tests/admin-plans-analytics-dashboard.unit.test.ts index f5027bde40..ab432843bc 100644 --- a/tests/admin-plans-analytics-dashboard.unit.test.ts +++ b/tests/admin-plans-analytics-dashboard.unit.test.ts @@ -53,7 +53,7 @@ const requiredMessages = { 'plans-analytics-partial-warning': 'Some organizations could not be classified from historical billing records and appear as Unknown.', 'plans-analytics-legacy-unavailable': 'Legacy Plans visits are unavailable because no event-time pathname could be verified.', 'plans-analytics-posthog-unconfigured': 'PostHog analytics is not configured.', - 'plans-analytics-posthog-timeout': 'This range was too large to process. Select a shorter period and try again.', + 'plans-analytics-posthog-timeout': 'Plans analytics timed out. Try again, or select a shorter period.', 'plans-analytics-range-too-large': 'This range returned too much data to process. Select a shorter period and try again.', 'plans-analytics-unavailable': 'Plans analytics is temporarily unavailable.', 'plans-analytics-empty': 'No Plans visits were recorded in this period.', @@ -264,6 +264,7 @@ describe('admin Plans analytics dashboard', () => { expect(messages[key]).toEqual(expect.any(String)) } expect(messages['plans-analytics-posthog-timeout']).toBe(requiredMessages['plans-analytics-posthog-timeout']) + expect(messages['plans-analytics-posthog-timeout']).not.toBe(messages['plans-analytics-range-too-large']) const page = await readFile(new URL('../src/pages/admin/dashboard/plans.vue', import.meta.url), 'utf8') expect(page).toContain('layout: admin') From c43fc17125022eaf32e2d12b70babd25fc7fddda Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Tue, 11 Aug 2026 14:33:10 +0200 Subject: [PATCH 17/19] fix(i18n): add plans analytics translation contexts --- messages/en.context.json | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/messages/en.context.json b/messages/en.context.json index bbdfedb451..ac428f374f 100644 --- a/messages/en.context.json +++ b/messages/en.context.json @@ -2098,6 +2098,37 @@ "plan-upgrade": "Used in Capgo web console areas: pages/settings/organization. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "plan-upgrade-v2": "Used in Capgo web console areas: components, components/tables, pages/settings/organization. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "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: analytics chart title. 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 text explaining that checkout-completion reporting is not implemented yet. 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: link label for checkout-completion implementation requirements. 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: analytics chart title. 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 text describing checkout starts attributed to daily Plans visitors. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "plans-analytics-did-not-start": "Used in Capgo web console areas: pages/admin/dashboard. Role: chart series label for organizations that did not start checkout. 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 when no Plans visits exist in the selected period. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "plans-analytics-legacy-unavailable": "Used in Capgo web console areas: pages/admin/dashboard. Role: status message when legacy Plans visits cannot be verified from an event-time pathname. 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: warning that some organizations have an unknown historical billing classification. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "plans-analytics-posthog-timeout": "Used in Capgo web console areas: pages/admin/dashboard. Role: timeout error with recovery guidance for Plans analytics. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "plans-analytics-posthog-unconfigured": "Used in Capgo web console areas: pages/admin/dashboard. Role: status message when PostHog analytics is not configured. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "plans-analytics-range-too-large": "Used in Capgo web console areas: pages/admin/dashboard. Role: error with recovery guidance when the selected analytics range returns too much data. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "plans-analytics-started-checkout": "Used in Capgo web console areas: pages/admin/dashboard. Role: chart series label for organizations that started checkout. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "plans-analytics-timezone": "Used in Capgo web console areas: pages/admin/dashboard. Role: reporting timezone label stating that analytics use UTC. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "plans-analytics-title": "Used in Capgo web console areas: constants, pages/admin/dashboard. Role: admin dashboard page title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "plans-analytics-total-opens": "Used in Capgo web console areas: pages/admin/dashboard. Role: chart series label for total logical Plans page openings. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "plans-analytics-traffic": "Used in Capgo web console areas: pages/admin/dashboard. Role: analytics chart title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "plans-analytics-traffic-description": "Used in Capgo web console areas: pages/admin/dashboard. Role: helper text describing unique visitor organizations and total Plans openings per UTC day. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "plans-analytics-unavailable": "Used in Capgo web console areas: pages/admin/dashboard. Role: temporary-unavailability error for Plans analytics. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "plans-analytics-unique-visitor-orgs": "Used in Capgo web console areas: pages/admin/dashboard. Role: chart series label for organizations on their first Plans visit in the selected period. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "plans-analytics-who-opened": "Used in Capgo web console areas: pages/admin/dashboard. Role: analytics chart title for Plans visitors grouped by billing state. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "plans-analytics-who-opened-checkout": "Used in Capgo web console areas: pages/admin/dashboard. Role: analytics chart title for checkout starters grouped by billing state. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "plans-analytics-who-opened-checkout-description": "Used in Capgo web console areas: pages/admin/dashboard. Role: helper text describing daily checkout starters by billing state at the attributed Plans opening. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "plans-analytics-who-opened-description": "Used in Capgo web console areas: pages/admin/dashboard. Role: helper text describing daily unique Plans visitors by billing state. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "plans-category-active-trial": "Used in Capgo web console areas: pages/admin/dashboard, services. Role: billing-state chart label for an active trial. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "plans-category-canceled": "Used in Capgo web console areas: pages/admin/dashboard, services. Role: billing-state chart label for a canceled subscription. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "plans-category-credits-only": "Used in Capgo web console areas: pages/admin/dashboard, services. Role: billing-state chart label for an organization using only credits. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "plans-category-expired-trial": "Used in Capgo web console areas: pages/admin/dashboard, services. Role: billing-state chart label for an expired trial that never subscribed. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "plans-category-paying": "Used in Capgo web console areas: pages/admin/dashboard, services. Role: billing-state chart label for a paying organization. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "plans-category-payment-problem": "Used in Capgo web console areas: pages/admin/dashboard, services. Role: billing-state chart label for an organization with a payment problem. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "plans-category-unknown": "Used in Capgo web console areas: pages/admin/dashboard, services. Role: billing-state chart label when the historical state cannot be classified. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "plans-super-only": "Used in Capgo web console areas: pages/settings/organization. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "platform": "Used in Capgo web console areas: components/dashboard, components/tables, composables, pages/admin/dashboard, pages/app. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "platform-android": "Used in Capgo web console areas: components/dashboard, components/tables. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", From 90b82a5e21f823abb3960642784a8ee98d910a9f Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Tue, 11 Aug 2026 14:39:26 +0200 Subject: [PATCH 18/19] test(admin): strengthen plans analytics regressions --- .../superpowers/plans/2026-08-10-plans-analytics-dashboard.md | 2 +- tests/admin-plans-analytics-dashboard.unit.test.ts | 4 ++-- tests/posthog-read.unit.test.ts | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/plans/2026-08-10-plans-analytics-dashboard.md b/docs/superpowers/plans/2026-08-10-plans-analytics-dashboard.md index d669c75c6f..5782e79b43 100644 --- a/docs/superpowers/plans/2026-08-10-plans-analytics-dashboard.md +++ b/docs/superpowers/plans/2026-08-10-plans-analytics-dashboard.md @@ -430,7 +430,7 @@ describe('Plans analytics model', () => { : timestampMs < ms('2026-08-02T10:00:00Z') ? 'paying' : 'credits_only', }) expect(result.traffic.uniqueVisitorOrganizations).toEqual([1, 1]) - expect(result.traffic.totalOpens).toEqual([1, 2]) + expect(result.traffic.totalOpens).toEqual([1, 3]) expect(result.visitorBreakdown.map(day => day.total)).toEqual([1, 2]) expect(result.checkoutIntent.map(day => day.startedCheckout + day.didNotStart)).toEqual([1, 2]) expect(result.checkoutVisitorBreakdown.map(day => day.total)).toEqual([0, 1]) diff --git a/tests/admin-plans-analytics-dashboard.unit.test.ts b/tests/admin-plans-analytics-dashboard.unit.test.ts index ab432843bc..a1196c8d9c 100644 --- a/tests/admin-plans-analytics-dashboard.unit.test.ts +++ b/tests/admin-plans-analytics-dashboard.unit.test.ts @@ -259,11 +259,11 @@ describe('admin Plans analytics dashboard', () => { expect(completionDoc).toContain('pending until the agreed observation window') expect(completionDoc).toContain('separate approved design') const messages = JSON.parse(messagesText) as Record - for (const key of Object.keys(requiredMessages)) { + for (const [key, expected] of Object.entries(requiredMessages)) { expect(messages).toHaveProperty(key) expect(messages[key]).toEqual(expect.any(String)) + expect(messages[key]).toBe(expected) } - expect(messages['plans-analytics-posthog-timeout']).toBe(requiredMessages['plans-analytics-posthog-timeout']) expect(messages['plans-analytics-posthog-timeout']).not.toBe(messages['plans-analytics-range-too-large']) const page = await readFile(new URL('../src/pages/admin/dashboard/plans.vue', import.meta.url), 'utf8') diff --git a/tests/posthog-read.unit.test.ts b/tests/posthog-read.unit.test.ts index f7e3554b36..ebae8bfaa8 100644 --- a/tests/posthog-read.unit.test.ts +++ b/tests/posthog-read.unit.test.ts @@ -49,8 +49,8 @@ describe('postHog read transport', () => { it.each([ ['key', posthogEnv({ POSTHOG_READ_KEY: '' })], - ['host override without project', posthogEnv({ POSTHOG_READ_PROJECT_ID: '' })], - ['project override without host', posthogEnv({ POSTHOG_READ_HOST: '' })], + ['project', posthogEnv({ POSTHOG_READ_PROJECT_ID: '' })], + ['host', posthogEnv({ POSTHOG_READ_HOST: '' })], ])('does not fetch when the %s part of the read configuration is missing', async (_missing, environment) => { const fetchMock = vi.fn() vi.stubGlobal('fetch', fetchMock) From 052ead907a7056f05db6e6c8d1a95a825d03498f Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Tue, 11 Aug 2026 14:58:14 +0200 Subject: [PATCH 19/19] test(observe): accept native empty state --- playwright/e2e/observe-tabs.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/playwright/e2e/observe-tabs.spec.ts b/playwright/e2e/observe-tabs.spec.ts index cc356a033d..146e7e9265 100644 --- a/playwright/e2e/observe-tabs.spec.ts +++ b/playwright/e2e/observe-tabs.spec.ts @@ -46,6 +46,7 @@ test.describe('Observe sections', () => { await nativeTab.click() await expect(page).toHaveURL(/\/app\/com\.demo\.app\/observe\/native(?:\?|$)/) - await expect(page.getByRole('heading', { name: 'All versions summary', exact: true })).toBeVisible() + await expect(nativeTab).toHaveAttribute('aria-current', 'page') + await expect(page.getByRole('heading', { name: 'Observe', exact: true, level: 1 })).toBeVisible() }) })