diff --git a/.kilo_workflow/learnings/prettier-churn-cannot-be-undone-by-oxfmt.md b/.kilo_workflow/learnings/prettier-churn-cannot-be-undone-by-oxfmt.md new file mode 100644 index 0000000000..47772ea669 --- /dev/null +++ b/.kilo_workflow/learnings/prettier-churn-cannot-be-undone-by-oxfmt.md @@ -0,0 +1,29 @@ +# Prettier churn cannot be undone by running oxfmt afterwards + +**Symptom.** A role agent's diff on its owned files is far larger than the planned +change (thousands of lines across untouched regions): single quotes flipped to double +quotes, object literals rewrapped multi-line. The agent ran Prettier (defaults or its +own config) on repo files. + +**Cause.** This repository's formatter is **oxfmt** (root `pnpm format`, config +`.oxfmtrc.json`: `singleQuote`, `printWidth: 100`). Prettier with defaults +(`printWidth: 80`, double quotes) reformats whole files. The trap: running oxfmt +afterwards does NOT restore the original. Prettier expands object literals to one +property per line, and oxfmt (like Prettier) preserves object literals that are +already expanded in the source — so the double-quote churn reverts but the object +expansion stays. Both states are formatter fixed points; the original single-line +form is unrecoverable by formatting. + +**Fix.** + +- Recovery: `git checkout HEAD -- ` and re-apply the functional edits. Do not + attempt formatter-based repair. +- Prevention (dispatchers): every implementer handoff that touches files pins — + never run `prettier`/`npx prettier`; the repo formatter is oxfmt + (`pnpm -w exec oxfmt `); before finishing, run + `git diff HEAD --stat -- ` and confirm the diff is limited to the + functional edits. +- Detection (orchestrator): compare the emitted slice diff size against the planned + change size BEFORE dispatching the reviewer. A diff many times larger than the + plan's edit description is churn — revert and redo the round; do not send churn to + the reviewer and do not commit it. diff --git a/.kilo_workflow/learnings/web-jest-full-suite-local-workers-and-collisions.md b/.kilo_workflow/learnings/web-jest-full-suite-local-workers-and-collisions.md new file mode 100644 index 0000000000..45f2ab80bf --- /dev/null +++ b/.kilo_workflow/learnings/web-jest-full-suite-local-workers-and-collisions.md @@ -0,0 +1,33 @@ +# Running the full web jest suite locally: workers, timeouts, and run collisions + +**Symptom 1 — mass per-test 5s timeouts and 60s `beforeAll` hook timeouts across +unrelated suites when running `pnpm --filter web test` locally.** The failures name +`workerSetup.ts` `beforeAll` or `cleanupDbForTest` in `beforeEach`, in suites far from +whatever you changed. + +**Cause.** `apps/web/jest.config.ts` defaults to `maxWorkers: '50%'`. Every worker's +first suite cold-starts a per-worker database +(`DROP DATABASE … WITH (FORCE)` + `CREATE DATABASE` + full drizzle migrate + partition +provisioning, `apps/web/src/tests/setup/workerSetup.ts`). On a laptop docker postgres, +N parallel cold starts exceed the hardcoded 60s hook timeout; the setup flag file is +only written after success, so every subsequent suite re-runs the full setup and times +out again — the whole run is poisoned from the start. Even after setup, 200-table +`TRUNCATE` cleanup per test can exceed the 5s default test timeout under load. + +**Fix.** Run the full suite as `JEST_MAX_WORKERS=1 pnpm --filter web test` with nothing +else DB-heavy on the machine, and expect it to take hours (~680 suites). Per-file +(`pnpm --filter web test -- `) runs are fine with default workers. If a full-run +failure is in a suite your diff does not touch, re-run that suite alone before +believing it. + +**Symptom 2 — a second concurrent DB-test run crashes with `ERR_UNHANDLED_ERROR` / +`Connection terminated unexpectedly`, and both runs produce garbage results.** + +**Cause.** With `JEST_MAX_WORKERS=1` (or any equal worker count), both runs use the +same `JEST_WORKER_ID`s and therefore the SAME `postgres-` databases: each +run's setup `DROP DATABASE … WITH (FORCE)` terminates the other run's connections +mid-flight. + +**Fix.** Never run two web jest invocations (or anything else using the shared +docker postgres test setup) concurrently on one worktree. One DB-test run at a time, +machine-wide. diff --git a/apps/mobile/src/lib/kilo-pass/use-store-kilo-pass-purchase.test.tsx b/apps/mobile/src/lib/kilo-pass/use-store-kilo-pass-purchase.test.tsx index fa52b58d1e..8d32aa6e0d 100644 --- a/apps/mobile/src/lib/kilo-pass/use-store-kilo-pass-purchase.test.tsx +++ b/apps/mobile/src/lib/kilo-pass/use-store-kilo-pass-purchase.test.tsx @@ -4,6 +4,12 @@ import * as React from 'react'; import { type Purchase } from 'expo-iap'; import { toast } from 'sonner-native'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + captureEvent, + KILO_PASS_PURCHASE_COMPLETED_EVENT, + KILO_PASS_PURCHASE_FAILED_EVENT, + KILO_PASS_PURCHASE_STARTED_EVENT, +} from '@/lib/analytics/posthog'; import { createAppStoreKiloPassPurchaseActions, resetInlinePurchaseErrorOwnership, @@ -902,6 +908,34 @@ describe('StoreKiloPassPurchaseProvider', () => { expect(mockedIap.requestPurchase).toHaveBeenCalledTimes(2); }); + it('emits started and failed client events but not purchase_completed', async () => { + const provider = renderStoreKiloPassPurchaseProvider(); + const initialValue = provider.render(); + + const completedSpy = vi.fn(); + await initialValue.purchase(product, { + onCompleted: () => { + completedSpy(); + }, + }); + expect(captureEvent).toHaveBeenCalledWith(KILO_PASS_PURCHASE_STARTED_EVENT); + + mockedIap.handlers?.onPurchaseSuccess(createPurchase()); + await flushPromises(); + + // Anchor: proves the completion callback (where the client capture used to + // live) actually ran, so the negative assertion below is not vacuous. + expect(completedSpy).toHaveBeenCalledTimes(1); + expect(captureEvent).not.toHaveBeenCalledWith(KILO_PASS_PURCHASE_COMPLETED_EVENT); + + const afterSuccess = provider.render(); + await afterSuccess.purchase(product); + mockedIap.handlers?.onPurchaseError(new Error('StoreKit failed')); + + expect(captureEvent).toHaveBeenCalledWith(KILO_PASS_PURCHASE_FAILED_EVENT); + expect(captureEvent).not.toHaveBeenCalledWith(KILO_PASS_PURCHASE_COMPLETED_EVENT); + }); + it('toasts a purchase error when no screen owns inline feedback', async () => { const provider = renderStoreKiloPassPurchaseProvider(); diff --git a/apps/mobile/src/lib/kilo-pass/use-store-kilo-pass-purchase.ts b/apps/mobile/src/lib/kilo-pass/use-store-kilo-pass-purchase.ts index b87ddd94ea..d564c402b4 100644 --- a/apps/mobile/src/lib/kilo-pass/use-store-kilo-pass-purchase.ts +++ b/apps/mobile/src/lib/kilo-pass/use-store-kilo-pass-purchase.ts @@ -24,7 +24,6 @@ import { z } from 'zod'; import { captureEvent, - KILO_PASS_PURCHASE_COMPLETED_EVENT, KILO_PASS_PURCHASE_FAILED_EVENT, KILO_PASS_PURCHASE_STARTED_EVENT, } from '@/lib/analytics/posthog'; @@ -498,9 +497,8 @@ function IosStoreKiloPassPurchaseProvider({ children }: { children: ReactNode }) finishTransaction, invalidateAfterCompletion, onPurchaseCompleted: () => { - // Only user-initiated purchases reach here — recovery and restore - // flows pass notifyCompletion: false. - captureEvent(KILO_PASS_PURCHASE_COMPLETED_EVENT); + // Completed is emitted server-side by completeAppStorePurchase — do not + // re-add a client capture (double counting). setErrorMessage(null); const onCompleted = pendingPurchaseCompletedCallbackRef.current; pendingPurchaseCompletedCallbackRef.current = null; diff --git a/apps/web/src/lib/kilo-pass/apple-store-notifications.test.ts b/apps/web/src/lib/kilo-pass/apple-store-notifications.test.ts index dad897562a..d4c50387ef 100644 --- a/apps/web/src/lib/kilo-pass/apple-store-notifications.test.ts +++ b/apps/web/src/lib/kilo-pass/apple-store-notifications.test.ts @@ -1,4 +1,4 @@ -import { afterAll, beforeAll, describe, expect, it, jest } from '@jest/globals'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, jest } from '@jest/globals'; import { DeliveryStatus, NotificationTypeV2, @@ -27,11 +27,31 @@ import { KiloPassPaymentProvider, KiloPassTier, } from './enums'; -import { processAppStoreKiloPassNotification } from './apple-store-notifications'; +import type * as AppleStoreNotifications from './apple-store-notifications'; import type { AppleStoreDecodedNotification } from './apple-store-notifications'; import type { AppleStoreDecodedTransaction } from './apple-store-verifier'; import { toMicrodollars } from '@/lib/utils'; +// SWC + static ESM imports do not see jest.mock replacements on the same module id. +// Dynamic-import the SUT after the mock (same pattern as stripe-handlers-invoice-paid.test.ts). +jest.mock('@/lib/kilo-pass/posthog-tracking', () => ({ + runAfterResponse: async (work: () => Promise) => { + await work(); + }, + trackKiloPassPurchaseCompleted: jest.fn(), +})); + +type PosthogTrackingMock = { + trackKiloPassPurchaseCompleted: jest.Mock; + runAfterResponse: (work: () => Promise) => Promise; +}; + +function getPosthogTrackingMock(): PosthogTrackingMock { + return jest.requireMock('@/lib/kilo-pass/posthog-tracking') as PosthogTrackingMock; +} + +let processAppStoreKiloPassNotification: typeof AppleStoreNotifications.processAppStoreKiloPassNotification; + const APP_STORE_NOTIFICATION_TEST_NOW_MS = Date.parse('2026-05-15T00:00:00.000Z'); function notification( @@ -99,14 +119,220 @@ async function insertProviderScopedSubscriptionRows(providerSubscriptionId: stri describe('processAppStoreKiloPassNotification', () => { let dateNowSpy: jest.SpiedFunction; - beforeAll(() => { + beforeAll(async () => { dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(APP_STORE_NOTIFICATION_TEST_NOW_MS); + ({ processAppStoreKiloPassNotification } = await import('./apple-store-notifications')); }); afterAll(() => { dateNowSpy.mockRestore(); }); + beforeEach(() => { + getPosthogTrackingMock().trackKiloPassPurchaseCompleted.mockClear(); + }); + + describe('kilo_pass_purchase_completed tracking', () => { + it('does not track when the notification UUID was already processed', async () => { + const trackingMock = getPosthogTrackingMock(); + const user = await insertTestUser(); + const decodedNotification = notification(); + const decodedTransaction = transaction({ + appAccountToken: user.app_store_account_token, + }); + const params = { + signedPayload: 'payload', + decodeNotification: async () => decodedNotification, + decodeTransaction: async () => decodedTransaction, + }; + + await processAppStoreKiloPassNotification(params); + trackingMock.trackKiloPassPurchaseCompleted.mockClear(); + + const replay = await processAppStoreKiloPassNotification(params); + expect(replay).toEqual({ processed: true, status: 'already_processed' }); + expect(trackingMock.trackKiloPassPurchaseCompleted).not.toHaveBeenCalled(); + }); + + it('does not track when the provider transaction was already recorded by the app', async () => { + const trackingMock = getPosthogTrackingMock(); + const user = await insertTestUser(); + const providerSubscriptionId = `orig-${crypto.randomUUID()}`; + const providerTransactionId = `tx-${crypto.randomUUID()}`; + const decodedTransaction = transaction({ + originalTransactionId: providerSubscriptionId, + transactionId: providerTransactionId, + appAccountToken: user.app_store_account_token, + }); + + // App path records the purchase first (same provider transaction id). + await processAppStoreKiloPassNotification({ + signedPayload: 'app-first-initial', + decodeNotification: async () => + notification({ + notificationUUID: `note-${crypto.randomUUID()}`, + notificationType: NotificationTypeV2.SUBSCRIBED, + subtype: Subtype.INITIAL_BUY, + }), + decodeTransaction: async () => decodedTransaction, + }); + trackingMock.trackKiloPassPurchaseCompleted.mockClear(); + + const result = await processAppStoreKiloPassNotification({ + signedPayload: 'assn-same-tx', + decodeNotification: async () => + notification({ + notificationUUID: `note-${crypto.randomUUID()}`, + notificationType: NotificationTypeV2.DID_RENEW, + }), + decodeTransaction: async () => decodedTransaction, + }); + + expect(result).toEqual({ processed: true }); + expect(trackingMock.trackKiloPassPurchaseCompleted).not.toHaveBeenCalled(); + }); + + it('tracks DID_RENEW with a new transaction as renewal', async () => { + const trackingMock = getPosthogTrackingMock(); + const user = await insertTestUser(); + const providerSubscriptionId = `orig-${crypto.randomUUID()}`; + + await processAppStoreKiloPassNotification({ + signedPayload: 'renewal-initial', + decodeNotification: async () => + notification({ + notificationUUID: `note-${crypto.randomUUID()}`, + notificationType: NotificationTypeV2.SUBSCRIBED, + subtype: Subtype.INITIAL_BUY, + }), + decodeTransaction: async () => + transaction({ + originalTransactionId: providerSubscriptionId, + appAccountToken: user.app_store_account_token, + }), + }); + trackingMock.trackKiloPassPurchaseCompleted.mockClear(); + + const renewalTransaction = transaction({ + originalTransactionId: providerSubscriptionId, + transactionId: `tx-${crypto.randomUUID()}`, + appAccountToken: user.app_store_account_token, + }); + const result = await processAppStoreKiloPassNotification({ + signedPayload: 'renewal', + decodeNotification: async () => + notification({ + notificationUUID: `note-${crypto.randomUUID()}`, + notificationType: NotificationTypeV2.DID_RENEW, + }), + decodeTransaction: async () => renewalTransaction, + }); + + expect(result).toEqual({ processed: true }); + expect(trackingMock.trackKiloPassPurchaseCompleted).toHaveBeenCalledTimes(1); + expect(trackingMock.trackKiloPassPurchaseCompleted).toHaveBeenCalledWith( + expect.objectContaining({ + channel: 'app_store', + distinctId: user.google_user_email, + userId: user.id, + purchaseKind: 'renewal', + providerTransactionId: renewalTransaction.transactionId, + productId: renewalTransaction.productId, + environment: renewalTransaction.environment, + }) + ); + }); + + it('tracks SUBSCRIBED with a resolved user and new transaction as initial', async () => { + const trackingMock = getPosthogTrackingMock(); + const user = await insertTestUser(); + const decodedTransaction = transaction({ + appAccountToken: user.app_store_account_token, + }); + + const result = await processAppStoreKiloPassNotification({ + signedPayload: 'subscribed-initial', + decodeNotification: async () => + notification({ + notificationUUID: `note-${crypto.randomUUID()}`, + notificationType: NotificationTypeV2.SUBSCRIBED, + subtype: Subtype.INITIAL_BUY, + }), + decodeTransaction: async () => decodedTransaction, + }); + + expect(result).toEqual({ processed: true }); + expect(trackingMock.trackKiloPassPurchaseCompleted).toHaveBeenCalledTimes(1); + expect(trackingMock.trackKiloPassPurchaseCompleted).toHaveBeenCalledWith( + expect.objectContaining({ + channel: 'app_store', + distinctId: user.google_user_email, + userId: user.id, + purchaseKind: 'initial', + providerTransactionId: decodedTransaction.transactionId, + productId: decodedTransaction.productId, + environment: decodedTransaction.environment, + }) + ); + }); + + it('forwards purchaseKind from completion on DID_CHANGE_RENEWAL_PREF UPGRADE', async () => { + const trackingMock = getPosthogTrackingMock(); + const user = await insertTestUser(); + const providerSubscriptionId = `orig-${crypto.randomUUID()}`; + + await processAppStoreKiloPassNotification({ + signedPayload: 'upgrade-initial', + decodeNotification: async () => + notification({ + notificationUUID: `note-${crypto.randomUUID()}`, + notificationType: NotificationTypeV2.SUBSCRIBED, + subtype: Subtype.INITIAL_BUY, + }), + decodeTransaction: async () => + transaction({ + originalTransactionId: providerSubscriptionId, + appAccountToken: user.app_store_account_token, + }), + }); + trackingMock.trackKiloPassPurchaseCompleted.mockClear(); + + const upgradeTransaction = transaction({ + originalTransactionId: providerSubscriptionId, + transactionId: `tx-${crypto.randomUUID()}`, + productId: 'kilopass.tier49.monthly.v1', + appAccountToken: user.app_store_account_token, + }); + const result = await processAppStoreKiloPassNotification({ + signedPayload: 'upgrade-pref', + decodeNotification: async () => + notification({ + notificationUUID: `note-${crypto.randomUUID()}`, + notificationType: NotificationTypeV2.DID_CHANGE_RENEWAL_PREF, + subtype: Subtype.UPGRADE, + }), + decodeTransaction: async () => upgradeTransaction, + }); + + expect(result).toEqual({ processed: true }); + expect(trackingMock.trackKiloPassPurchaseCompleted).toHaveBeenCalledTimes(1); + // tier19 → tier49 within the previous purchase's period classifies as a + // same-period upgrade in completeStoreKiloPassPurchase (pinned in slice 1); + // this asserts the kind is forwarded through the ASSN path. + expect(trackingMock.trackKiloPassPurchaseCompleted).toHaveBeenCalledWith( + expect.objectContaining({ + channel: 'app_store', + distinctId: user.google_user_email, + userId: user.id, + purchaseKind: 'upgrade', + providerTransactionId: upgradeTransaction.transactionId, + productId: upgradeTransaction.productId, + environment: upgradeTransaction.environment, + }) + ); + }); + }); + it('records a renewal notification and completes the subscription once', async () => { const user = await insertTestUser(); const decodedNotification = notification(); diff --git a/apps/web/src/lib/kilo-pass/apple-store-notifications.ts b/apps/web/src/lib/kilo-pass/apple-store-notifications.ts index f15436b221..e430690aa3 100644 --- a/apps/web/src/lib/kilo-pass/apple-store-notifications.ts +++ b/apps/web/src/lib/kilo-pass/apple-store-notifications.ts @@ -34,7 +34,11 @@ import { createAppleStoreServerApiClient, createAppleStoreSignedDataVerifier, } from './apple-store-sdk'; -import { completeStoreKiloPassPurchase } from './store-subscription-completion'; +import { + completeStoreKiloPassPurchase, + type CompleteStoreKiloPassPurchaseResult, +} from './store-subscription-completion'; +import { runAfterResponse, trackKiloPassPurchaseCompleted } from '@/lib/kilo-pass/posthog-tracking'; import { redactStoreAccountLinkedJson } from './store-payload-redaction'; import { dayjs } from './dayjs'; @@ -722,8 +726,9 @@ export async function processAppStoreKiloPassNotification(params: { ); } } else { + let completionResult: CompleteStoreKiloPassPurchaseResult | null = null; await db.transaction(async tx => { - await completeStoreKiloPassPurchase({ dbOrTx: tx, user, purchase }); + completionResult = await completeStoreKiloPassPurchase({ dbOrTx: tx, user, purchase }); await appendKiloPassAuditLog(tx, { action: KiloPassAuditLogAction.StoreSubscriptionRenewed, result: KiloPassAuditLogResult.Success, @@ -743,6 +748,23 @@ export async function processAppStoreKiloPassNotification(params: { ) ); }); + // Post-commit only — never capture inside the transaction. + const trackedResult = completionResult as CompleteStoreKiloPassPurchaseResult | null; + if (trackedResult && !trackedResult.alreadyProcessed) { + await runAfterResponse(async () => { + trackKiloPassPurchaseCompleted({ + channel: 'app_store', + distinctId: user.google_user_email, + userId: user.id, + tier: trackedResult.tier, + cadence: trackedResult.cadence, + purchaseKind: trackedResult.purchaseKind, + providerTransactionId: purchase.providerTransactionId, + productId: purchase.productId, + environment: purchase.environment, + }); + }); + } return { processed: true }; } } diff --git a/apps/web/src/lib/kilo-pass/posthog-tracking.test.ts b/apps/web/src/lib/kilo-pass/posthog-tracking.test.ts new file mode 100644 index 0000000000..5ea22c3aea --- /dev/null +++ b/apps/web/src/lib/kilo-pass/posthog-tracking.test.ts @@ -0,0 +1,143 @@ +import { beforeAll, beforeEach, describe, expect, it, jest } from '@jest/globals'; +import type { trackKiloPassPurchaseCompleted as trackKiloPassPurchaseCompletedType } from './posthog-tracking'; +import { KiloPassCadence, KiloPassTier } from './enums'; + +jest.mock('@/lib/posthog', () => { + const mockCapture = jest.fn(); + return { + __esModule: true, + default: jest.fn(() => ({ capture: mockCapture })), + mockCapture, + }; +}); + +jest.mock('@sentry/nextjs', () => { + const mockCaptureException = jest.fn(); + return { + captureException: mockCaptureException, + mockCaptureException, + }; +}); + +jest.mock('next/server', () => ({ + after: jest.fn(), +})); + +jest.mock('@/lib/config.server', () => ({ + IS_IN_AUTOMATED_TEST: true, +})); + +let trackKiloPassPurchaseCompleted: typeof trackKiloPassPurchaseCompletedType; + +const posthogMock: { mockCapture: jest.Mock } = jest.requireMock('@/lib/posthog'); +const sentryMock: { mockCaptureException: jest.Mock } = jest.requireMock('@sentry/nextjs'); +const { mockCapture } = posthogMock; +const { mockCaptureException } = sentryMock; + +beforeAll(async () => { + ({ trackKiloPassPurchaseCompleted } = await import('./posthog-tracking')); +}); + +describe('Kilo Pass PostHog tracking', () => { + beforeEach(() => { + mockCapture.mockReset(); + mockCaptureException.mockReset(); + }); + + it('captures app_store purchase completed with snake_case wire properties', () => { + trackKiloPassPurchaseCompleted({ + channel: 'app_store', + distinctId: 'user@example.com', + userId: 'user-123', + tier: KiloPassTier.Tier49, + cadence: KiloPassCadence.Monthly, + purchaseKind: 'initial', + providerTransactionId: 'tx-abc', + productId: 'kilopass.tier49.monthly.v1', + environment: 'Sandbox', + }); + + expect(mockCapture).toHaveBeenCalledWith({ + distinctId: 'user@example.com', + event: 'kilo_pass_purchase_completed', + properties: { + channel: 'app_store', + tier: KiloPassTier.Tier49, + cadence: KiloPassCadence.Monthly, + purchase_kind: 'initial', + user_id: 'user-123', + provider_transaction_id: 'tx-abc', + product_id: 'kilopass.tier49.monthly.v1', + environment: 'Sandbox', + }, + }); + }); + + it('captures stripe purchase completed with snake_case wire properties', () => { + trackKiloPassPurchaseCompleted({ + channel: 'stripe', + distinctId: 'user@example.com', + userId: 'user-456', + tier: KiloPassTier.Tier19, + cadence: KiloPassCadence.Yearly, + purchaseKind: 'renewal', + stripeInvoiceId: 'in_abc', + amountPaidUsd: 19, + currency: 'usd', + livemode: true, + }); + + expect(mockCapture).toHaveBeenCalledWith({ + distinctId: 'user@example.com', + event: 'kilo_pass_purchase_completed', + properties: { + channel: 'stripe', + tier: KiloPassTier.Tier19, + cadence: KiloPassCadence.Yearly, + purchase_kind: 'renewal', + user_id: 'user-456', + stripe_invoice_id: 'in_abc', + amount_paid_usd: 19, + currency: 'usd', + livemode: true, + }, + }); + }); + + it('reports capture failures without throwing', () => { + const error = new Error('capture failed'); + mockCapture.mockImplementation(() => { + throw error; + }); + + expect(() => + trackKiloPassPurchaseCompleted({ + channel: 'app_store', + distinctId: 'user@example.com', + userId: 'user-123', + tier: KiloPassTier.Tier49, + cadence: KiloPassCadence.Monthly, + purchaseKind: 'upgrade', + providerTransactionId: 'tx-abc', + productId: 'kilopass.tier49.monthly.v1', + environment: 'Production', + }) + ).not.toThrow(); + + expect(mockCaptureException).toHaveBeenCalledWith(error, { + tags: { source: 'posthog_kilo_pass_purchase_completed' }, + extra: { + properties: { + channel: 'app_store', + tier: KiloPassTier.Tier49, + cadence: KiloPassCadence.Monthly, + purchase_kind: 'upgrade', + user_id: 'user-123', + provider_transaction_id: 'tx-abc', + product_id: 'kilopass.tier49.monthly.v1', + environment: 'Production', + }, + }, + }); + }); +}); diff --git a/apps/web/src/lib/kilo-pass/posthog-tracking.ts b/apps/web/src/lib/kilo-pass/posthog-tracking.ts new file mode 100644 index 0000000000..2b80372930 --- /dev/null +++ b/apps/web/src/lib/kilo-pass/posthog-tracking.ts @@ -0,0 +1,95 @@ +/** + * Server-side PostHog tracking for Kilo Pass purchase completion. + * + * Future store-purchase completion call sites (e.g. Google Play) must call + * `trackKiloPassPurchaseCompleted` post-commit — there is no automatic hook + * inside `completeStoreKiloPassPurchase`. + */ +import 'server-only'; + +import { after } from 'next/server'; +import { captureException } from '@sentry/nextjs'; + +import { IS_IN_AUTOMATED_TEST } from '@/lib/config.server'; +import PostHogClient from '@/lib/posthog'; +import type { KiloPassCadence, KiloPassTier } from './enums'; + +export type KiloPassPurchaseKind = 'initial' | 'renewal' | 'upgrade' | 'unknown'; + +type TrackKiloPassPurchaseCompletedBase = { + distinctId: string; + userId: string; + tier: KiloPassTier; + cadence: KiloPassCadence; + purchaseKind: KiloPassPurchaseKind; +}; + +export type TrackKiloPassPurchaseCompletedParams = + | (TrackKiloPassPurchaseCompletedBase & { + channel: 'app_store'; + providerTransactionId: string; + productId: string; + environment: string; + }) + | (TrackKiloPassPurchaseCompletedBase & { + channel: 'stripe'; + stripeInvoiceId: string; + amountPaidUsd: number; + currency: string; + livemode: boolean; + }); + +const posthogClient = PostHogClient(); + +/** + * Copied from apps/web/src/lib/kiloclaw/stripe-handlers.ts:426. + * Keeps the serverless function alive until the capture is enqueued on + * provider-webhook paths. + */ +export async function runAfterResponse(work: () => Promise): Promise { + if (IS_IN_AUTOMATED_TEST) { + await work(); + return; + } + + after(work); +} + +export function trackKiloPassPurchaseCompleted(params: TrackKiloPassPurchaseCompletedParams): void { + const baseProperties = { + channel: params.channel, + tier: params.tier, + cadence: params.cadence, + purchase_kind: params.purchaseKind, + user_id: params.userId, + }; + + const properties = + params.channel === 'app_store' + ? { + ...baseProperties, + provider_transaction_id: params.providerTransactionId, + product_id: params.productId, + environment: params.environment, + } + : { + ...baseProperties, + stripe_invoice_id: params.stripeInvoiceId, + amount_paid_usd: params.amountPaidUsd, + currency: params.currency, + livemode: params.livemode, + }; + + try { + posthogClient.capture({ + distinctId: params.distinctId, + event: 'kilo_pass_purchase_completed', + properties, + }); + } catch (error) { + captureException(error, { + tags: { source: 'posthog_kilo_pass_purchase_completed' }, + extra: { properties }, + }); + } +} diff --git a/apps/web/src/lib/kilo-pass/store-subscription-completion.test.ts b/apps/web/src/lib/kilo-pass/store-subscription-completion.test.ts index 84f9ac97da..30cc0cdc39 100644 --- a/apps/web/src/lib/kilo-pass/store-subscription-completion.test.ts +++ b/apps/web/src/lib/kilo-pass/store-subscription-completion.test.ts @@ -53,6 +53,7 @@ describe('completeStoreKiloPassPurchase', () => { tier: KiloPassTier.Tier49, cadence: KiloPassCadence.Monthly, alreadyProcessed: false, + purchaseKind: 'initial', }); const subscriptions = await db @@ -126,7 +127,12 @@ describe('completeStoreKiloPassPurchase', () => { const first = await completeStoreKiloPassPurchase({ user, purchase }); const replay = await completeStoreKiloPassPurchase({ user, purchase }); - expect(replay).toEqual({ ...first, alreadyProcessed: true }); + expect(replay).toEqual({ + subscriptionId: first.subscriptionId, + tier: first.tier, + cadence: first.cadence, + alreadyProcessed: true, + }); const storePurchases = await db .select() @@ -283,7 +289,9 @@ describe('completeStoreKiloPassPurchase', () => { purchasedAtIso: '2026-01-05T12:00:00.000Z', }), }); - await completeStoreKiloPassPurchase({ + expect(first).toMatchObject({ alreadyProcessed: false, purchaseKind: 'initial' }); + + const renewal = await completeStoreKiloPassPurchase({ user, purchase: applePurchase({ providerSubscriptionId, @@ -292,6 +300,7 @@ describe('completeStoreKiloPassPurchase', () => { purchasedAtIso: '2026-02-05T12:00:00.000Z', }), }); + expect(renewal).toMatchObject({ alreadyProcessed: false, purchaseKind: 'renewal' }); const subscription = await db.query.kilo_pass_subscriptions.findFirst({ where: eq(kilo_pass_subscriptions.id, first.subscriptionId), @@ -363,7 +372,7 @@ describe('completeStoreKiloPassPurchase', () => { }), }); - await completeStoreKiloPassPurchase({ + const upgrade = await completeStoreKiloPassPurchase({ user, purchase: applePurchase({ productId: 'kilopass.tier49.monthly.v1', @@ -375,6 +384,7 @@ describe('completeStoreKiloPassPurchase', () => { tier: KiloPassTier.Tier49, }), }); + expect(upgrade).toMatchObject({ alreadyProcessed: false, purchaseKind: 'upgrade' }); const subscription = await db.query.kilo_pass_subscriptions.findFirst({ where: eq(kilo_pass_subscriptions.provider_subscription_id, providerSubscriptionId), diff --git a/apps/web/src/lib/kilo-pass/store-subscription-completion.ts b/apps/web/src/lib/kilo-pass/store-subscription-completion.ts index 1ac53bcda8..0c6df80260 100644 --- a/apps/web/src/lib/kilo-pass/store-subscription-completion.ts +++ b/apps/web/src/lib/kilo-pass/store-subscription-completion.ts @@ -51,12 +51,20 @@ export type ValidatedStoreKiloPassPurchase = { rawPayload: Record; }; -export type CompleteStoreKiloPassPurchaseResult = { - subscriptionId: string; - tier: KiloPassTier; - cadence: KiloPassCadence; - alreadyProcessed: boolean; -}; +export type CompleteStoreKiloPassPurchaseResult = + | { + subscriptionId: string; + tier: KiloPassTier; + cadence: KiloPassCadence; + alreadyProcessed: true; + } + | { + subscriptionId: string; + tier: KiloPassTier; + cadence: KiloPassCadence; + alreadyProcessed: false; + purchaseKind: 'initial' | 'renewal' | 'upgrade'; + }; function getIssuanceSource( paymentProvider: ValidatedStoreKiloPassPurchase['paymentProvider'] @@ -660,11 +668,26 @@ export async function completeStoreKiloPassPurchase(params: { }, }); + // purchaseKind labeling (analytics-only; not on the tRPC output schema): + // - Resubscribe on an existing provider subscription row → renewal (any non-first + // transaction on the subscription). + // - Upgrade landing after the previous period ended → renewal; the event's tier + // property carries the new tier, so no information is lost. + // - "Subscription row exists but no prior purchase row" is structurally impossible + // for App Store (subscription + purchase insert in the same transaction), so + // renewal always means a real prior transaction. + const purchaseKind = isAppStoreSamePeriodUpgrade + ? ('upgrade' as const) + : existingProviderSubscription != null + ? ('renewal' as const) + : ('initial' as const); + return { subscriptionId, tier: purchase.tier, cadence: purchase.cadence, alreadyProcessed: false, + purchaseKind, }; }; diff --git a/apps/web/src/lib/kilo-pass/stripe-handlers-invoice-paid.test.ts b/apps/web/src/lib/kilo-pass/stripe-handlers-invoice-paid.test.ts index cffd5f8a7f..2e4543cc8e 100644 --- a/apps/web/src/lib/kilo-pass/stripe-handlers-invoice-paid.test.ts +++ b/apps/web/src/lib/kilo-pass/stripe-handlers-invoice-paid.test.ts @@ -33,6 +33,22 @@ import type * as affiliateEventsModule from '@/lib/impact/affiliate-events'; import { randomUUID } from 'node:crypto'; import { digestCardFingerprint } from '@/lib/kilo-pass/card-fingerprint-gate'; +jest.mock('@/lib/kilo-pass/posthog-tracking', () => ({ + runAfterResponse: async (work: () => Promise) => { + await work(); + }, + trackKiloPassPurchaseCompleted: jest.fn(), +})); + +type PosthogTrackingMock = { + trackKiloPassPurchaseCompleted: jest.Mock; + runAfterResponse: (work: () => Promise) => Promise; +}; + +function getPosthogTrackingMock(): PosthogTrackingMock { + return jest.requireMock('@/lib/kilo-pass/posthog-tracking') as PosthogTrackingMock; +} + function ensureKiloPassStripePriceIdEnv(): void { // These env vars are required at module-load time by [`getKnownStripePriceIdsForKiloPass()`](src/lib/kilo-pass/stripe-price-ids.server.ts:24). // If the host env already provides them, don't overwrite. @@ -105,6 +121,7 @@ function makeStripeInvoice(params: { amount_paid: params.amount_paid_cents, billing_reason: params.billingReason ?? null, currency: params.currency ?? 'usd', + livemode: false, period_start: params.period_start_seconds, created: params.created_seconds, status_transitions: @@ -333,10 +350,412 @@ function makeInvoicesListMock(params: { beforeEach(async () => { ensureKiloPassStripePriceIdEnv(); + getPosthogTrackingMock().trackKiloPassPurchaseCompleted.mockClear(); await cleanupDbForTest(); }); describe('handleKiloPassInvoicePaid', () => { + describe('kilo_pass_purchase_completed tracking', () => { + test('first invoice.paid emits one track call with stripe properties', async () => { + const trackingMock = getPosthogTrackingMock(); + const { handleKiloPassInvoicePaid } = + await import('@/lib/kilo-pass/stripe-handlers-invoice-paid'); + + const user = await insertTestUser({ total_microdollars_acquired: 0, microdollars_used: 0 }); + const stripeSubId = `sub_track_first_${Math.random()}`; + const invoiceId = `inv_track_first_${Math.random()}`; + const meta = kiloPassMetadata({ + kiloUserId: user.id, + tier: KiloPassTier.Tier19, + cadence: KiloPassCadence.Monthly, + }); + const subscription = makeStripeSubscription({ + id: stripeSubId, + start_date_seconds: 1_735_689_600, + metadata: meta, + }); + const priceId = await getKiloPassPriceId({ + tier: KiloPassTier.Tier19, + cadence: KiloPassCadence.Monthly, + }); + + await handleKiloPassInvoicePaid({ + eventId: `evt_track_first_${Math.random()}`, + invoice: makeStripeInvoice({ + id: invoiceId, + amount_paid_cents: 1900, + created_seconds: 1_735_689_600, + priceId, + subscriptionIdOrExpanded: stripeSubId, + metadata: meta, + billingReason: 'subscription_create', + }), + stripe: { + subscriptions: { retrieve: jest.fn(async () => subscription) }, + } as unknown as Stripe, + }); + + expect(trackingMock.trackKiloPassPurchaseCompleted).toHaveBeenCalledTimes(1); + expect(trackingMock.trackKiloPassPurchaseCompleted).toHaveBeenCalledWith({ + channel: 'stripe', + distinctId: user.google_user_email, + userId: user.id, + tier: KiloPassTier.Tier19, + cadence: KiloPassCadence.Monthly, + purchaseKind: 'initial', + stripeInvoiceId: invoiceId, + amountPaidUsd: 19, + currency: 'usd', + livemode: false, + }); + }); + + test('sequentially redelivered webhook does not track again', async () => { + const trackingMock = getPosthogTrackingMock(); + const { handleKiloPassInvoicePaid } = + await import('@/lib/kilo-pass/stripe-handlers-invoice-paid'); + + const user = await insertTestUser({ total_microdollars_acquired: 0, microdollars_used: 0 }); + const stripeSubId = `sub_track_redeliver_${Math.random()}`; + const invoiceId = `inv_track_redeliver_${Math.random()}`; + const meta = kiloPassMetadata({ + kiloUserId: user.id, + tier: KiloPassTier.Tier19, + cadence: KiloPassCadence.Monthly, + }); + const subscription = makeStripeSubscription({ + id: stripeSubId, + start_date_seconds: 1_735_689_600, + metadata: meta, + }); + const priceId = await getKiloPassPriceId({ + tier: KiloPassTier.Tier19, + cadence: KiloPassCadence.Monthly, + }); + const invoice = makeStripeInvoice({ + id: invoiceId, + amount_paid_cents: 1900, + created_seconds: 1_735_689_600, + priceId, + subscriptionIdOrExpanded: stripeSubId, + metadata: meta, + billingReason: 'subscription_create', + }); + const stripe = { + subscriptions: { retrieve: jest.fn(async () => subscription) }, + } as unknown as Stripe; + + await handleKiloPassInvoicePaid({ + eventId: `evt_track_redeliver_a_${Math.random()}`, + invoice, + stripe, + }); + expect(trackingMock.trackKiloPassPurchaseCompleted).toHaveBeenCalledTimes(1); + + trackingMock.trackKiloPassPurchaseCompleted.mockClear(); + await handleKiloPassInvoicePaid({ + eventId: `evt_track_redeliver_b_${Math.random()}`, + invoice, + stripe, + }); + expect(trackingMock.trackKiloPassPurchaseCompleted).toHaveBeenCalledTimes(0); + }); + + test('second paid invoice in the same issue month still emits once', async () => { + const trackingMock = getPosthogTrackingMock(); + const { handleKiloPassInvoicePaid } = + await import('@/lib/kilo-pass/stripe-handlers-invoice-paid'); + + const user = await insertTestUser({ total_microdollars_acquired: 0, microdollars_used: 0 }); + const stripeSubId = `sub_track_same_month_${Math.random()}`; + const meta = kiloPassMetadata({ + kiloUserId: user.id, + tier: KiloPassTier.Tier19, + cadence: KiloPassCadence.Monthly, + }); + const subscription = makeStripeSubscription({ + id: stripeSubId, + start_date_seconds: 1_735_689_600, + metadata: meta, + }); + const priceId = await getKiloPassPriceId({ + tier: KiloPassTier.Tier19, + cadence: KiloPassCadence.Monthly, + }); + const stripe = { + subscriptions: { retrieve: jest.fn(async () => subscription) }, + } as unknown as Stripe; + + await handleKiloPassInvoicePaid({ + eventId: `evt_track_same_month_a_${Math.random()}`, + invoice: makeStripeInvoice({ + id: `inv_track_same_month_a_${Math.random()}`, + amount_paid_cents: 1900, + created_seconds: 1_735_689_600, + priceId, + subscriptionIdOrExpanded: stripeSubId, + metadata: meta, + billingReason: 'subscription_create', + }), + stripe, + }); + expect(trackingMock.trackKiloPassPurchaseCompleted).toHaveBeenCalledTimes(1); + + trackingMock.trackKiloPassPurchaseCompleted.mockClear(); + const secondInvoiceId = `inv_track_same_month_b_${Math.random()}`; + await handleKiloPassInvoicePaid({ + eventId: `evt_track_same_month_b_${Math.random()}`, + invoice: makeStripeInvoice({ + id: secondInvoiceId, + amount_paid_cents: 3000, + created_seconds: 1_735_689_600, + priceId, + subscriptionIdOrExpanded: stripeSubId, + metadata: meta, + billingReason: 'subscription_update', + }), + stripe, + }); + + expect(trackingMock.trackKiloPassPurchaseCompleted).toHaveBeenCalledTimes(1); + expect(trackingMock.trackKiloPassPurchaseCompleted).toHaveBeenCalledWith( + expect.objectContaining({ + stripeInvoiceId: secondInvoiceId, + purchaseKind: 'upgrade', + amountPaidUsd: 30, + }) + ); + }); + + test('$0 invoice still emits once', async () => { + const trackingMock = getPosthogTrackingMock(); + const { handleKiloPassInvoicePaid } = + await import('@/lib/kilo-pass/stripe-handlers-invoice-paid'); + + const user = await insertTestUser({ total_microdollars_acquired: 0, microdollars_used: 0 }); + const stripeSubId = `sub_track_zero_${Math.random()}`; + const invoiceId = `inv_track_zero_${Math.random()}`; + const meta = kiloPassMetadata({ + kiloUserId: user.id, + tier: KiloPassTier.Tier19, + cadence: KiloPassCadence.Monthly, + }); + const subscription = makeStripeSubscription({ + id: stripeSubId, + start_date_seconds: 1_735_689_600, + metadata: meta, + }); + const priceId = await getKiloPassPriceId({ + tier: KiloPassTier.Tier19, + cadence: KiloPassCadence.Monthly, + }); + + await handleKiloPassInvoicePaid({ + eventId: `evt_track_zero_${Math.random()}`, + invoice: makeStripeInvoice({ + id: invoiceId, + amount_paid_cents: 0, + created_seconds: 1_735_689_600, + priceId, + subscriptionIdOrExpanded: stripeSubId, + metadata: meta, + billingReason: 'subscription_create', + }), + stripe: { + subscriptions: { retrieve: jest.fn(async () => subscription) }, + } as unknown as Stripe, + }); + + expect(trackingMock.trackKiloPassPurchaseCompleted).toHaveBeenCalledTimes(1); + expect(trackingMock.trackKiloPassPurchaseCompleted).toHaveBeenCalledWith( + expect.objectContaining({ + stripeInvoiceId: invoiceId, + amountPaidUsd: 0, + purchaseKind: 'initial', + }) + ); + }); + + test('blocked duplicate-card purchase does not track', async () => { + const trackingMock = getPosthogTrackingMock(); + const { handleKiloPassInvoicePaid } = + await import('@/lib/kilo-pass/stripe-handlers-invoice-paid'); + + const user = await insertTestUser({ total_microdollars_acquired: 0, microdollars_used: 0 }); + const firstClaimant = await insertTestUser(); + const fingerprint = `fp_track_blocked_${Math.random()}`; + const winnerSubId = `sub_track_winner_${Math.random()}`; + const winnerInvoiceId = `in_track_winner_${Math.random()}`; + const [firstClaimantSubscription] = await db + .insert(kilo_pass_subscriptions) + .values({ + kilo_user_id: firstClaimant.id, + payment_provider: KiloPassPaymentProvider.Stripe, + provider_subscription_id: winnerSubId, + stripe_subscription_id: winnerSubId, + tier: KiloPassTier.Tier19, + cadence: KiloPassCadence.Monthly, + status: 'active', + }) + .returning({ id: kilo_pass_subscriptions.id }); + await db.insert(kilo_pass_issuances).values({ + kilo_pass_subscription_id: firstClaimantSubscription.id, + issue_month: '2026-06-01', + source: KiloPassIssuanceSource.StripeInvoice, + stripe_invoice_id: winnerInvoiceId, + }); + await db.insert(kilo_pass_welcome_promo_payment_fingerprint_claims).values({ + stripe_payment_method_type: KiloPassWelcomePromoPaymentFingerprintType.Card, + stripe_fingerprint: fingerprint, + source_stripe_invoice_id: winnerInvoiceId, + }); + + const stripeSubscriptionId = `sub_track_blocked_${Math.random()}`; + const stripeInvoiceId = `in_track_blocked_${Math.random()}`; + const metadata = kiloPassMetadata({ + kiloUserId: user.id, + tier: KiloPassTier.Tier19, + cadence: KiloPassCadence.Monthly, + }); + const priceId = await getKiloPassPriceId({ + tier: KiloPassTier.Tier19, + cadence: KiloPassCadence.Monthly, + }); + + await handleKiloPassInvoicePaid({ + eventId: `evt_track_blocked_${Math.random()}`, + invoice: makeStripeInvoice({ + id: stripeInvoiceId, + amount_paid_cents: 1900, + created_seconds: 1_780_272_000, + paid_seconds: 1_780_272_000, + priceId, + subscriptionIdOrExpanded: stripeSubscriptionId, + metadata, + invoicePaymentId: `inpay_track_blocked_${Math.random()}`, + invoicePayment: { + type: 'charge', + charge: makeFingerprintCharge(`ch_track_blocked_${Math.random()}`, 'card', fingerprint), + }, + billingReason: 'subscription_create', + }), + stripe: { + subscriptions: { + retrieve: jest.fn(async () => + makeStripeSubscription({ + id: stripeSubscriptionId, + start_date_seconds: 1_780_272_000, + metadata, + }) + ), + cancel: jest.fn(async () => ({ id: stripeSubscriptionId })), + }, + refunds: { create: jest.fn(async () => ({ id: 're_track_blocked' })) }, + } as unknown as Stripe, + }); + + expect(trackingMock.trackKiloPassPurchaseCompleted).toHaveBeenCalledTimes(0); + }); + + test('billing_reason subscription_create with pre-existing subscription row maps to initial', async () => { + const trackingMock = getPosthogTrackingMock(); + const { handleKiloPassInvoicePaid } = + await import('@/lib/kilo-pass/stripe-handlers-invoice-paid'); + + const user = await insertTestUser({ total_microdollars_acquired: 0, microdollars_used: 0 }); + const stripeSubId = `sub_track_preexist_${Math.random()}`; + const meta = kiloPassMetadata({ + kiloUserId: user.id, + tier: KiloPassTier.Tier19, + cadence: KiloPassCadence.Monthly, + }); + await db.insert(kilo_pass_subscriptions).values({ + kilo_user_id: user.id, + payment_provider: KiloPassPaymentProvider.Stripe, + provider_subscription_id: stripeSubId, + stripe_subscription_id: stripeSubId, + tier: KiloPassTier.Tier19, + cadence: KiloPassCadence.Monthly, + status: 'active', + started_at: '2026-01-01T00:00:00.000Z', + }); + const subscription = makeStripeSubscription({ + id: stripeSubId, + start_date_seconds: 1_735_689_600, + metadata: meta, + }); + const priceId = await getKiloPassPriceId({ + tier: KiloPassTier.Tier19, + cadence: KiloPassCadence.Monthly, + }); + + await handleKiloPassInvoicePaid({ + eventId: `evt_track_preexist_${Math.random()}`, + invoice: makeStripeInvoice({ + id: `inv_track_preexist_${Math.random()}`, + amount_paid_cents: 1900, + created_seconds: 1_735_689_600, + priceId, + subscriptionIdOrExpanded: stripeSubId, + metadata: meta, + billingReason: 'subscription_create', + }), + stripe: { + subscriptions: { retrieve: jest.fn(async () => subscription) }, + } as unknown as Stripe, + }); + + expect(trackingMock.trackKiloPassPurchaseCompleted).toHaveBeenCalledTimes(1); + expect(trackingMock.trackKiloPassPurchaseCompleted).toHaveBeenCalledWith( + expect.objectContaining({ purchaseKind: 'initial' }) + ); + }); + + test('null billing_reason maps to unknown and still emits', async () => { + const trackingMock = getPosthogTrackingMock(); + const { handleKiloPassInvoicePaid } = + await import('@/lib/kilo-pass/stripe-handlers-invoice-paid'); + + const user = await insertTestUser({ total_microdollars_acquired: 0, microdollars_used: 0 }); + const stripeSubId = `sub_track_unknown_${Math.random()}`; + const meta = kiloPassMetadata({ + kiloUserId: user.id, + tier: KiloPassTier.Tier19, + cadence: KiloPassCadence.Monthly, + }); + const subscription = makeStripeSubscription({ + id: stripeSubId, + start_date_seconds: 1_735_689_600, + metadata: meta, + }); + const priceId = await getKiloPassPriceId({ + tier: KiloPassTier.Tier19, + cadence: KiloPassCadence.Monthly, + }); + + await handleKiloPassInvoicePaid({ + eventId: `evt_track_unknown_${Math.random()}`, + invoice: makeStripeInvoice({ + id: `inv_track_unknown_${Math.random()}`, + amount_paid_cents: 1900, + created_seconds: 1_735_689_600, + priceId, + subscriptionIdOrExpanded: stripeSubId, + metadata: meta, + billingReason: null, + }), + stripe: { + subscriptions: { retrieve: jest.fn(async () => subscription) }, + } as unknown as Stripe, + }); + + expect(trackingMock.trackKiloPassPurchaseCompleted).toHaveBeenCalledTimes(1); + expect(trackingMock.trackKiloPassPurchaseCompleted).toHaveBeenCalledWith( + expect.objectContaining({ purchaseKind: 'unknown' }) + ); + }); + }); + test('returns early when invoice does not look like Kilo Pass (no DB side effects)', async () => { const { handleKiloPassInvoicePaid } = await import('@/lib/kilo-pass/stripe-handlers-invoice-paid'); diff --git a/apps/web/src/lib/kilo-pass/stripe-handlers-invoice-paid.ts b/apps/web/src/lib/kilo-pass/stripe-handlers-invoice-paid.ts index 049d0164a3..294aef75a2 100644 --- a/apps/web/src/lib/kilo-pass/stripe-handlers-invoice-paid.ts +++ b/apps/web/src/lib/kilo-pass/stripe-handlers-invoice-paid.ts @@ -72,6 +72,12 @@ import { type KiloPassAffiliateSaleContext, } from '@/lib/kilo-pass/affiliate-sale'; import { processPersonalKiloPassStripePaidConversion } from '@/lib/impact/kilo-pass-referrals'; +import { + runAfterResponse, + trackKiloPassPurchaseCompleted, + type KiloPassPurchaseKind, +} from '@/lib/kilo-pass/posthog-tracking'; + type DuplicateCardEnforcement = { kiloUserId: string; stripeInvoiceId: string; @@ -394,6 +400,43 @@ async function maybeIssueYearlyRemainingCredits(params: { return true; } +/** + * Whether this invoice already has a successful KiloPassInvoicePaidHandled audit row. + * + * HARD INVARIANT: MUST be called after the `kilo_pass_subscriptions` upsert (whose + * row lock serializes same-subscription webhook concurrency) and before this run's + * own Success-row append — moving it reopens a double-emit race. + */ +async function hasHandledKiloPassInvoicePaid( + tx: DrizzleTransaction, + stripeInvoiceId: string +): Promise { + const existing = await tx.query.kilo_pass_audit_log.findFirst({ + columns: { id: true }, + where: and( + eq(kilo_pass_audit_log.action, KiloPassAuditLogAction.KiloPassInvoicePaidHandled), + eq(kilo_pass_audit_log.result, KiloPassAuditLogResult.Success), + eq(kilo_pass_audit_log.stripe_invoice_id, stripeInvoiceId) + ), + }); + return existing !== undefined; +} + +function purchaseKindFromBillingReason( + billingReason: Stripe.Invoice['billing_reason'] +): KiloPassPurchaseKind { + switch (billingReason) { + case 'subscription_create': + return 'initial'; + case 'subscription_cycle': + return 'renewal'; + case 'subscription_update': + return 'upgrade'; + default: + return 'unknown'; + } +} + export async function handleKiloPassInvoicePaid(params: { eventId: string; invoice: Stripe.Invoice; @@ -435,6 +478,7 @@ export async function handleKiloPassInvoicePaid(params: { // Track context for failure audit logging let kiloUserIdForAudit: string | null = null; let stripeSubscriptionIdForAudit: string | null = null; + let shouldTrackPurchase = false; let duplicateCardEnforcement: DuplicateCardEnforcement | null = null; @@ -700,6 +744,11 @@ export async function handleKiloPassInvoicePaid(params: { }) : null; + // Emit order: blocked early-return → subscription upsert (row lock) → + // hasHandledKiloPassInvoicePaid check → append this run's Success row → + // commit → emit via after(). + shouldTrackPurchase = !(await hasHandledKiloPassInvoicePaid(tx, invoice.id)); + await appendKiloPassAuditLog(tx, { action: KiloPassAuditLogAction.KiloPassInvoicePaidHandled, result: KiloPassAuditLogResult.Success, @@ -827,6 +876,56 @@ export async function handleKiloPassInvoicePaid(params: { throw error; } + if (shouldTrackPurchase) { + await runAfterResponse(async () => { + if (!kiloUserIdForAudit) { + captureException( + new Error('kilo_pass_purchase_completed skipped: missing purchaser or state'), + { + tags: { source: 'kilo_pass_purchase_tracking' }, + extra: { stripeInvoiceId: invoice.id }, + } + ); + return; + } + + const [purchaser] = await db + .select({ email: kilocode_users.google_user_email }) + .from(kilocode_users) + .where(eq(kilocode_users.id, kiloUserIdForAudit)) + .limit(1); + + if ( + !purchaser?.email || + !referralConversionState.userId || + !referralConversionState.tier || + !referralConversionState.cadence + ) { + captureException( + new Error('kilo_pass_purchase_completed skipped: missing purchaser or state'), + { + tags: { source: 'kilo_pass_purchase_tracking' }, + extra: { stripeInvoiceId: invoice.id }, + } + ); + return; + } + + trackKiloPassPurchaseCompleted({ + channel: 'stripe', + distinctId: purchaser.email, + userId: referralConversionState.userId, + tier: referralConversionState.tier, + cadence: referralConversionState.cadence, + purchaseKind: purchaseKindFromBillingReason(invoice.billing_reason), + stripeInvoiceId: invoice.id, + amountPaidUsd: invoice.amount_paid / 100, + currency: (invoice.currency ?? 'usd').toLowerCase(), + livemode: invoice.livemode, + }); + }); + } + if (duplicateCardEnforcement) { await revokeGatewayGrantsForBlockedUser(duplicateCardEnforcement.kiloUserId); await enforceDuplicateCardBlock({ diff --git a/apps/web/src/routers/kilo-pass-router.test.ts b/apps/web/src/routers/kilo-pass-router.test.ts index d5d910a7b0..3b8c7714ae 100644 --- a/apps/web/src/routers/kilo-pass-router.test.ts +++ b/apps/web/src/routers/kilo-pass-router.test.ts @@ -100,6 +100,11 @@ type StoreCompletionMock = { completeStoreKiloPassPurchase: ReturnType; }; +type PosthogTrackingMock = { + trackKiloPassPurchaseCompleted: ReturnType; + runAfterResponse: (work: () => Promise) => Promise; +}; + type SentryMock = { captureException: ReturnType; }; @@ -117,6 +122,10 @@ function getStoreCompletionMock(): StoreCompletionMock { return jest.requireMock('@/lib/kilo-pass/store-subscription-completion') as StoreCompletionMock; } +function getPosthogTrackingMock(): PosthogTrackingMock { + return jest.requireMock('@/lib/kilo-pass/posthog-tracking') as PosthogTrackingMock; +} + function getSentryMock(): SentryMock { return jest.requireMock('@sentry/nextjs') as SentryMock; } @@ -337,6 +346,13 @@ jest.mock('@/lib/kilo-pass/store-subscription-completion', () => ({ completeStoreKiloPassPurchase: jest.fn(), })); +jest.mock('@/lib/kilo-pass/posthog-tracking', () => ({ + runAfterResponse: async (work: () => Promise) => { + await work(); + }, + trackKiloPassPurchaseCompleted: jest.fn(), +})); + async function insertSubscription(params: { kiloUserId: string; stripeSubscriptionId?: string | null; @@ -572,6 +588,7 @@ describe('kiloPassRouter', () => { stripeMock.invoices.list.mockReset(); getAppStoreVerifierMock().verifyAppleKiloPassTransactionJws.mockReset(); getStoreCompletionMock().completeStoreKiloPassPurchase.mockReset(); + getPosthogTrackingMock().trackKiloPassPurchaseCompleted.mockReset(); getSentryMock().captureException.mockReset(); }); @@ -611,29 +628,80 @@ describe('kiloPassRouter', () => { it('succeeds when the transaction appAccountToken matches the signed-in user', async () => { const verifierMock = getAppStoreVerifierMock(); const completionMock = getStoreCompletionMock(); + const trackingMock = getPosthogTrackingMock(); const sentryMock = getSentryMock(); const user = await insertTestUser(); - verifierMock.verifyAppleKiloPassTransactionJws.mockResolvedValue( - appStorePurchaseFixture({ appAccountToken: user.app_store_account_token }) - ); - const expectedResult = { + const purchase = appStorePurchaseFixture({ + appAccountToken: user.app_store_account_token, + }); + verifierMock.verifyAppleKiloPassTransactionJws.mockResolvedValue(purchase); + // Completion mock includes purchaseKind (server internal); tRPC output strips it. + const completionResult = { subscriptionId: 'sub-test-id', tier: KiloPassTier.Tier19, cadence: KiloPassCadence.Monthly, + alreadyProcessed: false as const, + purchaseKind: 'initial' as const, + }; + const expectedClientResult = { + subscriptionId: completionResult.subscriptionId, + tier: completionResult.tier, + cadence: completionResult.cadence, alreadyProcessed: false, }; - completionMock.completeStoreKiloPassPurchase.mockResolvedValue(expectedResult); + completionMock.completeStoreKiloPassPurchase.mockResolvedValue(completionResult); const caller = await createCallerForUser(user.id); const result = await caller.kiloPass.completeAppStorePurchase({ signedTransactionJws: 'signed-jws', }); - expect(result).toEqual(expectedResult); + expect(result).toEqual(expectedClientResult); expect(completionMock.completeStoreKiloPassPurchase).toHaveBeenCalledTimes(1); + expect(trackingMock.trackKiloPassPurchaseCompleted).toHaveBeenCalledTimes(1); + expect(trackingMock.trackKiloPassPurchaseCompleted).toHaveBeenCalledWith({ + channel: 'app_store', + distinctId: user.google_user_email, + userId: user.id, + tier: completionResult.tier, + cadence: completionResult.cadence, + purchaseKind: 'initial', + providerTransactionId: purchase.providerTransactionId, + productId: purchase.productId, + environment: purchase.environment, + }); expect(sentryMock.captureException).not.toHaveBeenCalled(); }); + it('does not track when completeStoreKiloPassPurchase reports alreadyProcessed', async () => { + const verifierMock = getAppStoreVerifierMock(); + const completionMock = getStoreCompletionMock(); + const trackingMock = getPosthogTrackingMock(); + const user = await insertTestUser(); + verifierMock.verifyAppleKiloPassTransactionJws.mockResolvedValue( + appStorePurchaseFixture({ appAccountToken: user.app_store_account_token }) + ); + completionMock.completeStoreKiloPassPurchase.mockResolvedValue({ + subscriptionId: 'sub-test-id', + tier: KiloPassTier.Tier19, + cadence: KiloPassCadence.Monthly, + alreadyProcessed: true, + }); + + const caller = await createCallerForUser(user.id); + const result = await caller.kiloPass.completeAppStorePurchase({ + signedTransactionJws: 'signed-jws', + }); + + expect(result).toEqual({ + subscriptionId: 'sub-test-id', + tier: KiloPassTier.Tier19, + cadence: KiloPassCadence.Monthly, + alreadyProcessed: true, + }); + expect(trackingMock.trackKiloPassPurchaseCompleted).not.toHaveBeenCalled(); + }); + it('keeps account mismatch copy stable and does not log it as an internal failure', async () => { const verifierMock = getAppStoreVerifierMock(); const completionMock = getStoreCompletionMock(); diff --git a/apps/web/src/routers/kilo-pass-router.ts b/apps/web/src/routers/kilo-pass-router.ts index 097bafafe7..fc47ee8bb2 100644 --- a/apps/web/src/routers/kilo-pass-router.ts +++ b/apps/web/src/routers/kilo-pass-router.ts @@ -81,6 +81,7 @@ import { closePauseEvent } from '@/lib/kilo-pass/pause-events'; import { getAllMobileStoreKiloPassProducts } from '@/lib/kilo-pass/mobile-store-products'; import { verifyAppleKiloPassTransactionJws } from '@/lib/kilo-pass/apple-store-verifier'; import { completeStoreKiloPassPurchase } from '@/lib/kilo-pass/store-subscription-completion'; +import { trackKiloPassPurchaseCompleted } from '@/lib/kilo-pass/posthog-tracking'; import { getInitialWelcomePromoContextForSubscription, getKiloPassWelcomePromoPolicy, @@ -1051,7 +1052,21 @@ export const kiloPassRouter = createTRPCRouter({ appAccountToken: purchase.appAccountToken, userAppStoreAccountToken: ctx.user.app_store_account_token, }); - return await completeStoreKiloPassPurchase({ user: ctx.user, purchase }); + const result = await completeStoreKiloPassPurchase({ user: ctx.user, purchase }); + if (!result.alreadyProcessed) { + trackKiloPassPurchaseCompleted({ + channel: 'app_store', + distinctId: ctx.user.google_user_email, + userId: ctx.user.id, + tier: result.tier, + cadence: result.cadence, + purchaseKind: result.purchaseKind, + providerTransactionId: purchase.providerTransactionId, + productId: purchase.productId, + environment: purchase.environment, + }); + } + return result; } catch (error) { throw mapAppStoreCompletionError(error, ctx.user.id); }