Skip to content

fix(stripe): pro-tier sync failure + reconciliation backstop (FAULT 16)#48

Draft
SoapyRED wants to merge 3 commits into
mainfrom
claude/fix-pro-tier-sync-AvjCX
Draft

fix(stripe): pro-tier sync failure + reconciliation backstop (FAULT 16)#48
SoapyRED wants to merge 3 commits into
mainfrom
claude/fix-pro-tier-sync-AvjCX

Conversation

@SoapyRED
Copy link
Copy Markdown
Owner

Summary

Hotfix for the first paying Pro customer (2026-05-20 ~19:22 BST signup) who paid £19 on Stripe but the site kept reading FREE / 100 req/day. Branch is claude/fix-pro-tier-sync-AvjCX (system-assigned override; the original task title hotfix/customer-pro-tier-sync-2026-05-21 is preserved in the audit doc).

Root cause: safeUpdateUserPlan in app/api/stripe/webhook/route.ts:32 called itself recursively instead of delegating to the imported updateUserPlan. Every checkout.session.completed event since the wrapper was added stack-overflowed → RangeError caught by try/catch with console.warn only (no Sentry capture) → webhook still returned 200 so Stripe never retried. 100% silent.

  • Phase 1 diagnosis at docs/audit/customer-pro-tier-sync-2026-05-21.md.
  • Phase 3 fix is the recursion bug + Sentry capture in the catch + a tier-reconciliation backstop wired into /api/auth/me + /api/auth/whoami.
  • scripts/admin/upgrade-user.mjs is the one-shot for Phase 2 (KV write requires prod creds Soap holds).
  • FAULT 16 encoded; runbook at docs/runbooks/customer-tier-sync.md.

What ships

File Change
app/api/stripe/webhook/route.ts recursion → delegated call; Sentry.captureException on catch
lib/auth/reconcile.ts new — reconcileTierFromStripe, cooldown-gated 1h/email, emits tier_mismatch_reconciled warning on heal
app/api/auth/me/route.ts calls reconcile after getUser
app/api/auth/whoami/route.ts calls reconcile only on plan: free (Pro fast path preserved)
scripts/admin/upgrade-user.mjs manual upgrade, Stripe-gated, refuses unpaid accounts
docs/audit/customer-pro-tier-sync-2026-05-21.md Phase 1 diagnosis
docs/FAULT-HISTORY-AND-PREVENTION.md FAULT CATEGORY 16 + log row
docs/runbooks/customer-tier-sync.md end-to-end triage runbook
STATE.md Customer incidents section + lastUpdated → 21 May
CHANGELOG.md + lib/changelog-data.ts mirrored 2026-05-21 entry

Soap's checklist before merge

1. Stripe goodwill coupon — apply via dashboard

Coupon already created via Stripe MCP: 2z8mCBes ("Goodwill 1 month - tier sync 2026-05-21", 100% off, duration repeating × 1 month).

  • Stripe Dashboard → Subscriptions → the customer's sub_* → "Update subscription" → "Add coupon" → select 2z8mCBes → save.
  • Confirm next invoice preview shows £0.

