@@ -98,6 +98,95 @@ var reusableSubscriptionStatuses = map[string]struct{}{
9898// from buying a plan they already pay for.
9999const errCheckoutAlreadyOnTier = "already_on_plan"
100100
101+ // ── BUG-P112 traffic_env derivation + live-key-in-dev guard ─────────────────
102+ //
103+ // Razorpay's API-key convention encodes the environment in the prefix:
104+ //
105+ // rzp_live_* → LIVE mode (real card mandates, real money)
106+ // rzp_test_* → TEST mode (sandbox card-mandate fixtures, no money moves)
107+ //
108+ // Surface this derived field on every checkout response (rule 22 — agents and
109+ // the SPA must be able to read the mode WITHOUT seeing the actual key value).
110+ // CRITICAL: the actual `RazorpayKeyID` value MUST NEVER leak in any response
111+ // — only the boolean derivation `traffic_env: "production" | "test"`. Tests
112+ // pin this constraint.
113+ //
114+ // In addition to surfacing the derivation, the handler short-circuits with
115+ // 503 billing_misconfigured when ENVIRONMENT≠"production" but the key is
116+ // LIVE. Real money flowing through a staging/dev deployment is the BUG-P111
117+ // failure mode (anonymous /app/checkout reaching a LIVE subscription page).
118+ // Fail fast at the API instead of minting the subscription.
119+
120+ // razorpayLiveKeyPrefix / razorpayTestKeyPrefix match the documented Razorpay
121+ // key-ID convention. https://razorpay.com/docs/api/authentication/#api-keys.
122+ // Lowercase comparison via strings.HasPrefix(strings.ToLower(...)) so a stray
123+ // uppercase key does not bypass the guard.
124+ const (
125+ razorpayLiveKeyPrefix = "rzp_live_"
126+ razorpayTestKeyPrefix = "rzp_test_"
127+ )
128+
129+ // trafficEnv classifies a Razorpay key ID as "production" (LIVE) or "test".
130+ // Returns ("test", false) for empty/unrecognised input — the safer default
131+ // is "test" because callers branching on the field then treat the deployment
132+ // as non-prod, and the missing-config branch (billing_not_configured) catches
133+ // the empty case before this is ever surfaced.
134+ //
135+ // recognised=true means the key carried a known prefix — the live/test
136+ // distinction is authoritative. recognised=false means the key was empty
137+ // or didn't match the convention; do not draw deployment conclusions.
138+ func trafficEnv (razorpayKeyID string ) (env string , recognised bool ) {
139+ k := strings .ToLower (strings .TrimSpace (razorpayKeyID ))
140+ if k == "" {
141+ return "test" , false
142+ }
143+ if strings .HasPrefix (k , razorpayLiveKeyPrefix ) {
144+ return "production" , true
145+ }
146+ if strings .HasPrefix (k , razorpayTestKeyPrefix ) {
147+ return "test" , true
148+ }
149+ return "test" , false
150+ }
151+
152+ // deploymentEnv normalises the configured ENVIRONMENT value for comparison
153+ // against the Razorpay key class. "production" is the only value that
154+ // permits a LIVE key; everything else (development, test, staging,
155+ // preview-*, "") rejects.
156+ func deploymentEnv (cfgEnvironment string ) string {
157+ return strings .ToLower (strings .TrimSpace (cfgEnvironment ))
158+ }
159+
160+ // detectBillingMisconfiguration returns ("", "") when the (deployment, key)
161+ // pairing is valid, or (code, message) when it is dangerous and the request
162+ // must be short-circuited with 503 before any Razorpay call.
163+ //
164+ // The single failure mode this catches: ENVIRONMENT="development" (or any
165+ // non-prod value) paired with a LIVE Razorpay key. That combination created
166+ // BUG-P111 — a staging or dev deployment minted a real LIVE subscription
167+ // against the prod Razorpay account. Tests pin every variant explicitly.
168+ //
169+ // Note: a production deployment with a TEST key is NOT caught here — that's
170+ // a different class of operator bug (test cards in prod) which the existing
171+ // billing_not_configured + plan_id-missing guards already cover and which
172+ // surfaces honest test-card behaviour to the user anyway. Adding a third
173+ // failure mode here would risk false-positiving every staging deploy that
174+ // uses a sandbox key by design.
175+ func detectBillingMisconfiguration (cfgEnvironment , razorpayKeyID string ) (code , message string ) {
176+ env , recognised := trafficEnv (razorpayKeyID )
177+ if ! recognised {
178+ // Key is empty or has an unknown prefix — the billing_not_configured
179+ // branch below handles this. Don't double-classify.
180+ return "" , ""
181+ }
182+ dep := deploymentEnv (cfgEnvironment )
183+ if env == "production" && dep != "production" {
184+ return "billing_misconfigured" ,
185+ "Razorpay LIVE key configured on a non-production deployment (ENVIRONMENT=" + dep + "). Refusing to mint a real subscription. Operator: rotate to a test key (rzp_test_*) or set ENVIRONMENT=production. See https://instanode.dev/docs/operator/billing-modes."
186+ }
187+ return "" , ""
188+ }
189+
101190// BillingHandler handles billing and Razorpay webhook endpoints.
102191type BillingHandler struct {
103192 db * sql.DB
@@ -706,6 +795,34 @@ func (h *BillingHandler) CreateCheckoutAPI(c *fiber.Ctx) error {
706795 }
707796 planID := h .razorpayPlanIDFor (plan , frequency )
708797
798+ // ── BUG-P112 live-key-in-dev guard ─────────────────────────────────────
799+ // A LIVE Razorpay key pointed at a non-prod deployment is the
800+ // BUG-P111/P112 root cause: anyone (even unauth, via the BUG-P111 SPA
801+ // regression) reaching this handler would have minted a REAL Razorpay
802+ // subscription on the prod Razorpay account. Fast-fail with a clear
803+ // operator agent_action BEFORE the create-subscription call so the
804+ // Razorpay dashboard is not polluted with phantom test subscriptions.
805+ //
806+ // Run BEFORE the billing_not_configured check: a live-key-in-dev
807+ // deployment is dangerous EVEN IF the operator forgot to set
808+ // RAZORPAY_PLAN_ID_TEAM — the underlying configuration drift is the
809+ // signal we must surface first.
810+ if code , message := detectBillingMisconfiguration (h .cfg .Environment , h .cfg .RazorpayKeyID ); code != "" {
811+ // Derive (but DO NOT log) the key class. Logging the actual key value
812+ // is a hard no — only the boolean derivation is safe.
813+ derivedTrafficEnv , _ := trafficEnv (h .cfg .RazorpayKeyID )
814+ slog .Error ("billing.checkout.misconfigured_live_key_in_nonprod" ,
815+ "team_id" , teamID ,
816+ "plan" , plan ,
817+ "deployment_environment" , h .cfg .Environment ,
818+ "traffic_env" , derivedTrafficEnv ,
819+ "request_id" , requestID ,
820+ )
821+ return respondErrorWithAgentAction (c , fiber .StatusServiceUnavailable , code , message ,
822+ "Operator: a LIVE Razorpay key is configured on a non-production deployment. Either rotate to a test key (rzp_test_*) or set ENVIRONMENT=production. Real subscriptions cannot be minted against this deployment until that is fixed." ,
823+ "https://instanode.dev/docs/operator/billing-modes" )
824+ }
825+
709826 if h .cfg .RazorpayKeyID == "" || h .cfg .RazorpayKeySecret == "" || planID == "" {
710827 slog .Warn ("billing.checkout.not_configured" ,
711828 "team_id" , teamID ,
@@ -762,11 +879,16 @@ func (h *BillingHandler) CreateCheckoutAPI(c *fiber.Ctx) error {
762879 // subscription_id the first checkout produced — same response shape as a
763880 // fresh create below.
764881 if reuseSubID , reuseURL , reuse := h .reusablePendingCheckout (c .Context (), teamID , requestID ); reuse {
882+ // BUG-P112: include traffic_env on the reuse path too — same
883+ // derivation, same NEVER-leak-the-key contract. SPA branches on
884+ // this field regardless of whether the sub was freshly minted.
885+ derivedTrafficEnv , _ := trafficEnv (h .cfg .RazorpayKeyID )
765886 return c .JSON (fiber.Map {
766887 "ok" : true ,
767888 "short_url" : reuseURL ,
768889 "subscription_id" : reuseSubID ,
769890 "reused" : true ,
891+ "traffic_env" : derivedTrafficEnv ,
770892 })
771893 }
772894 // ────────────────────────────────────────────────────────────────────────
@@ -935,10 +1057,15 @@ func (h *BillingHandler) CreateCheckoutAPI(c *fiber.Ctx) error {
9351057 "request_id" , requestID ,
9361058 )
9371059
1060+ // BUG-P112: surface the derived `traffic_env` so clients (the SPA, MCP
1061+ // agents, curl users) can detect production-vs-test mode without ever
1062+ // seeing the actual RAZORPAY_KEY_ID. NEVER include the key value here.
1063+ derivedTrafficEnv , _ := trafficEnv (h .cfg .RazorpayKeyID )
9381064 return c .JSON (fiber.Map {
9391065 "ok" : true ,
9401066 "short_url" : shortURL ,
9411067 "subscription_id" : subID ,
1068+ "traffic_env" : derivedTrafficEnv ,
9421069 })
9431070}
9441071
0 commit comments