Skip to content

Commit 3ab0350

Browse files
feat(billing): Wave 4b — cohort test-mode (rzp_test_*) checkout routing (#268)
* feat(billing): Wave 4b — cohort test-mode (rzp_test_*) checkout routing Enables a synthetic test-cohort team (teams.is_test_cohort=true, mig 067) to drive a REAL Razorpay TEST-mode hosted checkout + test-card payment in CI with NO real money and WITHOUT the live-recurring approval that blocks prod (test mode has no recurring gate). The live billing path is provably untouched. docs/ci/01-CI-INTEGRATION-DESIGN.md §"Razorpay test-card payment E2E". Config (all default "" = INERT; never leak in any response): RAZORPAY_TEST_KEY_ID / _SECRET / _WEBHOOK_SECRET RAZORPAY_TEST_PLAN_ID_{HOBBY,HOBBY_PLUS,PRO} Routing (CreateCheckoutAPI): - cohort + test key/secret + tier test-plan set → mint via rzp_test_* (resolveCheckoutTestMode), swap to the test plan_id, route the create through test creds via a private subBody flag (subBodyTestModeKey, stripped before Razorpay), and bypass the live-key / billing_not_configured guards. - cohort + test mode unset/partial → inert: existing synthetic_test_cohort 403 skip; never mints against the live plan; no crash. - non-cohort → ALWAYS the live path, regardless of test-key config. - DB blip on is_test_cohort → fail CLOSED (live path), never route a real customer through the test account. Webhook: try-both verification (live secret first, then RAZORPAY_TEST_WEBHOOK_ SECRET) so a real TEST-mode subscription.charged/activated upgrades the cohort team; live webhooks unaffected; unset test secret is a no-op; constant-time. Tests (internal/handlers green): pure-function inert proofs (no DB, always run) + DB-gated routing (cohort uses test plan, inert-when-unset, inert-when-no-plan, non-cohort live path, key-leak contract) + webhook try-both/inert. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(billing): cover Wave 4b test-mode key-swap + fail-closed branches (100% patch) The 100%-patch diff-cover gate flagged two uncovered branches: - billing.go:305-307 — the rzp_test_* key-swap inside the PRODUCTION default CreateSubscription closure (tests override the field, so the default body with the flag-true branch never ran). Added ExerciseCreateSubscriptionTestMode which invokes the default closure WITH subBodyTestModeKey set. - billing.go:597-602 — resolveCheckoutTestMode fail-CLOSED branch on an is_test_cohort DB error. Added TestResolveCheckoutTestMode_FailsClosedOnDBError (closed *sql.DB → IsTestCohort errors → useTest=false). Both confirmed covered via go tool cover on the previously-missing line ranges. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent bfd08b7 commit 3ab0350

4 files changed

Lines changed: 620 additions & 33 deletions

File tree

internal/config/config.go

Lines changed: 53 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,29 @@ type Config struct {
4444
RazorpayPlanIDProYearly string // RAZORPAY_PLAN_ID_PRO_YEARLY — plan_id for pro tier (yearly)
4545
RazorpayPlanIDGrowthYearly string // RAZORPAY_PLAN_ID_GROWTH_ANNUAL — plan_id for growth tier (yearly)
4646
RazorpayPlanIDTeamYearly string // RAZORPAY_PLAN_ID_TEAM_YEARLY — plan_id for team tier (yearly)
47-
ResendAPIKey string
47+
48+
// ── Razorpay TEST-mode credentials (Wave 4b, docs/ci/01-CI-INTEGRATION-DESIGN.md) ──
49+
// These are the rzp_test_* keys + their plan_ids used ONLY for the
50+
// synthetic test-cohort (teams.is_test_cohort=true, migration 067) so CI can
51+
// drive a real test-mode hosted checkout + test-card payment WITHOUT touching
52+
// the live Razorpay account and WITHOUT needing the live-recurring approval
53+
// (test mode has no recurring gate). Every field defaults to "" (empty) so
54+
// the whole test-mode path is INERT in any deployment where the operator has
55+
// not configured it — a non-cohort team always uses the live keys above, and
56+
// a cohort team falls back to the normal (skip/inert) behaviour when these
57+
// are unset. The actual key values MUST NEVER leak in any API response
58+
// (same NEVER-leak contract as RazorpayKeyID — see trafficEnv/BUG-P112).
59+
RazorpayTestKeyID string // RAZORPAY_TEST_KEY_ID — rzp_test_* API key ID (test-cohort only)
60+
RazorpayTestKeySecret string // RAZORPAY_TEST_KEY_SECRET — rzp_test_* API key secret (test-cohort only)
61+
RazorpayTestWebhookSecret string // RAZORPAY_TEST_WEBHOOK_SECRET — webhook signature secret for test-mode events
62+
// Test-mode plan_ids for the self-serve checkout tiers (hobby / hobby_plus /
63+
// pro, monthly). Created by the operator in the Razorpay TEST dashboard. When
64+
// a tier's test plan_id is unset, a cohort checkout for that tier falls back
65+
// to the inert path (no test-mode subscription is minted).
66+
RazorpayTestPlanIDHobby string // RAZORPAY_TEST_PLAN_ID_HOBBY
67+
RazorpayTestPlanIDHobbyPlus string // RAZORPAY_TEST_PLAN_ID_HOBBY_PLUS
68+
RazorpayTestPlanIDPro string // RAZORPAY_TEST_PLAN_ID_PRO
69+
ResendAPIKey string
4870
// EmailProvider explicitly selects the outbound email backend. Accepted
4971
// values: "brevo" | "resend" | "noop". When empty, internal/email
5072
// auto-detects: BREVO_API_KEY > RESEND_API_KEY (≠ "CHANGE_ME") > noop.
@@ -368,27 +390,36 @@ func Load() *Config {
368390
RazorpayPlanIDProYearly: os.Getenv("RAZORPAY_PLAN_ID_PRO_ANNUAL"),
369391
RazorpayPlanIDGrowthYearly: os.Getenv("RAZORPAY_PLAN_ID_GROWTH_ANNUAL"),
370392
RazorpayPlanIDTeamYearly: os.Getenv("RAZORPAY_PLAN_ID_TEAM_ANNUAL"),
371-
ResendAPIKey: os.Getenv("RESEND_API_KEY"),
372-
EmailProvider: os.Getenv("EMAIL_PROVIDER"),
373-
BrevoAPIKey: os.Getenv("BREVO_API_KEY"),
374-
EmailFromName: os.Getenv("EMAIL_FROM_NAME"),
375-
EmailFromAddress: os.Getenv("EMAIL_FROM_ADDRESS"),
376-
GitHubClientID: os.Getenv("GITHUB_CLIENT_ID"),
377-
GitHubClientSecret: os.Getenv("GITHUB_CLIENT_SECRET"),
378-
GoogleClientID: os.Getenv("GOOGLE_CLIENT_ID"),
379-
GoogleClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"),
380-
GoogleRedirectURI: os.Getenv("GOOGLE_REDIRECT_URI"),
381-
EnabledServices: getenv("INSTANT_ENABLED_SERVICES", "redis,postgres,mongodb,queue"),
382-
Environment: getenv("ENVIRONMENT", "development"),
383-
TrustedProxyCIDRs: os.Getenv("TRUSTED_PROXY_CIDRS"),
384-
RedisProvisionBackend: getenv("REDIS_PROVISION_BACKEND", "local"),
385-
RedisProvisionHost: getenv("REDIS_PROVISION_HOST", "localhost"),
386-
MongoAdminURI: getenv("MONGO_ADMIN_URI", "mongodb://root:root@localhost:27017"),
387-
MongoHost: getenv("MONGO_HOST", "localhost:27017"),
388-
PostgresProvisionBackend: getenv("POSTGRES_PROVISION_BACKEND", "local"),
389-
NeonAPIKey: os.Getenv("NEON_API_KEY"),
390-
NeonRegionID: getenv("NEON_REGION_ID", "aws-us-east-1"),
391-
PostgresCustomersURL: getenv("POSTGRES_CUSTOMERS_URL", "postgres://postgres:postgres@postgres-customers:5432/postgres"),
393+
394+
// Razorpay TEST-mode (rzp_test_*) creds for the synthetic test cohort
395+
// only. All default "" (inert) — see the struct doc above (Wave 4b).
396+
RazorpayTestKeyID: os.Getenv("RAZORPAY_TEST_KEY_ID"),
397+
RazorpayTestKeySecret: os.Getenv("RAZORPAY_TEST_KEY_SECRET"),
398+
RazorpayTestWebhookSecret: os.Getenv("RAZORPAY_TEST_WEBHOOK_SECRET"),
399+
RazorpayTestPlanIDHobby: os.Getenv("RAZORPAY_TEST_PLAN_ID_HOBBY"),
400+
RazorpayTestPlanIDHobbyPlus: os.Getenv("RAZORPAY_TEST_PLAN_ID_HOBBY_PLUS"),
401+
RazorpayTestPlanIDPro: os.Getenv("RAZORPAY_TEST_PLAN_ID_PRO"),
402+
ResendAPIKey: os.Getenv("RESEND_API_KEY"),
403+
EmailProvider: os.Getenv("EMAIL_PROVIDER"),
404+
BrevoAPIKey: os.Getenv("BREVO_API_KEY"),
405+
EmailFromName: os.Getenv("EMAIL_FROM_NAME"),
406+
EmailFromAddress: os.Getenv("EMAIL_FROM_ADDRESS"),
407+
GitHubClientID: os.Getenv("GITHUB_CLIENT_ID"),
408+
GitHubClientSecret: os.Getenv("GITHUB_CLIENT_SECRET"),
409+
GoogleClientID: os.Getenv("GOOGLE_CLIENT_ID"),
410+
GoogleClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"),
411+
GoogleRedirectURI: os.Getenv("GOOGLE_REDIRECT_URI"),
412+
EnabledServices: getenv("INSTANT_ENABLED_SERVICES", "redis,postgres,mongodb,queue"),
413+
Environment: getenv("ENVIRONMENT", "development"),
414+
TrustedProxyCIDRs: os.Getenv("TRUSTED_PROXY_CIDRS"),
415+
RedisProvisionBackend: getenv("REDIS_PROVISION_BACKEND", "local"),
416+
RedisProvisionHost: getenv("REDIS_PROVISION_HOST", "localhost"),
417+
MongoAdminURI: getenv("MONGO_ADMIN_URI", "mongodb://root:root@localhost:27017"),
418+
MongoHost: getenv("MONGO_HOST", "localhost:27017"),
419+
PostgresProvisionBackend: getenv("POSTGRES_PROVISION_BACKEND", "local"),
420+
NeonAPIKey: os.Getenv("NEON_API_KEY"),
421+
NeonRegionID: getenv("NEON_REGION_ID", "aws-us-east-1"),
422+
PostgresCustomersURL: getenv("POSTGRES_CUSTOMERS_URL", "postgres://postgres:postgres@postgres-customers:5432/postgres"),
392423
}
393424
cfg.ProvisionerAddr = os.Getenv("PROVISIONER_ADDR") // intentionally empty = use local providers
394425
cfg.ProvisionerSecret = os.Getenv("PROVISIONER_SECRET")

0 commit comments

Comments
 (0)