Skip to content

fix(billing): skip downgrade on stale/superseded subscription cancel#239

Merged
mastermanas805 merged 2 commits into
masterfrom
fix/billing-stale-sub-cancel-guard-2026-06-04
Jun 4, 2026
Merged

fix(billing): skip downgrade on stale/superseded subscription cancel#239
mastermanas805 merged 2 commits into
masterfrom
fix/billing-stale-sub-cancel-guard-2026-06-04

Conversation

@mastermanas805

Copy link
Copy Markdown
Member

Summary

P1, money-sensitive. A subscription.cancelled / subscription.halted / subscription.deauthenticated webhook (all three route to handleSubscriptionCancelled, plus subscription.completed with paid_count==0 delegates to it) carries notes.team_id verbatim from whatever subscription fired it — including a superseded one.

After a hobby→pro plan change the old hobby subscription stays alive in Razorpay carrying the same notes.team_id. Its eventual cancellation silently downgraded an actively-paying Pro customer to hobby/free, sent a cancellation email, and 402'd new provisions — while billing on the live Pro sub continued.

Fix

Mirrors the existing charged-path lower_tier guard. After resolving the team (and reading teams.stripe_customer_id — the legacy column that stores the Razorpay subscription id, exposed as Team.RazorpaySubscriptionID):

  • If the webhook's sub.ID does not match the team's stored live subscription id and that live id is non-empty → skip the downgrade, WARN + emit a billing.charge_undeliverable audit row (reason=stale_subscription_cancel) for operator reconciliation, return nil (non-retryable, keep the 200). No downgrade, no cancellation email.
  • Empty live id (never stored / lookup miss) → falls through to historical downgrade behaviour.

Preserves the documented downgrade-cap asymmetry (no teardown of over-cap resources).

Coverage block

Symptom:        active Pro customer downgraded by a superseded sub's cancellation webhook
Enumeration:    grep handleSubscriptionCancelled call sites in billing.go dispatch
Sites found:    4 (subscription.cancelled, .halted, .deauthenticated, .completed[paid==0])
Sites touched:  1 (the shared handleSubscriptionCancelled — covers all 4 routes)
Coverage test:  TestBillingWebhook_SubscriptionCancelled_StaleSub_IsIgnored
Live verified:  awaiting deploy (webhook-path tests pass against test DB)

Tests (all pass against test Postgres)

  • TestBillingWebhook_SubscriptionCancelled_LiveSub_StillDowngrades — (a) cancel for the LIVE sub still downgrades, no stale-skip audit.
  • TestBillingWebhook_SubscriptionCancelled_StaleSub_IsIgnored — (b) cancel for a non-matching sub id is IGNORED: tier kept, stale-skip audit emitted, no subscription.canceled row.
  • TestBillingWebhook_SubscriptionCancelled_EmptyLiveSub_FallsThrough — (c) empty live id → historical downgrade.

All exercise the real signature-verify → dispatch → handler path.

🤖 Generated with Claude Code

A subscription.cancelled/halted/deauthenticated webhook carries
notes.team_id verbatim from whatever subscription fired it — including a
SUPERSEDED one. After a hobby->pro plan change the old hobby subscription
stays alive in Razorpay carrying the same notes.team_id; its eventual
cancellation (or a halt/deauth, which all route to
handleSubscriptionCancelled) silently downgraded the team that is now
actively paying on a DIFFERENT live Pro subscription.

Mirror the charged-path lower_tier guard: after resolving the team, if the
webhook's sub.ID does NOT match the team's stored live subscription id
(teams.stripe_customer_id, == razorpay_subscription_id) AND that live id is
non-empty, skip the downgrade, WARN + emit a billing.charge_undeliverable
audit row (reason=stale_subscription_cancel), and return nil (non-retryable,
keep the 200). An empty live id falls through to historical behaviour.

Preserves the documented downgrade-cap asymmetry (no teardown). Covers all
three routed event types (cancelled/halted/deauthenticated) and the
paid-completed path that delegates to this handler.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@mastermanas805 mastermanas805 enabled auto-merge (squash) June 4, 2026 02:39
@mastermanas805 mastermanas805 merged commit 52ec36a into master Jun 4, 2026
18 checks passed
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