Skip to content

fix(kilo-pass): emit kilo_pass_purchase_completed server-side, once per sale - #4892

Merged
iscekic merged 11 commits into
mainfrom
kilo-pass-event-audit-924f
Jul 30, 2026
Merged

fix(kilo-pass): emit kilo_pass_purchase_completed server-side, once per sale#4892
iscekic merged 11 commits into
mainfrom
kilo-pass-event-audit-924f

Conversation

@iscekic

@iscekic iscekic commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Summary

What: kilo_pass_purchase_completed is now emitted server-side, at most once per completed sale, at exactly three call sites — tRPC completeAppStorePurchase, Apple App Store Server Notifications completion, and Stripe handleKiloPassInvoicePaid — and the mobile client stops emitting it. New purchase_kind / channel (and per-channel) properties make the series filterable.

Why: the event undercounted actual Kilo Pass sales. The data team flagged that the PostHog count sits below real sales; the investigation found four root causes (below). The count stepping up to the true number is the fix, not an anomaly.

Investigation narrative (root causes, with evidence)

Sales are recorded on the server across two channels and five code paths; the event was emitted only on one lossy client-side path.

  • RC1 — recovery/restore App Store completions never emitted (systematic). The event fired only in onPurchaseCompleted (apps/mobile/src/lib/kilo-pass/use-store-kilo-pass-purchase.ts:503), reachable only when onPurchaseSuccess matched activePurchaseRequestRef.current?.sku === purchase.productId. That ref is in-memory: if the app was backgrounded/killed/restarted between Apple's payment sheet succeeding and JS handling the success, the purchase completed later via the availablePurchases recovery effect (:577) or recoverPurchases — both pass notifyCompletion: false, so the backend recorded the sale and no event fired.
  • RC2 — Stripe web purchases never emitted at all (systematic). Web checkout (KiloPassSubscribeCard.tsx) and the Stripe webhook flow (stripe-handlers-invoice-paid.ts) contained no PostHog emission at all. Both channels feed kilo_pass_subscriptions, so "passes sold" excluding Stripe was guaranteed to undercount.
  • RC3 — client-side capture was lossy (statistical). captureEvent (apps/mobile/src/lib/analytics/posthog.ts) enqueues in memory; an app suspend right after capture lost the queued event (and capture is disabled in dev builds).
  • RC4 — semantic mismatch (definition). Renewals never emitted (recovery/ASSN paths pass notifyCompletion: false), and TestFlight/sandbox purchases DID emit with no property to filter them out. The event as defined could never equal any backend-derived "passes sold" number.

How (design)

Server is the sole emitter, once per completed sale. The single choke point for App Store sales is completeStoreKiloPassPurchase (inserts one kilo_pass_store_purchases row per provider transaction id, unique constraint on (payment_provider, provider_transaction_id), returns alreadyProcessed). Emission gates on alreadyProcessed === false, so recovery replays, ASSN/app races, and tRPC retries collapse to one event. For Stripe, the anchor is the absence of a prior kilo_pass_audit_log row with action = KiloPassInvoicePaidHandled, result = Success for the invoice id — checked in-transaction after the kilo_pass_subscriptions upsert (whose row lock serializes same-subscription webhook concurrency) and before this run's own Success-row append. baseCreditsResult.wasIssued was considered and rejected: issuance is unique per (subscription, issue_month), not per invoice, so second paid invoices in one month (upgrades, prorations) would silently never emit.

Delivery guarantee: at most once, best-effort after commit. The hard requirement is no double counting; emission happens only after the recording transaction commits (fire-and-forget on the user-facing tRPC mutation, Next.js after() on both provider webhooks so the serverless function stays alive). A crash between commit and capture loses that one event — accepted residual. Concurrent same-invoice Stripe redelivery is covered by the upsert row-lock reasoning above, not a DB uniqueness constraint — accepted residual; sequential redelivery is the tested bar. A full at-least-once outbox is out of scope (possible follow-up).

