diff --git a/plans/plans.go b/plans/plans.go index 929aac0..2a5e007 100644 --- a/plans/plans.go +++ b/plans/plans.go @@ -66,6 +66,29 @@ type Limits struct { // DeploymentsApps is the maximum number of deployable applications per team. // -1 means unlimited; 0 means deployments are not available on this tier. DeploymentsApps int `yaml:"deployments_apps"` + + // BackupRetentionDays is how long the worker keeps Postgres backups for + // resources in this tier. 0 means backups are not taken at all (anonymous, + // free). Hobby = 7, hobby_plus = 14, Pro/Growth = 30, Team = 90. + BackupRetentionDays int `yaml:"backup_retention_days"` + + // BackupRestoreEnabled gates POST /api/v1/resources/:id/restore. When + // false the handler returns 402 upgrade_required with a sales nudge. + // Hobby = false (sales lever); hobby_plus / Pro / Team = true. + BackupRestoreEnabled bool `yaml:"backup_restore_enabled"` + + // ManualBackupsPerDay caps the number of ad-hoc backups a team can + // trigger via POST /api/v1/resources/:id/backup per UTC day. 0 means + // manual backups are not allowed. + ManualBackupsPerDay int `yaml:"manual_backups_per_day"` + + // VectorStorageMB is the maximum storage per pgvector-enabled Postgres + // database in megabytes. Mirrors PostgresStorageMB because pgvector + // runs on the same underlying Postgres backend. + VectorStorageMB int `yaml:"vector_storage_mb"` + // VectorConnections is the maximum concurrent connections per pgvector + // database. Mirrors PostgresConnections. + VectorConnections int `yaml:"vector_connections"` } // Features describes the boolean capabilities unlocked by a plan tier. @@ -94,8 +117,6 @@ type Plan struct { // plans set this to "yearly" so callers can distinguish them from the // monthly counterpart at billing-cycle time. Empty == "monthly". BillingPeriod string `yaml:"billing_period"` - // TrialDays is the length of the free trial in days (0 = no trial). - TrialDays int `yaml:"trial_days"` // Limits holds all quantitative constraints for this tier. Limits Limits `yaml:"limits"` // Features holds the boolean feature flags for this tier. @@ -226,12 +247,16 @@ func (r *Registry) ValidatePromotion(code, targetTier string) (*Promotion, error } // StorageLimitMB returns the storage limit in MB for the given tier and service type. -// service must be one of "postgres", "redis", "mongodb". Returns -1 for unlimited. +// service must be one of "postgres", "vector", "redis", "mongodb", "queue", +// "storage", "webhook". Returns -1 for unlimited. "vector" mirrors "postgres" +// because pgvector runs on the same underlying Postgres backend. func (r *Registry) StorageLimitMB(tier, service string) int { p := r.Get(tier) switch service { case "postgres": return p.Limits.PostgresStorageMB + case "vector": + return p.Limits.VectorStorageMB case "redis": return p.Limits.RedisMemoryMB case "mongodb": @@ -247,12 +272,15 @@ func (r *Registry) StorageLimitMB(tier, service string) int { } // ConnectionsLimit returns the max concurrent connections for the given tier and service. -// Returns -1 for unlimited. +// Returns -1 for unlimited. "vector" mirrors "postgres" because pgvector runs +// on the same underlying Postgres backend. func (r *Registry) ConnectionsLimit(tier, service string) int { p := r.Get(tier) switch service { case "postgres": return p.Limits.PostgresConnections + case "vector": + return p.Limits.VectorConnections case "mongodb": return p.Limits.MongoConnections } @@ -301,11 +329,6 @@ func (r *Registry) DisplayName(tier string) string { return r.Get(tier).DisplayName } -// TrialDays returns the free-trial length in days for the tier (0 = no trial). -func (r *Registry) TrialDays(tier string) int { - return r.Get(tier).TrialDays -} - // IsDedicatedTier reports whether the tier provisions dedicated backends. func (r *Registry) IsDedicatedTier(tier string) bool { return r.Get(tier).Features.Dedicated @@ -379,6 +402,37 @@ func (r *Registry) DeploymentsAppsLimit(tier string) int { return p.Limits.DeploymentsApps } +// BackupRetentionDays returns how long the worker keeps Postgres backups for +// the given tier. 0 means no backups are taken. +func (r *Registry) BackupRetentionDays(tier string) int { + p := r.Get(tier) + if p == nil { + return 0 + } + return p.Limits.BackupRetentionDays +} + +// BackupRestoreEnabled reports whether the tier may self-serve restore from a +// backup. Hobby/free/anonymous get false (sales lever — restore is an upgrade +// hook). hobby_plus / Pro / Team return true. +func (r *Registry) BackupRestoreEnabled(tier string) bool { + p := r.Get(tier) + if p == nil { + return false + } + return p.Limits.BackupRestoreEnabled +} + +// ManualBackupsPerDay returns the per-team daily cap on POST /backup calls. +// 0 means manual backups are not allowed at all. -1 means unlimited. +func (r *Registry) ManualBackupsPerDay(tier string) int { + p := r.Get(tier) + if p == nil { + return 0 + } + return p.Limits.ManualBackupsPerDay +} + // Default returns a Registry built from hardcoded defaults. // Used in tests and when plans.yaml is not present (development convenience). func Default() *Registry { @@ -397,11 +451,12 @@ plans: anonymous: display_name: "Anonymous" price_monthly_cents: 0 - trial_days: 0 limits: provisions_per_day: 5 postgres_storage_mb: 10 postgres_connections: 2 + vector_storage_mb: 10 + vector_connections: 2 redis_memory_mb: 5 redis_commands_per_day: 1000 mongodb_storage_mb: 5 @@ -414,6 +469,9 @@ plans: vault_max_entries: 0 vault_envs_allowed: [] deployments_apps: 0 + backup_retention_days: 0 + backup_restore_enabled: false + manual_backups_per_day: 0 features: alerts: false custom_domains: false @@ -426,11 +484,12 @@ plans: free: display_name: "Free" price_monthly_cents: 0 - trial_days: 0 limits: provisions_per_day: 5 postgres_storage_mb: 10 postgres_connections: 2 + vector_storage_mb: 10 + vector_connections: 2 redis_memory_mb: 5 redis_commands_per_day: 1000 mongodb_storage_mb: 5 @@ -443,6 +502,9 @@ plans: vault_max_entries: 0 vault_envs_allowed: [] deployments_apps: 0 + backup_retention_days: 0 + backup_restore_enabled: false + manual_backups_per_day: 0 features: alerts: false custom_domains: false @@ -450,11 +512,12 @@ plans: hobby: display_name: "Hobby" price_monthly_cents: 900 - trial_days: 14 limits: provisions_per_day: -1 postgres_storage_mb: 1024 postgres_connections: 8 + vector_storage_mb: 500 + vector_connections: 5 redis_memory_mb: 50 redis_commands_per_day: 10000 mongodb_storage_mb: 100 @@ -467,10 +530,84 @@ plans: vault_max_entries: 20 vault_envs_allowed: ["production"] deployments_apps: 1 + backup_retention_days: 7 + backup_restore_enabled: false + manual_backups_per_day: 1 features: alerts: true custom_domains: false sla: false + # hobby_plus — $19/mo mid-step between Hobby ($9) and Pro ($49). + # The W11 mid-tier insertion (2026-05-13). Research-backed pricing + # decoy: triple-tier $9/$19/$49 lifts conversion ~22% vs $9/$49 by + # anchoring against the middle price. Same limits as hobby plus: + # - 2 deployment apps (vs hobby's 1) + # - custom_domains: true (the first paid tier with this feature) + # - 5 GB object storage (vs hobby's 512 MB) — small bump + # - 50 vault entries with multi-env support (vs hobby's 20 prod-only) + hobby_plus: + display_name: "Hobby Plus" + price_monthly_cents: 1900 + limits: + provisions_per_day: -1 + postgres_storage_mb: 1024 + postgres_connections: 8 + vector_storage_mb: 1024 + vector_connections: 8 + redis_memory_mb: 50 + redis_commands_per_day: 10000 + mongodb_storage_mb: 1024 + mongodb_connections: 5 + mongodb_ops_per_minute: 1000 + queue_storage_mb: 5120 + storage_storage_mb: 5120 + webhook_requests_stored: 5000 + team_members: 1 + vault_max_entries: 50 + vault_envs_allowed: ["development", "staging", "production"] + deployments_apps: 2 + backup_retention_days: 14 + backup_restore_enabled: true + manual_backups_per_day: 5 + features: + alerts: true + custom_domains: true + sla: false + # hobby_plus_yearly — annual variant of hobby_plus. + # $199/yr ≈ $16.58/mo (~13% off). Discount sits between hobby's + # "save 1 month" (~8%) and pro/team's "2 months free" (~17%) — the + # mid-tier gets a mid-discount so the savings ladder reads: + # Hobby $9 → save 1 month / Hobby Plus $19 → save ~1.5 months / + # Pro $49 → save 2 months. + hobby_plus_yearly: + display_name: "Hobby Plus (yearly)" + price_monthly_cents: 19900 + billing_period: "yearly" + limits: + provisions_per_day: -1 + postgres_storage_mb: 1024 + postgres_connections: 8 + vector_storage_mb: 1024 + vector_connections: 8 + redis_memory_mb: 50 + redis_commands_per_day: 10000 + mongodb_storage_mb: 1024 + mongodb_connections: 5 + mongodb_ops_per_minute: 1000 + queue_storage_mb: 5120 + storage_storage_mb: 5120 + webhook_requests_stored: 5000 + team_members: 1 + vault_max_entries: 50 + vault_envs_allowed: ["development", "staging", "production"] + deployments_apps: 2 + backup_retention_days: 14 + backup_restore_enabled: true + manual_backups_per_day: 5 + features: + alerts: true + custom_domains: true + sla: false # hobby_yearly mirrors hobby exactly — same limits + features. Only the # billing period and price differ ($99/yr = $9 x 11 — "save 1 month" vs # $9 x 12). Hobby Annual gets a smaller discount than Pro/Team Annual @@ -485,11 +622,12 @@ plans: display_name: "Hobby (yearly)" price_monthly_cents: 9900 billing_period: "yearly" - trial_days: 14 limits: provisions_per_day: -1 postgres_storage_mb: 1024 postgres_connections: 8 + vector_storage_mb: 500 + vector_connections: 5 redis_memory_mb: 50 redis_commands_per_day: 10000 mongodb_storage_mb: 100 @@ -502,6 +640,9 @@ plans: vault_max_entries: 20 vault_envs_allowed: ["production"] deployments_apps: 1 + backup_retention_days: 7 + backup_restore_enabled: false + manual_backups_per_day: 1 features: alerts: true custom_domains: false @@ -509,11 +650,12 @@ plans: pro: display_name: "Pro" price_monthly_cents: 4900 - trial_days: 0 limits: provisions_per_day: -1 postgres_storage_mb: 5120 postgres_connections: 20 + vector_storage_mb: 5120 + vector_connections: 20 redis_memory_mb: 256 redis_commands_per_day: 500000 mongodb_storage_mb: 2048 @@ -526,6 +668,9 @@ plans: vault_max_entries: 200 vault_envs_allowed: [] deployments_apps: 10 + backup_retention_days: 30 + backup_restore_enabled: true + manual_backups_per_day: 100 features: alerts: true custom_domains: true @@ -535,11 +680,12 @@ plans: display_name: "Pro (yearly)" price_monthly_cents: 49000 billing_period: "yearly" - trial_days: 0 limits: provisions_per_day: -1 postgres_storage_mb: 5120 postgres_connections: 20 + vector_storage_mb: 5120 + vector_connections: 20 redis_memory_mb: 256 redis_commands_per_day: 500000 mongodb_storage_mb: 2048 @@ -552,6 +698,9 @@ plans: vault_max_entries: 200 vault_envs_allowed: [] deployments_apps: 10 + backup_retention_days: 30 + backup_restore_enabled: true + manual_backups_per_day: 100 features: alerts: true custom_domains: true @@ -559,11 +708,12 @@ plans: team: display_name: "Team" price_monthly_cents: 19900 - trial_days: 0 limits: 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 @@ -576,6 +726,9 @@ plans: vault_max_entries: -1 vault_envs_allowed: [] deployments_apps: -1 + backup_retention_days: 90 + backup_restore_enabled: true + manual_backups_per_day: 1000 features: alerts: true custom_domains: true @@ -585,11 +738,12 @@ plans: display_name: "Team (yearly)" price_monthly_cents: 199000 billing_period: "yearly" - trial_days: 0 limits: 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 @@ -602,6 +756,9 @@ plans: vault_max_entries: -1 vault_envs_allowed: [] deployments_apps: -1 + backup_retention_days: 90 + backup_restore_enabled: true + manual_backups_per_day: 1000 features: alerts: true custom_domains: true @@ -609,11 +766,12 @@ plans: growth: display_name: "Growth" price_monthly_cents: 9900 - trial_days: 0 limits: provisions_per_day: -1 postgres_storage_mb: 5120 postgres_connections: 20 + vector_storage_mb: 5120 + vector_connections: 20 redis_memory_mb: 256 redis_commands_per_day: -1 mongodb_storage_mb: -1 @@ -626,6 +784,9 @@ plans: vault_max_entries: 200 vault_envs_allowed: [] deployments_apps: 5 + backup_retention_days: 30 + backup_restore_enabled: true + manual_backups_per_day: 100 features: alerts: true custom_domains: true diff --git a/plans/plans_test.go b/plans/plans_test.go index 928b951..eb89c11 100644 --- a/plans/plans_test.go +++ b/plans/plans_test.go @@ -102,11 +102,12 @@ func TestLoad_InvalidYAML_ReturnsError(t *testing.T) { func TestAll_ReturnsAllPlans(t *testing.T) { r := plans.Default() all := r.All() - // 6 base tiers + 3 yearly variants (hobby_yearly, pro_yearly, team_yearly) = 9. - assert.Len(t, all, 9, "default registry must have 9 plans (6 base + 3 yearly variants)") + // 7 base tiers + 4 yearly variants (hobby_yearly, hobby_plus_yearly, + // pro_yearly, team_yearly) = 11. W11 added hobby_plus (+yearly). + assert.Len(t, all, 11, "default registry must have 11 plans (7 base + 4 yearly variants)") for _, name := range []string{ - "anonymous", "free", "hobby", "pro", "team", "growth", - "hobby_yearly", "pro_yearly", "team_yearly", + "anonymous", "free", "hobby", "hobby_plus", "pro", "team", "growth", + "hobby_yearly", "hobby_plus_yearly", "pro_yearly", "team_yearly", } { assert.Contains(t, all, name) } @@ -119,7 +120,7 @@ func TestAll_ReturnsAllPlans(t *testing.T) { // would get different headroom than a monthly Pro subscriber. func TestYearlyVariants_MirrorMonthlyLimits(t *testing.T) { r := plans.Default() - for _, base := range []string{"hobby", "pro", "team"} { + for _, base := range []string{"hobby", "hobby_plus", "pro", "team"} { yearly := r.Get(base + "_yearly") monthly := r.Get(base) assert.Equal(t, monthly.Limits, yearly.Limits, @@ -136,11 +137,11 @@ func TestYearlyVariants_MirrorMonthlyLimits(t *testing.T) { // report "yearly". func TestBillingPeriod_MonthlyDefault(t *testing.T) { r := plans.Default() - for _, t1 := range []string{"hobby", "pro", "team", "growth", "anonymous", "free"} { + for _, t1 := range []string{"hobby", "hobby_plus", "pro", "team", "growth", "anonymous", "free"} { assert.Equal(t, "monthly", r.BillingPeriod(t1), "tier %q must default to monthly when billing_period is unset", t1) } - for _, t1 := range []string{"hobby_yearly", "pro_yearly", "team_yearly"} { + for _, t1 := range []string{"hobby_yearly", "hobby_plus_yearly", "pro_yearly", "team_yearly"} { assert.Equal(t, "yearly", r.BillingPeriod(t1), "tier %q must report yearly", t1) } @@ -150,9 +151,11 @@ func TestBillingPeriod_MonthlyDefault(t *testing.T) { func TestCanonicalTier(t *testing.T) { cases := []struct{ in, want string }{ {"hobby_yearly", "hobby"}, + {"hobby_plus_yearly", "hobby_plus"}, {"pro_yearly", "pro"}, {"team_yearly", "team"}, {"hobby", "hobby"}, + {"hobby_plus", "hobby_plus"}, {"pro", "pro"}, {"team", "team"}, {"anonymous", "anonymous"}, @@ -169,7 +172,7 @@ func TestCanonicalTier(t *testing.T) { // "Save $X" badge is honest. func TestYearlyPrices_DiscountedVsMonthlyTimesTwelve(t *testing.T) { r := plans.Default() - for _, base := range []string{"hobby", "pro", "team"} { + for _, base := range []string{"hobby", "hobby_plus", "pro", "team"} { monthly := r.Get(base).PriceMonthly yearly := r.Get(base + "_yearly").PriceMonthly assert.Less(t, yearly, monthly*12, @@ -281,13 +284,11 @@ plans: anonymous: display_name: "Anon" price_monthly_cents: 0 - trial_days: 0 limits: {provisions_per_day: 5, postgres_storage_mb: 10, redis_memory_mb: 5} features: {alerts: false, custom_domains: false, sla: false} pro: display_name: "Pro" price_monthly_cents: 4900 - trial_days: 0 limits: {provisions_per_day: -1, postgres_storage_mb: 5120, redis_memory_mb: 256} features: {alerts: true, custom_domains: false, sla: false} promotions: @@ -313,13 +314,11 @@ plans: anonymous: display_name: "Anon" price_monthly_cents: 0 - trial_days: 0 limits: {provisions_per_day: 5, postgres_storage_mb: 10, redis_memory_mb: 5} features: {alerts: false, custom_domains: false, sla: false} pro: display_name: "Pro" price_monthly_cents: 4900 - trial_days: 0 limits: {provisions_per_day: -1, postgres_storage_mb: 5120, redis_memory_mb: 256} features: {alerts: true, custom_domains: false, sla: false} promotions: @@ -350,13 +349,11 @@ plans: anonymous: display_name: "Anon" price_monthly_cents: 0 - trial_days: 0 limits: {provisions_per_day: 5, postgres_storage_mb: 10, redis_memory_mb: 5} features: {alerts: false, custom_domains: false, sla: false} pro: display_name: "Pro" price_monthly_cents: 4900 - trial_days: 0 limits: {provisions_per_day: -1, postgres_storage_mb: 5120, redis_memory_mb: 256} features: {alerts: true, custom_domains: false, sla: false} promotions: @@ -396,15 +393,20 @@ func TestRegistry_TierHelpers(t *testing.T) { assert.Equal(t, 0, r.PriceMonthly("anonymous")) assert.Equal(t, 4900, r.PriceMonthly("pro")) assert.Equal(t, "Pro", r.DisplayName("pro")) - assert.Equal(t, 14, r.TrialDays("hobby")) assert.False(t, r.IsDedicatedTier("pro")) assert.True(t, r.IsDedicatedTier("growth")) + // W11: hobby_plus is the mid-tier — $19/mo, custom domains, 2 apps. + assert.Equal(t, 1900, r.PriceMonthly("hobby_plus"), + "hobby_plus monthly price must be $19/mo (1900 cents)") + assert.Equal(t, "Hobby Plus", r.DisplayName("hobby_plus")) } func TestVaultMaxEntries_Tiers(t *testing.T) { r := plans.Default() assert.Equal(t, 0, r.VaultMaxEntries("anonymous")) assert.Equal(t, 20, r.VaultMaxEntries("hobby")) + assert.Equal(t, 50, r.VaultMaxEntries("hobby_plus"), + "hobby_plus must allow 50 vault entries (mid-tier between hobby:20 and pro:200)") assert.Equal(t, 200, r.VaultMaxEntries("pro")) assert.Equal(t, -1, r.VaultMaxEntries("team")) } @@ -413,17 +415,63 @@ func TestVaultEnvsAllowed_HobbyIsProductionOnly(t *testing.T) { r := plans.Default() assert.Equal(t, []string{"production"}, r.VaultEnvsAllowed("hobby")) assert.Empty(t, r.VaultEnvsAllowed("pro")) + // hobby_plus is the first paid tier with multi-env support (dev/staging/prod). + assert.Equal(t, []string{"development", "staging", "production"}, + r.VaultEnvsAllowed("hobby_plus"), + "hobby_plus must allow dev/staging/prod envs (multi-env is the upgrade lever)") } func TestDeploymentsAppsLimit_Tiers(t *testing.T) { r := plans.Default() assert.Equal(t, 0, r.DeploymentsAppsLimit("anonymous")) assert.Equal(t, 1, r.DeploymentsAppsLimit("hobby")) + assert.Equal(t, 2, r.DeploymentsAppsLimit("hobby_plus"), + "hobby_plus must allow 2 deployment apps (doubles hobby's 1, vs pro's 10)") assert.Equal(t, 10, r.DeploymentsAppsLimit("pro")) assert.Equal(t, -1, r.DeploymentsAppsLimit("team")) assert.Equal(t, 5, r.DeploymentsAppsLimit("growth")) } +// TestHobbyPlus_TierMatrix is the W11 lock-in test for the hobby_plus tier. +// Asserts every documented field of the new $19/mo mid-tier exists and +// matches the documented values. If anyone changes a hobby_plus limit +// without updating the marketing copy + dashboard tier card, this test +// fails so the inconsistency is caught at unit-test time, not in prod. +func TestHobbyPlus_TierMatrix(t *testing.T) { + r := plans.Default() + p := r.Get("hobby_plus") + require.NotNil(t, p, "hobby_plus tier must exist in default registry") + assert.Equal(t, "hobby_plus", p.Name) + assert.Equal(t, "Hobby Plus", p.DisplayName) + assert.Equal(t, 1900, p.PriceMonthly, "$19/mo = 1900 cents") + assert.Equal(t, "", p.BillingPeriod, "monthly tier omits billing_period (defaults to monthly)") + // Storage / connection limits — hobby_plus matches hobby on the cheap + // services (postgres / redis), bumps mongodb + storage to mid-tier + // values, and doubles webhooks to 5000. + assert.Equal(t, 1024, p.Limits.PostgresStorageMB) + assert.Equal(t, 8, p.Limits.PostgresConnections) + assert.Equal(t, 50, p.Limits.RedisMemoryMB) + assert.Equal(t, 1024, p.Limits.MongoStorageMB, + "hobby_plus mongodb = 1 GB (vs hobby's 100 MB, pro's 2 GB)") + assert.Equal(t, 5, p.Limits.MongoConnections) + assert.Equal(t, 5120, p.Limits.StorageStorageMB, + "hobby_plus object storage = 5 GB (vs hobby's 512 MB, pro's 10 GB)") + assert.Equal(t, 5000, p.Limits.WebhookRequestsStored, + "hobby_plus webhook stored = 5000 (5x hobby's 1000, half of pro's 10k)") + assert.Equal(t, 2, p.Limits.DeploymentsApps, + "hobby_plus = 2 deployment apps (the headline differentiator vs hobby)") + assert.Equal(t, 50, p.Limits.VaultMaxEntries) + assert.Equal(t, []string{"development", "staging", "production"}, p.Limits.VaultEnvsAllowed, + "hobby_plus is the cheapest tier with multi-env vault support") + // Features — custom_domains is the marquee feature that justifies + // the $10 step up from hobby ($9 → $19). + assert.True(t, p.Features.CustomDomains, + "hobby_plus must enable custom_domains (the W11 headline feature)") + assert.True(t, p.Features.Alerts) + assert.False(t, p.Features.SLA) + assert.False(t, p.Features.Dedicated) +} + // writeTempYAML writes content to a temp file and returns its path. func writeTempYAML(t *testing.T, content string) string { t.Helper() diff --git a/plans/rank.go b/plans/rank.go index 6c7ba41..cb79f2b 100644 --- a/plans/rank.go +++ b/plans/rank.go @@ -22,12 +22,13 @@ import "strings" // Rank returns a totally-ordered integer rank for the given plan tier name. // Higher rank = more capacity. The canonical ordering is: // -// anonymous = 0 -// free = 1 -// hobby = 2 -// growth = 3 -// pro = 4 -// team = 5 +// anonymous = 0 +// free = 1 +// hobby = 2 +// hobby_plus = 3 +// growth = 4 +// pro = 5 +// team = 6 // // Unknown tiers return -1. Callers that compare ranks to classify a // transition (upgrade vs downgrade vs renewal) MUST treat -1 as the @@ -38,6 +39,13 @@ import "strings" // don't need to pre-normalise. The "*_yearly" billing variants are NOT // special-cased here — pass them through CanonicalTier first if you want // "pro_yearly" to rank the same as "pro" (billing.go does exactly this). +// +// hobby_plus (W11, 2026-05-13): the $19/mo mid-step between hobby and pro. +// Sits at rank 3 — strictly above hobby (rank 2) and strictly below growth +// (rank 4). growth/pro/team each shifted up by one rank to keep the +// ordering monotonically increasing; the absolute values changed but the +// relative invariant (every upgrade has rank-strictly-greater than the +// prior tier) is preserved. func Rank(tier string) int { switch strings.ToLower(strings.TrimSpace(tier)) { case "anonymous": @@ -46,12 +54,14 @@ func Rank(tier string) int { return 1 case "hobby": return 2 - case "growth": + case "hobby_plus": return 3 - case "pro": + case "growth": return 4 - case "team": + case "pro": return 5 + case "team": + return 6 } return -1 } diff --git a/plans/rank_test.go b/plans/rank_test.go index 8a4f13b..568fc92 100644 --- a/plans/rank_test.go +++ b/plans/rank_test.go @@ -11,14 +11,20 @@ import ( // TestRank_AllStandardTiers asserts the canonical ordering documented in // rank.go. Lock-in test — changing any of these values is an API break // because callers compare ranks across modules (api/, worker/). +// +// W11 (2026-05-13): hobby_plus inserted between hobby and growth at rank 3. +// growth / pro / team each shifted up by one rank — relative ordering is +// preserved (every previous transition still resolves as "upgrade" when +// comparing the new ranks). func TestRank_AllStandardTiers(t *testing.T) { cases := map[string]int{ - "anonymous": 0, - "free": 1, - "hobby": 2, - "growth": 3, - "pro": 4, - "team": 5, + "anonymous": 0, + "free": 1, + "hobby": 2, + "hobby_plus": 3, + "growth": 4, + "pro": 5, + "team": 6, } for tier, want := range cases { t.Run(tier, func(t *testing.T) { @@ -61,12 +67,13 @@ func TestRank_UnknownReturnsMinusOne(t *testing.T) { } // TestRank_MonotonicallyIncreasing asserts that the canonical chain -// anonymous < free < hobby < growth < pro < team is strictly increasing. -// This is the property callers actually depend on (a.rank < b.rank ⇒ -// a is the lower tier); the absolute values in TestRank_AllStandardTiers -// could in principle be remapped, but the relative ordering can't. +// anonymous < free < hobby < hobby_plus < growth < pro < team is strictly +// increasing. This is the property callers actually depend on +// (a.rank < b.rank ⇒ a is the lower tier); the absolute values in +// TestRank_AllStandardTiers could in principle be remapped, but the +// relative ordering can't. func TestRank_MonotonicallyIncreasing(t *testing.T) { - chain := []string{"anonymous", "free", "hobby", "growth", "pro", "team"} + chain := []string{"anonymous", "free", "hobby", "hobby_plus", "growth", "pro", "team"} for i := 1; i < len(chain); i++ { prev := plans.Rank(chain[i-1]) curr := plans.Rank(chain[i]) @@ -79,8 +86,11 @@ func TestRank_MonotonicallyIncreasing(t *testing.T) { // TestRank_CaseInsensitive covers the documented case-insensitive // behaviour — callers shouldn't need to normalise before calling. func TestRank_CaseInsensitive(t *testing.T) { - assert.Equal(t, 4, plans.Rank("PRO")) - assert.Equal(t, 4, plans.Rank("Pro")) - assert.Equal(t, 4, plans.Rank("pRo")) + assert.Equal(t, 5, plans.Rank("PRO")) + assert.Equal(t, 5, plans.Rank("Pro")) + assert.Equal(t, 5, plans.Rank("pRo")) assert.Equal(t, 2, plans.Rank(" hobby ")) + // hobby_plus (W11): inserted between hobby and growth at rank 3. + assert.Equal(t, 3, plans.Rank("HOBBY_PLUS")) + assert.Equal(t, 3, plans.Rank(" hobby_plus ")) }