fix(billing): skip downgrade on stale/superseded subscription cancel#239
Merged
mastermanas805 merged 2 commits intoJun 4, 2026
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
P1, money-sensitive. A
subscription.cancelled/subscription.halted/subscription.deauthenticatedwebhook (all three route tohandleSubscriptionCancelled, plussubscription.completedwithpaid_count==0delegates to it) carriesnotes.team_idverbatim 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_tierguard. After resolving the team (and readingteams.stripe_customer_id— the legacy column that stores the Razorpay subscription id, exposed asTeam.RazorpaySubscriptionID):sub.IDdoes not match the team's stored live subscription id and that live id is non-empty → skip the downgrade,WARN+ emit abilling.charge_undeliverableaudit row (reason=stale_subscription_cancel) for operator reconciliation, returnnil(non-retryable, keep the 200). No downgrade, no cancellation email.Preserves the documented downgrade-cap asymmetry (no teardown of over-cap resources).
Coverage block
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, nosubscription.canceledrow.TestBillingWebhook_SubscriptionCancelled_EmptyLiveSub_FallsThrough— (c) empty live id → historical downgrade.All exercise the real signature-verify → dispatch → handler path.
🤖 Generated with Claude Code