diff --git a/internal/handlers/agent_action_contract_test.go b/internal/handlers/agent_action_contract_test.go index 0c11ebe..70dbabe 100644 --- a/internal/handlers/agent_action_contract_test.go +++ b/internal/handlers/agent_action_contract_test.go @@ -27,33 +27,33 @@ import ( func agentActionContractCases() map[string]string { cases := map[string]string{ // Static constants. - "AgentActionMultiEnvUpgradeRequired": AgentActionMultiEnvUpgradeRequired, - "AgentActionStackPromoteMissingImageRef": AgentActionStackPromoteMissingImageRef, - "AgentActionBindingFamilyDisabled": AgentActionBindingFamilyDisabled, - "AgentActionBindingLookupFailed": AgentActionBindingLookupFailed, - "RecycleGateAgentAction": RecycleGateAgentAction, - "AgentActionPrivateDeployRequiresPro": AgentActionPrivateDeployRequiresPro, - "AgentActionPrivateDeployRequiresAllowedIPs": AgentActionPrivateDeployRequiresAllowedIPs, - "AgentActionAdminRequired": AgentActionAdminRequired, - "AgentActionPromotionInvalid": AgentActionPromotionInvalid, - "AgentActionPromotionAlreadyUsed": AgentActionPromotionAlreadyUsed, - "AgentActionPromotionExpired": AgentActionPromotionExpired, - "AgentActionPromoteTokenExpired": AgentActionPromoteTokenExpired, - "AgentActionReadOnlySession": AgentActionReadOnlySession, - "AgentActionNotifyWebhookInvalid": AgentActionNotifyWebhookInvalid, - "AgentActionPauseRequiresPro": AgentActionPauseRequiresPro, - "AgentActionResourceAlreadyPaused": AgentActionResourceAlreadyPaused, - "AgentActionResourceNotPaused": AgentActionResourceNotPaused, - "AgentActionBackupRequiresClaim": AgentActionBackupRequiresClaim, - "AgentActionRestoreRequiresPro": AgentActionRestoreRequiresPro, - "AgentActionRestoreRequiresHobbyPlus": AgentActionRestoreRequiresHobbyPlus, - "AgentActionRestoreBackupNotReady": AgentActionRestoreBackupNotReady, - "AgentActionRestoreInflight": AgentActionRestoreInflight, - "AgentActionRestoreDestructiveAckRequired": AgentActionRestoreDestructiveAckRequired, - "AgentActionRestoreTargetCrossTeam": AgentActionRestoreTargetCrossTeam, - "AgentActionBackupIntegrityFailed": AgentActionBackupIntegrityFailed, - "AgentActionMetricsRequiresUpgrade": AgentActionMetricsRequiresUpgrade, - "AgentActionEmailNotVerified": AgentActionEmailNotVerified, + "AgentActionMultiEnvUpgradeRequired": AgentActionMultiEnvUpgradeRequired, + "AgentActionStackPromoteMissingImageRef": AgentActionStackPromoteMissingImageRef, + "AgentActionBindingFamilyDisabled": AgentActionBindingFamilyDisabled, + "AgentActionBindingLookupFailed": AgentActionBindingLookupFailed, + "RecycleGateAgentAction": RecycleGateAgentAction, + "AgentActionPrivateDeployRequiresPro": AgentActionPrivateDeployRequiresPro, + "AgentActionPrivateDeployRequiresAllowedIPs": AgentActionPrivateDeployRequiresAllowedIPs, + "AgentActionAdminRequired": AgentActionAdminRequired, + "AgentActionPromotionInvalid": AgentActionPromotionInvalid, + "AgentActionPromotionAlreadyUsed": AgentActionPromotionAlreadyUsed, + "AgentActionPromotionExpired": AgentActionPromotionExpired, + "AgentActionPromoteTokenExpired": AgentActionPromoteTokenExpired, + "AgentActionReadOnlySession": AgentActionReadOnlySession, + "AgentActionNotifyWebhookInvalid": AgentActionNotifyWebhookInvalid, + "AgentActionPauseRequiresPro": AgentActionPauseRequiresPro, + "AgentActionResourceAlreadyPaused": AgentActionResourceAlreadyPaused, + "AgentActionResourceNotPaused": AgentActionResourceNotPaused, + "AgentActionBackupRequiresClaim": AgentActionBackupRequiresClaim, + "AgentActionRestoreRequiresPro": AgentActionRestoreRequiresPro, + "AgentActionRestoreRequiresHobbyPlus": AgentActionRestoreRequiresHobbyPlus, + "AgentActionRestoreBackupNotReady": AgentActionRestoreBackupNotReady, + "AgentActionRestoreInflight": AgentActionRestoreInflight, + "AgentActionRestoreDestructiveAckRequired": AgentActionRestoreDestructiveAckRequired, + "AgentActionRestoreTargetCrossTeam": AgentActionRestoreTargetCrossTeam, + "AgentActionBackupIntegrityFailed": AgentActionBackupIntegrityFailed, + "AgentActionMetricsRequiresUpgrade": AgentActionMetricsRequiresUpgrade, + "AgentActionEmailNotVerified": AgentActionEmailNotVerified, // Wave FIX-J deploy TTL walls. The long-form success-path // newAgentActionDeployAutoExpire24h is documented in // agent_action.go as the canonical exception to the 280-char @@ -61,26 +61,26 @@ func agentActionContractCases() map[string]string { // is intentionally NOT exercised by this contract gate — // covered instead by deploy_ttl_test.go which spot-checks the // imperative opening + URL inclusion. - "AgentActionDeployMakePermanentAnonymous": AgentActionDeployMakePermanentAnonymous, - "AgentActionDeployTTLHoursOutOfRange": AgentActionDeployTTLHoursOutOfRange, - "AgentActionTeamSettingsInvalidTTLPolicy": AgentActionTeamSettingsInvalidTTLPolicy, + "AgentActionDeployMakePermanentAnonymous": AgentActionDeployMakePermanentAnonymous, + "AgentActionDeployTTLHoursOutOfRange": AgentActionDeployTTLHoursOutOfRange, + "AgentActionTeamSettingsInvalidTTLPolicy": AgentActionTeamSettingsInvalidTTLPolicy, // Builders — representative inputs covering tier/env/role/limit // interpolation. - "newAgentActionDeploymentLimitReached(hobby,1)": newAgentActionDeploymentLimitReached("hobby", 1), - "newAgentActionBackupRateLimited(hobby,1)": newAgentActionBackupRateLimited("hobby", 1), - "newAgentActionMetricsWindowTooLarge(hobby,1h)": newAgentActionMetricsWindowTooLarge("hobby", "1h"), - "newAgentActionPromoteApprovalSent(prod,email)": newAgentActionPromoteApprovalSent("production", "owner@example.com"), - "newAgentActionStorageLimitReached(hobby,500)": newAgentActionStorageLimitReached("hobby", 500), - "newAgentActionVaultQuotaExceeded(hobby,50)": newAgentActionVaultQuotaExceeded("hobby", 50), - "newAgentActionEnvPolicyDenied(prod,deploy)": newAgentActionEnvPolicyDenied("production", "deploy", "owner", "developer"), - "newAgentActionOwnerRequired(developer)": newAgentActionOwnerRequired("developer"), - "newAgentActionBindingInvalidUUID(KEY)": newAgentActionBindingInvalidUUID("DATABASE_URL", "not-a-uuid"), - "newAgentActionBindingNotFound(KEY)": newAgentActionBindingNotFound("DATABASE_URL"), - "newAgentActionBindingCrossTeam(KEY)": newAgentActionBindingCrossTeam("DATABASE_URL"), - "newAgentActionBindingNoEnvTwin(uuid,name,env)": newAgentActionBindingNoEnvTwin("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", "owner-db", "staging"), - "newAgentActionAdminTierChanged(team,pro)": newAgentActionAdminTierChanged("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", "pro"), - "newAgentActionAdminPromoIssued(team,code)": newAgentActionAdminPromoIssued("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", "01H8XGZJ"), + "newAgentActionDeploymentLimitReached(hobby,1)": newAgentActionDeploymentLimitReached("hobby", 1), + "newAgentActionBackupRateLimited(hobby,1)": newAgentActionBackupRateLimited("hobby", 1), + "newAgentActionMetricsWindowTooLarge(hobby,1h)": newAgentActionMetricsWindowTooLarge("hobby", "1h"), + "newAgentActionPromoteApprovalSent(prod,email)": newAgentActionPromoteApprovalSent("production", "owner@example.com"), + "newAgentActionStorageLimitReached(hobby,500)": newAgentActionStorageLimitReached("hobby", 500), + "newAgentActionVaultQuotaExceeded(hobby,50)": newAgentActionVaultQuotaExceeded("hobby", 50), + "newAgentActionEnvPolicyDenied(prod,deploy)": newAgentActionEnvPolicyDenied("production", "deploy", "owner", "developer"), + "newAgentActionOwnerRequired(developer)": newAgentActionOwnerRequired("developer"), + "newAgentActionBindingInvalidUUID(KEY)": newAgentActionBindingInvalidUUID("DATABASE_URL", "not-a-uuid"), + "newAgentActionBindingNotFound(KEY)": newAgentActionBindingNotFound("DATABASE_URL"), + "newAgentActionBindingCrossTeam(KEY)": newAgentActionBindingCrossTeam("DATABASE_URL"), + "newAgentActionBindingNoEnvTwin(uuid,name,env)": newAgentActionBindingNoEnvTwin("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", "owner-db", "staging"), + "newAgentActionAdminTierChanged(team,pro)": newAgentActionAdminTierChanged("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", "pro"), + "newAgentActionAdminPromoIssued(team,code)": newAgentActionAdminPromoIssued("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", "01H8XGZJ"), } // codeToAgentAction registry — every entry must also pass the contract. @@ -161,8 +161,8 @@ func assertContract(t *testing.T, name, s string) { "Re-issue", "re-issue", // approval-link expired → re-issue from app "Re-enter", "re-enter", // invalid_email → re-enter address "Refresh", "refresh", // stale slug/state → refresh from app - "POST ", // multipart endpoints — POST is the action - "GET ", // list endpoints — GET is the action + "POST ", // multipart endpoints — POST is the action + "GET ", // list endpoints — GET is the action "Request", "request", // magic-link not found → request new one "Add ", "add ", // missing_email / missing_env etc → add the field "Trim ", "trim ", // env_too_large / tarball_too_large @@ -287,11 +287,14 @@ func TestAgentActionContract_RegistryCoverage(t *testing.T) { // Quota walls. "quota_exceeded", "storage_limit_reached", "vault_quota_exceeded", "vault_not_available", "vault_env_not_allowed", "member_limit", - // "tier_unavailable" was dropped 2026-05-29 alongside the Team-tier - // checkout/change-plan guards (CEO BIZ-1). It was the only code in - // this registry that no handler emitted; the orphan-coverage gate - // flagged it. If a future feature reintroduces a "tier is genuinely - // unavailable" surface, re-add the code + its emitter in one PR. + // "tier_unavailable" was dropped 2026-05-29 (CEO BIZ-1) and + // superseded 2026-06-04 by "tier_not_yet_available" when Team was + // RE-GATED out of self-serve checkout + change-plan (CEO directive: + // Team not rolled out until unlimited-resource delivery is proven + // built). A drop of "tier_not_yet_available" without migrating its + // emitters is a contract regression — agents branching on the code + // would lose the "contact sales" remediation. + "tier_not_yet_available", "upgrade_required", "rate_limit_exceeded", // B7-P1-7 (BugBash 2026-05-20): `claim_required` is the honest // 402 for anonymous-tier walls whose remediation is a FREE claim diff --git a/internal/handlers/billing.go b/internal/handlers/billing.go index f92e4b8..ef5e1ed 100644 --- a/internal/handlers/billing.go +++ b/internal/handlers/billing.go @@ -789,19 +789,27 @@ func (h *BillingHandler) CreateCheckoutAPI(c *fiber.Ctx) error { } switch plan { - case "hobby", "hobby_plus", "pro", "team": + case "hobby", "hobby_plus", "pro": // fall through — plan_id is resolved by razorpayPlanIDFor below. - // Team enabled 2026-05-29 (CEO BIZ-1 ship call): marketing, - // dashboard PricingGrid, and llms.txt all sell Team @ $199/mo, - // but this handler used to return 400 tier_unavailable on every - // team checkout — turning away the highest-AOV prospect mid-funnel. - // If RAZORPAY_PLAN_ID_TEAM (or RAZORPAY_PLAN_ID_TEAM_ANNUAL) is - // unset in this environment the request now falls through to the - // shared 503 billing_not_configured branch below (a clear - // operator signal), not 400 tier_unavailable (a customer signal - // that the tier itself doesn't exist). + case "team": + // Team is RE-GATED out of self-serve checkout per the 2026-06-04 + // CEO directive: the Team plan ($199 "unlimited") is NOT rolled + // out and must not be marketable / claimable / chargeable until + // its unlimited-resource delivery is PROVEN built. This reverses + // the 2026-05-29 (BIZ-1) change that had enabled Team checkout — + // do NOT re-enable Team here without explicit, written CEO + // confirmation that unlimited-resource delivery is proven. + // Refs: memory `project_team_plan_not_rolled_out_no_payment` and + // docs/sessions/2026-06-04/TEAM-PLAN-GATE-AND-BUILD.md. + // + // A DISTINCT code (`tier_not_yet_available`, not the generic + // `invalid_plan`) so the dashboard/agents render the correct + // "contact sales / not yet available" message instead of telling + // the user they made a typo. + return respondError(c, fiber.StatusBadRequest, "tier_not_yet_available", + "The Team plan is not yet available for self-serve checkout — contact support@instanode.dev.") default: - return respondError(c, fiber.StatusBadRequest, "invalid_plan", "plan must be 'hobby', 'hobby_plus', 'pro', or 'team'") + return respondError(c, fiber.StatusBadRequest, "invalid_plan", "plan must be 'hobby', 'hobby_plus', or 'pro'") } planID := h.razorpayPlanIDFor(plan, frequency) @@ -3207,9 +3215,24 @@ func (h *BillingHandler) ChangePlanAPI(c *fiber.Ctx) error { if strings.EqualFold(strings.TrimSpace(planTier), target) { return respondError(c, fiber.StatusBadRequest, "same_plan", "Already on requested plan") } + // Team is RE-GATED out of self-serve plan changes per the 2026-06-04 + // CEO directive (same gate as CreateCheckoutAPI): a hobby/pro team must + // not be able to self-upgrade to Team — that is another chargeable + // self-serve path for a tier that is NOT rolled out until its + // unlimited-resource delivery is proven built. Reverses the 2026-05-29 + // (BIZ-1) enablement. Do NOT re-enable without explicit CEO sign-off. + // Refs: memory `project_team_plan_not_rolled_out_no_payment` and + // docs/sessions/2026-06-04/TEAM-PLAN-GATE-AND-BUILD.md. Checked BEFORE + // the razorpayPlanIDs membership test so the response is the distinct + // `tier_not_yet_available` regardless of whether RAZORPAY_PLAN_ID_TEAM + // happens to be set in this environment. + if target == "team" { + return respondError(c, fiber.StatusBadRequest, "tier_not_yet_available", + "The Team plan is not yet available for self-serve plan changes — contact support@instanode.dev.") + } planIDs := h.razorpayPlanIDs() if _, ok := planIDs[target]; !ok { - return respondError(c, fiber.StatusBadRequest, "invalid_plan", "target_plan must be hobby, hobby_plus, pro, or team") + return respondError(c, fiber.StatusBadRequest, "invalid_plan", "target_plan must be hobby, hobby_plus, or pro") } // No self-serve downgrade — see project memory // project_no_self_serve_cancel_downgrade.md. A target whose plan rank is @@ -3225,13 +3248,8 @@ func (h *BillingHandler) ChangePlanAPI(c *fiber.Ctx) error { "Tell the user that downgrading to a lower plan is support-assisted. Have them email support@instanode.dev with their team and the target plan.", "mailto:support@instanode.dev") } - // Team-tier ChangePlan is now allowed for the same reason Team - // checkout is: marketing + dashboard + llms.txt sell Team @ $199/mo - // as a self-serve upgrade path. If the operator hasn't created the - // Razorpay plan_id yet, razorpayPlanIDFor / portal.ChangePlan - // surfaces the configuration error downstream — never 400 - // tier_unavailable from this layer. (Enabled 2026-05-29 alongside - // the checkout-creation team guard removal.) + // (Target=team is rejected above with tier_not_yet_available — the + // 2026-06-04 CEO re-gate. Only hobby/hobby_plus/pro upgrades reach here.) portal := h.billingPortal() if _, err := portal.SubscriptionID(c.Context(), teamID); err != nil { return respondError(c, fiber.StatusBadRequest, "no_subscription", "no active subscription to change") diff --git a/internal/handlers/billing_coverage2_test.go b/internal/handlers/billing_coverage2_test.go index dbc8cf3..9fe1941 100644 --- a/internal/handlers/billing_coverage2_test.go +++ b/internal/handlers/billing_coverage2_test.go @@ -747,28 +747,27 @@ func TestCov2_ChangePlan_DowngradeNotSelfServe(t *testing.T) { assert.Equal(t, "downgrade_not_self_serve", body["error"]) } -// TestCov2_ChangePlan_TeamTierAccepted locks in the 2026-05-29 BIZ-1 fix: -// ChangePlan no longer 400-rejects upgrades targeting the Team tier. A -// hobby team with no active Razorpay subscription now flows past the -// (deleted) tier_unavailable guard and is stopped by the downstream -// no_subscription check instead — confirming the team-tier path is -// reachable end-to-end and only fails on a real per-request condition. -// Regression guard: if a future engineer reintroduces the tier_unavailable -// branch on the team tier, this test flips back to RED. -func TestCov2_ChangePlan_TeamTierAccepted(t *testing.T) { +// TestCov2_ChangePlan_TeamTierRejected locks in the 2026-06-04 CEO re-gate: +// ChangePlan REJECTS target_plan=team with 400 tier_not_yet_available — the +// Team plan ($199 "unlimited") is not rolled out until its unlimited-resource +// delivery is proven built, so a hobby team must not be able to self-upgrade +// to it. This intentionally REVERSES the 2026-05-29 (BIZ-1) enablement; do not +// "re-fix" by re-allowing team. The rejection fires BEFORE the downstream +// no_subscription guard and regardless of whether RAZORPAY_PLAN_ID_TEAM is set. +func TestCov2_ChangePlan_TeamTierRejected(t *testing.T) { cov2NeedsDB(t) db, clean := testhelpers.SetupTestDB(t) defer clean() teamID := testhelpers.MustCreateTeamDB(t, db, "hobby") defer db.Exec(`DELETE FROM teams WHERE id = $1::uuid`, teamID) + // RAZORPAY_PLAN_ID_TEAM set on purpose: proves the gate fires even when + // the operator has configured the Team plan_id. cfg := &config.Config{JWTSecret: "test-secret-that-is-at-least-32-bytes-long!!", RazorpayKeyID: "k", RazorpayKeySecret: "s", RazorpayPlanIDHobby: "plan_hobby", RazorpayPlanIDPro: "plan_pro", RazorpayPlanIDTeam: "plan_team"} app := changePlanAppReal(t, db, cfg, teamID) code, body := changePlanReq(t, app, map[string]any{"target_plan": "team"}) assert.Equal(t, http.StatusBadRequest, code) - assert.NotEqual(t, "tier_unavailable", body["error"], - "team must no longer be tier_unavailable — marketing+dashboard+llms.txt sell Team @ $199/mo") - assert.Equal(t, "no_subscription", body["error"], - "hobby team has no active subscription → expect the downstream guard, not tier_unavailable") + assert.Equal(t, "tier_not_yet_available", body["error"], + "target_plan=team must return tier_not_yet_available — Team is gated out of self-serve (2026-06-04 CEO directive)") } func TestCov2_ChangePlan_NoSubscription(t *testing.T) { @@ -1299,68 +1298,48 @@ func TestCov2_Checkout_PromoCode_ExpiredSkipsBookkeeping(t *testing.T) { assert.False(t, present, "an expired admin promo code must not stamp the notes") } -// TestCov2_Checkout_TeamTierAccepted locks in the 2026-05-29 BIZ-1 fix: -// Team checkout no longer 400s with tier_unavailable. With Razorpay -// credentials + RAZORPAY_PLAN_ID_TEAM configured, the team plan reaches -// the CreateSubscription codepath and returns a short_url like every -// other paid tier. CEO BIZ-1: marketing/dashboard/llms.txt all sell -// Team @ $199/mo; the API now matches that story. -func TestCov2_Checkout_TeamTierAccepted(t *testing.T) { +// TestCov2_Checkout_TeamTierRejected locks in the 2026-06-04 CEO re-gate: +// Team checkout returns 400 tier_not_yet_available even with Razorpay +// credentials + RAZORPAY_PLAN_ID_TEAM fully configured. The Team plan is +// NOT rolled out until its unlimited-resource delivery is proven built, so +// the request must NEVER reach the CreateSubscription codepath. This +// REVERSES the 2026-05-29 (BIZ-1) enablement — do not "re-fix" by re-allowing +// team. The fake CreateSubscription would fail the test loudly if the gate +// ever lets a team checkout through. +func TestCov2_Checkout_TeamTierRejected(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!!", RazorpayKeyID: "k", RazorpayKeySecret: "s", RazorpayPlanIDTeam: "plan_team"} + cfg := &config.Config{JWTSecret: "test-secret-that-is-at-least-32-bytes-long!!", RazorpayKeyID: "k", RazorpayKeySecret: "s", RazorpayPlanIDTeam: "plan_team", RazorpayPlanIDTeamYearly: "plan_team_y"} teamID, userID := seedVerifiedTeamUser(t, db, "free") app, bh := cov2CheckoutApp(t, db, cfg, teamID, userID) - freshSub := "sub_team_" + uuid.NewString() bh.CreateSubscription = func(_ map[string]any) (map[string]any, error) { - return map[string]any{"id": freshSub, "short_url": "https://rzp.io/team"}, nil + t.Fatal("CreateSubscription must NOT be called for a gated Team checkout") + return nil, nil } code, body := postCheckoutReq(t, app, map[string]any{"plan": "team"}) - require.Equal(t, http.StatusOK, code, "body=%v", body) - assert.Equal(t, freshSub, body["subscription_id"]) - assert.Equal(t, "https://rzp.io/team", body["short_url"]) - assert.NotEqual(t, "tier_unavailable", body["error"], - "Team must no longer return tier_unavailable — see CEO memo 2026-05-29 BIZ-1.") + require.Equal(t, http.StatusBadRequest, code, "body=%v", body) + assert.Equal(t, "tier_not_yet_available", body["error"], + "Team must return tier_not_yet_available — Team is gated out of self-serve checkout (2026-06-04 CEO directive).") } -// TestCov2_Checkout_TeamTierYearlyAccepted mirrors the monthly accept -// for plan_frequency=yearly. RAZORPAY_PLAN_ID_TEAM_ANNUAL configured → -// the yearly Team subscription is created. Regression guard against a -// future engineer wiring yearly behind a separate tier_unavailable check. -func TestCov2_Checkout_TeamTierYearlyAccepted(t *testing.T) { +// TestCov2_Checkout_TeamTierYearlyRejected mirrors the monthly rejection +// for plan_frequency=yearly: a yearly Team checkout is also gated, even +// with RAZORPAY_PLAN_ID_TEAM_ANNUAL configured. +func TestCov2_Checkout_TeamTierYearlyRejected(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!!", RazorpayKeyID: "k", RazorpayKeySecret: "s", RazorpayPlanIDTeamYearly: "plan_team_y"} teamID, userID := seedVerifiedTeamUser(t, db, "free") app, bh := cov2CheckoutApp(t, db, cfg, teamID, userID) - freshSub := "sub_team_y_" + uuid.NewString() bh.CreateSubscription = func(_ map[string]any) (map[string]any, error) { - return map[string]any{"id": freshSub, "short_url": "https://rzp.io/team_y"}, nil + t.Fatal("CreateSubscription must NOT be called for a gated yearly Team checkout") + return nil, nil } code, body := postCheckoutReq(t, app, map[string]any{"plan": "team", "plan_frequency": "yearly"}) - require.Equal(t, http.StatusOK, code, "body=%v", body) - assert.Equal(t, freshSub, body["subscription_id"]) -} - -// TestCov2_Checkout_TeamTierNotConfigured covers the operator-config -// gap surfaced by memory `project_razorpay_recurring_not_enabled.md`: -// when RAZORPAY_PLAN_ID_TEAM is unset the request now falls through to -// the shared 503 billing_not_configured branch (clear operator signal) -// rather than 400 tier_unavailable (a customer signal that the tier -// itself doesn't exist). -func TestCov2_Checkout_TeamTierNotConfigured(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!!", RazorpayKeyID: "k", RazorpayKeySecret: "s"} - teamID, userID := seedVerifiedTeamUser(t, db, "free") - app, _ := cov2CheckoutApp(t, db, cfg, teamID, userID) - code, body := postCheckoutReq(t, app, map[string]any{"plan": "team"}) - assert.Equal(t, http.StatusServiceUnavailable, code) - assert.Equal(t, "billing_not_configured", body["error"]) - assert.NotEqual(t, "tier_unavailable", body["error"]) + require.Equal(t, http.StatusBadRequest, code, "body=%v", body) + assert.Equal(t, "tier_not_yet_available", body["error"]) } func TestCov2_Checkout_InvalidPlan(t *testing.T) { diff --git a/internal/handlers/billing_residual_test.go b/internal/handlers/billing_residual_test.go index c0efd66..e007782 100644 --- a/internal/handlers/billing_residual_test.go +++ b/internal/handlers/billing_residual_test.go @@ -102,13 +102,13 @@ func TestResidualChangePlan_Downgrade_400(t *testing.T) { assert.Equal(t, "downgrade_not_self_serve", body["error"]) } -// TestResidualChangePlan_TeamTier_Accepted locks the 2026-05-29 BIZ-1 -// fix at the change-plan surface: a Pro team requesting target_plan=team -// no longer 400s with tier_unavailable. The dev-lock branch was removed -// alongside the matching checkout branch, so the request flows to the -// downstream no_subscription guard (this team has no Razorpay -// subscription_id on file). -func TestResidualChangePlan_TeamTier_Accepted(t *testing.T) { +// TestResidualChangePlan_TeamTier_Rejected locks the 2026-06-04 CEO re-gate +// at the change-plan surface: a Pro team requesting target_plan=team is +// rejected with 400 tier_not_yet_available — the Team plan is gated out of +// self-serve until its unlimited-resource delivery is proven built. This +// REVERSES the 2026-05-29 (BIZ-1) enablement; the rejection fires before the +// downstream no_subscription guard and regardless of RAZORPAY_PLAN_ID_TEAM. +func TestResidualChangePlan_TeamTier_Rejected(t *testing.T) { db, clean := testhelpers.SetupTestDB(t) defer clean() teamID := mkVerifiedTeam(t, db, "pro") @@ -117,10 +117,8 @@ func TestResidualChangePlan_TeamTier_Accepted(t *testing.T) { app := billingAppNoAuth(t, db, cfg, teamID) status, body := changePlanPost(t, app, `{"target_plan":"team"}`) assert.Equal(t, http.StatusBadRequest, status) - assert.NotEqual(t, "tier_unavailable", body["error"], - "target_plan=team must no longer return tier_unavailable") - assert.Equal(t, "no_subscription", body["error"], - "pro team with no Razorpay sub id → expect the downstream no_subscription guard") + assert.Equal(t, "tier_not_yet_available", body["error"], + "target_plan=team must return tier_not_yet_available — Team is gated out of self-serve (2026-06-04 CEO directive)") } // TestResidualChangePlan_NoSubscription_400 hits no_subscription: a valid diff --git a/internal/handlers/billing_test.go b/internal/handlers/billing_test.go index eb77049..bb941b3 100644 --- a/internal/handlers/billing_test.go +++ b/internal/handlers/billing_test.go @@ -13,6 +13,7 @@ import ( "net/http" "net/http/httptest" "os" + "strings" "testing" "time" @@ -26,6 +27,7 @@ import ( "instant.dev/internal/handlers" "instant.dev/internal/middleware" "instant.dev/internal/models" + "instant.dev/internal/plans" "instant.dev/internal/razorpaybilling" "instant.dev/internal/testhelpers" ) @@ -941,11 +943,11 @@ func postCheckout(t *testing.T, app *fiber.App, body map[string]any) (int, map[s // contacted — a typo can't silently fall back to monthly. func TestCheckout_PlanFrequency_InvalidValue_Returns400(t *testing.T) { cfg := &config.Config{ - JWTSecret: "test-secret-that-is-at-least-32-bytes-long!!", - RazorpayKeyID: "rzp_test_key", - RazorpayKeySecret: "rzp_test_secret", - RazorpayPlanIDPro: "plan_monthly_pro", - RazorpayPlanIDProYearly: "plan_yearly_pro", + JWTSecret: "test-secret-that-is-at-least-32-bytes-long!!", + RazorpayKeyID: "rzp_test_key", + RazorpayKeySecret: "rzp_test_secret", + RazorpayPlanIDPro: "plan_monthly_pro", + RazorpayPlanIDProYearly: "plan_yearly_pro", } app := checkoutAppNoDB(t, cfg) status, body := postCheckout(t, app, map[string]any{ @@ -998,18 +1000,101 @@ func TestCheckout_PlanFrequency_MonthlyDefault_NoFrequency(t *testing.T) { assert.Equal(t, "billing_not_configured", body["error"]) } -// Note: the full positive-path Team-tier checkout regression lives in -// billing_coverage2_test.go (TestCov2_Checkout_TeamTierAccepted / -// _TeamTierYearlyAccepted / _TeamTierNotConfigured) — those need a -// real test DB to clear the post-validation email-verify gate, so they -// can't run in the no-DB harness this file uses for input-validation -// branches. The no-DB-friendly negative case (typo'd plan still 400s) -// stays here. - -// TestCheckout_RejectsUnknownPlan locks the negative side of the -// 2026-05-29 BIZ-1 fix: a typo'd plan name still returns 400 -// invalid_plan, and the error message now lists team as an accepted -// plan since the dedicated tier_unavailable branch is gone. +// Note: the Team-tier checkout/change-plan REJECTION regressions that need +// a real test DB (to clear the post-validation email-verify gate) live in +// billing_coverage2_test.go (TestCov2_Checkout_TeamTierRejected / +// _TeamTierYearlyRejected, TestCov2_ChangePlan_TeamTierRejected). The +// no-DB-friendly Team rejection + the typo'd-plan negative case stay here — +// the email-verify gate fails open when no user_id is on the request, so the +// plan switch is reachable without a DB. + +// TestCreateCheckout_TeamPlan_Rejected is the 2026-06-04 CEO re-gate guard: +// POST /api/v1/billing/checkout with plan=team returns 400 with the DISTINCT +// code tier_not_yet_available (NOT the generic invalid_plan) even when the +// Team Razorpay plan_id is fully configured. This REVERSES the 2026-05-29 +// (BIZ-1) enablement — the Team plan ($199 "unlimited") must not be +// chargeable until its unlimited-resource delivery is proven built. Do not +// "re-fix" by re-allowing team. +func TestCreateCheckout_TeamPlan_Rejected(t *testing.T) { + cfg := &config.Config{ + JWTSecret: "test-secret-that-is-at-least-32-bytes-long!!", + RazorpayKeyID: "rzp_test_key", + RazorpayKeySecret: "rzp_test_secret", + RazorpayPlanIDTeam: "plan_monthly_team", + RazorpayPlanIDTeamYearly: "plan_yearly_team", + } + app := checkoutAppNoDB(t, cfg) + status, resp := postCheckout(t, app, map[string]any{"plan": "team"}) + assert.Equal(t, http.StatusBadRequest, status) + assert.Equal(t, "tier_not_yet_available", resp["error"], + "plan=team must return the distinct tier_not_yet_available code, not invalid_plan") +} + +// TestCheckout_SelfServePurchasablePlans_AreExactlyHobbyHobbyPlusPro is the +// rule-18 registry-iterating guard for the 2026-06-04 CEO re-gate. It walks +// the LIVE plans registry (not a hand-typed list) and asserts the set of +// tiers that POST /api/v1/billing/checkout accepts into the subscription-mint +// path is EXACTLY {hobby, hobby_plus, pro}. team is gated with the distinct +// tier_not_yet_available code; every other tier (free/anonymous/growth, plus +// any future tier added to plans.yaml) is rejected with invalid_plan. If a +// future engineer re-adds team to the checkout accept-case — or adds a new +// purchasable tier without filing it here — this test goes RED. +// +// Classification is by error code with NO plan_id configured for the accepted +// tiers, so an accepted tier lands on 503 billing_not_configured (it cleared +// the plan switch) rather than minting a real subscription: +// - accepted (hobby/hobby_plus/pro) → 503 billing_not_configured +// - team → 400 tier_not_yet_available +// - anything else → 400 invalid_plan +func TestCheckout_SelfServePurchasablePlans_AreExactlyHobbyHobbyPlusPro(t *testing.T) { + selfServe := map[string]bool{"hobby": true, "hobby_plus": true, "pro": true} + + // Test/empty-env key + secret so the BUG-P112 live-key-in-dev guard never + // fires and we always reach the post-plan-switch billing_not_configured + // branch for accepted tiers. No plan_ids set on purpose. + cfg := &config.Config{ + JWTSecret: "test-secret-that-is-at-least-32-bytes-long!!", + RazorpayKeyID: "rzp_test_key", + RazorpayKeySecret: "rzp_test_secret", + } + app := checkoutAppNoDB(t, cfg) + + all := plans.Default().All() + require.NotEmpty(t, all, "plans registry empty — cannot validate purchasable set") + + for tier := range all { + // Yearly variants are not checkout `plan` names — the cycle is + // selected via plan_frequency, not the plan field — so skip them. + if strings.HasSuffix(tier, "_yearly") { + continue + } + tier := tier + t.Run(tier, func(t *testing.T) { + status, body := postCheckout(t, app, map[string]any{"plan": tier}) + code, _ := body["error"].(string) + switch { + case selfServe[tier]: + assert.Equal(t, http.StatusServiceUnavailable, status, + "%q is a self-serve plan and must clear the plan switch (→ billing_not_configured here), body=%v", tier, body) + assert.Equal(t, "billing_not_configured", code, + "%q must reach the post-switch config branch, not a plan-rejection code", tier) + case tier == "team": + assert.Equal(t, http.StatusBadRequest, status, "body=%v", body) + assert.Equal(t, "tier_not_yet_available", code, + "team must be gated with the distinct tier_not_yet_available code (2026-06-04 CEO re-gate)") + default: + assert.Equal(t, http.StatusBadRequest, status, "body=%v", body) + assert.Equal(t, "invalid_plan", code, + "%q is not self-serve purchasable and must be rejected with invalid_plan", tier) + } + }) + } +} + +// TestCheckout_RejectsUnknownPlan locks the negative side: a typo'd plan name +// still returns 400 invalid_plan, and the error message lists the three +// self-serve-purchasable plans (team is no longer among them — re-gated +// 2026-06-04). func TestCheckout_RejectsUnknownPlan(t *testing.T) { cfg := &config.Config{ JWTSecret: "test-secret-that-is-at-least-32-bytes-long!!", @@ -1023,8 +1108,8 @@ func TestCheckout_RejectsUnknownPlan(t *testing.T) { assert.Equal(t, http.StatusBadRequest, status) assert.Equal(t, "invalid_plan", resp["error"]) if msg, ok := resp["message"].(string); ok { - assert.Contains(t, msg, "team", - "invalid_plan message should list team as an accepted plan now that the guard is gone") + assert.NotContains(t, msg, "team", + "invalid_plan message must not list team now that Team is re-gated out of self-serve") } } @@ -1056,7 +1141,7 @@ func TestPlanIDToTier_MapsYearlyPlanIDsToCanonicalTier(t *testing.T) { // "hobby" (lowest paid tier), NOT "pro". An env-var typo grants $9 // Hobby instead of $49 Pro — 5× smaller blast radius; the reconciler // corrects upward within 15 min once the env var is fixed. - {"", handlers.PlanIDToTierFallbackForTest}, // empty → safe fallback + {"", handlers.PlanIDToTierFallbackForTest}, // empty → safe fallback {"plan_unknown_xx", handlers.PlanIDToTierFallbackForTest}, // unrecognised → safe fallback } for _, c := range cases { @@ -1248,7 +1333,7 @@ func TestBillingWebhook_SubscriptionCharged_PromoCode_NotConsumed(t *testing.T) // Build a subscription.charged event that references the promo code in notes. notes := map[string]any{ - "team_id": teamID, + "team_id": teamID, "admin_promo_code_id": codeID.String(), } subEntity, _ := json.Marshal(map[string]any{ diff --git a/internal/handlers/billing_traffic_env_test.go b/internal/handlers/billing_traffic_env_test.go index f829c87..232ddfe 100644 --- a/internal/handlers/billing_traffic_env_test.go +++ b/internal/handlers/billing_traffic_env_test.go @@ -184,26 +184,29 @@ func TestBillingCheckout_DetectsLiveKeyInDevEnv(t *testing.T) { tc := tc t.Run(tc.name, func(t *testing.T) { cfg := &config.Config{ - JWTSecret: "test-secret-that-is-at-least-32-bytes-long!!", - Environment: tc.environment, - RazorpayKeyID: tc.key, - RazorpayKeySecret: "secret-fixture", // non-empty so the not_configured branch doesn't shadow - RazorpayPlanIDPro: "plan_monthly_pro", - RazorpayPlanIDProYearly: "plan_yearly_pro", - RazorpayPlanIDHobby: "plan_monthly_hobby", - // RazorpayPlanIDTeam intentionally LEFT EMPTY. The test - // requests plan="team" — a valid plan name in the switch - // (passes the 400 invalid_plan branch) but with no plan_id - // configured, so a guard-cleared request falls through to - // the 503 billing_not_configured branch. This cleanly - // distinguishes: + JWTSecret: "test-secret-that-is-at-least-32-bytes-long!!", + Environment: tc.environment, + RazorpayKeyID: tc.key, + RazorpayKeySecret: "secret-fixture", // non-empty so the not_configured branch doesn't shadow + RazorpayPlanIDPro: "plan_monthly_pro", + RazorpayPlanIDProYearly: "plan_yearly_pro", + RazorpayPlanIDHobby: "plan_monthly_hobby", + // RazorpayPlanIDHobbyPlus intentionally LEFT EMPTY. The test + // requests plan="hobby_plus" — a self-serve-purchasable plan + // name in the switch (passes the 400 invalid_plan branch and is + // NOT the re-gated team tier) but with no plan_id configured, so + // a guard-cleared request falls through to the 503 + // billing_not_configured branch. This cleanly distinguishes: // - guard fired → 503 billing_misconfigured // - guard let through → 503 billing_not_configured // without depending on a DB for the email-verify gate. + // (Was plan="team" before the 2026-06-04 Team re-gate, which + // now short-circuits team with 400 tier_not_yet_available + // before these branches.) } app := checkoutAppNoDB(t, cfg) status, body := postCheckout(t, app, map[string]any{ - "plan": "team", + "plan": "hobby_plus", }) assert.Equal(t, tc.wantStatus, status, "body=%v", body) assert.Equal(t, tc.wantErrorCode, body["error"], "body=%v", body) @@ -220,10 +223,10 @@ func TestBillingCheckout_DetectsLiveKeyInDevEnv(t *testing.T) { // Fiber harness above. Tests the package-internal exported helpers // by re-deriving them from the surface contract: // -// trafficEnv("rzp_live_X") -> ("production", true) -// trafficEnv("rzp_test_X") -> ("test", true) -// trafficEnv("") -> ("test", false) -// trafficEnv("garbage") -> ("test", false) +// trafficEnv("rzp_live_X") -> ("production", true) +// trafficEnv("rzp_test_X") -> ("test", true) +// trafficEnv("") -> ("test", false) +// trafficEnv("garbage") -> ("test", false) // // We assert through the public CreateCheckoutAPI surface because the // helpers are package-private. The matrix above already exercises every @@ -243,11 +246,11 @@ func TestBillingCheckout_TrafficEnvDerivation_OnlyProductionOrTest(t *testing.T) tc := tc t.Run(tc.want+"_"+tc.key, func(t *testing.T) { cfg := &config.Config{ - JWTSecret: "test-secret-that-is-at-least-32-bytes-long!!", - Environment: envForKey(tc.key), // production iff live - RazorpayKeyID: tc.key, - RazorpayKeySecret: "secret-fixture", - RazorpayPlanIDPro: "plan_monthly_pro", + JWTSecret: "test-secret-that-is-at-least-32-bytes-long!!", + Environment: envForKey(tc.key), // production iff live + RazorpayKeyID: tc.key, + RazorpayKeySecret: "secret-fixture", + RazorpayPlanIDPro: "plan_monthly_pro", } // Invalid plan body forces 400 — but the error envelope // doesn't carry traffic_env, so we can't observe the derivation @@ -284,7 +287,7 @@ func TestBillingCheckout_ResponseIncludesTrafficEnv(t *testing.T) { db, clean := testhelpers.SetupTestDB(t) defer clean() cfg := &config.Config{ - JWTSecret: "test-secret-that-is-at-least-32-bytes-long!!", + JWTSecret: "test-secret-that-is-at-least-32-bytes-long!!", // production deployment + live key is the intended pairing // — guard does not fire; happy path proceeds to the // fake CreateSubscription. diff --git a/internal/handlers/helpers.go b/internal/handlers/helpers.go index ab12c21..00757c4 100644 --- a/internal/handlers/helpers.go +++ b/internal/handlers/helpers.go @@ -143,12 +143,18 @@ var codeToAgentAction = map[string]errorCodeMeta{ UpgradeURL: "https://instanode.dev/claim", }, // "tier_unavailable" was removed 2026-05-29 along with the Team-tier - // checkout/change-plan guards (CEO BIZ-1). The only emitters of this - // code lived in those two billing branches; with both gone, the - // codeToAgentAction entry was orphan-flagged by - // TestCodeToAgentAction_NoOrphans. If a future feature reintroduces - // a "tier is genuinely unavailable" surface, re-add the entry here - // and emit it from the new site in the same PR. + // checkout/change-plan guards (CEO BIZ-1). It was superseded by + // "tier_not_yet_available" below on 2026-06-04 when Team was RE-GATED + // out of self-serve checkout + change-plan (CEO directive: Team is not + // rolled out until its unlimited-resource delivery is proven built). + // Emitted from billing.go CreateCheckoutAPI + ChangePlanAPI. A DISTINCT + // code from the generic invalid_plan so the dashboard/agents render the + // right "not yet available / contact sales" message. Refs: memory + // `project_team_plan_not_rolled_out_no_payment`. + "tier_not_yet_available": { + AgentAction: "Tell the user the Team plan is not yet available for self-serve purchase. They should contact support@instanode.dev — see https://instanode.dev/pricing.", + UpgradeURL: "https://instanode.dev/pricing", + }, "events_query_failed": { AgentAction: "Tell the user the deployment-events read is temporarily unavailable. Retry in 30 seconds; the deploy itself isn't affected. Status: https://instanode.dev/status", UpgradeURL: "", diff --git a/internal/handlers/openapi.go b/internal/handlers/openapi.go index 037a494..78371c6 100644 --- a/internal/handlers/openapi.go +++ b/internal/handlers/openapi.go @@ -1308,12 +1308,12 @@ const openAPISpec = `{ "/api/v1/billing/checkout": { "post": { "summary": "Create a Razorpay subscription and return its hosted-page URL", - "description": "Mints a Razorpay subscription for the requested plan (hobby, hobby_plus, pro, or team) tied to the authenticated team. The dashboard redirects the user to the returned short_url to complete payment; on success Razorpay fires subscription.activated AND subscription.charged to /razorpay/webhook — both trigger the same idempotent tier-elevation path so the team is upgraded as soon as the mandate is authorised, even before the first invoice is collected. Team-tier checkout was enabled 2026-05-29 (CEO BIZ-1) — if RAZORPAY_PLAN_ID_TEAM / _TEAM_ANNUAL is unset on the environment the request returns 503 billing_not_configured (operator signal), not 400 tier_unavailable. plan_frequency selects monthly (default) vs yearly billing — yearly returns 503 billing_not_configured until the operator creates the yearly Razorpay plan and sets RAZORPAY_PLAN_ID_*_YEARLY. promotion_code: admin-issued codes are bookmarked in the subscription notes for future discount wiring (no Razorpay Offer is applied yet — codes are not consumed until a real discount is confirmed). IDEMPOTENT: the endpoint never mints a second subscription for a team that already has a live one — if the team already holds the requested tier (or higher) it returns 400 already_on_plan, and if a prior checkout's subscription is still payable at Razorpay (status created/authenticated/pending) it returns that subscription's short_url with reused:true instead of creating a new one. This prevents a confused re-click from producing two parallel subscriptions that both charge the card.", + "description": "Mints a Razorpay subscription for the requested plan (hobby, hobby_plus, or pro) tied to the authenticated team. The dashboard redirects the user to the returned short_url to complete payment; on success Razorpay fires subscription.activated AND subscription.charged to /razorpay/webhook — both trigger the same idempotent tier-elevation path so the team is upgraded as soon as the mandate is authorised, even before the first invoice is collected. The Team plan ($199 unlimited) is NOT yet available for self-serve checkout — requesting plan=team returns 400 tier_not_yet_available (contact sales: support@instanode.dev). plan_frequency selects monthly (default) vs yearly billing — yearly returns 503 billing_not_configured until the operator creates the yearly Razorpay plan and sets RAZORPAY_PLAN_ID_*_YEARLY. promotion_code: admin-issued codes are bookmarked in the subscription notes for future discount wiring (no Razorpay Offer is applied yet — codes are not consumed until a real discount is confirmed). IDEMPOTENT: the endpoint never mints a second subscription for a team that already has a live one — if the team already holds the requested tier (or higher) it returns 400 already_on_plan, and if a prior checkout's subscription is still payable at Razorpay (status created/authenticated/pending) it returns that subscription's short_url with reused:true instead of creating a new one. This prevents a confused re-click from producing two parallel subscriptions that both charge the card.", "security": [{ "bearerAuth": [] }], - "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["plan"], "properties": { "plan": { "type": "string", "enum": ["hobby", "hobby_plus", "pro", "team"] }, "plan_frequency": { "type": "string", "enum": ["monthly", "yearly"], "default": "monthly", "description": "Billing cycle. Empty = monthly. Yearly variants follow the same canonical-tier mapping on the webhook side — teams.plan_tier still stores the bare tier name." } } } } } }, + "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["plan"], "properties": { "plan": { "type": "string", "enum": ["hobby", "hobby_plus", "pro"], "description": "Self-serve purchasable plans. The Team plan is NOT yet available for self-serve checkout (contact sales: support@instanode.dev) — plan=team returns 400 tier_not_yet_available." }, "plan_frequency": { "type": "string", "enum": ["monthly", "yearly"], "default": "monthly", "description": "Billing cycle. Empty = monthly. Yearly variants follow the same canonical-tier mapping on the webhook side — teams.plan_tier still stores the bare tier name." } } } } } }, "responses": { "200": { "description": "Subscription created (or an existing live one reused) — redirect user to short_url. reused:true means the short_url belongs to a checkout the team started earlier and no new subscription was minted. traffic_env reports whether this deployment talks to the LIVE or TEST Razorpay environment (derived from the RAZORPAY_KEY_ID prefix) — agents and the SPA branch on it without ever seeing the raw key.", "content": { "application/json": { "schema": { "type": "object", "properties": { "ok": { "type": "boolean" }, "short_url": { "type": "string", "format": "uri" }, "subscription_id": { "type": "string" }, "reused": { "type": "boolean", "description": "Present and true only when an existing still-payable subscription was returned instead of minting a new one." }, "traffic_env": { "type": "string", "enum": ["production", "test"], "description": "Derived from the configured RAZORPAY_KEY_ID prefix (rzp_live_* → production, rzp_test_* → test). The raw key value is NEVER exposed in any response. Use this to detect a staging deployment accidentally pointing at the live key (which is also enforced server-side via 503 billing_misconfigured)." } } } } } }, - "400": { "description": "Invalid plan, invalid plan_frequency, or already_on_plan (the team already holds the requested tier or higher)" }, + "400": { "description": "Invalid plan, invalid plan_frequency, already_on_plan (the team already holds the requested tier or higher), or tier_not_yet_available (plan=team — the Team plan is not yet self-serve purchasable; contact sales)" }, "401": { "description": "Missing or invalid session token" }, "502": { "description": "Razorpay rejected the create-subscription call" }, "503": { "description": "Razorpay not configured on this environment (incl. yearly plan_id unset) OR a LIVE Razorpay key (rzp_live_*) is paired with a non-production deployment (billing_misconfigured — operator must rotate to a test key or set ENVIRONMENT=production)" } @@ -1347,12 +1347,12 @@ const openAPISpec = `{ "/api/v1/billing/change-plan": { "post": { "summary": "Switch the team's subscription to a different tier", - "description": "Hobby ↔ Hobby Plus ↔ Pro ↔ Team on the same Razorpay subscription. Proration is handled by Razorpay; the new plan takes effect at the end of the current billing period. Team-tier ChangePlan was enabled 2026-05-29 (CEO BIZ-1) alongside Team checkout.", + "description": "Hobby ↔ Hobby Plus ↔ Pro on the same Razorpay subscription (upgrades only — downgrades are support-assisted). Proration is handled by Razorpay; the new plan takes effect at the end of the current billing period. The Team plan is NOT yet available for self-serve plan changes — target_plan=team returns 400 tier_not_yet_available (contact sales: support@instanode.dev).", "security": [{ "bearerAuth": [] }], - "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["plan"], "properties": { "plan": { "type": "string", "enum": ["hobby", "hobby_plus", "pro", "team"] } } } } } }, + "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["target_plan"], "properties": { "target_plan": { "type": "string", "enum": ["hobby", "hobby_plus", "pro"], "description": "Target tier. The Team plan is not yet self-serve purchasable (contact sales) — target_plan=team returns 400 tier_not_yet_available." } } } } } }, "responses": { "200": { "description": "Plan change accepted by Razorpay" }, - "400": { "description": "Invalid plan" }, + "400": { "description": "Invalid plan, downgrade_not_self_serve, or tier_not_yet_available (target_plan=team — the Team plan is not yet self-serve purchasable; contact sales)" }, "401": { "description": "Missing or invalid session token" }, "404": { "description": "No active subscription" }, "503": { "description": "Razorpay not configured" } diff --git a/openapi.snapshot.json b/openapi.snapshot.json index 0c326da..299c8e8 100644 --- a/openapi.snapshot.json +++ b/openapi.snapshot.json @@ -3040,24 +3040,24 @@ }, "/api/v1/billing/change-plan": { "post": { - "description": "Hobby ↔ Hobby Plus ↔ Pro ↔ Team on the same Razorpay subscription. Proration is handled by Razorpay; the new plan takes effect at the end of the current billing period. Team-tier ChangePlan was enabled 2026-05-29 (CEO BIZ-1) alongside Team checkout.", + "description": "Hobby ↔ Hobby Plus ↔ Pro on the same Razorpay subscription (upgrades only — downgrades are support-assisted). Proration is handled by Razorpay; the new plan takes effect at the end of the current billing period. The Team plan is NOT yet available for self-serve plan changes — target_plan=team returns 400 tier_not_yet_available (contact sales: support@instanode.dev).", "requestBody": { "content": { "application/json": { "schema": { "properties": { - "plan": { + "target_plan": { + "description": "Target tier. The Team plan is not yet self-serve purchasable (contact sales) — target_plan=team returns 400 tier_not_yet_available.", "enum": [ "hobby", "hobby_plus", - "pro", - "team" + "pro" ], "type": "string" } }, "required": [ - "plan" + "target_plan" ], "type": "object" } @@ -3070,7 +3070,7 @@ "description": "Plan change accepted by Razorpay" }, "400": { - "description": "Invalid plan" + "description": "Invalid plan, downgrade_not_self_serve, or tier_not_yet_available (target_plan=team — the Team plan is not yet self-serve purchasable; contact sales)" }, "401": { "description": "Missing or invalid session token" @@ -3092,18 +3092,18 @@ }, "/api/v1/billing/checkout": { "post": { - "description": "Mints a Razorpay subscription for the requested plan (hobby, hobby_plus, pro, or team) tied to the authenticated team. The dashboard redirects the user to the returned short_url to complete payment; on success Razorpay fires subscription.activated AND subscription.charged to /razorpay/webhook — both trigger the same idempotent tier-elevation path so the team is upgraded as soon as the mandate is authorised, even before the first invoice is collected. Team-tier checkout was enabled 2026-05-29 (CEO BIZ-1) — if RAZORPAY_PLAN_ID_TEAM / _TEAM_ANNUAL is unset on the environment the request returns 503 billing_not_configured (operator signal), not 400 tier_unavailable. plan_frequency selects monthly (default) vs yearly billing — yearly returns 503 billing_not_configured until the operator creates the yearly Razorpay plan and sets RAZORPAY_PLAN_ID_*_YEARLY. promotion_code: admin-issued codes are bookmarked in the subscription notes for future discount wiring (no Razorpay Offer is applied yet — codes are not consumed until a real discount is confirmed). IDEMPOTENT: the endpoint never mints a second subscription for a team that already has a live one — if the team already holds the requested tier (or higher) it returns 400 already_on_plan, and if a prior checkout's subscription is still payable at Razorpay (status created/authenticated/pending) it returns that subscription's short_url with reused:true instead of creating a new one. This prevents a confused re-click from producing two parallel subscriptions that both charge the card.", + "description": "Mints a Razorpay subscription for the requested plan (hobby, hobby_plus, or pro) tied to the authenticated team. The dashboard redirects the user to the returned short_url to complete payment; on success Razorpay fires subscription.activated AND subscription.charged to /razorpay/webhook — both trigger the same idempotent tier-elevation path so the team is upgraded as soon as the mandate is authorised, even before the first invoice is collected. The Team plan ($199 unlimited) is NOT yet available for self-serve checkout — requesting plan=team returns 400 tier_not_yet_available (contact sales: support@instanode.dev). plan_frequency selects monthly (default) vs yearly billing — yearly returns 503 billing_not_configured until the operator creates the yearly Razorpay plan and sets RAZORPAY_PLAN_ID_*_YEARLY. promotion_code: admin-issued codes are bookmarked in the subscription notes for future discount wiring (no Razorpay Offer is applied yet — codes are not consumed until a real discount is confirmed). IDEMPOTENT: the endpoint never mints a second subscription for a team that already has a live one — if the team already holds the requested tier (or higher) it returns 400 already_on_plan, and if a prior checkout's subscription is still payable at Razorpay (status created/authenticated/pending) it returns that subscription's short_url with reused:true instead of creating a new one. This prevents a confused re-click from producing two parallel subscriptions that both charge the card.", "requestBody": { "content": { "application/json": { "schema": { "properties": { "plan": { + "description": "Self-serve purchasable plans. The Team plan is NOT yet available for self-serve checkout (contact sales: support@instanode.dev) — plan=team returns 400 tier_not_yet_available.", "enum": [ "hobby", "hobby_plus", - "pro", - "team" + "pro" ], "type": "string" }, @@ -3162,7 +3162,7 @@ "description": "Subscription created (or an existing live one reused) — redirect user to short_url. reused:true means the short_url belongs to a checkout the team started earlier and no new subscription was minted. traffic_env reports whether this deployment talks to the LIVE or TEST Razorpay environment (derived from the RAZORPAY_KEY_ID prefix) — agents and the SPA branch on it without ever seeing the raw key." }, "400": { - "description": "Invalid plan, invalid plan_frequency, or already_on_plan (the team already holds the requested tier or higher)" + "description": "Invalid plan, invalid plan_frequency, already_on_plan (the team already holds the requested tier or higher), or tier_not_yet_available (plan=team — the Team plan is not yet self-serve purchasable; contact sales)" }, "401": { "description": "Missing or invalid session token" diff --git a/plans.yaml b/plans.yaml index 3fe7fd7..3bb2f98 100644 --- a/plans.yaml +++ b/plans.yaml @@ -386,6 +386,14 @@ plans: custom_domains: true sla: false + # GATED FOR ROLLOUT (2026-06-04 CEO directive): the Team plan ($199 + # "unlimited") is NOT yet self-serve purchasable. POST /api/v1/billing/checkout + # and /change-plan reject plan=team with 400 tier_not_yet_available until Team's + # unlimited-resource delivery is PROVEN built. These limits are kept here as the + # eventual contract — do NOT market/claim/charge Team without explicit CEO + # sign-off. Refs: memory `project_team_plan_not_rolled_out_no_payment`, + # docs/sessions/2026-06-04/TEAM-PLAN-GATE-AND-BUILD.md. (Comment only — no limit + # numbers changed in this PR.) team: display_name: "Team" price_monthly_cents: 19900