Skip to content

Commit cac653a

Browse files
fix(billing): re-gate Team tier out of self-serve checkout + plan-change (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 enabled Team checkout. - billing.go: reject plan=team on both /billing/checkout and the in-app plan-change path with a DISTINCT 400 `tier_not_yet_available` (not the generic `invalid_plan`) so the UI/agents render "contact sales", not "you made a typo". Self-serve-purchasable set is now exactly {hobby, hobby_plus, pro}. - registry-iterating test asserts team can never silently re-enter the purchasable set (rule 18). - openapi + plans.yaml: note Team is gated for rollout (no limit changes). Refs: memory project_team_plan_not_rolled_out_no_payment, docs/sessions/2026-06-04/TEAM-PLAN-GATE-AND-BUILD.md Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 294bbe7 commit cac653a

10 files changed

Lines changed: 304 additions & 204 deletions

internal/handlers/agent_action_contract_test.go

Lines changed: 54 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -27,60 +27,60 @@ import (
2727
func agentActionContractCases() map[string]string {
2828
cases := map[string]string{
2929
// Static constants.
30-
"AgentActionMultiEnvUpgradeRequired": AgentActionMultiEnvUpgradeRequired,
31-
"AgentActionStackPromoteMissingImageRef": AgentActionStackPromoteMissingImageRef,
32-
"AgentActionBindingFamilyDisabled": AgentActionBindingFamilyDisabled,
33-
"AgentActionBindingLookupFailed": AgentActionBindingLookupFailed,
34-
"RecycleGateAgentAction": RecycleGateAgentAction,
35-
"AgentActionPrivateDeployRequiresPro": AgentActionPrivateDeployRequiresPro,
36-
"AgentActionPrivateDeployRequiresAllowedIPs": AgentActionPrivateDeployRequiresAllowedIPs,
37-
"AgentActionAdminRequired": AgentActionAdminRequired,
38-
"AgentActionPromotionInvalid": AgentActionPromotionInvalid,
39-
"AgentActionPromotionAlreadyUsed": AgentActionPromotionAlreadyUsed,
40-
"AgentActionPromotionExpired": AgentActionPromotionExpired,
41-
"AgentActionPromoteTokenExpired": AgentActionPromoteTokenExpired,
42-
"AgentActionReadOnlySession": AgentActionReadOnlySession,
43-
"AgentActionNotifyWebhookInvalid": AgentActionNotifyWebhookInvalid,
44-
"AgentActionPauseRequiresPro": AgentActionPauseRequiresPro,
45-
"AgentActionResourceAlreadyPaused": AgentActionResourceAlreadyPaused,
46-
"AgentActionResourceNotPaused": AgentActionResourceNotPaused,
47-
"AgentActionBackupRequiresClaim": AgentActionBackupRequiresClaim,
48-
"AgentActionRestoreRequiresPro": AgentActionRestoreRequiresPro,
49-
"AgentActionRestoreRequiresHobbyPlus": AgentActionRestoreRequiresHobbyPlus,
50-
"AgentActionRestoreBackupNotReady": AgentActionRestoreBackupNotReady,
51-
"AgentActionRestoreInflight": AgentActionRestoreInflight,
52-
"AgentActionRestoreDestructiveAckRequired": AgentActionRestoreDestructiveAckRequired,
53-
"AgentActionRestoreTargetCrossTeam": AgentActionRestoreTargetCrossTeam,
54-
"AgentActionBackupIntegrityFailed": AgentActionBackupIntegrityFailed,
55-
"AgentActionMetricsRequiresUpgrade": AgentActionMetricsRequiresUpgrade,
56-
"AgentActionEmailNotVerified": AgentActionEmailNotVerified,
30+
"AgentActionMultiEnvUpgradeRequired": AgentActionMultiEnvUpgradeRequired,
31+
"AgentActionStackPromoteMissingImageRef": AgentActionStackPromoteMissingImageRef,
32+
"AgentActionBindingFamilyDisabled": AgentActionBindingFamilyDisabled,
33+
"AgentActionBindingLookupFailed": AgentActionBindingLookupFailed,
34+
"RecycleGateAgentAction": RecycleGateAgentAction,
35+
"AgentActionPrivateDeployRequiresPro": AgentActionPrivateDeployRequiresPro,
36+
"AgentActionPrivateDeployRequiresAllowedIPs": AgentActionPrivateDeployRequiresAllowedIPs,
37+
"AgentActionAdminRequired": AgentActionAdminRequired,
38+
"AgentActionPromotionInvalid": AgentActionPromotionInvalid,
39+
"AgentActionPromotionAlreadyUsed": AgentActionPromotionAlreadyUsed,
40+
"AgentActionPromotionExpired": AgentActionPromotionExpired,
41+
"AgentActionPromoteTokenExpired": AgentActionPromoteTokenExpired,
42+
"AgentActionReadOnlySession": AgentActionReadOnlySession,
43+
"AgentActionNotifyWebhookInvalid": AgentActionNotifyWebhookInvalid,
44+
"AgentActionPauseRequiresPro": AgentActionPauseRequiresPro,
45+
"AgentActionResourceAlreadyPaused": AgentActionResourceAlreadyPaused,
46+
"AgentActionResourceNotPaused": AgentActionResourceNotPaused,
47+
"AgentActionBackupRequiresClaim": AgentActionBackupRequiresClaim,
48+
"AgentActionRestoreRequiresPro": AgentActionRestoreRequiresPro,
49+
"AgentActionRestoreRequiresHobbyPlus": AgentActionRestoreRequiresHobbyPlus,
50+
"AgentActionRestoreBackupNotReady": AgentActionRestoreBackupNotReady,
51+
"AgentActionRestoreInflight": AgentActionRestoreInflight,
52+
"AgentActionRestoreDestructiveAckRequired": AgentActionRestoreDestructiveAckRequired,
53+
"AgentActionRestoreTargetCrossTeam": AgentActionRestoreTargetCrossTeam,
54+
"AgentActionBackupIntegrityFailed": AgentActionBackupIntegrityFailed,
55+
"AgentActionMetricsRequiresUpgrade": AgentActionMetricsRequiresUpgrade,
56+
"AgentActionEmailNotVerified": AgentActionEmailNotVerified,
5757
// Wave FIX-J deploy TTL walls. The long-form success-path
5858
// newAgentActionDeployAutoExpire24h is documented in
5959
// agent_action.go as the canonical exception to the 280-char
6060
// soft target (it has to enumerate THREE next actions), so it
6161
// is intentionally NOT exercised by this contract gate —
6262
// covered instead by deploy_ttl_test.go which spot-checks the
6363
// imperative opening + URL inclusion.
64-
"AgentActionDeployMakePermanentAnonymous": AgentActionDeployMakePermanentAnonymous,
65-
"AgentActionDeployTTLHoursOutOfRange": AgentActionDeployTTLHoursOutOfRange,
66-
"AgentActionTeamSettingsInvalidTTLPolicy": AgentActionTeamSettingsInvalidTTLPolicy,
64+
"AgentActionDeployMakePermanentAnonymous": AgentActionDeployMakePermanentAnonymous,
65+
"AgentActionDeployTTLHoursOutOfRange": AgentActionDeployTTLHoursOutOfRange,
66+
"AgentActionTeamSettingsInvalidTTLPolicy": AgentActionTeamSettingsInvalidTTLPolicy,
6767

6868
// Builders — representative inputs covering tier/env/role/limit
6969
// interpolation.
70-
"newAgentActionDeploymentLimitReached(hobby,1)": newAgentActionDeploymentLimitReached("hobby", 1),
71-
"newAgentActionBackupRateLimited(hobby,1)": newAgentActionBackupRateLimited("hobby", 1),
72-
"newAgentActionMetricsWindowTooLarge(hobby,1h)": newAgentActionMetricsWindowTooLarge("hobby", "1h"),
73-
"newAgentActionPromoteApprovalSent(prod,email)": newAgentActionPromoteApprovalSent("production", "owner@example.com"),
74-
"newAgentActionStorageLimitReached(hobby,500)": newAgentActionStorageLimitReached("hobby", 500),
75-
"newAgentActionVaultQuotaExceeded(hobby,50)": newAgentActionVaultQuotaExceeded("hobby", 50),
76-
"newAgentActionEnvPolicyDenied(prod,deploy)": newAgentActionEnvPolicyDenied("production", "deploy", "owner", "developer"),
77-
"newAgentActionOwnerRequired(developer)": newAgentActionOwnerRequired("developer"),
78-
"newAgentActionBindingInvalidUUID(KEY)": newAgentActionBindingInvalidUUID("DATABASE_URL", "not-a-uuid"),
79-
"newAgentActionBindingNotFound(KEY)": newAgentActionBindingNotFound("DATABASE_URL"),
80-
"newAgentActionBindingCrossTeam(KEY)": newAgentActionBindingCrossTeam("DATABASE_URL"),
81-
"newAgentActionBindingNoEnvTwin(uuid,name,env)": newAgentActionBindingNoEnvTwin("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", "owner-db", "staging"),
82-
"newAgentActionAdminTierChanged(team,pro)": newAgentActionAdminTierChanged("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", "pro"),
83-
"newAgentActionAdminPromoIssued(team,code)": newAgentActionAdminPromoIssued("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", "01H8XGZJ"),
70+
"newAgentActionDeploymentLimitReached(hobby,1)": newAgentActionDeploymentLimitReached("hobby", 1),
71+
"newAgentActionBackupRateLimited(hobby,1)": newAgentActionBackupRateLimited("hobby", 1),
72+
"newAgentActionMetricsWindowTooLarge(hobby,1h)": newAgentActionMetricsWindowTooLarge("hobby", "1h"),
73+
"newAgentActionPromoteApprovalSent(prod,email)": newAgentActionPromoteApprovalSent("production", "owner@example.com"),
74+
"newAgentActionStorageLimitReached(hobby,500)": newAgentActionStorageLimitReached("hobby", 500),
75+
"newAgentActionVaultQuotaExceeded(hobby,50)": newAgentActionVaultQuotaExceeded("hobby", 50),
76+
"newAgentActionEnvPolicyDenied(prod,deploy)": newAgentActionEnvPolicyDenied("production", "deploy", "owner", "developer"),
77+
"newAgentActionOwnerRequired(developer)": newAgentActionOwnerRequired("developer"),
78+
"newAgentActionBindingInvalidUUID(KEY)": newAgentActionBindingInvalidUUID("DATABASE_URL", "not-a-uuid"),
79+
"newAgentActionBindingNotFound(KEY)": newAgentActionBindingNotFound("DATABASE_URL"),
80+
"newAgentActionBindingCrossTeam(KEY)": newAgentActionBindingCrossTeam("DATABASE_URL"),
81+
"newAgentActionBindingNoEnvTwin(uuid,name,env)": newAgentActionBindingNoEnvTwin("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", "owner-db", "staging"),
82+
"newAgentActionAdminTierChanged(team,pro)": newAgentActionAdminTierChanged("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", "pro"),
83+
"newAgentActionAdminPromoIssued(team,code)": newAgentActionAdminPromoIssued("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", "01H8XGZJ"),
8484
}
8585

8686
// codeToAgentAction registry — every entry must also pass the contract.
@@ -161,8 +161,8 @@ func assertContract(t *testing.T, name, s string) {
161161
"Re-issue", "re-issue", // approval-link expired → re-issue from app
162162
"Re-enter", "re-enter", // invalid_email → re-enter address
163163
"Refresh", "refresh", // stale slug/state → refresh from app
164-
"POST ", // multipart endpoints — POST is the action
165-
"GET ", // list endpoints — GET is the action
164+
"POST ", // multipart endpoints — POST is the action
165+
"GET ", // list endpoints — GET is the action
166166
"Request", "request", // magic-link not found → request new one
167167
"Add ", "add ", // missing_email / missing_env etc → add the field
168168
"Trim ", "trim ", // env_too_large / tarball_too_large
@@ -287,11 +287,14 @@ func TestAgentActionContract_RegistryCoverage(t *testing.T) {
287287
// Quota walls.
288288
"quota_exceeded", "storage_limit_reached", "vault_quota_exceeded",
289289
"vault_not_available", "vault_env_not_allowed", "member_limit",
290-
// "tier_unavailable" was dropped 2026-05-29 alongside the Team-tier
291-
// checkout/change-plan guards (CEO BIZ-1). It was the only code in
292-
// this registry that no handler emitted; the orphan-coverage gate
293-
// flagged it. If a future feature reintroduces a "tier is genuinely
294-
// unavailable" surface, re-add the code + its emitter in one PR.
290+
// "tier_unavailable" was dropped 2026-05-29 (CEO BIZ-1) and
291+
// superseded 2026-06-04 by "tier_not_yet_available" when Team was
292+
// RE-GATED out of self-serve checkout + change-plan (CEO directive:
293+
// Team not rolled out until unlimited-resource delivery is proven
294+
// built). A drop of "tier_not_yet_available" without migrating its
295+
// emitters is a contract regression — agents branching on the code
296+
// would lose the "contact sales" remediation.
297+
"tier_not_yet_available",
295298
"upgrade_required", "rate_limit_exceeded",
296299
// B7-P1-7 (BugBash 2026-05-20): `claim_required` is the honest
297300
// 402 for anonymous-tier walls whose remediation is a FREE claim

internal/handlers/billing.go

Lines changed: 37 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -789,19 +789,27 @@ func (h *BillingHandler) CreateCheckoutAPI(c *fiber.Ctx) error {
789789
}
790790

791791
switch plan {
792-
case "hobby", "hobby_plus", "pro", "team":
792+
case "hobby", "hobby_plus", "pro":
793793
// fall through — plan_id is resolved by razorpayPlanIDFor below.
794-
// Team enabled 2026-05-29 (CEO BIZ-1 ship call): marketing,
795-
// dashboard PricingGrid, and llms.txt all sell Team @ $199/mo,
796-
// but this handler used to return 400 tier_unavailable on every
797-
// team checkout — turning away the highest-AOV prospect mid-funnel.
798-
// If RAZORPAY_PLAN_ID_TEAM (or RAZORPAY_PLAN_ID_TEAM_ANNUAL) is
799-
// unset in this environment the request now falls through to the
800-
// shared 503 billing_not_configured branch below (a clear
801-
// operator signal), not 400 tier_unavailable (a customer signal
802-
// that the tier itself doesn't exist).
794+
case "team":
795+
// Team is RE-GATED out of self-serve checkout per the 2026-06-04
796+
// CEO directive: the Team plan ($199 "unlimited") is NOT rolled
797+
// out and must not be marketable / claimable / chargeable until
798+
// its unlimited-resource delivery is PROVEN built. This reverses
799+
// the 2026-05-29 (BIZ-1) change that had enabled Team checkout —
800+
// do NOT re-enable Team here without explicit, written CEO
801+
// confirmation that unlimited-resource delivery is proven.
802+
// Refs: memory `project_team_plan_not_rolled_out_no_payment` and
803+
// docs/sessions/2026-06-04/TEAM-PLAN-GATE-AND-BUILD.md.
804+
//
805+
// A DISTINCT code (`tier_not_yet_available`, not the generic
806+
// `invalid_plan`) so the dashboard/agents render the correct
807+
// "contact sales / not yet available" message instead of telling
808+
// the user they made a typo.
809+
return respondError(c, fiber.StatusBadRequest, "tier_not_yet_available",
810+
"The Team plan is not yet available for self-serve checkout — contact support@instanode.dev.")
803811
default:
804-
return respondError(c, fiber.StatusBadRequest, "invalid_plan", "plan must be 'hobby', 'hobby_plus', 'pro', or 'team'")
812+
return respondError(c, fiber.StatusBadRequest, "invalid_plan", "plan must be 'hobby', 'hobby_plus', or 'pro'")
805813
}
806814
planID := h.razorpayPlanIDFor(plan, frequency)
807815

@@ -3207,9 +3215,24 @@ func (h *BillingHandler) ChangePlanAPI(c *fiber.Ctx) error {
32073215
if strings.EqualFold(strings.TrimSpace(planTier), target) {
32083216
return respondError(c, fiber.StatusBadRequest, "same_plan", "Already on requested plan")
32093217
}
3218+
// Team is RE-GATED out of self-serve plan changes per the 2026-06-04
3219+
// CEO directive (same gate as CreateCheckoutAPI): a hobby/pro team must
3220+
// not be able to self-upgrade to Team — that is another chargeable
3221+
// self-serve path for a tier that is NOT rolled out until its
3222+
// unlimited-resource delivery is proven built. Reverses the 2026-05-29
3223+
// (BIZ-1) enablement. Do NOT re-enable without explicit CEO sign-off.
3224+
// Refs: memory `project_team_plan_not_rolled_out_no_payment` and
3225+
// docs/sessions/2026-06-04/TEAM-PLAN-GATE-AND-BUILD.md. Checked BEFORE
3226+
// the razorpayPlanIDs membership test so the response is the distinct
3227+
// `tier_not_yet_available` regardless of whether RAZORPAY_PLAN_ID_TEAM
3228+
// happens to be set in this environment.
3229+
if target == "team" {
3230+
return respondError(c, fiber.StatusBadRequest, "tier_not_yet_available",
3231+
"The Team plan is not yet available for self-serve plan changes — contact support@instanode.dev.")
3232+
}
32103233
planIDs := h.razorpayPlanIDs()
32113234
if _, ok := planIDs[target]; !ok {
3212-
return respondError(c, fiber.StatusBadRequest, "invalid_plan", "target_plan must be hobby, hobby_plus, pro, or team")
3235+
return respondError(c, fiber.StatusBadRequest, "invalid_plan", "target_plan must be hobby, hobby_plus, or pro")
32133236
}
32143237
// No self-serve downgrade — see project memory
32153238
// 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 {
32253248
"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.",
32263249
"mailto:support@instanode.dev")
32273250
}
3228-
// Team-tier ChangePlan is now allowed for the same reason Team
3229-
// checkout is: marketing + dashboard + llms.txt sell Team @ $199/mo
3230-
// as a self-serve upgrade path. If the operator hasn't created the
3231-
// Razorpay plan_id yet, razorpayPlanIDFor / portal.ChangePlan
3232-
// surfaces the configuration error downstream — never 400
3233-
// tier_unavailable from this layer. (Enabled 2026-05-29 alongside
3234-
// the checkout-creation team guard removal.)
3251+
// (Target=team is rejected above with tier_not_yet_available — the
3252+
// 2026-06-04 CEO re-gate. Only hobby/hobby_plus/pro upgrades reach here.)
32353253
portal := h.billingPortal()
32363254
if _, err := portal.SubscriptionID(c.Context(), teamID); err != nil {
32373255
return respondError(c, fiber.StatusBadRequest, "no_subscription", "no active subscription to change")

0 commit comments

Comments
 (0)