diff --git a/internal/handlers/billing_usage_coverage_test.go b/internal/handlers/billing_usage_coverage_test.go index 31d6cc74..f2c703ca 100644 --- a/internal/handlers/billing_usage_coverage_test.go +++ b/internal/handlers/billing_usage_coverage_test.go @@ -6,8 +6,9 @@ package handlers_test // - GetUsage with no team local → 401 unauthorized. // - computeUsage tier-lookup error → 500 usage_failed (propagated through // the cache GetOrSet loader). -// - mbToBytes unlimited (-1) path via the team tier whose storage limit is -// unlimited. +// - mbToBytes unlimited (-1) + finite paths: exercised directly in +// mb_to_bytes_internal_test.go (no real tier carries -1 post strict-margin +// redesign). import ( "database/sql" @@ -27,7 +28,6 @@ import ( "github.com/stretchr/testify/require" "instant.dev/internal/handlers" - "instant.dev/internal/middleware" "instant.dev/internal/plans" ) @@ -112,55 +112,9 @@ func TestBillingUsage_StorageSumError_Returns500(t *testing.T) { assert.Equal(t, http.StatusInternalServerError, resp.StatusCode) } -// TestBillingUsage_UnlimitedTier_MbToBytesNegative covers mbToBytes(-1) → -1: -// the team tier has unlimited storage, so each storage metric's limit_bytes -// must render as -1 (the dashboard's "∞"). -func TestBillingUsage_UnlimitedTier_MbToBytesNegative(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - mr, err := miniredis.Run() - require.NoError(t, err) - defer mr.Close() - rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) - defer rdb.Close() - - teamID := uuid.New() - // team tier → unlimited storage (-1) → mbToBytes(-1) path. - mock.ExpectQuery(`SELECT.*FROM teams WHERE id`). - WithArgs(teamID). - WillReturnRows(sqlmock.NewRows([]string{ - "id", "name", "plan_tier", "stripe_customer_id", "created_at", "default_deployment_ttl_policy", - }).AddRow(teamID, sql.NullString{}, "team", sql.NullString{}, time.Now(), "auto_24h")) - for range []string{"postgres", "redis", "mongodb"} { - mock.ExpectQuery(`SELECT COALESCE\(SUM\(storage_bytes\)`). - WillReturnRows(sqlmock.NewRows([]string{"sum"}).AddRow(int64(0))) - } - mock.ExpectQuery(`(?i)SELECT count\(\*\)\s+FROM deployments`). - WithArgs(teamID). - WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0)) - mock.ExpectQuery(`SELECT COUNT\(\*\)\s+FROM resources`). - WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0)) - mock.ExpectQuery(`SELECT COUNT\(DISTINCT key\) FROM vault_secrets`). - WithArgs(teamID). - WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0)) - mock.ExpectQuery(`SELECT COUNT\(\*\) FROM users WHERE team_id`). - WithArgs(teamID). - WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) - - app := newUsageApp(t, db, rdb, teamID) - req := httptest.NewRequest(http.MethodGet, "/api/v1/billing/usage", nil) - resp, err := app.Test(req, 5000) - require.NoError(t, err) - defer resp.Body.Close() - require.Equal(t, http.StatusOK, resp.StatusCode) - - var body struct { - Usage map[string]struct { - LimitBytes int64 `json:"limit_bytes"` - } `json:"usage"` - } - require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) - assert.Equal(t, int64(-1), body.Usage["postgres"].LimitBytes, "unlimited tier storage limit must serialise as -1") - _ = middleware.LocalKeyTeamID -} +// NOTE: the unlimited (mbToBytes(-1) → -1) path is exercised directly with +// synthetic inputs in mb_to_bytes_internal_test.go (package handlers). Post +// strict-80%-margin redesign no real tier carries an unlimited (-1) storage +// limit, so the -1 path can no longer be reached via the team tier through +// the HTTP usage handler; testing the helper directly keeps the defensive +// "-1 → ∞" rendering covered. diff --git a/internal/handlers/mb_to_bytes_internal_test.go b/internal/handlers/mb_to_bytes_internal_test.go new file mode 100644 index 00000000..c2b5027a --- /dev/null +++ b/internal/handlers/mb_to_bytes_internal_test.go @@ -0,0 +1,33 @@ +package handlers + +// mb_to_bytes_internal_test.go — directly exercises the unexported mbToBytes +// helper. Lives in package handlers (not handlers_test) because the symbol is +// unexported. +// +// History: the unlimited (-1 → -1, "∞") path used to be covered by routing the +// team tier (whose storage limit was -1) through the HTTP usage handler in +// billing_usage_coverage_test.go. The strict-≥80%-margin tier redesign +// (2026-06-05) retired every real -1 storage limit to a finite cap, so that +// path can no longer be reached via any tier. The defensive "-1 → ∞" branch +// still ships (a negative value may arrive from non-storage limits such as +// provisions_per_day, or from a future tier), so it is exercised here with +// synthetic inputs instead. + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMbToBytes_UnlimitedAndFinite(t *testing.T) { + // Unlimited sentinel: any negative input renders -1 ("∞" on the dashboard). + assert.Equal(t, int64(-1), mbToBytes(-1), "unlimited (-1) limit must serialise as -1") + assert.Equal(t, int64(-1), mbToBytes(-9999), "any negative limit is treated as unlimited (-1)") + + // Finite conversions: MB → bytes (×1024×1024). + assert.Equal(t, int64(0), mbToBytes(0)) + assert.Equal(t, int64(1024*1024), mbToBytes(1)) + // Team's new finite postgres cap (51200 MB = 50 GiB) — the value the + // retired HTTP test wrongly expected as -1 now serialises as real bytes. + assert.Equal(t, int64(51200)*1024*1024, mbToBytes(51200)) +} diff --git a/internal/handlers/misc_routes_block_integration_test.go b/internal/handlers/misc_routes_block_integration_test.go index ec753f34..f7025cfd 100644 --- a/internal/handlers/misc_routes_block_integration_test.go +++ b/internal/handlers/misc_routes_block_integration_test.go @@ -277,19 +277,21 @@ func TestMiscBlock_UsageWall_RealDBContract(t *testing.T) { assert.Equal(t, float64(87), body["percent_used"]) }) - t.Run("team tier short-circuits to near_wall=false even with a wall row", func(t *testing.T) { + t.Run("team tier with a wall row returns near_wall=true (no unlimited short-circuit)", func(t *testing.T) { teamID := testhelpers.MustCreateTeamDB(t, db, "team") jwt := miscSeedUser(t, db, teamID) - // Even if a row somehow existed, the team-tier gate returns false - // before the audit query — assert the gate, not the absence of a row. + // strict-80%-margin redesign (2026-06-05): Team is no longer unlimited, + // so the prior team-tier early-return was removed. Team now falls through + // to the same audit-row query as every other finite tier — a seeded wall + // row therefore surfaces as near_wall=true. miscSeedWallRow(t, db, teamID) resp := miscBlockReq(t, app, http.MethodGet, "/api/v1/usage/wall", jwt) require.Equal(t, http.StatusOK, resp.StatusCode) body := miscDecode(t, resp) assert.Equal(t, true, body["ok"]) - assert.Equal(t, false, body["near_wall"], - "team tier is unlimited — no walls, short-circuit before the audit scan") + assert.Equal(t, true, body["near_wall"], + "Team is finite post strict-margin redesign — its wall row must surface") }) t.Run("cross-team isolation: team A session never sees team B's wall", func(t *testing.T) { diff --git a/internal/handlers/openapi.go b/internal/handlers/openapi.go index d065c884..b2619521 100644 --- a/internal/handlers/openapi.go +++ b/internal/handlers/openapi.go @@ -1318,7 +1318,7 @@ 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, 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.", + "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, finite high-capacity limits — not unlimited) is NOT yet available for self-serve checkout — requesting plan=team returns 400 tier_not_yet_available (contact sales: support@instanode.dev). Capacity beyond the Team caps is Enterprise (contact sales). 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"], "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": { @@ -2529,7 +2529,7 @@ const openAPISpec = `{ "/api/v1/usage/wall": { "get": { "summary": "Quota-wall nudge state (dashboard upgrade banner)", - "description": "Returns the most recent near_quota_wall row written by the worker's QuotaWallNudgeWorker, scoped to the caller's team and bounded to the last 24h. The dashboard polls this on mount and every 5 minutes to decide whether to render the upgrade banner. Team-tier callers always get near_wall=false (team is unlimited). Fails open — a DB error returns 503 rather than a misleading near_wall=false.", + "description": "Returns the most recent near_quota_wall row written by the worker's QuotaWallNudgeWorker, scoped to the caller's team and bounded to the last 24h. The dashboard polls this on mount and every 5 minutes to decide whether to render the upgrade banner. As of the 2026-06-05 strict-margin redesign Team has finite limits too, so Team callers can also approach a wall (next step above Team is Enterprise/contact-sales). Fails open — a DB error returns 503 rather than a misleading near_wall=false.", "security": [{ "bearerAuth": [] }], "responses": { "200": { "description": "Usage-wall state", "content": { "application/json": { "schema": { "type": "object", "properties": { "ok": { "type": "boolean" }, "near_wall": { "type": "boolean", "description": "True when the team has crossed the 80% quota threshold within the freshness window." }, "at": { "type": "string", "format": "date-time", "description": "When the worker recorded the threshold crossing. Present only when near_wall is true." }, "tier": { "type": "string", "description": "Team plan tier at the time the row was written." }, "axis": { "type": "string", "description": "Which quota axis tripped (e.g. 'storage')." }, "service": { "type": "string", "description": "Which service the axis belongs to (postgres / redis / mongodb / …)." }, "current": { "type": "integer", "description": "Measured usage at the time of the crossing." }, "limit": { "type": "integer", "description": "The tier limit the usage is approaching." }, "percent_used": { "type": "number", "description": "current / limit as a percent." } }, "required": ["ok", "near_wall"] } } } }, diff --git a/internal/handlers/small_handlers_final_test.go b/internal/handlers/small_handlers_final_test.go index e263f726..274cf441 100644 --- a/internal/handlers/small_handlers_final_test.go +++ b/internal/handlers/small_handlers_final_test.go @@ -64,16 +64,16 @@ func TestWhoamiFinal_Enrichment(t *testing.T) { assert.Equal(t, email, m["email"]) } -// usage_wall.GetWall: the usage query errors → db_failed (usage_wall.go:118). -// team-tier check(1) errors-or-misses, then the usage query(2) errors. Use a -// non-team tier so the early-return is skipped; failAfter=1 makes the usage -// query error. +// usage_wall.GetWall: the audit-row query errors → db_failed. +// strict-80%-margin redesign (2026-06-05): the team-tier early-return was +// removed, so the audit-row query is now the FIRST DB call in GetWall for +// every tier — failAfter=0 makes that first query error. func TestUsageWallFinal_DBError_503(t *testing.T) { seedDB, clean := testhelpers.SetupTestDB(t) defer clean() teamID := uuid.MustParse(testhelpers.MustCreateTeamDB(t, seedDB, "pro")) - app := newUsageWallApp(t, openFaultDB(t, 1), teamID) + app := newUsageWallApp(t, openFaultDB(t, 0), teamID) resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/api/v1/usage/wall", nil), 5000) require.NoError(t, err) defer resp.Body.Close() diff --git a/internal/handlers/team_coverage_mock_test.go b/internal/handlers/team_coverage_mock_test.go index bf7b4114..2534df94 100644 --- a/internal/handlers/team_coverage_mock_test.go +++ b/internal/handlers/team_coverage_mock_test.go @@ -230,20 +230,27 @@ func TestTeamMembers_InviteMember_LegacyMemberSuccess(t *testing.T) { mock.ExpectQuery(`SELECT COALESCE\(role, 'member'\) FROM users WHERE id`). WithArgs(userID, teamID). WillReturnRows(sqlmock.NewRows([]string{"role"}).AddRow("owner")) - // 5. withinMemberLimit — team tier is unlimited (limit<0) so the model - // skips the count query; but to be robust we allow an optional count. - // The "team" tier member_limit is unlimited (-1) so withinMemberLimit - // returns early without querying. Next is the existing-member COUNT. + // 5. withinMemberLimit — post strict-80%-margin redesign the "team" tier + // member limit is FINITE (25, was -1/unlimited), so withinMemberLimit + // now queries teamSeatTotal = CountTeamMembers + CountPendingInvitations. + // 1 member + 0 pending = 1 seat < 25 → within limit. + mock.ExpectQuery(`SELECT COUNT\(\*\) FROM users WHERE team_id = \$1$`). + WithArgs(teamID). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) + mock.ExpectQuery(`SELECT COUNT\(\*\) FROM team_invitations WHERE team_id = \$1 AND status = 'pending'`). + WithArgs(teamID). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0)) + // 6. existing-member COUNT (the email dedup check). mock.ExpectQuery(`SELECT COUNT\(\*\) FROM users WHERE team_id = \$1 AND lower\(email\)`). WithArgs(teamID, sqlmock.AnyArg()). WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0)) - // 6. INSERT ... RETURNING the invitation row + // 7. INSERT ... RETURNING the invitation row invID := uuid.New() mock.ExpectQuery(`INSERT INTO team_invitations`). WillReturnRows(sqlmock.NewRows([]string{ "id", "team_id", "email", "role", "status", "invited_by", "created_at", "expires_at", }).AddRow(invID, teamID, "x@y.com", "member", "pending", userID, time.Now(), time.Now().Add(7*24*time.Hour))) - // 7. best-effort audit insert — accept any exec. + // 8. best-effort audit insert — accept any exec. mock.ExpectExec(`INSERT INTO audit_log`).WillReturnResult(sqlmock.NewResult(1, 1)) app := teamCoverageApp(t, db, nil, userID.String(), teamID.String()) diff --git a/internal/handlers/tier_enforcement_test.go b/internal/handlers/tier_enforcement_test.go index 35d539c3..bcf723e7 100644 --- a/internal/handlers/tier_enforcement_test.go +++ b/internal/handlers/tier_enforcement_test.go @@ -310,13 +310,16 @@ func TestStackProvisionTierCap_UnlimitedTier(t *testing.T) { ensureStackTables(t, db) planReg := plans.Default() - require.Equal(t, -1, planReg.DeploymentsAppsLimit("team"), - "team.deployments_apps must be -1 (unlimited) for this test") + // strict-80% margin redesign (2026-06-05): team.deployments_apps is now a + // finite 100 (was -1). Seeding 5 stacks stays well under the cap, so the + // "must not block at low count" guarantee still holds. + require.Greater(t, planReg.DeploymentsAppsLimit("team"), 5, + "team.deployments_apps must comfortably exceed the 5 seeded stacks for this test") teamID := testhelpers.MustCreateTeamDB(t, db, "team") sessionJWT := testhelpers.MustSignSessionJWT(t, "user-a5-team", teamID, "a5team@example.com") - // Seed 5 stacks — team tier is unlimited, must not block. + // Seed 5 stacks — far under team's finite cap (100), must not block. for i := range 5 { slug := fmt.Sprintf("stk-team-%d-%s", i, teamID[:6]) _, err := db.ExecContext(context.Background(), ` @@ -452,17 +455,18 @@ func TestQueueProvisionTierCap_HobbyUnderLimit(t *testing.T) { } } -// TestQueueProvisionTierCap_GrowthUnlimited verifies that a growth-tier team -// (queue_count=-1) is never blocked by the queue cap. -func TestQueueProvisionTierCap_GrowthUnlimited(t *testing.T) { +// TestQueueProvisionTierCap_GrowthUnderCap verifies that a growth-tier team +// is not blocked while under its finite queue cap (50 after the 2026-06-05 +// strict-margin redesign; was -1/unlimited). +func TestQueueProvisionTierCap_GrowthUnderCap(t *testing.T) { requireTestDB(t) db, cleanDB := testhelpers.SetupTestDB(t) defer cleanDB() ensureStackTables(t, db) planReg := plans.Default() - require.Equal(t, -1, planReg.QueueCountLimit("growth"), - "growth.queue_count must be -1 (unlimited)") + require.Greater(t, planReg.QueueCountLimit("growth"), 20, + "growth.queue_count must comfortably exceed the 20 seeded queues for this test") app, cleanup := testhelpers.NewTestAppWithServices(t, db, nil, "queue") defer cleanup() @@ -470,7 +474,7 @@ func TestQueueProvisionTierCap_GrowthUnlimited(t *testing.T) { teamID := testhelpers.MustCreateTeamDB(t, db, "growth") sessionJWT := testhelpers.MustSignSessionJWT(t, "user-a6-growth", teamID, "a6growth@example.com") - // Seed 20 queues — unlimited tier must not block. + // Seed 20 queues — under growth's finite cap (50), must not block. for range 20 { insertActiveQueueForTier(t, db, teamID) } @@ -482,20 +486,22 @@ func TestQueueProvisionTierCap_GrowthUnlimited(t *testing.T) { if resp.StatusCode == http.StatusPaymentRequired { b := decodeTierErrBody(t, resp) assert.NotEqual(t, "queue_limit_reached", b.Error, - "growth tier (unlimited queues) must not hit queue_limit_reached") + "growth tier (under finite cap) must not hit queue_limit_reached") } } -// TestQueueProvisionTierCap_TeamUnlimited verifies that team-tier teams are also unlimited. -func TestQueueProvisionTierCap_TeamUnlimited(t *testing.T) { +// TestQueueProvisionTierCap_TeamUnderCap verifies team-tier teams are not +// blocked while under their finite queue cap (100 after the 2026-06-05 +// strict-margin redesign; was -1/unlimited). +func TestQueueProvisionTierCap_TeamUnderCap(t *testing.T) { requireTestDB(t) db, cleanDB := testhelpers.SetupTestDB(t) defer cleanDB() ensureStackTables(t, db) planReg := plans.Default() - require.Equal(t, -1, planReg.QueueCountLimit("team"), - "team.queue_count must be -1 (unlimited)") + require.Greater(t, planReg.QueueCountLimit("team"), 1, + "team.queue_count must exceed the 1 seeded queue for this test") app, cleanup := testhelpers.NewTestAppWithServices(t, db, nil, "queue") defer cleanup() @@ -511,7 +517,7 @@ func TestQueueProvisionTierCap_TeamUnlimited(t *testing.T) { if resp.StatusCode == http.StatusPaymentRequired { b := decodeTierErrBody(t, resp) assert.NotEqual(t, "queue_limit_reached", b.Error, - "team tier (unlimited queues) must not hit queue_limit_reached") + "team tier (under finite cap) must not hit queue_limit_reached") } } @@ -560,14 +566,14 @@ func TestPlansRegistry_QueueCountLimit(t *testing.T) { want int } + // strict-80% margin redesign (2026-06-05): every queue_count is now finite + // (was -1 on anonymous/free/growth/team). exact values set in plans.yaml. cases := []tc{ - // unlimited tiers - {"anonymous", -1}, - {"free", -1}, - {"growth", -1}, - {"team", -1}, - {"team_yearly", -1}, - // capped tiers — exact values set in plans.yaml + {"anonymous", 1}, + {"free", 1}, + {"growth", 50}, + {"team", 100}, + {"team_yearly", 100}, {"hobby", 3}, {"hobby_yearly", 3}, {"hobby_plus", 5}, diff --git a/internal/handlers/usage_wall.go b/internal/handlers/usage_wall.go index 25d8f16d..2118e83e 100644 --- a/internal/handlers/usage_wall.go +++ b/internal/handlers/usage_wall.go @@ -21,12 +21,12 @@ package handlers // "at": "2026-05-12T11:02:00Z" // } // -// When there is no row within the last 24h, or the team is on the "team" -// tier (no walls), the response is `{"ok": true, "near_wall": false}`. +// When there is no row within the last 24h, the response is +// `{"ok": true, "near_wall": false}`. // -// Tier gate: "team" tier callers always get near_wall=false without a -// DB hit. The worker won't have written a row anyway, but the early -// return saves an audit_log scan for the most-active paid tier. +// strict-80% margin redesign (2026-06-05): Team is no longer unlimited, so +// the prior "team tier → near_wall=false" early-return was removed. Every +// finite tier (now including Team) is served by the same audit-row query. // // Caching: 60s in Redis is enough — the worker writes at most one row // per team per 24h, and the dashboard polls every 5 minutes. We don't @@ -44,7 +44,6 @@ import ( "github.com/google/uuid" "instant.dev/internal/middleware" - "instant.dev/internal/models" ) // usageWallKind is the audit_log.kind value the worker writes and this @@ -129,13 +128,13 @@ func (h *UsageWallHandler) GetWall(c *fiber.Ctx) error { return respondError(c, fiber.StatusUnauthorized, "unauthorized", "Authentication required") } - // Team-tier early return — team tier is unlimited, so no walls. - // Fail-open: if team lookup errors, fall through to the audit - // query rather than refusing to serve. - if team, terr := models.GetTeamByID(c.Context(), h.db, teamID); terr == nil && team != nil && team.PlanTier == "team" { - h.setUsageWallCacheHeaders(c) - return c.JSON(fiber.Map{"ok": true, "near_wall": false}) - } + // strict-80% margin redesign (2026-06-05): Team is no longer unlimited — + // every Team limit is now finite (plans.yaml), so Team customers DO have + // quota walls. The prior team-tier early-return suppressed the upgrade + // banner for the top tier; it is removed so Team falls through to the + // same audit-row query as every other finite tier. (When Team approaches + // a cap the next step is Enterprise/contact-sales, surfaced by the worker + // in the wall row's metadata.) cutoff := time.Now().Add(-usageWallFreshness) diff --git a/internal/handlers/usage_wall_test.go b/internal/handlers/usage_wall_test.go index 4380a62e..58899b75 100644 --- a/internal/handlers/usage_wall_test.go +++ b/internal/handlers/usage_wall_test.go @@ -6,7 +6,9 @@ package handlers_test // 1. Latest row inside the 24h window → returns near_wall=true with the // audit metadata flattened into the response. // 2. No row (or stale row outside 24h) → returns near_wall=false with 200. -// 3. team-tier callers always get near_wall=false without an audit query. +// 3. team-tier callers flow through the same audit query as every other +// finite tier (the former unlimited-Team short-circuit was removed by +// the 2026-06-05 strict-margin redesign). // // Uses sqlmock so the tests are hermetic and don't depend on a live DB. @@ -53,18 +55,10 @@ func newUsageWallApp(t *testing.T, db *sql.DB, teamID uuid.UUID) *fiber.App { return app } -// expectTeamLookup primes the team-row SELECT used by the tier gate. -// The lookup runs first inside GetWall — every test (except the team -// tier one) wants this to return a non-team tier so the audit query -// proceeds. -func expectTeamLookup(mock sqlmock.Sqlmock, teamID uuid.UUID, tier string) { - // Wave FIX-J: GetTeamByID includes default_deployment_ttl_policy. - mock.ExpectQuery(`SELECT.*FROM teams WHERE id`). - WithArgs(teamID). - WillReturnRows(sqlmock.NewRows([]string{ - "id", "name", "plan_tier", "stripe_customer_id", "created_at", "default_deployment_ttl_policy", - }).AddRow(teamID, sql.NullString{}, tier, sql.NullString{}, time.Now(), "auto_24h")) -} +// strict-80% margin redesign (2026-06-05): GetWall no longer does a team +// lookup / team-tier short-circuit (Team is finite now and has walls like +// every other tier), so the former expectTeamLookup helper was removed — +// the handler issues exactly one query (the audit_log SELECT) for all tiers. // TestUsageWall_ReturnsLatestRowWithMetadata is the headline test: an // 87%-storage row written by the worker shows up in the response with @@ -75,7 +69,6 @@ func TestUsageWall_ReturnsLatestRowWithMetadata(t *testing.T) { defer db.Close() teamID := uuid.New() - expectTeamLookup(mock, teamID, "hobby") createdAt := time.Now().Add(-2 * time.Hour) metadata := `{"tier":"hobby","axis":"storage","service":"postgres","current":471859200,"limit":536870912,"percent_used":87}` @@ -112,7 +105,6 @@ func TestUsageWall_ReturnsFalseWhenNoRecentRow(t *testing.T) { defer db.Close() teamID := uuid.New() - expectTeamLookup(mock, teamID, "hobby") mock.ExpectQuery(`SELECT metadata, created_at\s+FROM audit_log`). WithArgs(teamID, "near_quota_wall", sqlmock.AnyArg()). @@ -134,29 +126,37 @@ func TestUsageWall_ReturnsFalseWhenNoRecentRow(t *testing.T) { } // TestUsageWall_CacheHeadersOnEvery200Path is the registry-iterating -// regression for BUG-API-420. /api/v1/usage/wall has three distinct 200 -// code paths in GetWall (team-tier short-circuit, no-recent-row, -// row-found-with-metadata) — every one MUST stamp the same Cache-Control -// and Vary headers, otherwise a dashboard polling the endpoint on every -// nav re-hits the DB on the busy team-scoped audit_log table. The cases -// table mirrors the three code paths in usage_wall.go; adding a fourth -// path without updating this test (which would mean the path skips the -// cache header) is the bug class rule 18 protects against. +// regression for BUG-API-420. /api/v1/usage/wall has two distinct 200 +// code paths in GetWall (no-recent-row, row-found-with-metadata) — every +// one MUST stamp the same Cache-Control and Vary headers, otherwise a +// dashboard polling the endpoint on every nav re-hits the DB on the busy +// team-scoped audit_log table. The cases table mirrors the code paths in +// usage_wall.go; adding a third path without updating this test (which +// would mean the path skips the cache header) is the bug class rule 18 +// protects against. +// +// strict-80% margin redesign (2026-06-05): the former team-tier +// short-circuit path was removed (Team is finite now), so a "team_tier" +// case is kept but now asserts Team flows through the SAME audit query as +// every other finite tier — proving the short-circuit is gone. func TestUsageWall_CacheHeadersOnEvery200Path(t *testing.T) { cases := []struct { name string prime func(mock sqlmock.Sqlmock, teamID uuid.UUID) }{ { - name: "team_tier_short_circuit", + name: "team_tier_now_queries_audit", prime: func(mock sqlmock.Sqlmock, teamID uuid.UUID) { - expectTeamLookup(mock, teamID, "team") + // Team is finite — it no longer short-circuits; it hits + // the audit_log query like any other tier. + mock.ExpectQuery(`SELECT metadata, created_at\s+FROM audit_log`). + WithArgs(teamID, "near_quota_wall", sqlmock.AnyArg()). + WillReturnError(sql.ErrNoRows) }, }, { name: "no_recent_row", prime: func(mock sqlmock.Sqlmock, teamID uuid.UUID) { - expectTeamLookup(mock, teamID, "hobby") mock.ExpectQuery(`SELECT metadata, created_at\s+FROM audit_log`). WithArgs(teamID, "near_quota_wall", sqlmock.AnyArg()). WillReturnError(sql.ErrNoRows) @@ -165,7 +165,6 @@ func TestUsageWall_CacheHeadersOnEvery200Path(t *testing.T) { { name: "row_found_with_metadata", prime: func(mock sqlmock.Sqlmock, teamID uuid.UUID) { - expectTeamLookup(mock, teamID, "hobby") metadata := `{"tier":"hobby","axis":"storage","service":"postgres","current":1,"limit":2,"percent_used":50}` mock.ExpectQuery(`SELECT metadata, created_at\s+FROM audit_log`). WithArgs(teamID, "near_quota_wall", sqlmock.AnyArg()). @@ -206,19 +205,24 @@ func TestUsageWall_CacheHeadersOnEvery200Path(t *testing.T) { } } -// TestUsageWall_TeamTierShortCircuits verifies the team-tier early -// return: a team-tier caller MUST get near_wall=false without an -// audit_log query (sqlmock strict mode catches the unexpected query). -func TestUsageWall_TeamTierShortCircuits(t *testing.T) { +// TestUsageWall_TeamTierFlowsThroughAuditQuery verifies the strict-80% +// margin redesign (2026-06-05): Team is no longer unlimited, so the former +// team-tier short-circuit is GONE — a team-tier caller MUST now hit the +// audit_log query like every other finite tier. The mock primes exactly +// the audit query (and no team lookup); sqlmock strict mode would fail if +// GetWall regressed back to a pre-query short-circuit (unmet audit +// expectation) or re-added the team lookup (unexpected query). +func TestUsageWall_TeamTierFlowsThroughAuditQuery(t *testing.T) { db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp)) require.NoError(t, err) defer db.Close() teamID := uuid.New() - expectTeamLookup(mock, teamID, "team") - // NO audit_log query expected. If GetWall regresses and queries - // audit_log for a team-tier caller, sqlmock strict mode fails the - // test ("unexpected query"). + // Team now flows through to the audit_log query. With no recent row it + // returns near_wall=false — but via the query, not a short-circuit. + mock.ExpectQuery(`SELECT metadata, created_at\s+FROM audit_log`). + WithArgs(teamID, "near_quota_wall", sqlmock.AnyArg()). + WillReturnError(sql.ErrNoRows) app := newUsageWallApp(t, db, teamID) req := httptest.NewRequest(http.MethodGet, "/api/v1/usage/wall", nil) diff --git a/internal/handlers/vector_test.go b/internal/handlers/vector_test.go index fb5fa042..1d6538ed 100644 --- a/internal/handlers/vector_test.go +++ b/internal/handlers/vector_test.go @@ -294,11 +294,13 @@ func TestPlansRegistry_VectorTierLimits(t *testing.T) { {"free", 10, 2}, {"hobby", 500, 5}, // 2026-05-15: Pro vector storage tracked Pro Postgres bump - // (5120 → 10240 MB). Growth bumped in tandem so the tier - // ladder stays ordered above Pro. + // (5120 → 10240 MB). {"pro", 10240, 20}, - {"team", -1, -1}, - {"growth", 20480, 20}, + // strict-80% margin redesign (2026-06-05): team vector -1 → finite + // 30720 MB / 100 conn; growth vector trimmed 20480 → 10240 MB + // (still ≥ Pro's 10240, ladder preserved). + {"team", 30720, 100}, + {"growth", 10240, 20}, } for _, tc := range cases { t.Run(tc.tier, func(t *testing.T) { diff --git a/internal/handlers/webhook_rbw_test.go b/internal/handlers/webhook_rbw_test.go index 7dcb17e3..8bab4029 100644 --- a/internal/handlers/webhook_rbw_test.go +++ b/internal/handlers/webhook_rbw_test.go @@ -29,19 +29,62 @@ func newWebhookHandlerForTest(t *testing.T) (*handlers.WebhookHandler, func()) { return h, func() { dbClean(); rClean() } } -// TestWebhookMaxStored covers all three arms: unlimited (-1 → 10000), the -// configured-positive value, and the safe floor. +// TestWebhookMaxStored covers the configured-positive value and the +// anonymous fallback. strict-80% margin redesign (2026-06-05): team's +// webhook_requests_stored is now a finite 100000 (was -1 unlimited), so the +// "-1 → 10000 clamp" arm is exercised separately via a synthetic registry in +// TestWebhookMaxStored_UnlimitedClampArm (no real tier carries -1 anymore). func TestWebhookMaxStored(t *testing.T) { h, clean := newWebhookHandlerForTest(t) defer clean() - // team tier → unlimited webhook stored → 10000 cap - require.Equal(t, int64(10_000), handlers.WebhookMaxStoredForTest(h, "team")) + // team tier → finite 100000 per plans.yaml + require.Equal(t, int64(100_000), handlers.WebhookMaxStoredForTest(h, "team")) // hobby → a finite positive cap (1000 per plans.yaml) require.Greater(t, handlers.WebhookMaxStoredForTest(h, "hobby"), int64(0)) // unknown tier → falls back to anonymous (100) via the int64(n) path require.Equal(t, int64(100), handlers.WebhookMaxStoredForTest(h, "no_such_tier")) } +// TestWebhookMaxStored_UnlimitedClampArm covers the -1 → 10000 clamp branch +// in webhookMaxStored. Since the 2026-06-05 strict-margin redesign retired +// every -1 webhook limit, the arm is now exercised via a synthetic registry +// whose tier carries webhook_requests_stored: -1, keeping the defensive +// clamp branch covered against a future re-introduction. +func TestWebhookMaxStored_UnlimitedClampArm(t *testing.T) { + limits := ` + limits: + provisions_per_day: 5 + postgres_storage_mb: 10 + postgres_connections: 2 + redis_memory_mb: 5 + mongodb_storage_mb: 5 + mongodb_connections: 2 + webhook_requests_stored: -1` + yaml := ` +plans: + anonymous: + display_name: "Anonymous" + price_monthly_cents: 0` + limits + ` + unlimitedhook: + display_name: "UnlimitedHook" + price_monthly_cents: 0` + limits + ` +` + dir := t.TempDir() + path := dir + "/plans.yaml" + require.NoError(t, os.WriteFile(path, []byte(yaml), 0o600)) + reg, err := plans.Load(path) + require.NoError(t, err) + + db, dbClean := testhelpers.SetupTestDB(t) + defer dbClean() + rdb, rClean := testhelpers.SetupTestRedis(t) + defer rClean() + cfg := &config.Config{Environment: "test", AESKey: testhelpers.TestAESKeyHex} + h := handlers.NewWebhookHandler(db, rdb, cfg, reg) + require.Equal(t, int64(10_000), handlers.WebhookMaxStoredForTest(h, "unlimitedhook"), + "-1 (unlimited) webhook tier must clamp to 10000 for the Redis LTRIM call") +} + // TestWebhookMaxStored_FloorArm covers the n<=0 safe-floor branch via a custom // plans registry whose tier has webhook_requests_stored: 0. func TestWebhookMaxStored_FloorArm(t *testing.T) { diff --git a/internal/plans/strict_margin_finite_limits_test.go b/internal/plans/strict_margin_finite_limits_test.go new file mode 100644 index 00000000..6506af3d --- /dev/null +++ b/internal/plans/strict_margin_finite_limits_test.go @@ -0,0 +1,82 @@ +package plans_test + +// strict_margin_finite_limits_test.go — rule-18 registry-iterating guard for +// the strict-≥80%-margin tier redesign (2026-06-05, PR #262 / common #46). +// +// The redesign retired every "unlimited" (-1) RESOURCE limit to a finite, +// worst-case-costed cap so saturated COGS stays ≥80% margin. The ONLY limit +// field intentionally left at -1 is ProvisionsPerDay (a per-day rate gate on +// paid tiers, not a resource-cost dimension). +// +// This test iterates the LIVE plans.yaml registry (not a hand-typed tier +// list) so that re-introducing a -1 on any costed limit — or adding a new +// tier that ships an unlimited resource cap — fails here rather than silently +// shipping an unbounded-COGS tier. Hand-typed slices would themselves be the +// single-site fallacy rule 18 warns against. + +import ( + "os" + "path/filepath" + "testing" + + "instant.dev/internal/plans" +) + +func TestStrictMargin_NoUnlimitedResourceLimits(t *testing.T) { + repoRoot := filepath.Join("..", "..", "plans.yaml") + if _, err := os.Stat(repoRoot); os.IsNotExist(err) { + t.Skip("plans.yaml not found in repo root — skipping finite-limit guard") + } + r, err := plans.Load(repoRoot) + if err != nil { + t.Fatalf("load plans.yaml: %v", err) + } + + all := r.All() + if len(all) == 0 { + t.Fatal("registry is empty — plans.yaml failed to populate any tier") + } + + for name, p := range all { + l := p.Limits + // Every integer RESOURCE limit must be finite (>= 0). -1 (unlimited) + // is forbidden post strict-margin redesign because an unbounded cap + // breaks the worst-case COGS / ≥80%-margin guarantee. (0 is allowed: + // it means "feature not available on this tier", e.g. deployments_apps + // on anonymous/free, vault on free.) + checks := map[string]int{ + "postgres_storage_mb": l.PostgresStorageMB, + "postgres_connections": l.PostgresConnections, + "vector_storage_mb": l.VectorStorageMB, + "vector_connections": l.VectorConnections, + "redis_memory_mb": l.RedisMemoryMB, + "redis_commands_per_day": l.RedisCommandsPerDay, + "mongodb_storage_mb": l.MongoStorageMB, + "mongodb_connections": l.MongoConnections, + "mongodb_ops_per_minute": l.MongoOpsPerMinute, + "queue_storage_mb": l.QueueStorageMB, + "queue_count": l.QueueCount, + "storage_storage_mb": l.StorageStorageMB, + "webhook_requests_stored": l.WebhookRequestsStored, + "team_members": l.TeamMembers, + "vault_max_entries": l.VaultMaxEntries, + "deployments_apps": l.DeploymentsApps, + "custom_domains_max": l.CustomDomainsMax, + "manual_backups_per_day": l.ManualBackupsPerDay, + } + for field, v := range checks { + if v < 0 { + t.Errorf("tier %q: %s = %d — unlimited (-1) resource limits were retired in the strict-80%%-margin redesign; every resource cap must be finite (>= 0)", + name, field, v) + } + } + + // ProvisionsPerDay is the ONLY field intentionally allowed at -1 + // (per-day rate gate, not a resource-cost dimension). Assert it is + // either finite (>= 0) or exactly -1 — never some other negative. + if l.ProvisionsPerDay < -1 { + t.Errorf("tier %q: provisions_per_day = %d — must be -1 (unlimited) or finite (>= 0)", + name, l.ProvisionsPerDay) + } + } +} diff --git a/internal/plans/tier_ladder_invariants_test.go b/internal/plans/tier_ladder_invariants_test.go index e5860052..e148056e 100644 --- a/internal/plans/tier_ladder_invariants_test.go +++ b/internal/plans/tier_ladder_invariants_test.go @@ -11,6 +11,7 @@ package plans_test import ( "path/filepath" + "reflect" "runtime" "testing" @@ -145,3 +146,58 @@ func normaliseUnlimited(v int) int { } return v } + +// noUnlimitedExceptProvisionsPerDay is the set of Limits int fields that +// are permitted to hold the -1 ("unlimited") sentinel after the +// 2026-06-05 strict-≥80%-margin redesign retired every other unlimited. +// +// - provisions_per_day: -1 stays by design — it's a per-fingerprint/team +// RATE limit (resources created per day), not a stored-resource cap, so +// it carries no per-GB COGS. (Known separate hardening: there is still +// no per-service resource-COUNT cap field, so a tenant could create many +// resources; tracked as a follow-up, not retired here.) +// +// Every OTHER int limit field on every tier MUST be finite (>= 0). This +// reflection-driven, registry-iterating test (CLAUDE.md rule 18) means a +// future YAML edit that reintroduces an "unlimited" on any costed field — +// or a brand-new int field added with -1 — reds the build instead of +// silently reopening the unbounded-COGS liability the CEO directive closed. +var noUnlimitedExceptProvisionsPerDay = map[string]bool{ + "ProvisionsPerDay": true, +} + +// TestPlansYAML_NoUnlimitedExceptProvisionsPerDay iterates EVERY tier in +// the live registry and EVERY int field on its Limits via reflection, +// asserting none holds -1 except the allowlisted provisions_per_day. +func TestPlansYAML_NoUnlimitedExceptProvisionsPerDay(t *testing.T) { + r := loadAPIPlansYAML(t) + all := r.All() + if len(all) == 0 { + t.Fatalf("registry is empty — loadAPIPlansYAML returned no tiers") + } + + for tier, p := range all { + v := reflect.ValueOf(p.Limits) + typ := v.Type() + for i := 0; i < v.NumField(); i++ { + f := typ.Field(i) + // Only int-kinded limit fields carry the -1 sentinel. Bool + // (e.g. BackupRestoreEnabled) and slice (VaultEnvsAllowed, + // where [] = all-envs, not unlimited) fields are skipped. + if v.Field(i).Kind() != reflect.Int { + continue + } + if v.Field(i).Int() != -1 { + continue + } + if noUnlimitedExceptProvisionsPerDay[f.Name] { + continue + } + t.Errorf("tier %q field %s (yaml:%q) is -1 (unlimited) — the strict-80%% "+ + "margin redesign retired every unlimited except provisions_per_day; "+ + "set a finite cap or, if a new field legitimately may be unlimited, "+ + "add it to noUnlimitedExceptProvisionsPerDay with justification", + tier, f.Name, f.Tag.Get("yaml")) + } + } +} diff --git a/openapi.snapshot.json b/openapi.snapshot.json index 299c8e80..1fbb25b7 100644 --- a/openapi.snapshot.json +++ b/openapi.snapshot.json @@ -3092,7 +3092,7 @@ }, "/api/v1/billing/checkout": { "post": { - "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.", + "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, finite high-capacity limits — not unlimited) is NOT yet available for self-serve checkout — requesting plan=team returns 400 tier_not_yet_available (contact sales: support@instanode.dev). Capacity beyond the Team caps is Enterprise (contact sales). 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": { @@ -7315,7 +7315,7 @@ }, "/api/v1/usage/wall": { "get": { - "description": "Returns the most recent near_quota_wall row written by the worker's QuotaWallNudgeWorker, scoped to the caller's team and bounded to the last 24h. The dashboard polls this on mount and every 5 minutes to decide whether to render the upgrade banner. Team-tier callers always get near_wall=false (team is unlimited). Fails open — a DB error returns 503 rather than a misleading near_wall=false.", + "description": "Returns the most recent near_quota_wall row written by the worker's QuotaWallNudgeWorker, scoped to the caller's team and bounded to the last 24h. The dashboard polls this on mount and every 5 minutes to decide whether to render the upgrade banner. As of the 2026-06-05 strict-margin redesign Team has finite limits too, so Team callers can also approach a wall (next step above Team is Enterprise/contact-sales). Fails open — a DB error returns 503 rather than a misleading near_wall=false.", "responses": { "200": { "content": { diff --git a/plans.yaml b/plans.yaml index 3bb2f984..ed01a199 100644 --- a/plans.yaml +++ b/plans.yaml @@ -22,8 +22,14 @@ plans: mongodb_storage_mb: 5 mongodb_connections: 2 mongodb_ops_per_minute: 100 - queue_storage_mb: 1024 - queue_count: -1 + # strict-80% margin redesign (2026-06-05): queue trimmed 1024 → 64 MB. + queue_storage_mb: 64 + # queue_count 1 (was -1). Retires the last `-1` on the free tiers. + # NOTE: queue_count == 0 is interpreted as "unlimited" by + # Registry.QueueCountLimit (pre-A6 zero-fallback), so 0 is NOT usable to + # mean "none" — anon/free get exactly 1 queue, bounded by the 64 MB slice. + # Hard-cap-only; no metered overage. + queue_count: 1 storage_storage_mb: 10 webhook_requests_stored: 100 team_members: 1 @@ -67,8 +73,10 @@ plans: mongodb_storage_mb: 5 mongodb_connections: 2 mongodb_ops_per_minute: 100 - queue_storage_mb: 1024 - queue_count: -1 + # strict-80% margin redesign (2026-06-05): queue trimmed 1024 → 64 MB. + queue_storage_mb: 64 + # queue_count 1 (was -1) — mirrors anonymous. 0 means unlimited, so 1. + queue_count: 1 storage_storage_mb: 10 webhook_requests_stored: 100 team_members: 1 @@ -106,7 +114,8 @@ plans: mongodb_storage_mb: 100 mongodb_connections: 5 mongodb_ops_per_minute: 1000 - queue_storage_mb: 5120 + # strict-80% margin redesign (2026-06-05): queue trimmed 5120 → 2048 MB. + queue_storage_mb: 2048 queue_count: 3 storage_storage_mb: 512 webhook_requests_stored: 1000 @@ -273,7 +282,8 @@ plans: mongodb_storage_mb: 100 mongodb_connections: 5 mongodb_ops_per_minute: 1000 - queue_storage_mb: 5120 + # strict-80% margin redesign (2026-06-05): queue trimmed 5120 → 2048 MB (mirror hobby). + queue_storage_mb: 2048 queue_count: 3 storage_storage_mb: 512 webhook_requests_stored: 1000 @@ -318,7 +328,8 @@ plans: mongodb_storage_mb: 5120 mongodb_connections: 20 mongodb_ops_per_minute: 10000 - queue_storage_mb: 10240 + # strict-80% margin redesign (2026-06-05): queue trimmed 10240 → 5120 MB. + queue_storage_mb: 5120 queue_count: 20 storage_storage_mb: 51200 webhook_requests_stored: 10000 @@ -362,7 +373,8 @@ plans: mongodb_storage_mb: 5120 mongodb_connections: 20 mongodb_ops_per_minute: 10000 - queue_storage_mb: 10240 + # strict-80% margin redesign (2026-06-05): queue trimmed 10240 → 5120 MB (mirror pro). + queue_storage_mb: 5120 queue_count: 20 storage_storage_mb: 51200 webhook_requests_stored: 10000 @@ -398,24 +410,28 @@ plans: display_name: "Team" price_monthly_cents: 19900 limits: + # strict-80% margin redesign (2026-06-05): every `-1` retired to finite, + # generous-but-bounded caps. Anything larger = Enterprise "contact us" + # (the #44 dedicated-infra delivery). Team stays GATED (no checkout) — + # these are the eventual contract numbers, not an un-gate. Hard-caps only. provisions_per_day: -1 - postgres_storage_mb: -1 - postgres_connections: -1 - vector_storage_mb: -1 - vector_connections: -1 - redis_memory_mb: -1 - redis_commands_per_day: -1 - mongodb_storage_mb: -1 - mongodb_connections: -1 - mongodb_ops_per_minute: -1 - queue_storage_mb: -1 - queue_count: -1 - storage_storage_mb: -1 - webhook_requests_stored: -1 - team_members: -1 - vault_max_entries: -1 + postgres_storage_mb: 51200 + postgres_connections: 100 + vector_storage_mb: 30720 + vector_connections: 100 + redis_memory_mb: 1536 + redis_commands_per_day: 10000000 + mongodb_storage_mb: 40960 + mongodb_connections: 50 + mongodb_ops_per_minute: 50000 + queue_storage_mb: 40960 + queue_count: 100 + storage_storage_mb: 307200 + webhook_requests_stored: 100000 + team_members: 25 + vault_max_entries: 1000 vault_envs_allowed: [] - deployments_apps: -1 + deployments_apps: 100 # Team: 90-day retention, 1000 manual backups/day, self-serve restore. # The longer retention is the compliance lever — enterprises asking # for 90-day point-in-time recovery land here. @@ -444,24 +460,26 @@ plans: price_monthly_cents: 199000 billing_period: "yearly" limits: + # strict-80% margin redesign (2026-06-05): mirror team monthly — every + # `-1` retired to the same finite caps. Only billing period differs. provisions_per_day: -1 - postgres_storage_mb: -1 - postgres_connections: -1 - vector_storage_mb: -1 - vector_connections: -1 - redis_memory_mb: -1 - redis_commands_per_day: -1 - mongodb_storage_mb: -1 - mongodb_connections: -1 - mongodb_ops_per_minute: -1 - queue_storage_mb: -1 - queue_count: -1 - storage_storage_mb: -1 - webhook_requests_stored: -1 - team_members: -1 - vault_max_entries: -1 + postgres_storage_mb: 51200 + postgres_connections: 100 + vector_storage_mb: 30720 + vector_connections: 100 + redis_memory_mb: 1536 + redis_commands_per_day: 10000000 + mongodb_storage_mb: 40960 + mongodb_connections: 50 + mongodb_ops_per_minute: 50000 + queue_storage_mb: 40960 + queue_count: 100 + storage_storage_mb: 307200 + webhook_requests_stored: 100000 + team_members: 25 + vault_max_entries: 1000 vault_envs_allowed: [] - deployments_apps: -1 + deployments_apps: 100 # Mirror team — same limits + features, only billing period differs. backup_retention_days: 90 backup_restore_enabled: true @@ -508,19 +526,22 @@ plans: # connection bump is intentionally absent — Pro already gets the # highest single-DB connection count under shared infra; further # connection headroom is a Team-tier (dedicated infra) benefit. + # strict-80% margin redesign (2026-06-05): every `-1` retired to a finite + # cap so worst-case-saturated COGS stays ≥80% margin. Hard-caps only + # (402 + upgrade prompt at limit) — no metered overage in v1. postgres_storage_mb: 20480 postgres_connections: 20 - vector_storage_mb: 20480 + vector_storage_mb: 10240 vector_connections: 20 redis_memory_mb: 1024 - redis_commands_per_day: -1 - mongodb_storage_mb: -1 - mongodb_connections: -1 - mongodb_ops_per_minute: -1 - queue_storage_mb: -1 - queue_count: -1 - storage_storage_mb: -1 - webhook_requests_stored: -1 + redis_commands_per_day: 5000000 + mongodb_storage_mb: 20480 + mongodb_connections: 50 + mongodb_ops_per_minute: 50000 + queue_storage_mb: 20480 + queue_count: 50 + storage_storage_mb: 153600 + webhook_requests_stored: 100000 team_members: 10 vault_max_entries: 200 vault_envs_allowed: []