Skip to content

fix(billing): catch tenant cancellations — ops alerts, end-customer guard, nightly Stripe reconcile - #60

Open
keithfawcett wants to merge 3 commits into
mainfrom
fix/billing-cancellation-sync
Open

fix(billing): catch tenant cancellations — ops alerts, end-customer guard, nightly Stripe reconcile#60
keithfawcett wants to merge 3 commits into
mainfrom
fix/billing-cancellation-sync

Conversation

@keithfawcett

Copy link
Copy Markdown
Contributor

Incident

A white-label tenant cancelled their subscription via the Stripe Customer Portal on Jul 17 with cancel-at-period-end. Nobody was notified; the subscription ended Aug 3; the final invoice ($41.06 white-label proration) went into dunning against a detached payment method; and our local state (billing mirror, Tenant.whiteLabel, custom-domain routing) stayed live because the event never reached us.

Root cause: the prod webhook endpoint is not subscribed to customer.subscription.updated / customer.subscription.deleted — the handlers exist, but docs/deploy-production.md §6 never listed those events, so the endpoint config (built from that doc) doesn't send them. There was also no notification anywhere in the handlers, and no fallback when a webhook is missed: the nightly entitlement sweep trusts the local mirror the missing webhook was supposed to maintain.

Fixes

  1. Ops notifications (PLATFORM_OPS_EMAIL, same channel as brand review — sendOpsEmail extracted to platform-ops-mail.ts):

    • cancellation scheduled / resumed — detected on subscription.updated via previous_attributes.cancel_at_period_end, so you hear about churn weeks before the sub actually ends, with the customer's cancellation feedback included
    • subscription ended (webhook or reconcile)
    • dunning failure on the tenant's own OpenPartner invoice, with a link to the invoice and a nudge to void it if the amount isn't owed
  2. End-customer guard (latent bug found during the fix): the webhook's subscription.updated/deleted handlers ran for any subscription event that resolved to a tenant — including a merchant end-customer's subscription resolved via the Identity chain. A churning end-customer could clobber Tenant.stripeSubscriptionId and disable the tenant's white-label. Both paths (plus the new invoice.payment_failed path) now require the event's customer to be the tenant's own billing customer.

  3. Nightly billing-subscription-reconcile job (04:25 UTC, before the domain jobs): polls Stripe for every tenant that locally claims a subscription and heals missed-webhook drift — an ended sub clears the mirror and revokes white-label + custom-domain routing exactly like the deleted webhook (shared handleTenantSubscriptionEnded), live subs refresh HostedBillingState, and a newly discovered cancel_at_period_end alerts once (Config-keyed dedupe). A missed cancellation now self-heals within a day instead of never.

Deploy notes

  • No DB migration.
  • Stripe Dashboard (manual, required): add customer.subscription.updated + customer.subscription.deleted to webhook Destination A. Doc updated with a warning that these are load-bearing.
  • After deploy, the 04:25 UTC job (or a manual run) will clean up the cancelled tenant's stale state automatically.

Tests

  • 6 new webhook tests: cancel-scheduled notify + dedupe marker, quiet on unrelated updates, deleted clears + notifies, end-customer deleted must not touch tenant state, own-invoice dunning alerts + skips attribution, end-customer dunning still audits.
  • New billing-reconcile.test.ts (6 tests): canceled/resource_missing heal, notify-once dedupe, marker reset on resume, mirror refresh, per-tenant error isolation.
  • Full API suite: 259 passing. Typecheck + lint clean.

🤖 Generated with Claude Code

keithfawcett and others added 3 commits August 7, 2026 13:54
…uard, nightly Stripe reconcile

A hosted tenant cancelled via the Stripe Customer Portal on Jul 17 and we
never noticed: the prod webhook endpoint wasn't subscribed to
customer.subscription.updated/deleted (docs listed neither), so the local
billing mirror, white-label entitlement, and custom-domain routing all
stayed live, and nobody was emailed. Three fixes:

- Ops notifications (PLATFORM_OPS_EMAIL) on tenant billing lifecycle:
  cancellation scheduled/resumed (detected via previous_attributes on
  subscription.updated), subscription ended, and dunning failures on the
  tenant's own invoices. Shared sendOpsEmail extracted from brand-review.

- Guard the webhook's tenant-billing paths on the event's customer being
  the tenant's OWN billing customer. Previously a merchant end-customer's
  subscription.updated/deleted (resolved via the Identity chain) would
  clobber Tenant.stripeSubscriptionId and could disable white-label.

- Nightly billing-subscription-reconcile job (04:25 UTC): polls Stripe for
  every tenant that locally claims a subscription and heals missed-webhook
  drift — ended subs clear the mirror + revoke white-label/custom-domain
  exactly like the deleted webhook, live subs refresh HostedBillingState,
  newly discovered cancel_at_period_end alerts once (Config-keyed dedupe).

Docs: deploy-production.md now lists subscription.updated/deleted as
required events for Destination A and explains why they're load-bearing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Codex review of PR #60 surfaced five real gaps; all fixed:

- resource_missing NEVER heals: a canceled sub stays retrievable forever,
  so "missing" means the key can't see the id (wrong account / test-mode
  key). Healing on it would let one config mistake mass-revoke every live
  tenant's white-label + custom domain. Now reported as an error instead.

- Stale-subscription safety: the deleted webhook and the nightly reconcile
  clear the pointer with ONE conditional UPDATE keyed on the exact sub id
  the event/poll referred to — a late retry for a since-replaced sub, or a
  resubscribe racing the poll, matches 0 rows and is skipped. The updated
  webhook likewise ignores subs that aren't the tenant's current one and
  never adopts a terminal sub into an empty pointer. Own-billing detection
  gains a sub-id fallback for tenants with no persisted stripeCustomerId.

- Webhook-retry email dedupe: the cancel-notice Config marker now guards
  the webhook path too (not just the reconcile), deleted retries are
  silenced by the conditional clear, and dunning alerts dedupe per
  (invoice, attempt) so redeliveries stay quiet while every new collection
  attempt still notifies.

- cancel_at coverage: cancellations scheduled via a bare cancel_at
  (dashboard date / Subscription Schedules) now notify, not just the
  Customer Portal's cancel_at_period_end.

- Notify-then-mark: sendOpsEmail reports transport success and the
  cancel-notice marker is only written after a successful send, so an SMTP
  outage retries next pass instead of losing the notice forever.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ated guard

Open review found a hole in the PR #60 stale-sub guard: it only rejected
TERMINAL snapshots when the pointer was null, so a late/resent/out-of-order
ACTIVE subscription.updated arriving after subscription.deleted (pointer
already cleared) passed both guards and re-persisted the old sub id,
resurrecting the canceled subscription and re-applying white-label. Stripe
guarantees no event ordering, so this reordering is real.

An updated may now touch billing state only when it refers to the tenant's
CURRENT subscription (pointer non-null AND equal to sub.id). A null pointer
is re-established solely by checkout.session.completed on a real
(re)subscribe, so an updated never legitimately adopts one from null. Added
a test for the active-after-delete ordering.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

1 participant