|
| 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