Skip to content

Commit 232a991

Browse files
fix(billing): test-plan-aware tier mapping + PAYMENT_TEST_MODE_ENABLED kill-switch (#271)
* fix(billing): test-plan-aware tier mapping + PAYMENT_TEST_MODE_ENABLED kill-switch Two fixes for the test-cohort Razorpay checkout path (the with-UI test-card payment E2E the CEO asked for): 1. planIDToTier / planIDRecognised now recognise the rzp_test_* plan IDs. A TEST-mode subscription.activated/charged webhook carries the TEST plan_id; previously it matched no live RAZORPAY_PLAN_ID_* and fell back to the safe fallback tier ("hobby") + emitted a bogus billing.charge_undeliverable — so the full UI card→webhook→Pro chain could NEVER actually reach Pro. Test plans exist only in Razorpay test mode, so they can't collide with a live plan_id. 2. PAYMENT_TEST_MODE_ENABLED — explicit kill-switch (default FALSE / fail-CLOSED) gating the whole test-cohort path on TOP of the rzp_test_* secrets. Required by testModeConfigured() (checkout routing) AND the webhook try-both verify. Separates the on/off decision (this flag) from the secret values (which stay in instant-secrets), so an operator can kill test-mode routing instantly without rotating keys, and a leftover test plan_id can never silently touch a live customer's billing. Tests: test-plan tier mapping (+ recognised), kill-switch inert proofs for both the checkout (403 synthetic_test_cohort when flag off) and webhook (test secret ignored when flag off) legs; existing cohort tests updated for the new flag. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(config): cover PAYMENT_TEST_MODE_ENABLED parse + add RAZORPAY_TEST_* to allKeys allKeys() (env host-state clearer) was missing the RAZORPAY_TEST_* vars and the new PAYMENT_TEST_MODE_ENABLED flag, so a leaked host env could bleed into config tests. Add them + a TestLoad_PaymentTestModeEnabled mirroring the other *_ENABLED flag tests (100%-patch coverage for the new parse branch). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 47e9313 commit 232a991

5 files changed

Lines changed: 217 additions & 40 deletions

File tree

internal/config/config.go

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,17 @@ type Config struct {
6666
RazorpayTestPlanIDHobby string // RAZORPAY_TEST_PLAN_ID_HOBBY
6767
RazorpayTestPlanIDHobbyPlus string // RAZORPAY_TEST_PLAN_ID_HOBBY_PLUS
6868
RazorpayTestPlanIDPro string // RAZORPAY_TEST_PLAN_ID_PRO
69-
ResendAPIKey string
69+
// PaymentTestModeEnabled is the explicit kill-switch for the whole
70+
// test-cohort checkout path (RAZORPAY_TEST_PLAN_MODE_ENABLED). Default
71+
// FALSE / fail-CLOSED: even when the rzp_test_* keys + plan_ids above are
72+
// all configured, NO checkout routes through them and NO test webhook
73+
// secret is honoured unless this flag is explicitly ON. This gives an
74+
// operator a single flip to disable real-money-adjacent test-mode routing
75+
// independently of the secret values (which stay in instant-secrets), so a
76+
// leftover test plan_id can never silently alter a live customer's billing.
77+
// resolveCheckoutTestMode + the webhook try-both verify both require it.
78+
PaymentTestModeEnabled bool
79+
ResendAPIKey string
7080
// EmailProvider explicitly selects the outbound email backend. Accepted
7181
// values: "brevo" | "resend" | "noop". When empty, internal/email
7282
// auto-detects: BREVO_API_KEY > RESEND_API_KEY (≠ "CHANGE_ME") > noop.
@@ -573,6 +583,21 @@ func Load() *Config {
573583
cfg.ResourceCountCapsEnabled = false
574584
}
575585

586+
// PAYMENT_TEST_MODE_ENABLED: default FALSE / fail-CLOSED. The explicit
587+
// kill-switch for test-cohort checkout routing through the rzp_test_* keys.
588+
// Off → resolveCheckoutTestMode never returns useTest=true and the webhook
589+
// handler ignores the test webhook secret, regardless of whether the
590+
// RAZORPAY_TEST_* secrets are configured. This separates the on/off decision
591+
// (this flag) from the secret values (instant-secrets) so test-mode routing
592+
// can be killed instantly without rotating keys, and a stale test plan_id
593+
// can never touch a live customer's billing.
594+
switch strings.ToLower(strings.TrimSpace(os.Getenv("PAYMENT_TEST_MODE_ENABLED"))) {
595+
case "true", "1", "yes":
596+
cfg.PaymentTestModeEnabled = true
597+
default:
598+
cfg.PaymentTestModeEnabled = false
599+
}
600+
576601
// GITHUB_APP_ENABLED: default FALSE (off until the operator registers the
577602
// App and provisions GITHUB_APP_* secrets — see infra/GITHUB-APP-RUNBOOK.md).
578603
switch strings.ToLower(strings.TrimSpace(os.Getenv("GITHUB_APP_ENABLED"))) {

internal/config/config_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ func allKeys() []string {
4040
"RAZORPAY_PLAN_ID_TEAM", "RAZORPAY_PLAN_ID_HOBBY_ANNUAL",
4141
"RAZORPAY_PLAN_ID_HOBBY_PLUS_ANNUAL", "RAZORPAY_PLAN_ID_PRO_ANNUAL",
4242
"RAZORPAY_PLAN_ID_GROWTH_ANNUAL", "RAZORPAY_PLAN_ID_TEAM_ANNUAL",
43+
"RAZORPAY_TEST_KEY_ID", "RAZORPAY_TEST_KEY_SECRET", "RAZORPAY_TEST_WEBHOOK_SECRET",
44+
"RAZORPAY_TEST_PLAN_ID_HOBBY", "RAZORPAY_TEST_PLAN_ID_HOBBY_PLUS",
45+
"RAZORPAY_TEST_PLAN_ID_PRO", "PAYMENT_TEST_MODE_ENABLED",
4346
"RESEND_API_KEY", "EMAIL_PROVIDER", "BREVO_API_KEY",
4447
"EMAIL_FROM_NAME", "EMAIL_FROM_ADDRESS",
4548
"GITHUB_CLIENT_ID", "GITHUB_CLIENT_SECRET",
@@ -419,6 +422,21 @@ func TestLoad_ResourceCountCapsEnabled(t *testing.T) {
419422
}
420423
}
421424

425+
func TestLoad_PaymentTestModeEnabled(t *testing.T) {
426+
for _, val := range []string{"true", "1", "yes", "TRUE", " Yes "} {
427+
applyBaselineEnv(t, map[string]string{"PAYMENT_TEST_MODE_ENABLED": val})
428+
if !Load().PaymentTestModeEnabled {
429+
t.Errorf("PAYMENT_TEST_MODE_ENABLED=%q should enable", val)
430+
}
431+
}
432+
for _, val := range []string{"false", "0", "no", "maybe", ""} {
433+
applyBaselineEnv(t, map[string]string{"PAYMENT_TEST_MODE_ENABLED": val})
434+
if Load().PaymentTestModeEnabled {
435+
t.Errorf("PAYMENT_TEST_MODE_ENABLED=%q should stay disabled (default OFF / fail-closed)", val)
436+
}
437+
}
438+
}
439+
422440
func TestLoad_GitHubAppEnabled(t *testing.T) {
423441
// When enabling the App, Load() fails closed unless the webhook secret +
424442
// private key + app id are present (review HIGH-1), so set them here.

internal/handlers/billing.go

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -552,11 +552,16 @@ func (h *BillingHandler) razorpayPlanIDFor(tier, frequency string) string {
552552

553553
// testModeConfigured reports whether the operator has wired the minimum
554554
// rzp_test_* credentials (key id + secret) for the synthetic test-cohort
555-
// checkout path. When false the whole test-mode path is INERT: a cohort team
556-
// falls back to the normal skip/inert behaviour (rejectIfTestCohort), and the
557-
// live path is never affected.
555+
// checkout path AND explicitly enabled the test-mode kill-switch
556+
// (PAYMENT_TEST_MODE_ENABLED). When false the whole test-mode path is INERT: a
557+
// cohort team falls back to the normal skip/inert behaviour
558+
// (rejectIfTestCohort), and the live path is never affected. The flag is
559+
// required on TOP of the secrets so the on/off decision is independent of the
560+
// secret values — an operator can kill test-mode routing instantly without
561+
// rotating keys, and a leftover test plan_id cannot silently affect billing.
558562
func (h *BillingHandler) testModeConfigured() bool {
559-
return h.cfg.RazorpayTestKeyID != "" && h.cfg.RazorpayTestKeySecret != ""
563+
return h.cfg.PaymentTestModeEnabled &&
564+
h.cfg.RazorpayTestKeyID != "" && h.cfg.RazorpayTestKeySecret != ""
560565
}
561566

562567
// razorpayTestPlanIDFor returns the configured rzp_test_* plan_id for a
@@ -667,18 +672,35 @@ func (h *BillingHandler) planIDToTier(planID string) string {
667672
if h.cfg.RazorpayPlanIDProYearly != "" && planID == h.cfg.RazorpayPlanIDProYearly {
668673
return "pro"
669674
}
675+
// rzp_test_* plan IDs (test-cohort checkout). A TEST-mode
676+
// subscription.activated/charged webhook carries the TEST plan_id, which must
677+
// map to the SAME canonical tier as its live counterpart — otherwise a
678+
// test-cohort upgrade silently lands on the fail-safe fallback (hobby) and
679+
// emits a bogus billing.charge_undeliverable. Test plans only exist in
680+
// Razorpay TEST mode, so they can never collide with a live plan_id. Grouped
681+
// with their tier to preserve the most-paid→least-paid ordering.
682+
// See resolveCheckoutTestMode + RAZORPAY_TEST_PLAN_ID_*.
683+
if h.cfg.RazorpayTestPlanIDPro != "" && planID == h.cfg.RazorpayTestPlanIDPro {
684+
return "pro"
685+
}
670686
if h.cfg.RazorpayPlanIDHobbyPlus != "" && planID == h.cfg.RazorpayPlanIDHobbyPlus {
671687
return "hobby_plus"
672688
}
673689
if h.cfg.RazorpayPlanIDHobbyPlusYearly != "" && planID == h.cfg.RazorpayPlanIDHobbyPlusYearly {
674690
return "hobby_plus"
675691
}
692+
if h.cfg.RazorpayTestPlanIDHobbyPlus != "" && planID == h.cfg.RazorpayTestPlanIDHobbyPlus {
693+
return "hobby_plus"
694+
}
676695
if h.cfg.RazorpayPlanIDHobby != "" && planID == h.cfg.RazorpayPlanIDHobby {
677696
return "hobby"
678697
}
679698
if h.cfg.RazorpayPlanIDHobbyYearly != "" && planID == h.cfg.RazorpayPlanIDHobbyYearly {
680699
return "hobby"
681700
}
701+
if h.cfg.RazorpayTestPlanIDHobby != "" && planID == h.cfg.RazorpayTestPlanIDHobby {
702+
return "hobby"
703+
}
682704
// No configured plan_id matched. Log at Error level so NR picks this up as
683705
// a critical alert — the operator must fix RAZORPAY_PLAN_ID_* env vars.
684706
// The reconciler will detect and correct the tier mismatch within 15 min.
@@ -706,6 +728,12 @@ func (h *BillingHandler) planIDRecognised(planID string) bool {
706728
h.cfg.RazorpayPlanIDPro, h.cfg.RazorpayPlanIDProYearly,
707729
h.cfg.RazorpayPlanIDHobbyPlus, h.cfg.RazorpayPlanIDHobbyPlusYearly,
708730
h.cfg.RazorpayPlanIDHobby, h.cfg.RazorpayPlanIDHobbyYearly,
731+
// rzp_test_* plan IDs (test-cohort checkout) are genuine, configured
732+
// plan IDs — a test-mode subscription.charged for one is recognised, not
733+
// a make-good guess. Kept in sync with planIDToTier's test-plan branches.
734+
h.cfg.RazorpayTestPlanIDPro,
735+
h.cfg.RazorpayTestPlanIDHobbyPlus,
736+
h.cfg.RazorpayTestPlanIDHobby,
709737
} {
710738
if configured != "" && planID == configured {
711739
return true
@@ -1356,15 +1384,17 @@ func (h *BillingHandler) RazorpayWebhook(c *fiber.Ctx) error {
13561384
sig := c.Get("X-Razorpay-Signature")
13571385

13581386
// Wave 4b: verify against the LIVE webhook secret first, then (only if that
1359-
// fails) the rzp_test_* webhook secret. This lets a real test-mode
1360-
// subscription.charged/activated from Razorpay's TEST account upgrade a
1361-
// synthetic cohort team WITHOUT a separate endpoint, while live webhooks are
1362-
// unaffected (live secret matches first, test branch never runs). Both legs
1363-
// use the same constant-time verifier; an unset test secret is a no-op
1364-
// (verifyRazorpaySignature returns false on an empty secret). The live-first
1365-
// ordering means the common path costs exactly one HMAC.
1387+
// fails AND the test-mode kill-switch is ON) the rzp_test_* webhook secret.
1388+
// This lets a real test-mode subscription.charged/activated from Razorpay's
1389+
// TEST account upgrade a synthetic cohort team WITHOUT a separate endpoint,
1390+
// while live webhooks are unaffected (live secret matches first, test branch
1391+
// never runs). Both legs use the same constant-time verifier. The test branch
1392+
// is additionally gated on PaymentTestModeEnabled so a configured-but-disabled
1393+
// deployment never honours a test-signed webhook (fail-CLOSED, same flag that
1394+
// gates checkout routing). An unset test secret is a no-op anyway. The
1395+
// live-first ordering means the common path costs exactly one HMAC.
13661396
sigOK := verifyRazorpaySignature(payload, sig, h.cfg.RazorpayWebhookSecret)
1367-
if !sigOK && h.cfg.RazorpayTestWebhookSecret != "" {
1397+
if !sigOK && h.cfg.PaymentTestModeEnabled && h.cfg.RazorpayTestWebhookSecret != "" {
13681398
sigOK = verifyRazorpaySignature(payload, sig, h.cfg.RazorpayTestWebhookSecret)
13691399
if sigOK {
13701400
slog.Info("billing.webhook.verified_test_secret")

internal/handlers/billing_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1150,6 +1150,47 @@ func TestPlanIDToTier_MapsYearlyPlanIDsToCanonicalTier(t *testing.T) {
11501150
}
11511151
}
11521152

1153+
// TestPlanIDToTier_MapsTestPlanIDsToCanonicalTier is the regression guard for
1154+
// the test-cohort webhook path: a TEST-mode subscription.activated/charged
1155+
// carries the rzp_test_* plan_id, which MUST map to the same canonical tier as
1156+
// its live counterpart. Before this mapping existed, a test-cohort pro upgrade
1157+
// silently resolved to the fail-safe fallback tier ("hobby") and emitted a bogus
1158+
// billing.charge_undeliverable — so the full UI card→webhook→Pro chain could
1159+
// never actually reach Pro. planIDRecognised must ALSO accept them (a configured
1160+
// test plan_id is a recognised plan, not a make-good guess). The map is keyed by
1161+
// the config field so a new test tier can't be added without a row here.
1162+
func TestPlanIDToTier_MapsTestPlanIDsToCanonicalTier(t *testing.T) {
1163+
cfg := &config.Config{
1164+
// live plan IDs (must keep mapping to their tiers, untouched)
1165+
RazorpayPlanIDPro: "plan_live_pro",
1166+
RazorpayPlanIDHobby: "plan_live_hobby",
1167+
// rzp_test_* plan IDs — DISTINCT strings (test plans only exist in test mode)
1168+
RazorpayTestPlanIDPro: "plan_test_pro",
1169+
RazorpayTestPlanIDHobbyPlus: "plan_test_hobby_plus",
1170+
RazorpayTestPlanIDHobby: "plan_test_hobby",
1171+
}
1172+
bh := handlers.NewBillingHandler(nil, cfg, email.NewNoop())
1173+
cases := []struct {
1174+
planID string
1175+
want string
1176+
}{
1177+
{"plan_test_pro", "pro"},
1178+
{"plan_test_hobby_plus", "hobby_plus"},
1179+
{"plan_test_hobby", "hobby"},
1180+
// live mappings still intact alongside the test ones
1181+
{"plan_live_pro", "pro"},
1182+
{"plan_live_hobby", "hobby"},
1183+
}
1184+
for _, c := range cases {
1185+
assert.Equal(t, c.want, handlers.ExportedPlanIDToTier(bh, c.planID), "planIDToTier(%q)", c.planID)
1186+
assert.True(t, handlers.ExportedPlanIDRecognised(bh, c.planID),
1187+
"planIDRecognised(%q) must be true — a configured test plan_id is recognised, not a guess", c.planID)
1188+
}
1189+
// A genuinely unknown plan_id is still unrecognised → fail-safe fallback.
1190+
assert.False(t, handlers.ExportedPlanIDRecognised(bh, "plan_test_unknown"))
1191+
assert.Equal(t, handlers.PlanIDToTierFallbackForTest, handlers.ExportedPlanIDToTier(bh, "plan_test_unknown"))
1192+
}
1193+
11531194
// ── Slice 1: planIDToTier fail-safe regression tests ─────────────────────────
11541195
//
11551196
// These table-driven tests are the regression guard for DESIGN-P1-B §4:

0 commit comments

Comments
 (0)