Semantics (deliberate change, documented for the data team):

  • Event name unchanged — dashboards keep working.
  • Per-transaction semantics: initial purchases, renewals, and upgrades each emit once, distinguished by purchase_kind (initial | renewal | upgrade | unknown). Rationale: under-delivery of events is unrecoverable downstream; over-delivery with a filter property is self-serve fixable. A renewal is a completed purchase (money moved); the DB records one row per charge. purchase_kind is a billing_reason/store-flow proxy, not a tier diff — proration/cadence-change invoices can read upgrade; a resubscribe on an existing provider subscription reads renewal; Stripe null/unmapped billing_reason reads unknown (never guessed from subscription-row presence, because customer.subscription.created can create the row before the first invoice.paid arrives — guessing would mislabel true first purchases).
  • $0 invoices (100% promo/trial first months) DO emit — a pass was granted and the subscription started; amount_paid_usd: 0 makes them filterable.
  • Wire properties (snake_case, matching claw_trial_started convention): both channels send channel, tier, cadence, purchase_kind, user_id; App Store adds provider_transaction_id, product_id, environment; Stripe adds stripe_invoice_id, amount_paid_usd, currency, livemode. distinctId is the user email, matching the web/mobile identify(email) convention so funnels stay joined.
  • Transition window: old app versions keep emitting client-side until users update. Those events carry no channel property — filter them out with channel IS SET. PostHog cannot dedupe a client event against a server event for the same purchase, which is why the client emission is removed rather than kept.
  • Blocked duplicate-card Stripe purchases return before the Success audit row is written and never emit; blocked/refunded purchases stay uncounted.

Data-team guidance

  • Trustworthy series: filter kilo_pass_purchase_completed to channel IS SET. Events without channel are the old lossy client series (transition window).
  • Unfiltered count (with channel IS SET) counts transactions (initial + renewal + upgrade), not conversions.
  • Funnel analysis (kilo_pass_purchase_started → completed): filter completed to channel = 'app_store' AND purchase_kind IN ('initial', 'upgrade') to approximate the old user-initiated funnel. Resubscribes read renewal and Stripe null/legacy billing_reason reads unknown; both are excluded from that funnel view — intentional. unknown should be ~zero in production.
  • Sandbox/TestFlight: filter via environment. $0 promos: filter via amount_paid_usd (and currency for correctness; Kilo Pass bills USD today).
  • Historical data: client-lost events are unrecoverable, but the full sale history exists in kilo_pass_store_purchases / Stripe invoices — a one-off PostHog bulk backfill is possible as a follow-up if wanted (not in this PR).

Verification

No manual testing: E2E is environment-limited — real App Store purchases cannot run in the simulator/E2E stack and PostHog capture is disabled in dev builds, so no E2E slot was taken. Verification is automated:

  • Tracking-module unit tests (event name, distinctId, exact snake_case wire properties per channel, capture-throw swallowed + Sentry).
  • purchaseKind classification unit tests (initial / renewal / upgrade) + idempotent-replay shape.
  • At-most-once tests at all three call sites: App Store tRPC (first → 1, replay → 0), ASSN (duplicate claim → 0; app-beat-ASSN → 0; DID_RENEW → 1 renewal; SUBSCRIBED → 1 initial; DID_CHANGE_RENEWAL_PREF+UPGRADE → 1), Stripe (first invoice → 1; sequential redelivery → 0; second invoice same month → 1; $0 invoice → 1; blocked duplicate-card → 0; subscription_create with pre-existing row → initial; null billing_reasonunknown).
  • Mobile test: successful purchase does NOT capture completed, still captures started; error still captures failed.
  • Full suites: mobile vitest 287 files / 2459 tests pass; web jest — slice suites pass (tracking module, completion, router, ASSN 33, Stripe handler 50); full web suite is CI-verified (locally it requires JEST_MAX_WORKERS=1 and hours — see the committed learning; failures there are pre-existing per-test 5s timeouts under docker postgres load, in suites this diff does not touch).

Visual Changes

N/A

