diff --git a/internal/config/config.go b/internal/config/config.go index 6e35f5f..f6f0423 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -66,7 +66,17 @@ type Config struct { RazorpayTestPlanIDHobby string // RAZORPAY_TEST_PLAN_ID_HOBBY RazorpayTestPlanIDHobbyPlus string // RAZORPAY_TEST_PLAN_ID_HOBBY_PLUS RazorpayTestPlanIDPro string // RAZORPAY_TEST_PLAN_ID_PRO - ResendAPIKey string + // PaymentTestModeEnabled is the explicit kill-switch for the whole + // test-cohort checkout path (RAZORPAY_TEST_PLAN_MODE_ENABLED). Default + // FALSE / fail-CLOSED: even when the rzp_test_* keys + plan_ids above are + // all configured, NO checkout routes through them and NO test webhook + // secret is honoured unless this flag is explicitly ON. This gives an + // operator a single flip to disable real-money-adjacent test-mode routing + // independently of the secret values (which stay in instant-secrets), so a + // leftover test plan_id can never silently alter a live customer's billing. + // resolveCheckoutTestMode + the webhook try-both verify both require it. + PaymentTestModeEnabled bool + ResendAPIKey string // EmailProvider explicitly selects the outbound email backend. Accepted // values: "brevo" | "resend" | "noop". When empty, internal/email // auto-detects: BREVO_API_KEY > RESEND_API_KEY (≠ "CHANGE_ME") > noop. @@ -573,6 +583,21 @@ func Load() *Config { cfg.ResourceCountCapsEnabled = false } + // PAYMENT_TEST_MODE_ENABLED: default FALSE / fail-CLOSED. The explicit + // kill-switch for test-cohort checkout routing through the rzp_test_* keys. + // Off → resolveCheckoutTestMode never returns useTest=true and the webhook + // handler ignores the test webhook secret, regardless of whether the + // RAZORPAY_TEST_* secrets are configured. This separates the on/off decision + // (this flag) from the secret values (instant-secrets) so test-mode routing + // can be killed instantly without rotating keys, and a stale test plan_id + // can never touch a live customer's billing. + switch strings.ToLower(strings.TrimSpace(os.Getenv("PAYMENT_TEST_MODE_ENABLED"))) { + case "true", "1", "yes": + cfg.PaymentTestModeEnabled = true + default: + cfg.PaymentTestModeEnabled = false + } + // GITHUB_APP_ENABLED: default FALSE (off until the operator registers the // App and provisions GITHUB_APP_* secrets — see infra/GITHUB-APP-RUNBOOK.md). switch strings.ToLower(strings.TrimSpace(os.Getenv("GITHUB_APP_ENABLED"))) { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index be2d60b..d57d479 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -40,6 +40,9 @@ func allKeys() []string { "RAZORPAY_PLAN_ID_TEAM", "RAZORPAY_PLAN_ID_HOBBY_ANNUAL", "RAZORPAY_PLAN_ID_HOBBY_PLUS_ANNUAL", "RAZORPAY_PLAN_ID_PRO_ANNUAL", "RAZORPAY_PLAN_ID_GROWTH_ANNUAL", "RAZORPAY_PLAN_ID_TEAM_ANNUAL", + "RAZORPAY_TEST_KEY_ID", "RAZORPAY_TEST_KEY_SECRET", "RAZORPAY_TEST_WEBHOOK_SECRET", + "RAZORPAY_TEST_PLAN_ID_HOBBY", "RAZORPAY_TEST_PLAN_ID_HOBBY_PLUS", + "RAZORPAY_TEST_PLAN_ID_PRO", "PAYMENT_TEST_MODE_ENABLED", "RESEND_API_KEY", "EMAIL_PROVIDER", "BREVO_API_KEY", "EMAIL_FROM_NAME", "EMAIL_FROM_ADDRESS", "GITHUB_CLIENT_ID", "GITHUB_CLIENT_SECRET", @@ -419,6 +422,21 @@ func TestLoad_ResourceCountCapsEnabled(t *testing.T) { } } +func TestLoad_PaymentTestModeEnabled(t *testing.T) { + for _, val := range []string{"true", "1", "yes", "TRUE", " Yes "} { + applyBaselineEnv(t, map[string]string{"PAYMENT_TEST_MODE_ENABLED": val}) + if !Load().PaymentTestModeEnabled { + t.Errorf("PAYMENT_TEST_MODE_ENABLED=%q should enable", val) + } + } + for _, val := range []string{"false", "0", "no", "maybe", ""} { + applyBaselineEnv(t, map[string]string{"PAYMENT_TEST_MODE_ENABLED": val}) + if Load().PaymentTestModeEnabled { + t.Errorf("PAYMENT_TEST_MODE_ENABLED=%q should stay disabled (default OFF / fail-closed)", val) + } + } +} + func TestLoad_GitHubAppEnabled(t *testing.T) { // When enabling the App, Load() fails closed unless the webhook secret + // private key + app id are present (review HIGH-1), so set them here. diff --git a/internal/handlers/billing.go b/internal/handlers/billing.go index aead838..bc64e65 100644 --- a/internal/handlers/billing.go +++ b/internal/handlers/billing.go @@ -552,11 +552,16 @@ func (h *BillingHandler) razorpayPlanIDFor(tier, frequency string) string { // testModeConfigured reports whether the operator has wired the minimum // rzp_test_* credentials (key id + secret) for the synthetic test-cohort -// checkout path. When false the whole test-mode path is INERT: a cohort team -// falls back to the normal skip/inert behaviour (rejectIfTestCohort), and the -// live path is never affected. +// checkout path AND explicitly enabled the test-mode kill-switch +// (PAYMENT_TEST_MODE_ENABLED). When false the whole test-mode path is INERT: a +// cohort team falls back to the normal skip/inert behaviour +// (rejectIfTestCohort), and the live path is never affected. The flag is +// required on TOP of the secrets so the on/off decision is independent of the +// secret values — an operator can kill test-mode routing instantly without +// rotating keys, and a leftover test plan_id cannot silently affect billing. func (h *BillingHandler) testModeConfigured() bool { - return h.cfg.RazorpayTestKeyID != "" && h.cfg.RazorpayTestKeySecret != "" + return h.cfg.PaymentTestModeEnabled && + h.cfg.RazorpayTestKeyID != "" && h.cfg.RazorpayTestKeySecret != "" } // razorpayTestPlanIDFor returns the configured rzp_test_* plan_id for a @@ -667,18 +672,35 @@ func (h *BillingHandler) planIDToTier(planID string) string { if h.cfg.RazorpayPlanIDProYearly != "" && planID == h.cfg.RazorpayPlanIDProYearly { return "pro" } + // rzp_test_* plan IDs (test-cohort checkout). A TEST-mode + // subscription.activated/charged webhook carries the TEST plan_id, which must + // map to the SAME canonical tier as its live counterpart — otherwise a + // test-cohort upgrade silently lands on the fail-safe fallback (hobby) and + // emits a bogus billing.charge_undeliverable. Test plans only exist in + // Razorpay TEST mode, so they can never collide with a live plan_id. Grouped + // with their tier to preserve the most-paid→least-paid ordering. + // See resolveCheckoutTestMode + RAZORPAY_TEST_PLAN_ID_*. + if h.cfg.RazorpayTestPlanIDPro != "" && planID == h.cfg.RazorpayTestPlanIDPro { + return "pro" + } if h.cfg.RazorpayPlanIDHobbyPlus != "" && planID == h.cfg.RazorpayPlanIDHobbyPlus { return "hobby_plus" } if h.cfg.RazorpayPlanIDHobbyPlusYearly != "" && planID == h.cfg.RazorpayPlanIDHobbyPlusYearly { return "hobby_plus" } + if h.cfg.RazorpayTestPlanIDHobbyPlus != "" && planID == h.cfg.RazorpayTestPlanIDHobbyPlus { + return "hobby_plus" + } if h.cfg.RazorpayPlanIDHobby != "" && planID == h.cfg.RazorpayPlanIDHobby { return "hobby" } if h.cfg.RazorpayPlanIDHobbyYearly != "" && planID == h.cfg.RazorpayPlanIDHobbyYearly { return "hobby" } + if h.cfg.RazorpayTestPlanIDHobby != "" && planID == h.cfg.RazorpayTestPlanIDHobby { + return "hobby" + } // No configured plan_id matched. Log at Error level so NR picks this up as // a critical alert — the operator must fix RAZORPAY_PLAN_ID_* env vars. // The reconciler will detect and correct the tier mismatch within 15 min. @@ -706,6 +728,12 @@ func (h *BillingHandler) planIDRecognised(planID string) bool { h.cfg.RazorpayPlanIDPro, h.cfg.RazorpayPlanIDProYearly, h.cfg.RazorpayPlanIDHobbyPlus, h.cfg.RazorpayPlanIDHobbyPlusYearly, h.cfg.RazorpayPlanIDHobby, h.cfg.RazorpayPlanIDHobbyYearly, + // rzp_test_* plan IDs (test-cohort checkout) are genuine, configured + // plan IDs — a test-mode subscription.charged for one is recognised, not + // a make-good guess. Kept in sync with planIDToTier's test-plan branches. + h.cfg.RazorpayTestPlanIDPro, + h.cfg.RazorpayTestPlanIDHobbyPlus, + h.cfg.RazorpayTestPlanIDHobby, } { if configured != "" && planID == configured { return true @@ -1356,15 +1384,17 @@ func (h *BillingHandler) RazorpayWebhook(c *fiber.Ctx) error { sig := c.Get("X-Razorpay-Signature") // Wave 4b: verify against the LIVE webhook secret first, then (only if that - // fails) the rzp_test_* webhook secret. This lets a real test-mode - // subscription.charged/activated from Razorpay's TEST account upgrade a - // synthetic cohort team WITHOUT a separate endpoint, while live webhooks are - // unaffected (live secret matches first, test branch never runs). Both legs - // use the same constant-time verifier; an unset test secret is a no-op - // (verifyRazorpaySignature returns false on an empty secret). The live-first - // ordering means the common path costs exactly one HMAC. + // fails AND the test-mode kill-switch is ON) the rzp_test_* webhook secret. + // This lets a real test-mode subscription.charged/activated from Razorpay's + // TEST account upgrade a synthetic cohort team WITHOUT a separate endpoint, + // while live webhooks are unaffected (live secret matches first, test branch + // never runs). Both legs use the same constant-time verifier. The test branch + // is additionally gated on PaymentTestModeEnabled so a configured-but-disabled + // deployment never honours a test-signed webhook (fail-CLOSED, same flag that + // gates checkout routing). An unset test secret is a no-op anyway. The + // live-first ordering means the common path costs exactly one HMAC. sigOK := verifyRazorpaySignature(payload, sig, h.cfg.RazorpayWebhookSecret) - if !sigOK && h.cfg.RazorpayTestWebhookSecret != "" { + if !sigOK && h.cfg.PaymentTestModeEnabled && h.cfg.RazorpayTestWebhookSecret != "" { sigOK = verifyRazorpaySignature(payload, sig, h.cfg.RazorpayTestWebhookSecret) if sigOK { slog.Info("billing.webhook.verified_test_secret") diff --git a/internal/handlers/billing_test.go b/internal/handlers/billing_test.go index bb941b3..4d54300 100644 --- a/internal/handlers/billing_test.go +++ b/internal/handlers/billing_test.go @@ -1150,6 +1150,47 @@ func TestPlanIDToTier_MapsYearlyPlanIDsToCanonicalTier(t *testing.T) { } } +// TestPlanIDToTier_MapsTestPlanIDsToCanonicalTier is the regression guard for +// the test-cohort webhook path: a TEST-mode subscription.activated/charged +// carries the rzp_test_* plan_id, which MUST map to the same canonical tier as +// its live counterpart. Before this mapping existed, a test-cohort pro upgrade +// silently resolved to the fail-safe fallback tier ("hobby") and emitted a bogus +// billing.charge_undeliverable — so the full UI card→webhook→Pro chain could +// never actually reach Pro. planIDRecognised must ALSO accept them (a configured +// test plan_id is a recognised plan, not a make-good guess). The map is keyed by +// the config field so a new test tier can't be added without a row here. +func TestPlanIDToTier_MapsTestPlanIDsToCanonicalTier(t *testing.T) { + cfg := &config.Config{ + // live plan IDs (must keep mapping to their tiers, untouched) + RazorpayPlanIDPro: "plan_live_pro", + RazorpayPlanIDHobby: "plan_live_hobby", + // rzp_test_* plan IDs — DISTINCT strings (test plans only exist in test mode) + RazorpayTestPlanIDPro: "plan_test_pro", + RazorpayTestPlanIDHobbyPlus: "plan_test_hobby_plus", + RazorpayTestPlanIDHobby: "plan_test_hobby", + } + bh := handlers.NewBillingHandler(nil, cfg, email.NewNoop()) + cases := []struct { + planID string + want string + }{ + {"plan_test_pro", "pro"}, + {"plan_test_hobby_plus", "hobby_plus"}, + {"plan_test_hobby", "hobby"}, + // live mappings still intact alongside the test ones + {"plan_live_pro", "pro"}, + {"plan_live_hobby", "hobby"}, + } + for _, c := range cases { + assert.Equal(t, c.want, handlers.ExportedPlanIDToTier(bh, c.planID), "planIDToTier(%q)", c.planID) + assert.True(t, handlers.ExportedPlanIDRecognised(bh, c.planID), + "planIDRecognised(%q) must be true — a configured test plan_id is recognised, not a guess", c.planID) + } + // A genuinely unknown plan_id is still unrecognised → fail-safe fallback. + assert.False(t, handlers.ExportedPlanIDRecognised(bh, "plan_test_unknown")) + assert.Equal(t, handlers.PlanIDToTierFallbackForTest, handlers.ExportedPlanIDToTier(bh, "plan_test_unknown")) +} + // ── Slice 1: planIDToTier fail-safe regression tests ───────────────────────── // // These table-driven tests are the regression guard for DESIGN-P1-B §4: diff --git a/internal/handlers/billing_test_cohort_checkout_test.go b/internal/handlers/billing_test_cohort_checkout_test.go index 767eb05..da6841b 100644 --- a/internal/handlers/billing_test_cohort_checkout_test.go +++ b/internal/handlers/billing_test_cohort_checkout_test.go @@ -57,9 +57,11 @@ const ( // ── Pure-function inert proofs (NO DB — always run in CI) ──────────────────── // TestTestModeConfigured_InertWhenUnset proves the whole test-mode path is -// INERT when the operator has not wired the rzp_test_* key+secret. This is the -// "default empty = inert" guarantee: on prod (no test keys) the cohort routing -// never engages, so live billing is provably untouched. +// INERT unless the operator has BOTH wired the rzp_test_* key+secret AND flipped +// the PAYMENT_TEST_MODE_ENABLED kill-switch on. This is the "default empty + +// default off = inert" guarantee: on prod (no test keys, flag off) the cohort +// routing never engages, so live billing is provably untouched — and even with +// the secrets present, the flag must be ON for test mode to arm. func TestTestModeConfigured_InertWhenUnset(t *testing.T) { t.Parallel() cases := []struct { @@ -70,7 +72,9 @@ func TestTestModeConfigured_InertWhenUnset(t *testing.T) { {"both unset", config.Config{}, false}, {"only id set", config.Config{RazorpayTestKeyID: testCohortKeyID}, false}, {"only secret set", config.Config{RazorpayTestKeySecret: testCohortKeySecret}, false}, - {"both set → configured", config.Config{RazorpayTestKeyID: testCohortKeyID, RazorpayTestKeySecret: testCohortKeySecret}, true}, + {"keys set, flag OFF → still inert", config.Config{RazorpayTestKeyID: testCohortKeyID, RazorpayTestKeySecret: testCohortKeySecret}, false}, + {"flag on, keys unset → inert", config.Config{PaymentTestModeEnabled: true}, false}, + {"keys set + flag ON → configured", config.Config{RazorpayTestKeyID: testCohortKeyID, RazorpayTestKeySecret: testCohortKeySecret, PaymentTestModeEnabled: true}, true}, } for _, tc := range cases { tc := tc @@ -121,7 +125,7 @@ func TestCreateSubscription_TestModeDefaultClosure(t *testing.T) { func TestResolveCheckoutTestMode_FailsClosedOnDBError(t *testing.T) { cov2NeedsDB(t) db, clean := testhelpers.SetupTestDB(t) - cfg := &config.Config{RazorpayTestKeyID: testCohortKeyID, RazorpayTestKeySecret: testCohortKeySecret, RazorpayTestPlanIDPro: testCohortPlanPro} + cfg := &config.Config{PaymentTestModeEnabled: true, RazorpayTestKeyID: testCohortKeyID, RazorpayTestKeySecret: testCohortKeySecret, RazorpayTestPlanIDPro: testCohortPlanPro} bh := handlers.NewBillingHandler(db, cfg, nil) clean() // close the DB so IsTestCohort errors useTest, planID := handlers.ExerciseResolveCheckoutTestMode(bh, context.Background(), uuid.New(), "pro") @@ -145,13 +149,14 @@ func TestCohortCheckout_UsesTestKeyAndPlan(t *testing.T) { JWTSecret: "test-secret-that-is-at-least-32-bytes-long!!", // Live key on a dev deployment would normally 503 via the BUG-P112 // guard — the test-mode path MUST bypass it for cohort teams. - Environment: "development", - RazorpayKeyID: liveKeyExample, - RazorpayKeySecret: "live-secret-fixture", - RazorpayPlanIDPro: "plan_LIVE_pro", - RazorpayTestKeyID: testCohortKeyID, - RazorpayTestKeySecret: testCohortKeySecret, - RazorpayTestPlanIDPro: testCohortPlanPro, + Environment: "development", + PaymentTestModeEnabled: true, + RazorpayKeyID: liveKeyExample, + RazorpayKeySecret: "live-secret-fixture", + RazorpayPlanIDPro: "plan_LIVE_pro", + RazorpayTestKeyID: testCohortKeyID, + RazorpayTestKeySecret: testCohortKeySecret, + RazorpayTestPlanIDPro: testCohortPlanPro, } teamID, userID := seedVerifiedTeamUser(t, db, "free") require.NoError(t, models.SetTestCohort(context.Background(), db, uuid.MustParse(teamID), true)) @@ -210,13 +215,14 @@ func TestCohortCheckout_InertWhenTierHasNoTestPlan(t *testing.T) { db, clean := testhelpers.SetupTestDB(t) defer clean() cfg := &config.Config{ - JWTSecret: "test-secret-that-is-at-least-32-bytes-long!!", - Environment: "production", - RazorpayKeyID: liveKeyExample, - RazorpayKeySecret: "live-secret-fixture", - RazorpayPlanIDPro: "plan_LIVE_pro", - RazorpayTestKeyID: testCohortKeyID, - RazorpayTestKeySecret: testCohortKeySecret, + JWTSecret: "test-secret-that-is-at-least-32-bytes-long!!", + Environment: "production", + PaymentTestModeEnabled: true, + RazorpayKeyID: liveKeyExample, + RazorpayKeySecret: "live-secret-fixture", + RazorpayPlanIDPro: "plan_LIVE_pro", + RazorpayTestKeyID: testCohortKeyID, + RazorpayTestKeySecret: testCohortKeySecret, // RazorpayTestPlanIDPro intentionally UNSET → no test plan for pro. } teamID, userID := seedVerifiedTeamUser(t, db, "free") @@ -239,14 +245,15 @@ func TestNonCohortCheckout_AlwaysLivePath(t *testing.T) { db, clean := testhelpers.SetupTestDB(t) defer clean() cfg := &config.Config{ - JWTSecret: "test-secret-that-is-at-least-32-bytes-long!!", - Environment: "production", - RazorpayKeyID: liveKeyExample, - RazorpayKeySecret: "live-secret-fixture", - RazorpayPlanIDPro: "plan_LIVE_pro", - RazorpayTestKeyID: testCohortKeyID, - RazorpayTestKeySecret: testCohortKeySecret, - RazorpayTestPlanIDPro: testCohortPlanPro, + JWTSecret: "test-secret-that-is-at-least-32-bytes-long!!", + Environment: "production", + PaymentTestModeEnabled: true, + RazorpayKeyID: liveKeyExample, + RazorpayKeySecret: "live-secret-fixture", + RazorpayPlanIDPro: "plan_LIVE_pro", + RazorpayTestKeyID: testCohortKeyID, + RazorpayTestKeySecret: testCohortKeySecret, + RazorpayTestPlanIDPro: testCohortPlanPro, } // NOT a cohort team — default is_test_cohort=false. teamID, userID := seedVerifiedTeamUser(t, db, "free") @@ -313,6 +320,7 @@ func postRzpWebhook(t *testing.T, app *fiber.App, body []byte, sig string) int { func TestWebhook_VerifiesTestSecret(t *testing.T) { t.Parallel() cfg := &config.Config{ + PaymentTestModeEnabled: true, RazorpayWebhookSecret: "live-webhook-secret", RazorpayTestWebhookSecret: testCohortWebhookSecret, } @@ -350,3 +358,58 @@ func TestWebhook_TestSecretInertWhenUnset(t *testing.T) { assert.Equal(t, http.StatusBadRequest, postRzpWebhook(t, app, body, signRzp(body, testCohortWebhookSecret)), "with no test secret configured, the test-secret leg must be inert") } + +// TestWebhook_TestSecretIgnoredWhenFlagOff is the kill-switch proof for the +// webhook leg: with the rzp_test_* webhook secret fully configured but +// PAYMENT_TEST_MODE_ENABLED OFF, a test-secret-signed payload is REJECTED (400). +// The flag disables test-mode independently of the secret values, so a leftover +// test secret can never be honoured on a deployment where test mode is off. +func TestWebhook_TestSecretIgnoredWhenFlagOff(t *testing.T) { + t.Parallel() + cfg := &config.Config{ + PaymentTestModeEnabled: false, // kill-switch OFF (explicit) + RazorpayWebhookSecret: "live-webhook-secret", + RazorpayTestWebhookSecret: testCohortWebhookSecret, // configured but must be ignored + } + bh := handlers.NewBillingHandler(nil, cfg, nil) + app := newRzpWebhookApp(bh) + body := []byte(`{"event":"order.paid","created_at":` + nowUnixStr() + `,"id":"evt_flagoff_4b"}`) + + assert.Equal(t, http.StatusOK, postRzpWebhook(t, app, body, signRzp(body, "live-webhook-secret")), + "the live secret must still verify regardless of the test-mode flag") + assert.Equal(t, http.StatusBadRequest, postRzpWebhook(t, app, body, signRzp(body, testCohortWebhookSecret)), + "with the kill-switch OFF, a configured test secret must NOT be honoured") +} + +// TestCohortCheckout_InertWhenFlagOff is the kill-switch proof for the checkout +// leg: a cohort team with the FULL rzp_test_* key+secret+plan configured but +// PAYMENT_TEST_MODE_ENABLED OFF must NOT mint a test subscription — it hits the +// inert synthetic_test_cohort skip (403) and never reaches CreateSubscription. +// This proves test-mode routing is gated on the flag, not merely on the presence +// of the secrets, so the operator has a single instant kill-switch. +func TestCohortCheckout_InertWhenFlagOff(t *testing.T) { + cov2NeedsDB(t) + db, clean := testhelpers.SetupTestDB(t) + defer clean() + cfg := &config.Config{ + JWTSecret: "test-secret-that-is-at-least-32-bytes-long!!", + Environment: "production", + PaymentTestModeEnabled: false, // kill-switch OFF — fully configured otherwise + RazorpayKeyID: liveKeyExample, + RazorpayKeySecret: "live-secret-fixture", + RazorpayPlanIDPro: "plan_LIVE_pro", + RazorpayTestKeyID: testCohortKeyID, + RazorpayTestKeySecret: testCohortKeySecret, + RazorpayTestPlanIDPro: testCohortPlanPro, + } + teamID, userID := seedVerifiedTeamUser(t, db, "free") + require.NoError(t, models.SetTestCohort(context.Background(), db, uuid.MustParse(teamID), true)) + + app, bh := cov2CheckoutApp(t, db, cfg, teamID, userID) + bh.CreateSubscription = func(_ map[string]any) (map[string]any, error) { + return nil, assertingError("kill-switch-off cohort must NOT mint a subscription") + } + status, respBody := postCheckoutReq(t, app, map[string]any{"plan": "pro"}) + require.Equal(t, http.StatusForbidden, status, "flag-off cohort must 403-skip, body=%v", respBody) + assert.Equal(t, "synthetic_test_cohort", respBody["error"]) +}