From 317674834e0ca9c1a1a5bbb36271d1c319f447d8 Mon Sep 17 00:00:00 2001 From: Fayner Brack Date: Mon, 6 Jul 2026 00:08:35 +1000 Subject: [PATCH] feat(observability): instrument the checkout funnel with started/completed/failed events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Emit three subscriptions-stream events from the hutch web app: checkout_started (POST /account/subscribe, all three Stripe-Checkout entry paths with a variant), checkout_completed (GET /auth/checkout/success paid path — the missing paid-conversion event), and checkout_return_failed (each failure branch with a reason). Reuses the existing initEmitSubscriptionEvent factory and SUBSCRIPTION_EVENTS constants; the composition root supplies a typed subscription logger like conversionLogger. Adds a "Checkout funnel per day" dashboard widget sourced from the hutch handler log group (widget count 27 → 28). Claude-Session: https://claude.ai/code/session_01En2uUiiwQoRQKWenCnkPi9 --- projects/hutch/src/runtime/app.ts | 2 + .../observability/analytics-dashboard.test.ts | 18 ++++- .../observability/analytics-dashboard.ts | 21 +++++ .../hutch/src/runtime/observability/events.ts | 21 +++++ .../observability/subscription-events.test.ts | 75 ++++++++++++++++++ .../observability/subscription-events.ts | 50 +++++++++++- projects/hutch/src/runtime/server.ts | 12 +++ projects/hutch/src/runtime/test-app.ts | 18 ++++- .../hutch/src/runtime/web/auth/auth.page.ts | 76 +++++++++++++------ .../web/auth/checkout-success.route.test.ts | 38 +++++++++- .../runtime/web/pages/account/account.page.ts | 20 ++++- .../web/pages/account/account.route.test.ts | 27 +++++++ 12 files changed, 347 insertions(+), 31 deletions(-) diff --git a/projects/hutch/src/runtime/app.ts b/projects/hutch/src/runtime/app.ts index 4a66528da..a3948e34d 100644 --- a/projects/hutch/src/runtime/app.ts +++ b/projects/hutch/src/runtime/app.ts @@ -111,6 +111,7 @@ import { createApp } from "./server"; import { initChangelogBannerSource } from "./web/changelog-banner-source"; import type { BotDefenseEvent } from "./web/auth/auth.page"; import type { ConversionEvent } from "./conversions"; +import type { SubscriptionLogEvent } from "./observability/subscription-events"; import type { AnalyticsEvent } from "@packages/web-analytics"; import { httpErrorMessageMapping } from "./web/pages/queue/queue.error"; import { initFoundingAllocation } from "./web/shared/founding-progress/founding-allocation"; @@ -734,6 +735,7 @@ export function createHutchApp(deps?: { now: () => new Date(), botDefenseLogger: HutchLogger.fromJSON(), conversionLogger: HutchLogger.fromJSON(), + subscriptionLogger: HutchLogger.fromJSON(), analytics: analyticsLogger, salt, foundingAllocation: initFoundingAllocation({ foundingMemberLimit }), diff --git a/projects/hutch/src/runtime/observability/analytics-dashboard.test.ts b/projects/hutch/src/runtime/observability/analytics-dashboard.test.ts index 8f84841f6..c56c8b2f5 100644 --- a/projects/hutch/src/runtime/observability/analytics-dashboard.test.ts +++ b/projects/hutch/src/runtime/observability/analytics-dashboard.test.ts @@ -70,9 +70,9 @@ function collectReferencedEvents(): Set { } describe("buildAnalyticsDashboardBody — drift prevention", () => { - it("emits 28 widgets (7 traffic+audience, 3 conversions, 3 imports+medium, 3 subscriptions, 2 view-funnel, 1 internal-clicks, 3 save-funnel, 1 summary-engagement, 2 audience-device, 1 errors, 1 homepage-ab, 1 blog-traffic) — adding or dropping one without updating this count is a deliberate signal to review the dashboard's scope", () => { + it("emits 29 widgets (7 traffic+audience, 3 conversions, 3 imports+medium, 3 subscriptions, 2 view-funnel, 1 internal-clicks, 3 save-funnel, 1 summary-engagement, 2 audience-device, 1 errors, 1 homepage-ab, 1 blog-traffic, 1 checkout-funnel) — adding or dropping one without updating this count is a deliberate signal to review the dashboard's scope", () => { const body = buildBody(); - expect(body.widgets).toHaveLength(28); + expect(body.widgets).toHaveLength(29); }); it("the homepage A/B widget compares arms by distinct visitors (assignment is sticky per browser, so raw counts pile a returning visitor's landings onto one arm) with raw landings alongside, grouped by variant (utm_content)", () => { @@ -232,7 +232,10 @@ describe("buildAnalyticsDashboardBody — drift prevention", () => { }); it("queries spanning subscription Lambda log groups emit one `SOURCE ''` per group joined by `|` — `logGroups(namePrefix: [...])` is a CLI-only form the dashboard renderer rejects", () => { - const subscriptionQueries = widgetQueries().filter((q) => q.includes(`"${STREAMS.subscriptions}"`)); + const hutchPrefix = `SOURCE '${LOG_GROUPS.hutchHandler}' | `; + const subscriptionQueries = widgetQueries().filter( + (q) => q.includes(`"${STREAMS.subscriptions}"`) && !q.startsWith(hutchPrefix), + ); const expectedSourcePrefix = `${SUBSCRIPTION_DASHBOARD_LOG_GROUPS.map((name) => `SOURCE '${name}'`).join(" | ")} | `; for (const q of subscriptionQueries) { expect(q.startsWith(expectedSourcePrefix)).toBe(true); @@ -240,6 +243,15 @@ describe("buildAnalyticsDashboardBody — drift prevention", () => { expect(subscriptionQueries.length).toBeGreaterThan(0); }); + it("the checkout-funnel widget sources the hutch handler log group — checkout events are emitted by the web app, not the subscription Lambdas", () => { + const q = widgetQueries().find((x) => x.includes(SUBSCRIPTION_EVENTS.checkoutStarted)); + expect(q).toBeDefined(); + expect(q?.startsWith(`SOURCE '${LOG_GROUPS.hutchHandler}' | `)).toBe(true); + expect(q).toContain(SUBSCRIPTION_EVENTS.checkoutCompleted); + expect(q).toContain(SUBSCRIPTION_EVENTS.checkoutReturnFailed); + expect(q).toContain("stats count(*) as checkouts by bin(1d), event"); + }); + it("widget positions do not overlap so every chart is visible side-by-side, not stacked", () => { const body = buildBody(); for (let i = 0; i < body.widgets.length; i++) { diff --git a/projects/hutch/src/runtime/observability/analytics-dashboard.ts b/projects/hutch/src/runtime/observability/analytics-dashboard.ts index 0fc2b1bfc..e00243526 100644 --- a/projects/hutch/src/runtime/observability/analytics-dashboard.ts +++ b/projects/hutch/src/runtime/observability/analytics-dashboard.ts @@ -618,6 +618,27 @@ export function buildAnalyticsDashboardBody(deps: BuildAnalyticsDashboardDeps): }), ); + // --- Checkout funnel --- + // These events are emitted by the hutch web app (POST /account/subscribe and + // GET /auth/checkout/success), not the subscription Lambdas, so the widget + // sources the hutch handler log group. + + widgets.push( + logWidget({ + region, + title: "Checkout funnel per day", + logGroupNames: [hutchLogGroupName], + query: [ + "fields @timestamp, event", + `| filter stream = "${STREAMS.subscriptions}"`, + `| filter event in ["${SUBSCRIPTION_EVENTS.checkoutStarted}", "${SUBSCRIPTION_EVENTS.checkoutCompleted}", "${SUBSCRIPTION_EVENTS.checkoutReturnFailed}"]`, + "| stats count(*) as checkouts by bin(1d), event", + ].join(" "), + x: 0, y: 130, width: 12, height: 8, + view: "timeSeries", + }), + ); + return { widgets }; } diff --git a/projects/hutch/src/runtime/observability/events.ts b/projects/hutch/src/runtime/observability/events.ts index bc0341073..d5170946e 100644 --- a/projects/hutch/src/runtime/observability/events.ts +++ b/projects/hutch/src/runtime/observability/events.ts @@ -15,8 +15,29 @@ export const SUBSCRIPTION_EVENTS = { chargeSucceeded: "charge_succeeded", chargeFailed: "charge_failed", cancelled: "cancelled", + checkoutStarted: "checkout_started", + checkoutCompleted: "checkout_completed", + checkoutReturnFailed: "checkout_return_failed", } as const; +export const CHECKOUT_VARIANTS = { + trialCheckout: "trial_checkout", + cancelledResubscribe: "cancelled_resubscribe", + cardDeclineFallback: "card_decline_fallback", +} as const; + +export type CheckoutVariant = (typeof CHECKOUT_VARIANTS)[keyof typeof CHECKOUT_VARIANTS]; + +export const CHECKOUT_RETURN_FAILURE_REASONS = { + invalidQuery: "invalid_query", + sessionNotFound: "session_not_found", + notPaid: "not_paid", + replayed: "replayed", +} as const; + +export type CheckoutReturnFailureReason = + (typeof CHECKOUT_RETURN_FAILURE_REASONS)[keyof typeof CHECKOUT_RETURN_FAILURE_REASONS]; + export const METRICS = { importsCompleted: { namespace: "Readplace/Imports", diff --git a/projects/hutch/src/runtime/observability/subscription-events.test.ts b/projects/hutch/src/runtime/observability/subscription-events.test.ts index 5f912239a..2a165e01b 100644 --- a/projects/hutch/src/runtime/observability/subscription-events.test.ts +++ b/projects/hutch/src/runtime/observability/subscription-events.test.ts @@ -1,5 +1,6 @@ import { UserIdSchema } from "@packages/domain/user"; import type { HutchLogger } from "@packages/hutch-logger"; +import { CHECKOUT_RETURN_FAILURE_REASONS, CHECKOUT_VARIANTS } from "./events"; import { initEmitSubscriptionEvent, type SubscriptionLogEvent } from "./subscription-events"; function createCapturingLogger(): { @@ -84,4 +85,78 @@ describe("initEmitSubscriptionEvent", () => { reason: "user_initiated_trial", }); }); + + it("emits a checkout_started event carrying the variant and checkout session id so the funnel can attribute the click", () => { + const { logger, captured } = createCapturingLogger(); + const emit = initEmitSubscriptionEvent({ logger, now: NOW }); + + emit.checkoutStarted({ + userId: USER_ID, + variant: CHECKOUT_VARIANTS.trialCheckout, + checkoutSessionId: "cs_test_1", + }); + + expect(captured).toEqual([{ + stream: "subscriptions", + event: "checkout_started", + timestamp: "2026-05-25T10:00:00.000Z", + user_id: USER_ID, + variant: "trial_checkout", + checkout_session_id: "cs_test_1", + }]); + }); + + it("emits a checkout_completed event with the subscription and checkout session ids — the missing paid-conversion signal", () => { + const { logger, captured } = createCapturingLogger(); + const emit = initEmitSubscriptionEvent({ logger, now: NOW }); + + emit.checkoutCompleted({ + userId: USER_ID, + subscriptionId: "sub_123", + checkoutSessionId: "cs_test_1", + }); + + expect(captured).toEqual([{ + stream: "subscriptions", + event: "checkout_completed", + timestamp: "2026-05-25T10:00:00.000Z", + user_id: USER_ID, + subscription_id: "sub_123", + checkout_session_id: "cs_test_1", + }]); + }); + + it("emits a checkout_return_failed event with user_id and checkout_session_id when both are known", () => { + const { logger, captured } = createCapturingLogger(); + const emit = initEmitSubscriptionEvent({ logger, now: NOW }); + + emit.checkoutReturnFailed({ + reason: CHECKOUT_RETURN_FAILURE_REASONS.notPaid, + userId: USER_ID, + checkoutSessionId: "cs_test_1", + }); + + expect(captured).toEqual([{ + stream: "subscriptions", + event: "checkout_return_failed", + timestamp: "2026-05-25T10:00:00.000Z", + reason: "not_paid", + user_id: USER_ID, + checkout_session_id: "cs_test_1", + }]); + }); + + it("omits user_id and checkout_session_id from checkout_return_failed when neither is known (anonymous return with no parseable session)", () => { + const { logger, captured } = createCapturingLogger(); + const emit = initEmitSubscriptionEvent({ logger, now: NOW }); + + emit.checkoutReturnFailed({ reason: CHECKOUT_RETURN_FAILURE_REASONS.invalidQuery }); + + expect(captured[0]).toEqual({ + stream: "subscriptions", + event: "checkout_return_failed", + timestamp: "2026-05-25T10:00:00.000Z", + reason: "invalid_query", + }); + }); }); diff --git a/projects/hutch/src/runtime/observability/subscription-events.ts b/projects/hutch/src/runtime/observability/subscription-events.ts index 267be3964..41931a1be 100644 --- a/projects/hutch/src/runtime/observability/subscription-events.ts +++ b/projects/hutch/src/runtime/observability/subscription-events.ts @@ -1,20 +1,38 @@ import type { UserId } from "@packages/domain/user"; import type { HutchLogger } from "@packages/hutch-logger"; import { STREAMS, SUBSCRIPTION_EVENTS } from "./events"; +import type { CheckoutReturnFailureReason, CheckoutVariant } from "./events"; export interface SubscriptionLogEvent { stream: typeof STREAMS.subscriptions; event: (typeof SUBSCRIPTION_EVENTS)[keyof typeof SUBSCRIPTION_EVENTS]; timestamp: string; - user_id: UserId; + user_id?: UserId; subscription_id?: string; reason?: string; + variant?: CheckoutVariant; + checkout_session_id?: string; } export interface EmitSubscriptionEvent { chargeSucceeded: (params: { userId: UserId; subscriptionId: string }) => void; chargeFailed: (params: { userId: UserId; reason: string }) => void; cancelled: (params: { userId: UserId; reason: string; subscriptionId?: string }) => void; + checkoutStarted: (params: { + userId: UserId; + variant: CheckoutVariant; + checkoutSessionId: string; + }) => void; + checkoutCompleted: (params: { + userId: UserId; + subscriptionId: string; + checkoutSessionId: string; + }) => void; + checkoutReturnFailed: (params: { + reason: CheckoutReturnFailureReason; + userId?: UserId; + checkoutSessionId?: string; + }) => void; } export function initEmitSubscriptionEvent(deps: { @@ -50,5 +68,35 @@ export function initEmitSubscriptionEvent(deps: { ...(subscriptionId ? { subscription_id: subscriptionId } : {}), }); }, + checkoutStarted: ({ userId, variant, checkoutSessionId }) => { + deps.logger.info({ + stream: STREAMS.subscriptions, + event: SUBSCRIPTION_EVENTS.checkoutStarted, + timestamp: deps.now().toISOString(), + user_id: userId, + variant, + checkout_session_id: checkoutSessionId, + }); + }, + checkoutCompleted: ({ userId, subscriptionId, checkoutSessionId }) => { + deps.logger.info({ + stream: STREAMS.subscriptions, + event: SUBSCRIPTION_EVENTS.checkoutCompleted, + timestamp: deps.now().toISOString(), + user_id: userId, + subscription_id: subscriptionId, + checkout_session_id: checkoutSessionId, + }); + }, + checkoutReturnFailed: ({ reason, userId, checkoutSessionId }) => { + deps.logger.info({ + stream: STREAMS.subscriptions, + event: SUBSCRIPTION_EVENTS.checkoutReturnFailed, + timestamp: deps.now().toISOString(), + reason, + ...(userId ? { user_id: userId } : {}), + ...(checkoutSessionId ? { checkout_session_id: checkoutSessionId } : {}), + }); + }, }; } diff --git a/projects/hutch/src/runtime/server.ts b/projects/hutch/src/runtime/server.ts index 2c256348f..a06e40343 100644 --- a/projects/hutch/src/runtime/server.ts +++ b/projects/hutch/src/runtime/server.ts @@ -135,6 +135,10 @@ import { import { initAuthRoutes } from "./web/auth/auth.page"; import type { BotDefenseEvent } from "./web/auth/auth.page"; import type { ConversionEvent } from "./conversions"; +import { + initEmitSubscriptionEvent, + type SubscriptionLogEvent, +} from "./observability/subscription-events"; import { initGoogleAuthRoutes } from "./web/auth/google-auth.page"; import { initAppleAuthRoutes } from "./web/auth/apple-auth.page"; import { initResolveLogin } from "@packages/web-session"; @@ -332,6 +336,7 @@ interface AppDependencies { stripePublishableKey: string | undefined; botDefenseLogger: HutchLogger.Typed; conversionLogger: HutchLogger.Typed; + subscriptionLogger: HutchLogger.Typed; analytics: HutchLogger.Typed; salt: string; foundingAllocation: FoundingAllocation; @@ -863,6 +868,11 @@ export function createApp(dependencies: AppDependencies): Express { const featureToggle = new QuerystringFeatureToggle(); + const emitSubscriptionEvent = initEmitSubscriptionEvent({ + logger: deps.subscriptionLogger, + now: deps.now, + }); + const authRouter = initAuthRoutes({ hashPassword: deps.hashPassword, createUserWithPasswordHash, @@ -896,6 +906,7 @@ export function createApp(dependencies: AppDependencies): Express { now: deps.now, botDefenseLogger: deps.botDefenseLogger, conversionLogger: deps.conversionLogger, + emitSubscriptionEvent, foundingAllocation, buildBannerState, consumeRateLimit: deps.consumeRateLimit, @@ -1166,6 +1177,7 @@ export function createApp(dependencies: AppDependencies): Express { }), now: deps.now, buildBannerState, + emitSubscriptionEvent, }); app.use("/account", requireAuth, accountRouter); diff --git a/projects/hutch/src/runtime/test-app.ts b/projects/hutch/src/runtime/test-app.ts index fad867c69..a7d9d2fbb 100644 --- a/projects/hutch/src/runtime/test-app.ts +++ b/projects/hutch/src/runtime/test-app.ts @@ -27,6 +27,7 @@ import { createApp } from "./server"; import type { GetChangelogBanner } from "./web/changelog-banner-source"; import { initFoundingAllocation } from "./web/shared/founding-progress/founding-allocation"; import { type AnalyticsEvent, createAnalyticsMiddleware } from "@packages/web-analytics"; +import type { SubscriptionLogEvent } from "./observability/subscription-events"; export type { AdminBundle, @@ -64,6 +65,11 @@ export interface AnalyticsBundle { events: AnalyticsEvent[]; } +export interface SubscriptionEventsBundle { + logger: HutchLogger.Typed; + events: SubscriptionLogEvent[]; +} + export interface TestAppResult { app: Express; auth: AuthBundle; @@ -84,11 +90,13 @@ export interface TestAppResult { botDefense: BotDefenseBundle; conversions: ConversionsBundle; analytics: AnalyticsBundle; + subscriptionEvents: SubscriptionEventsBundle; } function flattenFixtureToAppDependencies( fixture: TestAppFixture, analyticsBundle: AnalyticsBundle, + subscriptionBundle: SubscriptionEventsBundle, ): Parameters[0] { return { validateSaveableUrl: fixture.shared.validateSaveableUrl, @@ -212,6 +220,7 @@ function flattenFixtureToAppDependencies( stripePublishableKey: fixture.stripePublishableKey, botDefenseLogger: fixture.botDefense.logger, conversionLogger: fixture.conversions.logger, + subscriptionLogger: subscriptionBundle.logger, analytics: analyticsBundle.logger, salt: "test-analytics-salt", foundingAllocation: initFoundingAllocation({ @@ -238,13 +247,19 @@ export function createTestApp( logger: { info: captureAnalytics, error: captureAnalytics, warn: captureAnalytics, debug: captureAnalytics }, events: analyticsEvents, }; + const subscriptionLogEvents: SubscriptionLogEvent[] = []; + const captureSubscription = (data: SubscriptionLogEvent) => { subscriptionLogEvents.push(data); }; + const subscriptionBundle: SubscriptionEventsBundle = { + logger: { info: captureSubscription, error: captureSubscription, warn: captureSubscription, debug: captureSubscription }, + events: subscriptionLogEvents, + }; const app = express() .use(createAnalyticsMiddleware({ logger: analyticsBundle.logger, salt: "test-analytics-salt", now: fixture.shared.now, })) - .use(createApp({ ...flattenFixtureToAppDependencies(fixture, analyticsBundle), ...overrides })); + .use(createApp({ ...flattenFixtureToAppDependencies(fixture, analyticsBundle, subscriptionBundle), ...overrides })); return { app, auth: fixture.auth, @@ -265,6 +280,7 @@ export function createTestApp( botDefense: fixture.botDefense, conversions: fixture.conversions, analytics: analyticsBundle, + subscriptionEvents: subscriptionBundle, }; } diff --git a/projects/hutch/src/runtime/web/auth/auth.page.ts b/projects/hutch/src/runtime/web/auth/auth.page.ts index 02b068244..ef0260291 100644 --- a/projects/hutch/src/runtime/web/auth/auth.page.ts +++ b/projects/hutch/src/runtime/web/auth/auth.page.ts @@ -35,6 +35,7 @@ import type { } from "@packages/provider-contracts/trial-scheduler"; import { CheckoutSessionIdSchema, + type CheckoutSessionId, type RetrieveCheckoutSession, } from "@packages/provider-contracts/hosted-checkout"; import type { @@ -69,6 +70,11 @@ import { readClickAttribution } from "@packages/web-analytics"; import { consumePendingSaveId } from "../pending-save"; import type { ConversionEvent } from "../../conversions"; import { emitUserCreated } from "../../conversions"; +import { + CHECKOUT_RETURN_FAILURE_REASONS, + type CheckoutReturnFailureReason, +} from "../../observability/events"; +import type { EmitSubscriptionEvent } from "../../observability/subscription-events"; const TokenQuerySchema = z.object({ token: z.string().optional() }).passthrough(); const CheckoutSuccessQuerySchema = z.object({ session_id: z.string().min(1) }).passthrough(); @@ -112,6 +118,10 @@ interface AuthDependencies { now: () => Date; botDefenseLogger: HutchLogger.Typed; conversionLogger: HutchLogger.Typed; + emitSubscriptionEvent: Pick< + EmitSubscriptionEvent, + "checkoutCompleted" | "checkoutReturnFailed" + >; foundingAllocation: FoundingAllocation; buildBannerState: BuildBannerState; consumeRateLimit: ConsumeRateLimit; @@ -393,42 +403,54 @@ export function initAuthRoutes(deps: AuthDependencies): Router { }); router.get("/auth/checkout/success", async (req: Request, res: Response) => { - const parsedQuery = CheckoutSuccessQuerySchema.safeParse(req.query); - if (!parsedQuery.success) { + const renderFailure = async (params: { + statusCode: number; + message: string; + reason: CheckoutReturnFailureReason; + checkoutSessionId?: CheckoutSessionId; + }) => { + deps.emitSubscriptionEvent.checkoutReturnFailed({ + reason: params.reason, + userId: req.userId, + checkoutSessionId: params.checkoutSessionId, + }); const userCount = await fetchUserCount(); sendComponent( req, res, - Base(SignupPage( - { - userCount, - foundingAllocation: deps.foundingAllocation, - loadedAt: deps.now().getTime(), - errors: [{ message: "Missing checkout session — please start again." }], - }, - { statusCode: 400 }, - ), bannerStateFromRequest(req)), + Base(SignupPage({ userCount, foundingAllocation: deps.foundingAllocation, loadedAt: deps.now().getTime(), errors: [{ message: params.message }] }, { statusCode: params.statusCode }), bannerStateFromRequest(req)), ); + }; + + const parsedQuery = CheckoutSuccessQuerySchema.safeParse(req.query); + if (!parsedQuery.success) { + await renderFailure({ + statusCode: 400, + message: "Missing checkout session — please start again.", + reason: CHECKOUT_RETURN_FAILURE_REASONS.invalidQuery, + }); return; } const checkoutSessionId = CheckoutSessionIdSchema.parse(parsedQuery.data.session_id); const session = await deps.retrieveCheckoutSession(checkoutSessionId); - const renderFailure = async (statusCode: number, message: string) => { - const userCount = await fetchUserCount(); - sendComponent( - req, res, - Base(SignupPage({ userCount, foundingAllocation: deps.foundingAllocation, loadedAt: deps.now().getTime(), errors: [{ message }] }, { statusCode }), bannerStateFromRequest(req)), - ); - }; - if (!session.ok) { - await renderFailure(404, "Checkout session not found — please start again."); + await renderFailure({ + statusCode: 404, + message: "Checkout session not found — please start again.", + reason: CHECKOUT_RETURN_FAILURE_REASONS.sessionNotFound, + checkoutSessionId, + }); return; } if (!session.paid) { - await renderFailure(402, "Payment was not completed. Please try again."); + await renderFailure({ + statusCode: 402, + message: "Payment was not completed. Please try again.", + reason: CHECKOUT_RETURN_FAILURE_REASONS.notPaid, + checkoutSessionId, + }); return; } @@ -438,7 +460,12 @@ export function initAuthRoutes(deps: AuthDependencies): Router { const pending = await deps.consumePendingSignup(checkoutSessionId); if (!pending) { - await renderFailure(409, "This checkout link has already been used."); + await renderFailure({ + statusCode: 409, + message: "This checkout link has already been used.", + reason: CHECKOUT_RETURN_FAILURE_REASONS.replayed, + checkoutSessionId, + }); return; } @@ -449,6 +476,11 @@ export function initAuthRoutes(deps: AuthDependencies): Router { }); await deps.trialScheduler.deleteTrialEndSchedule({ userId: pending.userId }); await deps.trialScheduler.deleteTrialReminderSchedule({ userId: pending.userId }); + deps.emitSubscriptionEvent.checkoutCompleted({ + userId: pending.userId, + subscriptionId, + checkoutSessionId, + }); res.redirect(303, parseReturnUrl({ return: pending.returnUrl })); }); diff --git a/projects/hutch/src/runtime/web/auth/checkout-success.route.test.ts b/projects/hutch/src/runtime/web/auth/checkout-success.route.test.ts index 39696c5f9..b5b1b43f3 100644 --- a/projects/hutch/src/runtime/web/auth/checkout-success.route.test.ts +++ b/projects/hutch/src/runtime/web/auth/checkout-success.route.test.ts @@ -3,6 +3,7 @@ import { JSDOM } from "jsdom"; import request from "supertest"; import { useTestServer } from "../../test-app"; import { TEST_APP_ORIGIN, createDefaultTestAppFixture } from "@packages/test-fixtures"; +import { CHECKOUT_RETURN_FAILURE_REASONS } from "../../observability/events"; import { completeCheckoutSignup } from "./test-helpers/complete-checkout-signup"; const useApp = useTestServer(); @@ -17,6 +18,13 @@ describe("GET /auth/checkout/success", () => { expect(doc.querySelector("[data-test-global-error]")?.textContent).toContain( "Missing checkout session", ); + + expect(harness.subscriptionEvents.events).toHaveLength(1); + const evt = harness.subscriptionEvents.events[0]; + expect(evt.event).toBe("checkout_return_failed"); + expect(evt.reason).toBe(CHECKOUT_RETURN_FAILURE_REASONS.invalidQuery); + expect(evt.user_id).toBeUndefined(); + expect(evt.checkout_session_id).toBeUndefined(); }); it("renders 404 when Stripe says the session does not exist", async () => { @@ -26,6 +34,12 @@ describe("GET /auth/checkout/success", () => { expect(response.status).toBe(404); const doc = new JSDOM(response.text).window.document; expect(doc.querySelector("[data-test-global-error]")?.textContent).toContain("not found"); + + expect(harness.subscriptionEvents.events).toHaveLength(1); + const evt = harness.subscriptionEvents.events[0]; + expect(evt.event).toBe("checkout_return_failed"); + expect(evt.reason).toBe(CHECKOUT_RETURN_FAILURE_REASONS.sessionNotFound); + expect(evt.checkout_session_id).toBe("cs_test_unknown"); }); it("renders 402 when the checkout has not been paid yet", async () => { @@ -45,6 +59,12 @@ describe("GET /auth/checkout/success", () => { expect(response.status).toBe(402); const doc = new JSDOM(response.text).window.document; expect(doc.querySelector("[data-test-global-error]")?.textContent).toContain("not completed"); + + expect(harness.subscriptionEvents.events).toHaveLength(1); + const evt = harness.subscriptionEvents.events[0]; + expect(evt.event).toBe("checkout_return_failed"); + expect(evt.reason).toBe(CHECKOUT_RETURN_FAILURE_REASONS.notPaid); + expect(evt.checkout_session_id).toBe(checkout.id); }); it("renders 409 when the checkout has been paid but the pending signup was already consumed", async () => { @@ -67,13 +87,21 @@ describe("GET /auth/checkout/success", () => { expect(replay.status).toBe(409); const doc = new JSDOM(replay.text).window.document; expect(doc.querySelector("[data-test-global-error]")?.textContent).toContain("already been used"); + + // First visit emitted checkout_completed; the replay emits checkout_return_failed. + const events = harness.subscriptionEvents.events; + expect(events).toHaveLength(2); + expect(events[0].event).toBe("checkout_completed"); + expect(events[1].event).toBe("checkout_return_failed"); + expect(events[1].reason).toBe(CHECKOUT_RETURN_FAILURE_REASONS.replayed); + expect(events[1].checkout_session_id).toBe(checkoutSessionId); }); it("marks the pre-existing user active and redirects to /queue on first paid visit", async () => { const harness = useApp(createDefaultTestAppFixture(TEST_APP_ORIGIN)); const { auth, hostedCheckout, subscriptionProviders, pendingSignup } = harness; - const { successResponse } = await completeCheckoutSignup({ + const { successResponse, checkoutSessionId } = await completeCheckoutSignup({ server: harness.server, auth, hostedCheckout, @@ -90,6 +118,14 @@ describe("GET /auth/checkout/success", () => { const subRow = await subscriptionProviders.findByUserId(lookup.userId); assert(subRow, "subscription row must exist after paid checkout"); expect(subRow.status).toBe("active"); + + expect(harness.subscriptionEvents.events).toHaveLength(1); + const evt = harness.subscriptionEvents.events[0]; + expect(evt.event).toBe("checkout_completed"); + expect(evt.user_id).toBe(lookup.userId); + expect(evt.checkout_session_id).toBe(checkoutSessionId); + expect(evt.subscription_id).toMatch(/^sub_test_/); + expect(typeof evt.timestamp).toBe("string"); }); it("writes an active subscription_providers row with the Stripe ids on first paid visit", async () => { diff --git a/projects/hutch/src/runtime/web/pages/account/account.page.ts b/projects/hutch/src/runtime/web/pages/account/account.page.ts index 2b7974593..1e66e817e 100644 --- a/projects/hutch/src/runtime/web/pages/account/account.page.ts +++ b/projects/hutch/src/runtime/web/pages/account/account.page.ts @@ -40,6 +40,8 @@ import type { } from "@packages/provider-contracts/trial-scheduler"; import type { StorePendingSignup } from "@packages/provider-contracts/pending-signup"; import { trialReminderFiresAt } from "../../../domain/stripe/stripe-trial-config"; +import { CHECKOUT_VARIANTS, type CheckoutVariant } from "../../../observability/events"; +import type { EmitSubscriptionEvent } from "../../../observability/subscription-events"; import { Base } from "../../base.component"; import type { BuildBannerState } from "../../banner-state"; import { HxRedirectPage } from "../../hx-redirect-page"; @@ -99,6 +101,7 @@ interface AccountDependencies { logger: HutchLogger; now: () => Date; buildBannerState: BuildBannerState; + emitSubscriptionEvent: Pick; } type SubscribeBranchKey = "trialing" | "cancelled" | "noop" | "forbidden"; @@ -457,6 +460,7 @@ export function initAccountRoutes(deps: AccountDependencies): Router { async function startCheckout( req: Request, + params: { variant: CheckoutVariant }, ): Promise<{ id: CheckoutSessionId; url: string }> { assert(req.userId, "userId required - route must be protected by requireAuth"); const userId = req.userId; @@ -480,6 +484,12 @@ export function initAccountRoutes(deps: AccountDependencies): Router { createdAt: deps.now().getTime(), }); + deps.emitSubscriptionEvent.checkoutStarted({ + userId, + variant: params.variant, + checkoutSessionId: checkout.id, + }); + return checkout; } @@ -503,7 +513,7 @@ export function initAccountRoutes(deps: AccountDependencies): Router { (req: Request, res: Response) => Promise > = { trialing: async (req, res) => { - const checkout = await startCheckout(req); + const checkout = await startCheckout(req, { variant: CHECKOUT_VARIANTS.trialCheckout }); redirectFullPage(req, res, checkout.url); }, cancelled: async (req, res) => { @@ -516,7 +526,9 @@ export function initAccountRoutes(deps: AccountDependencies): Router { "[subscribe] cancelled row without customerId — falling back to checkout", { userId }, ); - const checkout = await startCheckout(req); + const checkout = await startCheckout(req, { + variant: CHECKOUT_VARIANTS.cancelledResubscribe, + }); redirectFullPage(req, res, checkout.url); return; } @@ -541,7 +553,9 @@ export function initAccountRoutes(deps: AccountDependencies): Router { "[subscribe/cancelled] one-click resub failed — falling back to checkout", { userId, error: err instanceof Error ? err.message : String(err) }, ); - const checkout = await startCheckout(req); + const checkout = await startCheckout(req, { + variant: CHECKOUT_VARIANTS.cardDeclineFallback, + }); redirectFullPage(req, res, checkout.url); } }, diff --git a/projects/hutch/src/runtime/web/pages/account/account.route.test.ts b/projects/hutch/src/runtime/web/pages/account/account.route.test.ts index 9474f26b6..b1d125a61 100644 --- a/projects/hutch/src/runtime/web/pages/account/account.route.test.ts +++ b/projects/hutch/src/runtime/web/pages/account/account.route.test.ts @@ -8,6 +8,7 @@ import { TEST_APP_ORIGIN, createDefaultTestAppFixture, } from "@packages/test-fixtures"; +import { CHECKOUT_VARIANTS } from "../../../observability/events"; function card(id: string, isPrimary: boolean, last4: string): SavedCard { return { @@ -457,6 +458,12 @@ describe("POST /account/subscribe", () => { expect(response.status).toBe(303); const location = response.headers.location; assert(typeof location === "string" && location.includes("checkout.stripe.test")); + + const started = harness.subscriptionEvents.events.filter((e) => e.event === "checkout_started"); + expect(started).toHaveLength(1); + expect(started[0].variant).toBe(CHECKOUT_VARIANTS.trialCheckout); + expect(started[0].user_id).toBe(userId); + expect(started[0].checkout_session_id).toMatch(/^cs_test_/); }); it("creates a Stripe checkout session for a trial-expired user (no second free trial)", async () => { @@ -504,6 +511,9 @@ describe("POST /account/subscribe", () => { expect(row.status).toBe("active"); expect(row.subscriptionId).toBe(created[0].subscriptionId); expect(row.customerId).toBe("cus_was_paid"); + + // One-click resub bypasses Stripe Checkout, so no checkout_started fires. + expect(harness.subscriptionEvents.events.filter((e) => e.event === "checkout_started")).toHaveLength(0); }); it("cancelled user with customerId — saved-card Stripe call throws → fall back to Stripe Checkout (not the dead-end error page), row stays cancelled until the new checkout completes", async () => { @@ -537,6 +547,11 @@ describe("POST /account/subscribe", () => { const row = await subscriptionProviders.findByUserId(userId); assert(row, "row must still exist"); expect(row.status).toBe("cancelled"); + + const started = harness.subscriptionEvents.events.filter((e) => e.event === "checkout_started"); + expect(started).toHaveLength(1); + expect(started[0].variant).toBe(CHECKOUT_VARIANTS.cardDeclineFallback); + expect(started[0].user_id).toBe(userId); }); it("trialing user via HTMX (hx-boost) — 200 with HX-Redirect to Stripe, not 303 Location (HTMX would XHR-follow cross-origin and fail to navigate)", async () => { @@ -595,6 +610,9 @@ describe("POST /account/subscribe", () => { expect(response.status).toBe(303); expect(response.headers.location).toBe("/account?error=payment_method"); + + // The throw happens before the pending-signup write and the emit. + expect(harness.subscriptionEvents.events.filter((e) => e.event === "checkout_started")).toHaveLength(0); }); it("cancelled user without customerId — Stripe Checkout fallback throws → 303 to /account?error=payment_method (no 500)", async () => { @@ -636,6 +654,11 @@ describe("POST /account/subscribe", () => { expect(response.status).toBe(303); const location = response.headers.location; assert(typeof location === "string" && location.includes("checkout.stripe.test")); + + const started = harness.subscriptionEvents.events.filter((e) => e.event === "checkout_started"); + expect(started).toHaveLength(1); + expect(started[0].variant).toBe(CHECKOUT_VARIANTS.cancelledResubscribe); + expect(started[0].user_id).toBe(userId); }); it("redirects active users back to /account instead of creating a Stripe checkout session", async () => { @@ -652,6 +675,8 @@ describe("POST /account/subscribe", () => { expect(response.status).toBe(303); expect(response.headers.location).toBe("/account"); + + expect(harness.subscriptionEvents.events).toHaveLength(0); }); it("returns 400 for a founding member (no row) trying to subscribe", async () => { @@ -661,6 +686,8 @@ describe("POST /account/subscribe", () => { const response = await agent.post("/account/subscribe"); expect(response.status).toBe(400); + + expect(harness.subscriptionEvents.events).toHaveLength(0); }); it("redirects unauthenticated POST /account/subscribe to /login", async () => {