(Programmatic apply isn't exposed via the Stripe MCP tool surface we use; STATE.md already pins "Chrome BLOCKED on Stripe — Soap-manual only".)

2. KV write — run the admin script

The container this PR was prepared in has no prod creds, so the KV write is yours:

cd /path/to/freighttools
git checkout claude/fix-pro-tier-sync-AvjCX
npm ci  # if not done
node --env-file=.env.local scripts/admin/upgrade-user.mjs \
  --email <customer email> \
  --stripe-customer cus_UYLSdNQnCwt5Tf

The script:

  1. Hits Stripe directly to verify the customer's cus_id has an active sub (refuses if not).
  2. Reads user:<email> from KV.
  3. Prints BEFORE state (API key masked to last 4).
  4. Writes plan: "pro" + stripeCustomerId to both user:<email> and mirror key:<apiKey>.
  5. Reads back. Verifies both records show plan: "pro".
  6. Prints AFTER state.

Exit codes: 0 upgrade done, 1 Stripe verify fail / KV error, 2 no record, 3 already Pro.

3. Customer-side verification

After step 2, confirm with the customer's existing API key (do NOT rotate):

curl -s "https://www.freightutils.com/api/auth/whoami" -H "X-API-Key: fu_live_<their key>" | jq

Must return tier: "pro".

4. Sentry alert rule (one-time, manual)

Sentry → Alerts → Create Alert Rule → Issue Alert:

  • When: event matches message:"tier_mismatch_reconciled" AND level:warning
  • Frequency: any single occurrence
  • Action: email Soap

The alert fires the moment the reconciliation backstop heals a paying customer — which by definition means the primary webhook path failed silently. One alert = one paying customer almost-churned. P1.

5. Smoke + Sentry sweep

  • npm run smoke-test against preview / prod.
  • 10-min prod-curl 5xx sweep on /api/stripe/*, /api/account, /api/auth/whoami, /api/auth/me.

Customer follow-up email (draft — copy/paste)

Subject: Your FreightUtils Pro account is live + one month on us

Hi Sytze,

Quick follow-up on your email. You're right — your Pro payment went through cleanly on Stripe, but the upgrade didn't land on your account on our end. Turns out a bug in the post-payment handler was silently swallowing the upgrade event. Diagnosed it this morning, fixed it, and added a check that auto-reconciles against Stripe on the next request so this can't repeat for anyone.

Your account is now on Pro (50,000 requests/month). Same API key — no rotation needed. You can confirm at any time with:

curl https://www.freightutils.com/api/auth/whoami -H "X-API-Key: <your key>"

By way of apology, I've added a free month to your subscription — your next invoice will be £0 and billing resumes after that.

I noticed you'd already been integrating with the TFFxpress backfill — happy to hop on a 15-minute call if that'd be useful, otherwise no pressure either way. Either way, thank you for being our first paying Pro customer.

Marius
founder, FreightUtils

FAULT 5 checklist

  • siteStats.ts — n/a (no displayed numbers changed)
  • Sitemap — n/a (no new pages)
  • public/openapi.json — n/a (no new endpoint)
  • /api-docs page — n/a
  • Nav dropdown — n/a (no new tool)
  • Homepage tool grid — n/a
  • CHANGELOG.md entry added — 2026-05-21
  • /changelog page (lib/changelog-data.ts) — entry added
  • MCP server tool registration — n/a
  • Footer links — n/a
  • SoapyRED/freightutils-mcp README — n/a (no MCP-tool changes)
  • npm freightutils-mcp version bump — n/a
  • Postman collection — n/a
  • Tool page ≥200 words — n/a
  • withAuditRest — the affected route is app/api/stripe/webhook which is in EXCLUDED_PATHS of scripts/lint-audit.mjs (webhook by design). lint:audit passes.
  • generateMetadata() — n/a (no new public page)
  • IndexNow — n/a (no new URLs)

Test plan

  • npx tsc --noEmit clean.
  • npm run lint:audit passes (20 routes wrapped, 18 excluded).
  • npm run lint:seo-titles passes.
  • npm run build succeeds.
  • node scripts/sentry-redact-smoke.mjs passes.
  • node --check scripts/admin/upgrade-user.mjs passes.
  • Soap: smoke test against preview.
  • Soap: KV upgrade script run; both records show plan: pro.
  • Soap: /api/auth/whoami returns tier: pro with customer's existing key.
  • Soap: Stripe goodwill coupon applied via dashboard.
  • Soap: Sentry alert rule on tier_mismatch_reconciled created.
  • Soap: 10-min prod-curl 5xx sweep on /api/stripe/*, /api/auth/*.

https://claude.ai/code/session_019A4f9SxA6vyzdoC67JLmTZ


Generated by Claude Code

claude added 3 commits May 21, 2026 07:44
Phase 1 of hotfix sprint customer-pro-tier-sync-2026-05-21. Stripe shows
a paid active Pro subscription for the first paying customer; site reads
FREE because the webhook handler's safeUpdateUserPlan wrapper calls
itself recursively (app/api/stripe/webhook/route.ts:32) instead of the
imported updateUserPlan. RangeError swallowed by try/catch, console.warn
only, no Sentry capture → 100% silent failure. Webhook returned 200 so
Stripe never retried. Phase 2 (immediate fix) + Phase 3 (recursion fix
+ reconciliation backstop + Sentry alert) follow in this PR.
The safeUpdateUserPlan wrapper in the Stripe webhook handler was
self-recursive instead of delegating to the imported updateUserPlan.
Every Pro upgrade since the wrapper was added stack-overflowed →
RangeError swallowed by try/catch with console.warn only (no Sentry
capture) → webhook still returned 200 so Stripe never retried. The
first paying Pro customer (2026-05-20) hit the bug.

- app/api/stripe/webhook/route.ts: call updateUserPlan; add
  Sentry.captureException in the catch so future swallows are visible.
- lib/auth/reconcile.ts: new tier reconciliation function. When a
  user record reads plan='free' AND has a stripeCustomerId, asks
  Stripe for active subs and self-heals if mismatched. Cooldown-gated
  1h per email; emits tier_mismatch_reconciled Sentry warning on heal
  (the future Sentry alert rule listens for this).
- app/api/auth/me/route.ts: call reconcile after getUser so the
  /account page self-heals on next visit.
- app/api/auth/whoami/route.ts: gated reconcile on free-tier reads
  only — Pro keys keep the fast path with one KV read.

Stripe is the source of truth. Middleware deliberately does NOT
reconcile (would hammer Stripe on every API request); the identity
endpoints are the right cooldown-friendly hook.
- scripts/admin/upgrade-user.mjs: one-shot manual tier upgrade.
  Verifies the customer has an active Pro subscription on Stripe
  BEFORE writing KV — refuses to upgrade an unpaid account. Reads
  back the user:<email> and key:<apiKey> records post-write and
  prints before/after for the audit trail. Distinct exit codes for
  Stripe-verify-fail / no-record / already-Pro.
- docs/runbooks/customer-tier-sync.md: end-to-end triage runbook.
  Stripe-side verification, KV inspection, manual upgrade, Stripe
  goodwill coupon (dashboard-applied per the Soap-manual rule),
  customer-side verification curl, Sentry alert wire-up.
- docs/FAULT-HISTORY-AND-PREVENTION.md: FAULT CATEGORY 16 added
  with root cause, prevention model, and 2026-05-21 log row.
- STATE.md: Customer incidents section logging 2026-05-21 with
  customer initials only per the no-PII-beyond-first-paying-customer
  rule. lastUpdated bumped to 21 May 2026.
- CHANGELOG.md + lib/changelog-data.ts: 2026-05-21 entry mirrored
  per FAULT 5 dual-source-until-collapsed rule.
@vercel
Copy link
Copy Markdown

vercel Bot commented May 21, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
freighttools Ready Ready Preview, Comment May 21, 2026 7:59am

Request Review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants