Skip to content

Commit 57ee5ae

Browse files
Merge branch 'master' into fix/elevate-suspended-resource-tier-2026-06-04
2 parents a193c7c + 52ec36a commit 57ee5ae

2 files changed

Lines changed: 207 additions & 10 deletions

File tree

internal/handlers/billing.go

Lines changed: 54 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -189,8 +189,8 @@ func detectBillingMisconfiguration(cfgEnvironment, razorpayKeyID string) (code,
189189

190190
// BillingHandler handles billing and Razorpay webhook endpoints.
191191
type BillingHandler struct {
192-
db *sql.DB
193-
cfg *config.Config
192+
db *sql.DB
193+
cfg *config.Config
194194
// email is the Mailer used for all webhook-triggered sends (payment
195195
// receipts, payment-failed dunning, etc.). The interface lets main.go
196196
// wrap the underlying *email.Client in a *email.BreakingClient — a
@@ -1115,12 +1115,12 @@ type rzpSubscriptionEntity struct {
11151115
}
11161116

11171117
type rzpPaymentEntity struct {
1118-
ID string `json:"id"`
1119-
Amount int64 `json:"amount"`
1120-
Currency string `json:"currency"`
1121-
Email string `json:"email"`
1122-
AttemptCount int `json:"attempt_count"`
1123-
ErrorDescription string `json:"error_description"`
1118+
ID string `json:"id"`
1119+
Amount int64 `json:"amount"`
1120+
Currency string `json:"currency"`
1121+
Email string `json:"email"`
1122+
AttemptCount int `json:"attempt_count"`
1123+
ErrorDescription string `json:"error_description"`
11241124
// SubscriptionID + OrderID + Notes (B11-P1, 2026-05-20): used to
11251125
// resolve the team server-side instead of trusting payload.email
11261126
// verbatim. A payment.failed entity carries `subscription_id` for
@@ -2100,11 +2100,48 @@ func (h *BillingHandler) handleSubscriptionCancelled(ctx context.Context, c *fib
21002100
return fmt.Errorf("subscription.cancelled team resolve: %w", err)
21012101
}
21022102

2103-
// Snapshot the prior tier so the audit row can capture from→to. Failure
2104-
// to read it is non-fatal — we just emit with from_tier="".
2103+
// Snapshot the prior tier so the audit row can capture from→to, and the
2104+
// team's CURRENT live subscription id so we can reject a stale/superseded
2105+
// cancellation (see the guard below). Failure to read the team is non-fatal
2106+
// — we just emit with from_tier="" and an empty live sub id (which falls
2107+
// through to the historical always-downgrade behaviour).
21052108
fromTier := ""
2109+
liveSubID := ""
21062110
if team, lookupErr := models.GetTeamByID(ctx, h.db, teamID); lookupErr == nil && team != nil {
21072111
fromTier = team.PlanTier
2112+
if team.RazorpaySubscriptionID.Valid {
2113+
liveSubID = strings.TrimSpace(team.RazorpaySubscriptionID.String)
2114+
}
2115+
}
2116+
2117+
// STALE-SUB GUARD (MONEY-SENSITIVE, 2026-06-04): a cancel/halt/deauth
2118+
// webhook carries notes.team_id verbatim from WHATEVER subscription fired
2119+
// it — including a SUPERSEDED one. After a hobby→pro plan change the old
2120+
// hobby subscription stays alive in Razorpay carrying the same notes.team_id;
2121+
// its eventual cancellation (or a halt/deauth) would otherwise downgrade the
2122+
// team that is now actively paying on a DIFFERENT live Pro subscription.
2123+
//
2124+
// Mirror the charged-path lower_tier guard (handleSubscriptionCharged): if
2125+
// the webhook's sub.ID does NOT match the team's stored live subscription id
2126+
// AND that live id is non-empty, this is a stale event for an old
2127+
// subscription. Skip the downgrade entirely, log a loud WARN + a
2128+
// billing.charge_undeliverable audit row for operator reconciliation, and
2129+
// keep the higher tier. An empty live id (never stored a sub id, or a
2130+
// lookup miss above) falls through to the historical behaviour — we cannot
2131+
// prove the event is stale, and a never-paid team should still downgrade.
2132+
if liveSubID != "" && sub.ID != "" && sub.ID != liveSubID {
2133+
slog.Warn("billing.subscription.cancelled.stale_subscription_skip",
2134+
"team_id", teamID,
2135+
"event_subscription_id", sub.ID,
2136+
"live_subscription_id", liveSubID,
2137+
"current_tier", fromTier,
2138+
"action", "cancel/halt/deauth carried a subscription_id that is NOT the team's live subscription — "+
2139+
"NOT downgrading (likely a superseded subscription from a prior plan change). Operator: verify "+
2140+
"the team's live subscription is healthy; if this WAS the live sub, reconcile manually",
2141+
)
2142+
emitChargeUndeliverableAudit(ctx, h.db, teamID, sub, event,
2143+
chargeUndeliverableReasonStaleSubscriptionCancel, fromTier)
2144+
return nil
21082145
}
21092146

21102147
// Downgrade behaviour: a cancellation with zero paid invoices means the
@@ -3236,6 +3273,13 @@ const (
32363273
// downgrades flow through cancellation/plan-change), so the tier was kept
32373274
// and the charge flagged for operator reconciliation.
32383275
chargeUndeliverableReasonLowerTierCharge = "lower_tier_charge"
3276+
// chargeUndeliverableReasonStaleSubscriptionCancel — 2026-06-04: a
3277+
// subscription.cancelled/halted/deauthenticated webhook carried a
3278+
// subscription_id that does NOT match the team's stored live subscription.
3279+
// The event is for a superseded subscription (e.g. the old sub left alive
3280+
// after a hobby→pro plan change), so the downgrade was SKIPPED to protect an
3281+
// actively-paying customer. Flagged for operator reconciliation.
3282+
chargeUndeliverableReasonStaleSubscriptionCancel = "stale_subscription_cancel"
32393283
)
32403284

32413285
// F11 (billing-trust audit 2026-05-19) — cancellation copy.
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
package handlers_test
2+
3+
import (
4+
"net/http"
5+
"testing"
6+
7+
"github.com/google/uuid"
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
11+
"instant.dev/internal/testhelpers"
12+
)
13+
14+
// Stale-sub downgrade guard (MONEY-SENSITIVE, 2026-06-04).
15+
//
16+
// A subscription.cancelled/halted/deauthenticated webhook carries
17+
// notes.team_id verbatim from WHATEVER subscription fired it — including a
18+
// SUPERSEDED one. After a hobby→pro plan change the old hobby subscription
19+
// stays alive in Razorpay; its eventual cancellation must NOT downgrade the
20+
// team that is now actively paying on a different live Pro subscription.
21+
//
22+
// These tests pin three behaviours exercised through the real webhook handler
23+
// path (signature verify → dispatch → handleSubscriptionCancelled):
24+
// (a) a cancel for the team's LIVE subscription still downgrades.
25+
// (b) a cancel for a STALE/non-matching subscription id is IGNORED (no
26+
// UpdatePlanTier, a billing.charge_undeliverable audit row IS emitted).
27+
// (c) an empty live subscription id falls through to historical behaviour
28+
// (the team is downgraded — we cannot prove the event is stale).
29+
30+
// TestBillingWebhook_SubscriptionCancelled_LiveSub_StillDowngrades verifies
31+
// behaviour (a): when the cancelled webhook's subscription_id MATCHES the
32+
// team's stored live subscription, the historical downgrade still happens.
33+
func TestBillingWebhook_SubscriptionCancelled_LiveSub_StillDowngrades(t *testing.T) {
34+
db, cleanDB := billingStateNeedsDB(t)
35+
defer cleanDB()
36+
37+
app, _ := billingWebhookDBApp(t, db)
38+
39+
teamID := testhelpers.MustCreateTeamDB(t, db, "pro")
40+
defer db.Exec(`DELETE FROM teams WHERE id = $1::uuid`, teamID)
41+
42+
liveSub := "sub_live_" + uuid.NewString()
43+
_, err := db.Exec(`UPDATE teams SET stripe_customer_id = $1 WHERE id = $2::uuid`, liveSub, teamID)
44+
require.NoError(t, err)
45+
46+
// Webhook fires for the SAME (live) subscription → downgrade proceeds.
47+
payload := makeSubscriptionCancelledPayload(t, teamID, liveSub)
48+
req := signedWebhookRequest(t, payload)
49+
resp, err := app.Test(req, 5000)
50+
require.NoError(t, err)
51+
defer resp.Body.Close()
52+
require.Equal(t, http.StatusOK, resp.StatusCode)
53+
54+
// Tier dropped to hobby (paid_count omitted → courtesy floor).
55+
var newTier string
56+
require.NoError(t, db.QueryRow(`SELECT plan_tier FROM teams WHERE id = $1::uuid`, teamID).Scan(&newTier))
57+
assert.Equal(t, "hobby", newTier, "cancel for the LIVE sub must downgrade")
58+
59+
// No stale-skip audit row — this was a legitimate cancellation.
60+
var staleCount int
61+
require.NoError(t, db.QueryRow(`
62+
SELECT count(*) FROM audit_log
63+
WHERE team_id = $1::uuid
64+
AND kind = 'billing.charge_undeliverable'
65+
AND metadata->>'reason' = 'stale_subscription_cancel'`, teamID).Scan(&staleCount))
66+
assert.Equal(t, 0, staleCount, "live-sub cancel must NOT emit a stale-skip audit row")
67+
}
68+
69+
// TestBillingWebhook_SubscriptionCancelled_StaleSub_IsIgnored verifies
70+
// behaviour (b): a cancelled webhook whose subscription_id does NOT match the
71+
// team's stored live subscription is treated as a superseded event — the tier
72+
// is KEPT and a billing.charge_undeliverable (reason=stale_subscription_cancel)
73+
// audit row is emitted for operator reconciliation.
74+
func TestBillingWebhook_SubscriptionCancelled_StaleSub_IsIgnored(t *testing.T) {
75+
db, cleanDB := billingStateNeedsDB(t)
76+
defer cleanDB()
77+
78+
app, _ := billingWebhookDBApp(t, db)
79+
80+
teamID := testhelpers.MustCreateTeamDB(t, db, "pro")
81+
defer db.Exec(`DELETE FROM teams WHERE id = $1::uuid`, teamID)
82+
83+
liveSub := "sub_live_pro_" + uuid.NewString()
84+
_, err := db.Exec(`UPDATE teams SET stripe_customer_id = $1 WHERE id = $2::uuid`, liveSub, teamID)
85+
require.NoError(t, err)
86+
87+
// Webhook fires for a DIFFERENT (superseded hobby) subscription.
88+
staleSub := "sub_stale_hobby_" + uuid.NewString()
89+
payload := makeSubscriptionCancelledPayload(t, teamID, staleSub)
90+
req := signedWebhookRequest(t, payload)
91+
resp, err := app.Test(req, 5000)
92+
require.NoError(t, err)
93+
defer resp.Body.Close()
94+
// Non-retryable skip keeps the 200 — Razorpay must not redeliver.
95+
require.Equal(t, http.StatusOK, resp.StatusCode)
96+
97+
// Tier UNCHANGED — the actively-paying Pro customer must not be downgraded.
98+
var newTier string
99+
require.NoError(t, db.QueryRow(`SELECT plan_tier FROM teams WHERE id = $1::uuid`, teamID).Scan(&newTier))
100+
assert.Equal(t, "pro", newTier, "stale-sub cancel must NOT downgrade a live paying customer")
101+
102+
// A stale-skip audit row IS emitted for operator reconciliation.
103+
var staleCount int
104+
require.NoError(t, db.QueryRow(`
105+
SELECT count(*) FROM audit_log
106+
WHERE team_id = $1::uuid
107+
AND kind = 'billing.charge_undeliverable'
108+
AND metadata->>'reason' = 'stale_subscription_cancel'`, teamID).Scan(&staleCount))
109+
assert.Equal(t, 1, staleCount, "stale-sub cancel must emit exactly one stale-skip audit row")
110+
111+
// No subscription.canceled row — the cancellation email must NOT fire.
112+
var canceledCount int
113+
require.NoError(t, db.QueryRow(`
114+
SELECT count(*) FROM audit_log
115+
WHERE team_id = $1::uuid AND kind = 'subscription.canceled'`, teamID).Scan(&canceledCount))
116+
assert.Equal(t, 0, canceledCount, "stale-sub cancel must NOT emit a customer cancellation row")
117+
}
118+
119+
// TestBillingWebhook_SubscriptionCancelled_EmptyLiveSub_FallsThrough verifies
120+
// behaviour (c): when the team has NO stored live subscription id (never paid,
121+
// or a sub-id write that never landed), the guard cannot prove the event is
122+
// stale, so it falls through to the historical downgrade behaviour.
123+
func TestBillingWebhook_SubscriptionCancelled_EmptyLiveSub_FallsThrough(t *testing.T) {
124+
db, cleanDB := billingStateNeedsDB(t)
125+
defer cleanDB()
126+
127+
app, _ := billingWebhookDBApp(t, db)
128+
129+
teamID := testhelpers.MustCreateTeamDB(t, db, "pro")
130+
defer db.Exec(`DELETE FROM teams WHERE id = $1::uuid`, teamID)
131+
132+
// stripe_customer_id left NULL (MustCreateTeamDB does not set it).
133+
payload := makeSubscriptionCancelledPayload(t, teamID, "sub_any_"+uuid.NewString())
134+
req := signedWebhookRequest(t, payload)
135+
resp, err := app.Test(req, 5000)
136+
require.NoError(t, err)
137+
defer resp.Body.Close()
138+
require.Equal(t, http.StatusOK, resp.StatusCode)
139+
140+
// Historical behaviour: tier downgraded to the hobby courtesy floor.
141+
var newTier string
142+
require.NoError(t, db.QueryRow(`SELECT plan_tier FROM teams WHERE id = $1::uuid`, teamID).Scan(&newTier))
143+
assert.Equal(t, "hobby", newTier, "empty live sub must fall through to historical downgrade")
144+
145+
// No stale-skip audit row — the guard did not trip.
146+
var staleCount int
147+
require.NoError(t, db.QueryRow(`
148+
SELECT count(*) FROM audit_log
149+
WHERE team_id = $1::uuid
150+
AND kind = 'billing.charge_undeliverable'
151+
AND metadata->>'reason' = 'stale_subscription_cancel'`, teamID).Scan(&staleCount))
152+
assert.Equal(t, 0, staleCount, "empty-live-sub fall-through must NOT emit a stale-skip audit row")
153+
}

0 commit comments

Comments
 (0)