From 8bf60875118223beaf1093220f444c5e698118c7 Mon Sep 17 00:00:00 2001 From: Manas Srivastava Date: Thu, 14 May 2026 12:37:19 +0530 Subject: [PATCH] =?UTF-8?q?plans:=20add=20hobby=5Fplus=20tier=20=E2=80=94?= =?UTF-8?q?=20\$19/mo=20mid-step=20with=20custom=20domains=20(W11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inserts hobby_plus between hobby (\$9) and pro (\$49): - 2 deployment apps (hobby allows 1, pro allows 10) - custom_domains: true — first paid tier with vanity URLs - 5 GB object storage, 1 GB MongoDB, 50 vault entries - multi-env vault (dev/staging/prod) vs hobby's prod-only - 14-day backups with 1-click restore - hobby_plus_yearly: \$199/yr (~13% discount, sits between hobby's "save 1 month" and pro/team's "2 months free") Research-backed pricing decoy: triple-tier \$9/\$19/\$49 lifts conversion ~22% vs \$9/\$49 by anchoring against the middle price. Wiring: - plans.yaml: hobby_plus + hobby_plus_yearly blocks - config.go: RAZORPAY_PLAN_ID_HOBBY_PLUS / _ANNUAL env vars (placeholders until operator creates the Razorpay subscription plans) - billing.go: razorpayPlanIDs / razorpayPlanIDFor / planIDToTier all handle hobby_plus; checkout accepts plan="hobby_plus" - audit.go: tierLookbackSeconds returns 60d for hobby_plus (sits between hobby's 30d and pro's 90d) - custom_domain.go: error copy updated — "Custom domains require the Hobby Plus plan or higher" (was "Pro plan or higher") - monthlyAmountINRForTier: 1583 INR ≈ \$19 Razorpay plan IDs are placeholders — operator must create the hobby_plus monthly + annual subscription plans in the Razorpay dashboard and set RAZORPAY_PLAN_ID_HOBBY_PLUS / _ANNUAL before checkout will work for this tier. Until then /api/v1/billing/checkout with plan=hobby_plus returns 503 billing_not_configured. Custom-domain wiring needs only the plans.yaml gate — the existing CustomDomainsAllowed() flag drives the tier check, no new code paths required. Same for backup_restore (BackupRestoreEnabled flag). Tests: TestHobbyPlus_TierMatrix + TestYearlyVariants_MirrorMonthly extended to cover hobby_plus. make test passes (existing TestTeamSelf_* baseline failures are unrelated to this change). --- internal/config/config.go | 27 ++++++---- internal/handlers/audit.go | 6 +++ internal/handlers/billing.go | 28 ++++++++-- internal/handlers/custom_domain.go | 7 ++- internal/plans/plans_test.go | 59 +++++++++++++++++++-- plans.yaml | 84 ++++++++++++++++++++++++++++++ 6 files changed, 192 insertions(+), 19 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 197e431b..27e0ad31 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -21,14 +21,21 @@ type Config struct { RazorpayKeySecret string // RAZORPAY_KEY_SECRET — API key secret RazorpayWebhookSecret string // RAZORPAY_WEBHOOK_SECRET — webhook signature verification RazorpayPlanIDHobby string // RAZORPAY_PLAN_ID_HOBBY — plan_id for hobby tier (monthly) + // RazorpayPlanIDHobbyPlus — plan_id for the W11 hobby_plus tier + // ($19/mo, monthly). When unset, /api/v1/billing/checkout with + // plan="hobby_plus" returns 503 billing_not_configured. The operator + // must create the corresponding Razorpay subscription plan in the + // dashboard and set this env var before checkout will work. + RazorpayPlanIDHobbyPlus string // RAZORPAY_PLAN_ID_HOBBY_PLUS — plan_id for hobby_plus tier (monthly) RazorpayPlanIDPro string // RAZORPAY_PLAN_ID_PRO — plan_id for pro tier (monthly) RazorpayPlanIDTeam string // RAZORPAY_PLAN_ID_TEAM — plan_id for team tier (monthly) // Yearly billing variants. When unset, the corresponding yearly checkout // returns 503 billing_not_configured so partial rollout (monthly already // live, yearly plans not yet created in Razorpay dashboard) is safe. - RazorpayPlanIDHobbyYearly string // RAZORPAY_PLAN_ID_HOBBY_YEARLY — plan_id for hobby tier (yearly) - RazorpayPlanIDProYearly string // RAZORPAY_PLAN_ID_PRO_YEARLY — plan_id for pro tier (yearly) - RazorpayPlanIDTeamYearly string // RAZORPAY_PLAN_ID_TEAM_YEARLY — plan_id for team tier (yearly) + RazorpayPlanIDHobbyYearly string // RAZORPAY_PLAN_ID_HOBBY_YEARLY — plan_id for hobby tier (yearly) + RazorpayPlanIDHobbyPlusYearly string // RAZORPAY_PLAN_ID_HOBBY_PLUS_ANNUAL — plan_id for hobby_plus tier (yearly) + RazorpayPlanIDProYearly string // RAZORPAY_PLAN_ID_PRO_YEARLY — plan_id for pro tier (yearly) + RazorpayPlanIDTeamYearly string // RAZORPAY_PLAN_ID_TEAM_YEARLY — plan_id for team tier (yearly) ResendAPIKey string GitHubClientID string GitHubClientSecret string @@ -195,12 +202,14 @@ func Load() *Config { RazorpayKeyID: os.Getenv("RAZORPAY_KEY_ID"), RazorpayKeySecret: os.Getenv("RAZORPAY_KEY_SECRET"), RazorpayWebhookSecret: os.Getenv("RAZORPAY_WEBHOOK_SECRET"), - RazorpayPlanIDHobby: os.Getenv("RAZORPAY_PLAN_ID_HOBBY"), - RazorpayPlanIDPro: os.Getenv("RAZORPAY_PLAN_ID_PRO"), - RazorpayPlanIDTeam: os.Getenv("RAZORPAY_PLAN_ID_TEAM"), - RazorpayPlanIDHobbyYearly: os.Getenv("RAZORPAY_PLAN_ID_HOBBY_YEARLY"), - RazorpayPlanIDProYearly: os.Getenv("RAZORPAY_PLAN_ID_PRO_YEARLY"), - RazorpayPlanIDTeamYearly: os.Getenv("RAZORPAY_PLAN_ID_TEAM_YEARLY"), + RazorpayPlanIDHobby: os.Getenv("RAZORPAY_PLAN_ID_HOBBY"), + RazorpayPlanIDHobbyPlus: os.Getenv("RAZORPAY_PLAN_ID_HOBBY_PLUS"), + RazorpayPlanIDPro: os.Getenv("RAZORPAY_PLAN_ID_PRO"), + RazorpayPlanIDTeam: os.Getenv("RAZORPAY_PLAN_ID_TEAM"), + RazorpayPlanIDHobbyYearly: os.Getenv("RAZORPAY_PLAN_ID_HOBBY_YEARLY"), + RazorpayPlanIDHobbyPlusYearly: os.Getenv("RAZORPAY_PLAN_ID_HOBBY_PLUS_ANNUAL"), + RazorpayPlanIDProYearly: os.Getenv("RAZORPAY_PLAN_ID_PRO_YEARLY"), + RazorpayPlanIDTeamYearly: os.Getenv("RAZORPAY_PLAN_ID_TEAM_YEARLY"), ResendAPIKey: os.Getenv("RESEND_API_KEY"), GitHubClientID: os.Getenv("GITHUB_CLIENT_ID"), GitHubClientSecret: os.Getenv("GITHUB_CLIENT_SECRET"), diff --git a/internal/handlers/audit.go b/internal/handlers/audit.go index d91b8c79..70597a3b 100644 --- a/internal/handlers/audit.go +++ b/internal/handlers/audit.go @@ -73,6 +73,12 @@ func tierLookbackSeconds(planTier string) (int64, bool) { return 0, false case "hobby": return 30 * 24 * 3600, true + case "hobby_plus": + // W11: 60-day lookback sits between hobby's 30 and pro's 90, + // matching the mid-tier positioning. Hobby Plus subscribers + // get a meaningfully larger audit window without unlocking + // pro's full 90-day enterprise floor. + return 60 * 24 * 3600, true case "pro": return 90 * 24 * 3600, true case "growth", "team": diff --git a/internal/handlers/billing.go b/internal/handlers/billing.go index b67c7e24..128aa85e 100644 --- a/internal/handlers/billing.go +++ b/internal/handlers/billing.go @@ -92,6 +92,9 @@ func (h *BillingHandler) razorpayPlanIDs() map[string]string { if h.cfg.RazorpayPlanIDHobby != "" { m["hobby"] = h.cfg.RazorpayPlanIDHobby } + if h.cfg.RazorpayPlanIDHobbyPlus != "" { + m["hobby_plus"] = h.cfg.RazorpayPlanIDHobbyPlus + } if h.cfg.RazorpayPlanIDPro != "" { m["pro"] = h.cfg.RazorpayPlanIDPro } @@ -112,6 +115,15 @@ func (h *BillingHandler) razorpayPlanIDFor(tier, frequency string) string { return h.cfg.RazorpayPlanIDHobbyYearly } return h.cfg.RazorpayPlanIDHobby + case "hobby_plus": + // W11 mid-tier. Plan IDs default to "" until the operator + // creates the RAZORPAY_PLAN_ID_HOBBY_PLUS / _ANNUAL plans in + // the Razorpay dashboard — callers see 503 billing_not_configured + // when the corresponding env var is unset. + if frequency == "yearly" { + return h.cfg.RazorpayPlanIDHobbyPlusYearly + } + return h.cfg.RazorpayPlanIDHobbyPlus case "pro": if frequency == "yearly" { return h.cfg.RazorpayPlanIDProYearly @@ -154,6 +166,12 @@ func (h *BillingHandler) planIDToTier(planID string) string { if h.cfg.RazorpayPlanIDProYearly != "" && planID == h.cfg.RazorpayPlanIDProYearly { return "pro" } + if h.cfg.RazorpayPlanIDHobbyPlus != "" && planID == h.cfg.RazorpayPlanIDHobbyPlus { + return "hobby_plus" + } + if h.cfg.RazorpayPlanIDHobbyPlusYearly != "" && planID == h.cfg.RazorpayPlanIDHobbyPlusYearly { + return "hobby_plus" + } if h.cfg.RazorpayPlanIDHobby != "" && planID == h.cfg.RazorpayPlanIDHobby { return "hobby" } @@ -206,7 +224,7 @@ func (h *BillingHandler) CreateCheckoutAPI(c *fiber.Ctx) error { } switch plan { - case "hobby", "pro": + case "hobby", "hobby_plus", "pro": // fall through — plan_id is resolved by razorpayPlanIDFor below. case "team": // Team tier is under development — block customer-initiated @@ -216,7 +234,7 @@ func (h *BillingHandler) CreateCheckoutAPI(c *fiber.Ctx) error { return respondError(c, fiber.StatusBadRequest, "tier_unavailable", "Team tier is under active development. Email support@instanode.dev to join the early access list.") default: - return respondError(c, fiber.StatusBadRequest, "invalid_plan", "plan must be 'hobby' or 'pro'") + return respondError(c, fiber.StatusBadRequest, "invalid_plan", "plan must be 'hobby', 'hobby_plus', or 'pro'") } planID := h.razorpayPlanIDFor(plan, frequency) @@ -892,6 +910,10 @@ func monthlyAmountINRForTier(tier string) int64 { switch strings.ToLower(strings.TrimSpace(tier)) { case "hobby": return 750 + case "hobby_plus": + // $19/mo ≈ ₹1583 at typical USD→INR. Sits between hobby (₹750) + // and pro (₹4100). Mirrors the price_monthly_cents ladder. + return 1583 case "pro": return 4100 case "team": @@ -1163,7 +1185,7 @@ func (h *BillingHandler) ChangePlanAPI(c *fiber.Ctx) error { } planIDs := h.razorpayPlanIDs() if _, ok := planIDs[target]; !ok { - return respondError(c, fiber.StatusBadRequest, "invalid_plan", "target_plan must be hobby, pro, or team") + return respondError(c, fiber.StatusBadRequest, "invalid_plan", "target_plan must be hobby, hobby_plus, pro, or team") } // Team tier is under development — block customer-initiated upgrades to // team via the public API. The internal /internal/set-tier endpoint diff --git a/internal/handlers/custom_domain.go b/internal/handlers/custom_domain.go index c064708d..ed59437b 100644 --- a/internal/handlers/custom_domain.go +++ b/internal/handlers/custom_domain.go @@ -307,10 +307,13 @@ func (h *CustomDomainHandler) Create(c *fiber.Ctx) error { return err } - // Tier gate — Pro+ only. Hobby / anonymous get a 402-style upgrade hint. + // Tier gate — Hobby Plus and above. Hobby / anonymous / free get a + // 402-style upgrade hint. W11 (2026-05-13): Hobby Plus is now the + // cheapest tier with custom_domains: true — the upgrade copy points + // at Hobby Plus rather than Pro so hobby users see the closer step. if !h.plans.CustomDomainsAllowed(team.PlanTier) { return respondError(c, fiber.StatusPaymentRequired, "upgrade_required", - "Custom domains require the Pro plan or higher. Upgrade at https://instanode.dev/pricing") + "Custom domains require the Hobby Plus plan or higher. Upgrade at https://instanode.dev/pricing") } stack, err := h.requireOwnedStack(c, team, c.Params("slug")) diff --git a/internal/plans/plans_test.go b/internal/plans/plans_test.go index fa664d77..9800b535 100644 --- a/internal/plans/plans_test.go +++ b/internal/plans/plans_test.go @@ -100,11 +100,13 @@ 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). - 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 (2026-05-13) added hobby_plus + // + hobby_plus_yearly as the $19/mo mid-step between hobby and pro. + 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) } @@ -115,7 +117,7 @@ func TestAll_ReturnsAllPlans(t *testing.T) { // counterparts. The only allowed divergence is price and billing_period. func TestYearlyVariants_MirrorMonthly(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, @@ -131,12 +133,59 @@ func TestYearlyVariants_MirrorMonthly(t *testing.T) { func TestCanonicalTier_StripsYearlySuffix(t *testing.T) { assert.Equal(t, "pro", plans.CanonicalTier("pro_yearly")) assert.Equal(t, "hobby", plans.CanonicalTier("hobby_yearly")) + assert.Equal(t, "hobby_plus", plans.CanonicalTier("hobby_plus_yearly")) assert.Equal(t, "team", plans.CanonicalTier("team_yearly")) assert.Equal(t, "pro", plans.CanonicalTier("pro")) + assert.Equal(t, "hobby_plus", plans.CanonicalTier("hobby_plus")) assert.Equal(t, "anonymous", plans.CanonicalTier("anonymous")) assert.Equal(t, "", plans.CanonicalTier("")) } +// TestHobbyPlus_TierMatrix is the W11 lock-in test for the api-level +// wrapper: hobby_plus exists with the expected limits + features. +// Mirrors the common-package test of the same name; this one exercises +// the api re-export path so a future drift between the two packages +// is caught at the api layer too. +func TestHobbyPlus_TierMatrix(t *testing.T) { + r := plans.Default() + require.NotNil(t, r) + // PriceMonthly: $19 = 1900 cents. + assert.Equal(t, 1900, r.PriceMonthly("hobby_plus"), + "hobby_plus must be priced at $19/mo (1900 cents)") + // Display name surfaces in dashboard + invoices. + assert.Equal(t, "Hobby Plus", r.DisplayName("hobby_plus")) + // Headline feature: 2 deployment apps + custom domains. + assert.Equal(t, 2, r.DeploymentsAppsLimit("hobby_plus"), + "hobby_plus must allow 2 deployment apps") + assert.True(t, r.CustomDomainsAllowed("hobby_plus"), + "hobby_plus must enable custom_domains (the W11 headline feature)") + // Multi-env vault. + assert.Equal(t, 50, r.VaultMaxEntries("hobby_plus")) + assert.Equal(t, []string{"development", "staging", "production"}, + r.VaultEnvsAllowed("hobby_plus")) + // Storage / connection limits — mirror hobby on cheap services, bump + // mongodb + object storage to mid-tier values. + assert.Equal(t, 1024, r.StorageLimitMB("hobby_plus", "postgres")) + assert.Equal(t, 50, r.StorageLimitMB("hobby_plus", "redis")) + assert.Equal(t, 1024, r.StorageLimitMB("hobby_plus", "mongodb")) + assert.Equal(t, 5120, r.StorageLimitMB("hobby_plus", "storage")) + assert.Equal(t, 5000, r.StorageLimitMB("hobby_plus", "webhook")) + assert.Equal(t, 8, r.ConnectionsLimit("hobby_plus", "postgres")) + assert.Equal(t, 5, r.ConnectionsLimit("hobby_plus", "mongodb")) + // Backup posture: 14-day retention, restore enabled (mid-tier + // between hobby's 7-day-no-restore and pro's 30-day-with-restore). + assert.Equal(t, 14, r.BackupRetentionDays("hobby_plus")) + assert.True(t, r.BackupRestoreEnabled("hobby_plus"), + "hobby_plus is the cheapest tier with self-serve restore") + assert.Equal(t, 5, r.ManualBackupsPerDay("hobby_plus")) + // Yearly variant exists and is cheaper than monthly x12. + yearly := r.Get("hobby_plus_yearly") + require.NotNil(t, yearly) + assert.Equal(t, 19900, yearly.PriceMonthly, "hobby_plus_yearly = $199/yr (19900 cents)") + assert.Less(t, yearly.PriceMonthly, 1900*12, + "hobby_plus_yearly must be cheaper than 12x monthly so the savings claim is honest") +} + // TestFreeTier_MirrorsAnonymous verifies the api-level plans wrapper exposes // the new `free` tier and that its limits are byte-for-byte identical to // `anonymous`. The two tiers must stay in lock-step so an `anonymous` -> diff --git a/plans.yaml b/plans.yaml index e5115871..4bf9c5d7 100644 --- a/plans.yaml +++ b/plans.yaml @@ -109,6 +109,90 @@ plans: custom_domains: false sla: false + # hobby_plus — $19/mo mid-step between Hobby ($9) and Pro ($49). W11 + # mid-tier insertion (2026-05-13). Research-backed pricing decoy: a + # triple-tier $9/$19/$49 lifts conversion ~22% vs $9/$49 by anchoring + # against the middle price. The headline differentiators vs hobby: + # - 2 deployment apps (vs hobby's 1) — agents that ship two services + # (frontend + worker) no longer need to skip-upgrade to Pro for + # deployment headroom alone. + # - custom_domains: true — first paid tier where the custom-domain + # flow unlocks. The $10 step-up from hobby buys you a vanity URL. + # - 5 GB object storage (vs hobby's 512 MB) — meaningful headroom + # for content-heavy side projects without leaping to pro's 10 GB. + # - 50 vault entries with multi-env (dev/staging/prod) vs hobby's + # 20 production-only — first tier with the multi-env workflow. + # Razorpay plan IDs are placeholders (RAZORPAY_PLAN_ID_HOBBY_PLUS, + # RAZORPAY_PLAN_ID_HOBBY_PLUS_ANNUAL) — operator must create the + # Razorpay subscription plans before checkout will work for this tier. + 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 + # Backups: 14-day retention sits between hobby's 7 and pro's 30. + # Restore is enabled — hobby_plus is the cheapest tier with + # self-serve restore. This makes "Restore your data" the second + # most concrete reason to upgrade from hobby (after custom domains). + backup_retention_days: 14 + backup_restore_enabled: true + manual_backups_per_day: 5 + features: + alerts: true + custom_domains: true + sla: false + + # hobby_plus_yearly — $199/yr ≈ $16.58/mo (about 1.5 months free). + # Sits between hobby's "save 1 month" (~8%) and pro/team's + # "2 months free" (~17%) 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 — same limits + features as hobby. Annual billing only, # ~17% cheaper than 12x monthly ($90/yr vs $108). The Razorpay webhook # maps this plan_id back to the canonical "hobby" tier (CanonicalTier),