Context
Discovered during Media.net escalation #321 cleanup sweep. Single drift case across 152 active orgs, but a real bug.
What happened
WPP Media (org_01KDTESPG8WZMME4AJ0VQ0AJKR) lapsed (invoice non-payment, sub auto-canceled by Stripe 2026-04-25T21:26:49Z), but our DB still shows them as subscription_status=active, membership_tier=company_icl, subscription_canceled_at=null.
Stripe delivered the cancellation event (evt_1TQDivHs54uYsO3aenPqnzI2, pending_webhooks: 0 — our endpoint ack'd 200). The handler at server/src/http.ts:3674-3880 did not update the org row.
Root cause hypothesis
WPP's org row has a customer↔sub mismatch:
stripe_customer_id = cus_TmJv7a2qMH5Ew1 (cameron.saxby@wppmedia.com — current)
stripe_subscription_id = sub_1SrKXOHs54uYsO3aclNCZ9Dv — belongs to cus_ToyU1L0YjiliFK per Stripe (NOT our linked customer)
When the cancellation event arrived for sub_1SrKXOHs54uYsO3aclNCZ9Dv, the event's customer was cus_ToyU1L0YjiliFK. The resolver (server/src/billing/webhook-helpers.ts:27-90) walks:
- Fast path:
stripe_customer_id lookup → miss (we have a different customer linked)
- Stripe customer metadata → empty (
cus_ToyU1L0YjiliFK has no metadata)
- Subscription metadata → has
workos_organization_id pointing at WPP. Should resolve.
- Tries
setStripeCustomerId(WPP_id, 'cus_ToyU1L0YjiliFK') → throws StripeCustomerConflictError (WPP already has a different customer linked). Caught and logged, function returns WPP.
Per the code, the handler should then proceed to UPDATE WPP's row to subscription_status=canceled, subscription_canceled_at=<ts>. It didn't. Without production logs from 2026-04-25, the exact failure point is unclear.
Leading hypotheses
- The
StripeCustomerConflictError path doesn't actually return the org cleanly — re-read the error/return semantics in webhook-helpers.ts:78-89. Possibly the catch swallows but then the function falls through to a code path that returns null.
isStaleNonLiveEvent guard at http.ts:3820-3826 misfires. Verified above it should NOT trigger here, but worth re-reading.
- UPDATE ran and was later overwritten by another event.
Proposed work
Out of scope
- The customer-link drift itself (how WPP ended up with a sub belonging to a different customer than the linked one). Probably a manual admin action; separate investigation if it recurs.
- WPP's specific data fix — Brian confirmed they lapsed; will reset state separately.
Context
Discovered during Media.net escalation #321 cleanup sweep. Single drift case across 152 active orgs, but a real bug.
What happened
WPP Media (
org_01KDTESPG8WZMME4AJ0VQ0AJKR) lapsed (invoice non-payment, sub auto-canceled by Stripe 2026-04-25T21:26:49Z), but our DB still shows them assubscription_status=active, membership_tier=company_icl, subscription_canceled_at=null.Stripe delivered the cancellation event (
evt_1TQDivHs54uYsO3aenPqnzI2,pending_webhooks: 0— our endpoint ack'd 200). The handler atserver/src/http.ts:3674-3880did not update the org row.Root cause hypothesis
WPP's org row has a customer↔sub mismatch:
stripe_customer_id = cus_TmJv7a2qMH5Ew1(cameron.saxby@wppmedia.com — current)stripe_subscription_id = sub_1SrKXOHs54uYsO3aclNCZ9Dv— belongs tocus_ToyU1L0YjiliFKper Stripe (NOT our linked customer)When the cancellation event arrived for
sub_1SrKXOHs54uYsO3aclNCZ9Dv, the event'scustomerwascus_ToyU1L0YjiliFK. The resolver (server/src/billing/webhook-helpers.ts:27-90) walks:stripe_customer_idlookup → miss (we have a different customer linked)cus_ToyU1L0YjiliFKhas no metadata)workos_organization_idpointing at WPP. Should resolve.setStripeCustomerId(WPP_id, 'cus_ToyU1L0YjiliFK')→ throwsStripeCustomerConflictError(WPP already has a different customer linked). Caught and logged, function returns WPP.Per the code, the handler should then proceed to UPDATE WPP's row to
subscription_status=canceled, subscription_canceled_at=<ts>. It didn't. Without production logs from 2026-04-25, the exact failure point is unclear.Leading hypotheses
StripeCustomerConflictErrorpath doesn't actually return the org cleanly — re-read the error/return semantics inwebhook-helpers.ts:78-89. Possibly the catch swallows but then the function falls through to a code path that returns null.isStaleNonLiveEventguard athttp.ts:3820-3826misfires. Verified above it should NOT trigger here, but worth re-reading.Proposed work
webhook-helpers.ts:27-90path with a test that simulates the customer-drift scenario and asserts the org row gets updated.customer.subscription.*event we process, even when no org found, so future drift cases are debuggable from our side without going to Stripe.subscription_status='active'orgs, retrieve sub from Stripe, flag drift). Could run weekly as a cronjob and post to an admin channel.Out of scope