Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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"))) {
Expand Down
18 changes: 18 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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.
Expand Down
54 changes: 42 additions & 12 deletions internal/handlers/billing.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
41 changes: 41 additions & 0 deletions internal/handlers/billing_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Loading