Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 18 additions & 9 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"),
Expand Down
6 changes: 6 additions & 0 deletions internal/handlers/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
28 changes: 25 additions & 3 deletions internal/handlers/billing.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
Expand Down Expand Up @@ -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"
}
Expand Down Expand Up @@ -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
Expand All @@ -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)

Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions internal/handlers/custom_domain.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
59 changes: 54 additions & 5 deletions internal/plans/plans_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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,
Expand All @@ -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` ->
Expand Down
84 changes: 84 additions & 0 deletions plans.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down