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
105 changes: 54 additions & 51 deletions internal/handlers/agent_action_contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,60 +27,60 @@ 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
// soft target (it has to enumerate THREE next actions), so it
// 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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
56 changes: 37 additions & 19 deletions internal/handlers/billing.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand All @@ -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")
Expand Down
Loading
Loading