Reviewer Notes

  • The load-bearing invariants: the Stripe audit-anchor ordering (after the subscription upsert, before this run's own Success append — moving it reopens a double-emit race), the discriminated-union result type (purchaseKind exists only when alreadyProcessed === false — no ?? fallbacks), and the snake_case wire map.
  • CompleteStorePurchaseOutputSchema is unchanged: purchaseKind is internal, Zod strips it from the tRPC response; the mobile contract is untouched.
  • Emission tests jest.mock the tracking module with a factory that keeps runAfterResponse working inline — a bare automock would no-op it and the emit callback would never run.
  • Two .kilo_workflow/learnings/ entries ride along (workflow metadata, not product code).
  • The mobile analytics constant KILO_PASS_PURCHASE_COMPLETED_EVENT remains exported in apps/mobile/src/lib/analytics/posthog.ts (owned by a parallel section); the negative test imports it, keeping knip green.

iscekic added 9 commits July 30, 2026 14:26
… result

Slice 1 of kilo-pass-event-audit: server becomes the sole emitter of
kilo_pass_purchase_completed. Adds trackKiloPassPurchaseCompleted
(discriminated app_store/stripe params, snake_case wire properties,
never throws) and runAfterResponse (Next.js after() for provider
webhooks). CompleteStoreKiloPassPurchaseResult is now a discriminated
union carrying purchaseKind (initial/renewal/upgrade) only when
alreadyProcessed is false.
Slice 3 of kilo-pass-event-audit: the server (completeAppStorePurchase /
Apple server notifications / Stripe invoice.paid) is now the sole emitter.
The client-only event never fired on recovery/restore completions (RC1)
and was lossy across app suspend (RC3). started/failed client events are
unchanged. The negative test imports the real event constant so knip
keeps the export used; posthog.ts itself is owned by a parallel section.
…e.paid

Slice 2b of kilo-pass-event-audit: the Stripe web channel never emitted
the event at all (RC2). Emits at most once per successfully handled
invoice, anchored on the absence of a prior KiloPassInvoicePaidHandled/
Success audit row, checked in-transaction after the subscription upsert
(whose row lock serializes same-subscription redeliveries) and before
this run's own Success append. Emission is post-commit via
runAfterResponse (Next.js after()) as the first post-commit statement.
purchase_kind maps from invoice.billing_reason only; $0 invoices emit;
blocked duplicate-card purchases never emit.
…l sites

Slice 2a of kilo-pass-event-audit: recovery/restore/ASSN completions never
emitted the event (RC1). completeAppStorePurchase tracks fire-and-forget
(user-facing mutation; claw_trial_started precedent); the Apple
server-notifications completion tracks post-commit via runAfterResponse
(provider webhook durability). Both gate on alreadyProcessed === false,
which the kilo_pass_store_purchases unique constraint on
(payment_provider, provider_transaction_id) enforces across replays,
races, and retries.
An implementer ran Prettier on its owned files, adding ~3000 lines of
reformat churn; oxfmt's expanded-object heuristic made the churn
unrecoverable by formatting. Records the revert-and-redo recovery and
the handoff/diff-size prevention pins.
…assignment

The let-outside/assign-inside pattern leaves completionResult narrowed to
null in straight-line flow; a widening 'as ... | null' on the post-commit
copy resets control-flow analysis. Also replace the union-illegal
purchaseKind destructure in the idempotency test with an explicit-field
expectation.
… SUT

oxlint consistent-type-imports forbids the import() type annotation used
to type the dynamic-import handle.
50%-worker cold starts exceed the 60s per-worker setup hook and poison
the whole run; two concurrent runs collide on JEST_WORKER_ID and DROP
each other's databases. Documents JEST_MAX_WORKERS=1 with no concurrent
DB runs as the local shape.
@iscekic iscekic self-assigned this Jul 30, 2026
Comment thread apps/web/src/lib/kilo-pass/apple-store-notifications.test.ts Outdated
Comment thread apps/web/src/lib/kilo-pass/apple-store-notifications.test.ts Outdated
Comment thread apps/web/src/lib/kilo-pass/stripe-handlers-invoice-paid.test.ts
@kilo-code-bot

kilo-code-bot Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Code Review Summary

Status: 3 Issues Found (all carried forward, author-declined) | Recommendation: Address before merge

Executive Summary

The incremental commit 28a8ac9bf only hardens the mobile negative test with an onCompleted anchor and introduces no new issues; the three remaining findings are unchanged-file items the author has explicitly declined with reasoned rationale.

Overview

Severity Count
CRITICAL 0
WARNING 1
SUGGESTION 2
Issue Details (click to expand)

WARNING

File Line Issue
apps/web/src/routers/kilo-pass-router.ts 1057 Emission not deferred with runAfterResponse (unlike both webhook call sites), so the fire-and-forget capture() can be dropped if the invocation freezes after the mutation response — re-verified still present at HEAD; author declined: accepted residual documented in the PR, matching the claw_trial_started precedent, with an at-least-once outbox as the honest follow-up

SUGGESTION

File Line Issue
apps/web/src/lib/kilo-pass/stripe-handlers-invoice-paid.ts 922 amount_paid_usd divides by 100 regardless of currency exponent and labels non-USD amounts as USD — re-verified still present at HEAD; author declined: Kilo Pass bills USD only today and the sibling currency property is the migration/honesty mechanism
apps/web/src/lib/kilo-pass/stripe-handlers-invoice-paid.test.ts 358 Documented "HARD INVARIANT" ordering is untested for yearly cadence and for rollback — author declined: by design, no code change planned
Resolved since the previous review
  • apps/mobile/src/lib/kilo-pass/use-store-kilo-pass-purchase.test.tsx:928 — the negative purchase_completed assertion now has a real anchor: the test passes an onCompleted spy and asserts toHaveBeenCalledTimes(1) before asserting captureEvent was not called with KILO_PASS_PURCHASE_COMPLETED_EVENT. Verified against the hook: onPurchaseCompleted (use-store-kilo-pass-purchase.ts:499-505) is exactly where the removed client capture lived and it invokes the pending onCompleted callback, so the spy firing proves the former capture site executed. The assertion can no longer pass vacuously.
Incremental scope reviewed (1 file)
  • apps/mobile/src/lib/kilo-pass/use-store-kilo-pass-purchase.test.tsx - 0 new issues

Diff reviewed: c7c19675..28a8ac9b (9 insertions, 1 deletion, test-only). No production code changed in this increment; no new memory-leak, ref, listener, or subscription surface introduced. No new markdown files, so the image-format rule does not apply.

Assumptions: no tests, typechecks, or builds were run (read-only review); the two carried-forward production findings were re-verified by reading the current code at HEAD (kilo-pass-router.ts:1056-1067 and stripe-handlers-invoice-paid.ts:914-924), not by execution.

Fix these issues in Kilo Cloud

Previous Review Summaries (2 snapshots, latest commit c7c1967)

Current summary above is authoritative. Previous snapshots are kept for context only.

Previous review (commit c7c1967)

Status: 4 Issues Found | Recommendation: Address before merge

Executive Summary

The at-most-once gating is sound on both channels, but the tRPC App Store call site emits without runAfterResponse, leaving the PR's primary purchase path exposed to the same post-response capture loss it removes from the mobile client.

Overview

Severity Count
CRITICAL 0
WARNING 1
SUGGESTION 3
Issue Details (click to expand)

WARNING

File Line Issue
apps/web/src/routers/kilo-pass-router.ts 1057 Emission not deferred with runAfterResponse (unlike both webhook call sites), so the fire-and-forget capture() can be dropped when the invocation freezes after the mutation response — RC3 relocated to the server on the highest-volume channel

SUGGESTION

File Line Issue
apps/web/src/lib/kilo-pass/stripe-handlers-invoice-paid.ts 922 amount_paid_usd divides by 100 regardless of currency exponent and labels non-USD amounts as USD
apps/mobile/src/lib/kilo-pass/use-store-kilo-pass-purchase.test.tsx 921 Negative assertion has no anchor proving onPurchaseCompleted ran, so the guard against re-adding a client capture can pass vacuously
apps/web/src/lib/kilo-pass/stripe-handlers-invoice-paid.test.ts 358 Documented "HARD INVARIANT" ordering is untested for yearly cadence and for rollback (author responded: by design, no code change planned)
Verified clean (checked, no finding)
  • At-most-once, App Store: both call sites gate on alreadyProcessed === false, which is anchored on the (payment_provider, provider_transaction_id) unique insert in store-subscription-completion.ts:547-594, so tRPC/ASSN races and recovery replays collapse to one emission.
  • At-most-once, Stripe: the audit-anchor check at stripe-handlers-invoice-paid.ts:750 sits after the kilo_pass_subscriptions upsert row lock and before this run's own Success append; the duplicate-card block returns at :679 before the gate, and the yearly early return at :835 is after it, so yearly still emits.
  • after() request scope: the only production callers are the route handlers app/api/kilo-pass/apple/notifications/route.ts:17 and app/api/stripe/webhook/route.tslib/stripe/index.ts:824; no script, cron, or queue path reaches runAfterResponse.
  • Security baseline: the Sentry extra.properties payload in posthog-tracking.ts:88-93 contains no email, token, header, or cookie — distinctId is deliberately excluded; user_id is an internal UUID, matching existing peers.
  • Performance: hasHandledKiloPassInvoicePaid is covered by IDX_kilo_pass_audit_log_stripe_invoice_id (packages/db/src/schema.ts:1907), so the in-transaction lookup is indexed.
  • Module-scope PostHogClient() matches the established pattern (cost-insights, security-agent, code-indexing tracking modules); shutdownPosthog() is test/script-only, so the cached reference cannot go stale in production.
  • Mobile: captureEvent and both remaining event constants are still used; KILO_PASS_PURCHASE_COMPLETED_EVENT stays referenced by the test, so knip remains green; no memory-leak or ref/listener change.
  • Previously reported and fixed at HEAD: the value-used-as-type annotation and the self-referential purchaseKind assertion in apple-store-notifications.test.ts are both correctly resolved by c7c19675.
  • Markdown: neither new .kilo_workflow/learnings/ file contains images or HTML <img> tags.
Files Reviewed (14 files)
  • apps/web/src/routers/kilo-pass-router.ts - 1 issue
  • apps/web/src/lib/kilo-pass/stripe-handlers-invoice-paid.ts - 1 issue
  • apps/mobile/src/lib/kilo-pass/use-store-kilo-pass-purchase.test.tsx - 1 issue
  • apps/web/src/lib/kilo-pass/stripe-handlers-invoice-paid.test.ts - 1 issue (carried forward)
  • apps/web/src/lib/kilo-pass/posthog-tracking.ts - 0 issues
  • apps/web/src/lib/kilo-pass/posthog-tracking.test.ts - 0 issues
  • apps/web/src/lib/kilo-pass/apple-store-notifications.ts - 0 issues
  • apps/web/src/lib/kilo-pass/apple-store-notifications.test.ts - 0 issues (prior findings fixed)
  • apps/web/src/lib/kilo-pass/store-subscription-completion.ts - 0 issues
  • apps/web/src/lib/kilo-pass/store-subscription-completion.test.ts - 0 issues
  • apps/web/src/routers/kilo-pass-router.test.ts - 0 issues
  • apps/mobile/src/lib/kilo-pass/use-store-kilo-pass-purchase.ts - 0 issues
  • .kilo_workflow/learnings/prettier-churn-cannot-be-undone-by-oxfmt.md - 0 issues
  • .kilo_workflow/learnings/web-jest-full-suite-local-workers-and-collisions.md - 0 issues

Assumptions: no tests, typechecks, or builds were run (read-only review); type-level conclusions are based on repo-internal precedent, not an executed tsgo --noEmit.

Fix these issues in Kilo Cloud

Previous review

Status: 3 Issues Found | Recommendation: Address before merge

Executive Summary

The emission logic itself (at-most-once gating on both channels) holds up under review; the blocking issue is a TypeScript error in the App Store notifications test that jest cannot catch but tsgo --noEmit will.

Overview

Severity Count
CRITICAL 1
WARNING 1
SUGGESTION 1
Issue Details (click to expand)

CRITICAL

File Line Issue
apps/web/src/lib/kilo-pass/apple-store-notifications.test.ts 55 ProcessAppStoreKiloPassNotificationFn is a value alias used as a type annotation (TS2749); missing typeof breaks pnpm --filter web typecheck, which includes src/**/*

WARNING

File Line Issue
apps/web/src/lib/kilo-pass/apple-store-notifications.test.ts 331 DID_CHANGE_RENEWAL_PREF UPGRADE test asserts purchaseKind against the recorded call's own value, so the property the test is named after cannot fail

SUGGESTION

File Line Issue
apps/web/src/lib/kilo-pass/stripe-handlers-invoice-paid.test.ts 358 New tracking suite is monthly-only; the yearly early return after the emission gate and the rollback-no-emit half of the documented "HARD INVARIANT" are untested
Verification notes and assumptions
  • Verified against HEAD a31e7dac6ffdbde5573e93888786461d81b4cfe4; no pre-existing PR comments to reconcile.
  • Could not execute pnpm typecheck or either jest/vitest suite in this sandbox (no installed node_modules, no PostgreSQL), so the CRITICAL finding is from static TypeScript semantics plus apps/web/tsconfig.json include: ["src/**/*"] and apps/web/package.json typecheck: tsgo --noEmit.
  • Reviewed and found sound: the Stripe audit-row emission gate ordering (after the kilo_pass_subscriptions upsert row lock at stripe-handlers-invoice-paid.ts:750, before the Success append at :752), the blocked duplicate-card early return never emitting, the alreadyProcessed-discriminated CompleteStoreKiloPassPurchaseResult union, the completionResult widening assertion at apple-store-notifications.ts:752, and the snake_case wire map in posthog-tracking.ts versus its test. KiloPassInvoicePaidHandled/Success is written from exactly one call site, so the anchor cannot be poisoned by other handlers.
  • Module-scope PostHogClient() and fire-and-forget capture without an awaited flush match existing server tracking modules (lib/cost-insights/posthog-tracking.ts, lib/code-indexing/posthog-tracking.ts), so they were not flagged.
  • Behavioural note for analytics consumers, not a defect: DID_RENEW now emits kilo_pass_purchase_completed, so dashboards that treated the event as "a purchase" need the purchase_kind / channel IS SET filters described in the PR body.
Files Reviewed (14 files)
  • apps/web/src/lib/kilo-pass/posthog-tracking.ts - 0 issues
  • apps/web/src/lib/kilo-pass/posthog-tracking.test.ts - 0 issues
  • apps/web/src/lib/kilo-pass/stripe-handlers-invoice-paid.ts - 0 issues
  • apps/web/src/lib/kilo-pass/stripe-handlers-invoice-paid.test.ts - 1 issue
  • apps/web/src/lib/kilo-pass/apple-store-notifications.ts - 0 issues
  • apps/web/src/lib/kilo-pass/apple-store-notifications.test.ts - 2 issues
  • apps/web/src/lib/kilo-pass/store-subscription-completion.ts - 0 issues
  • apps/web/src/lib/kilo-pass/store-subscription-completion.test.ts - 0 issues
  • apps/web/src/routers/kilo-pass-router.ts - 0 issues
  • apps/web/src/routers/kilo-pass-router.test.ts - 0 issues
  • apps/mobile/src/lib/kilo-pass/use-store-kilo-pass-purchase.ts - 0 issues
  • apps/mobile/src/lib/kilo-pass/use-store-kilo-pass-purchase.test.tsx - 0 issues
  • .kilo_workflow/learnings/prettier-churn-cannot-be-undone-by-oxfmt.md - 0 issues
  • .kilo_workflow/learnings/web-jest-full-suite-local-workers-and-collisions.md - 0 issues

Fix these issues in Kilo Cloud


Reviewed by claude-opus-5 · Input: 32 · Output: 6.2K · Cached: 709.4K

Review guidance: REVIEW.md from base branch main

…de kind

Kilobot CRITICAL: the import-type alias did not propagate contextual
typing (TS7006 downstream); a type-only namespace import + typeof member
satisfies both tsgo and oxlint consistent-type-imports. Kilobot WARNING:
the DID_CHANGE_RENEWAL_PREF UPGRADE case asserted purchaseKind against
the recorded call's own value (vacuous); the fixture is a same-period
tier19 -> tier49 upgrade, so assert 'upgrade' concretely.
@iscekic

iscekic commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

(bot) @kilocode-bot please review

Comment thread apps/web/src/routers/kilo-pass-router.ts
Comment thread apps/web/src/lib/kilo-pass/stripe-handlers-invoice-paid.ts
Comment thread apps/mobile/src/lib/kilo-pass/use-store-kilo-pass-purchase.test.tsx
Kilobot: without proof the completion callback ran, the negative
assertion passes vacuously. Assert the onCompleted spy fired (the
callback where the client capture used to live) before asserting no
completed event was captured.
@iscekic iscekic added the human-ready The PR is ready for human review. label Jul 30, 2026
@kilo-code-bot

kilo-code-bot Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Code Review Summary

Reviewed at: 28a8ac9bf (independent review — every carried-forward claim re-verified against the current tree, not copied)
Status: 1 WARNING, 5 SUGGESTIONS | Recommendation: the at-most-once design is sound; nothing here blocks correctness of the "once per sale" guarantee

Overview

Severity Count
CRITICAL 0
WARNING 1
SUGGESTION 5

Previously flagged items — re-verified

  • apple-store-notifications.test.ts:53 (value-as-type / typeof)RESOLVED. Current code is import type * as AppleStoreNotifications from './apple-store-notifications'; (line 29) plus let processAppStoreKiloPassNotification: typeof AppleStoreNotifications.processAppStoreKiloPassNotification; (line 53). That is valid TS: a type-only namespace import used in a typeof member query is a type position, and the annotation propagates the full signature (so no TS7006 downstream in the call sites). The earlier form (alias used directly as an annotation) would indeed have been a hard tsgo error; it is gone.
  • apple-store-notifications.test.ts:~331 (tautological purchaseKind)RESOLVED. The DID_CHANGE_RENEWAL_PREF/UPGRADE case now asserts the literal purchaseKind: 'upgrade' inside expect.objectContaining, and the fixture really is a same-period tier19 → tier49 change (prior SUBSCRIBED purchase on the same originalTransactionId), which isAppStoreSamePeriodUpgrade classifies as upgrade in store-subscription-completion.ts:492-501. The assertion can now fail.
  • Mobile negative assertion anchorRESOLVED by 28a8ac9bf (completedSpy asserted before the not.toHaveBeenCalledWith), and the anchor is real: onPurchaseCompleted (use-store-kilo-pass-purchase.ts:497-505) is exactly where the removed capture lived.

"Once per sale" guarantees — verified

  • App Store (both call sites). completeStoreKiloPassPurchase starts with SELECT … FROM kilocode_users … FOR UPDATE (store-subscription-completion.ts:102-113), so concurrent tRPC/ASSN completions for the same user are strictly serialized; the second one sees the existing kilo_pass_store_purchases row (:449) or the onConflictDoNothing empty return (:547-592) and yields alreadyProcessed: true. Gating on alreadyProcessed === false therefore collapses recovery replays, tRPC retries and ASSN/app races to exactly one emission. The discriminated union makes purchaseKind unavailable on the replay branch, so a silent ?? 'unknown' cannot creep in. ✅
  • Stripe. The audit anchor (:750) sits after the kilo_pass_subscriptions upsert (:569-597) and before this run's own Success append (:752). Under the pool's default isolation (READ COMMITTED — no isolationLevel override on these transactions), a concurrent redelivery of the same invoice blocks on the upsert's row lock, then re-reads the audit table with a fresh statement snapshot and sees the committed Success row → no double emit. Under REPEATABLE READ the same upsert would raise a serialization failure and roll back → also no double emit. Both branches are safe, so the "HARD INVARIANT" comment is accurate and load-bearing. ✅
  • Blocked / rollback paths. The duplicate-card gate returns at :679, i.e. before the anchor, so blocked purchases never emit. The catch block re-throws at :876 before the emit block, so a rolled-back run never emits. The yearly-cadence early return (:835) is after the anchor, so yearly still emits. ✅
  • Indexing. hasHandledKiloPassInvoicePaid is covered by IDX_kilo_pass_audit_log_stripe_invoice_id (packages/db/src/schema.ts:1907). Only this module ever writes KiloPassInvoicePaidHandled rows, so the anchor cannot be forged by another path. ✅
  • after() request scope. The only production entrypoints are app/api/kilo-pass/apple/notifications/route.ts:17 and app/api/stripe/webhook/route.tslib/stripe/index.ts:824. No cron, script, or queue path reaches runAfterResponse, so after() is always called inside a request scope. ✅
  • Residual (correctly documented, not a defect): a crash between commit and capture() loses that one event permanently, because the gate is now closed for every retry. Accepted in the PR text.

WARNING

apps/web/src/routers/kilo-pass-router.ts:1055-1067 — tRPC emission is neither deferred nor isolated from the error mapper.

Two distinct problems in one place:

  1. Durability. Unlike both webhook call sites, this site calls trackKiloPassPurchaseCompleted directly. PostHogClient is configured flushAt: 1, flushInterval: 0 (lib/posthog.ts), so capture() only starts an HTTP send; nothing keeps the invocation alive past the mutation response. On the highest-volume channel this reintroduces exactly the class of loss (RC3) the PR removes from the client. runAfterResponse already exists and is imported one module away.
  2. Blast radius. The call sits inside the try whose catch runs mapAppStoreCompletionError (:266-300). trackKiloPassPurchaseCompleted swallows capture() throws, so this is unlikely — but if anything in that path ever throws, a successfully recorded sale is reported to Sentry as a completion failure and returned to the mobile client as an error. Moving the emit below the try/catch (or into runAfterResponse) fixes both points at once and makes the three call sites symmetric.

I acknowledge the author declined (1) as an accepted residual matching the claw_trial_started precedent; (2) has not been raised before and is independently cheap to remove.

SUGGESTION

File Line Finding
apps/web/src/lib/kilo-pass/stripe-handlers-invoice-paid.ts 879-927 The emit block is placed before duplicate-card enforcement (:929), referral/affiliate reporting (:938-981) and cache recomputation (:990). In production after() defers the closure, so ordering is moot — but under IS_IN_AUTOMATED_TEST runAfterResponse awaits inline, and the closure contains an unguarded db.select(...). A DB hiccup there would propagate out of handleKiloPassInvoicePaid post-commit and skip those security/billing-relevant steps, and it means the tests exercise a different post-commit ordering than production. Wrapping the closure body in try/catch (analytics must never break the webhook) and/or moving the block to the end of the function removes both concerns.
apps/web/src/lib/kilo-pass/apple-store-notifications.ts 729-767 The let completionResult + as CompleteStoreKiloPassPurchaseResult | null widening cast (added in ab891dfd to fight tsgo's in-closure narrowing) is avoidable: have the transaction callback return the result — const completionResult = await db.transaction(async tx => { const r = await completeStoreKiloPassPurchase(...); …; return r; }); — which keeps full discriminated-union narrowing with no cast and no mutable outer binding.
apps/web/src/lib/kilo-pass/posthog-tracking.ts 49-56 runAfterResponse is a verbatim copy of the private helper in lib/kiloclaw/stripe-handlers.ts:426 (the comment says so). It is generic infrastructure living in a Kilo-Pass-specific tracking module; extracting one shared helper (e.g. lib/after-response.ts) would stop the third copy from appearing.
apps/web/src/lib/kilo-pass/posthog-tracking.test.ts 22-28 next/server.after is mocked and IS_IN_AUTOMATED_TEST is pinned true, so the production branch of runAfterResponse is never exercised — the helper that carries the durability guarantee for both webhooks has zero assertions. Two cheap tests (test mode → work awaited inline; non-test mode → after called with the work fn) would pin it.
apps/web/src/lib/kilo-pass/stripe-handlers-invoice-paid.ts 881-912 The two guard branches emit the identical Sentry message 'kilo_pass_purchase_completed skipped: missing purchaser or state', so a real alert can't tell which precondition failed; adding the offending field to extra (or distinct messages) would make it actionable. Note also that the second branch's tier/cadence checks are effectively unreachable — every path that sets shouldTrackPurchase = true passes :707-710 first — so today it is pure defence.

Optional follow-up (not this PR): Stripe at-most-once currently rests on lock-order reasoning rather than a constraint. A partial unique index on (action, result, stripe_invoice_id) where action = 'KiloPassInvoicePaidHandled' AND result = 'Success' would make the invariant DB-enforced and turn the "concurrent same-invoice redelivery" residual into a hard guarantee.

Also checked, no finding

  • Wire map / semantics. snake_case property map and the discriminated params type match the tests in posthog-tracking.test.ts exactly; purchase_kind never falls back; $0 invoices emit with amount_paid_usd: 0; CompleteStorePurchaseOutputSchema is unchanged so purchaseKind is stripped from the tRPC response (asserted in kilo-pass-router.test.ts).
  • purchaseKind classification. App Store: upgrade only for same-period tier increases, renewal whenever a provider subscription row already exists (structurally implies a prior transaction, since subscription + purchase rows are inserted in one transaction), else initial. Stripe: derived from billing_reason only — subscription_create → initial, subscription_cycle → renewal, subscription_update → upgrade, everything else unknown; never guessed from row presence. Consistent with the PR's data-team guidance.
  • Security baseline. The Sentry extra.properties payload contains no email/token/header/cookie; distinctId is deliberately excluded; user_id is an internal UUID.
  • Module-scope PostHogClient() matches the established pattern (cost-insights, security-agent, shell-security, code-indexing tracking modules).
  • amount_paid_usd = invoice.amount_paid / 100 — I agree with the author's decline: the same /100 + raw currency shape already exists in the adjacent referral call (:959-960), and the sibling currency property keeps the series honest. Not re-raised.

Verification performed (and what I could not run)

  • oxlint on apps/web/src/lib/kilo-pass + kilo-pass-router.ts: 0 warnings, 0 errors (58 files).
  • vitest run src/lib/kilo-pass/use-store-kilo-pass-purchase.test.tsx in apps/mobile at 28a8ac9bf: 32/32 pass.
  • pnpm --filter web typecheck: could not complete in this sandbox — @kilocode/trpc build plus tsgo --noEmit was killed after ~12 min CPU on a 6 GB box (Node 22 vs the repo's required 24 also warns). The specific TS construct in question was verified by reading (typeof on a type-only namespace import is valid TS); CI remains the authority for the full check.
  • ❌ Web jest slices (ASSN / Stripe handler / router / completion): could not run — no Docker in this sandbox, so no Postgres. Reviewed the assertions by reading instead; the emission suites look non-vacuous (concrete literals, mockClear() between phases, jest.mock factory that keeps runAfterResponse inline).

@kilo-code-bot

kilo-code-bot Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

The review is already posted. Summary: 1 new WARNING (tRPC emit call at kilo-pass-router.ts:1055 is inside a try whose catch maps errors to client-visible failures — so a throw during the fire-and-forget capture could turn a successful, recorded sale into a client-visible error; also still not deferred via runAfterResponse), plus 5 minor suggestions. Both previously-flagged items are confirmed fixed, and the core "once per sale" guarantee is verified sound for both App Store and Stripe paths.

No further action needed unless you want me to spawn a follow-up Cloud Agent session to fix the tRPC try/catch issue.

@iscekic
iscekic merged commit bbd4984 into main Jul 30, 2026
22 checks passed
@iscekic
iscekic deleted the kilo-pass-event-audit-924f branch July 30, 2026 18:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

human-ready The PR is ready for human review.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants