Skip to content

W11: add hobby_plus tier ($19/mo) to api#92

Merged
mastermanas805 merged 1 commit into
masterfrom
feat/w11-hobby-plus-tier-api-fresh
May 14, 2026
Merged

W11: add hobby_plus tier ($19/mo) to api#92
mastermanas805 merged 1 commit into
masterfrom
feat/w11-hobby-plus-tier-api-fresh

Conversation

@mastermanas805
Copy link
Copy Markdown
Member

Summary

Adds the hobby_plus tier ($19/mo) to the api — a research-backed pricing decoy that sits between Hobby ($9) and Pro ($49). Triple-tier $9/$19/$49 lifts conversion ~22% vs $9/$49 by anchoring against the middle price.

  • plans.yaml: new hobby_plus + hobby_plus_yearly blocks with all limits
  • config.go: RAZORPAY_PLAN_ID_HOBBY_PLUS + RAZORPAY_PLAN_ID_HOBBY_PLUS_ANNUAL env vars
  • billing.go: razorpayPlanIDs / razorpayPlanIDFor / planIDToTier 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")
  • TestHobbyPlus_TierMatrix in internal/plans locks the api-wrapper helpers

Depends on InstaNode-dev/common#11 for the shared registry.

Pricing — Hobby Plus

Monthly Yearly Discount
Hobby $9 $99 save 1 month (~8%)
Hobby Plus $19 $199 ~13% (1.5 months free)
Pro $49 $490 2 months free (~17%)
Team $199 $1990 2 months free (~17%)

The discount ladder is deliberately graduated — hobbyists upgrading get a small annual nudge, mid-tier customers get a moderate one, and pro/team get the marquee "2 months free" anchor.

Operator follow-up — REQUIRED before checkout works

The api ships with placeholder env-var slots (default ""). Until the operator creates the Razorpay subscription plans and sets both env vars, /api/v1/billing/checkout with plan=hobby_plus returns 503 billing_not_configured.

  1. Create hobby_plus monthly subscription plan in Razorpay dashboard ($19/mo)
    • Set RAZORPAY_PLAN_ID_HOBBY_PLUS in infra/k8s/secrets.yaml
  2. Create hobby_plus yearly plan ($199/yr)
    • Set RAZORPAY_PLAN_ID_HOBBY_PLUS_ANNUAL
  3. kubectl apply the updated secrets + rollout restart instant-api

Custom-domain wiring follow-up — none required

Hobby Plus enables custom domains via the existing features.custom_domains flag in plans.yaml. The CustomDomainsAllowed() helper reads that flag, and CustomDomainHandler.Create gates on h.plans.CustomDomainsAllowed(team.PlanTier) — no new code paths required. Same applies to backup restore (driven by BackupRestoreEnabled flag).

The only line of code added beyond pure data: updated the 402 error message from "Custom domains require the Pro plan or higher" to "Custom domains require the Hobby Plus plan or higher" so hobby users see the closer upgrade step.

Test plan

  • go test ./... passes (the 2 baseline failures TestTeamSelf_* are pre-existing, unrelated to this change)
  • TestHobbyPlus_TierMatrix asserts price + every documented field via the api wrapper
  • TestYearlyVariants_MirrorMonthly now covers hobby_plus_yearly
  • TestCanonicalTier handles hobby_plus_yearly → hobby_plus

Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com

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).
@mastermanas805 mastermanas805 merged commit 0faba54 into master May 14, 2026
mastermanas805 added a commit that referenced this pull request May 14, 2026
…ent-Replay (P1 regressions) (#93)

* api: W11 — scrub internal_url for anonymous-tier provision responses

Anonymous /db/new, /cache/new, /nosql/new, /queue/new, /vector/new
responses leaked the cluster-internal proxy FQDN
(instant-pg-proxy.instant.svc.cluster.local, redis-proxy, mongo-proxy,
nats-proxy) to any unauthenticated curl. P1 (anon AI agent persona)
flagged this as infra topology disclosure with zero utility for the
caller — anon resources can't run /deploy/new workloads against the
proxy because POST /deploy/new requires a claimed team.

This change introduces setInternalURL(resp, tier, connectionURL, kind)
in internal_url.go as the single chokepoint for the omit-on-anon rule.
~12 handler callsites switch from inline `"internal_url":` map literals
to setInternalURL calls (or get the field stripped entirely on
known-anonymous response paths). Twin response paths (db.go, cache.go,
nosql.go) keep an inline defensive guard against tier=="anonymous"
even though twin requires an authenticated team in practice.

Paid tiers (hobby, hobby_plus, pro, growth, team) still receive
internal_url unchanged — Pro users running compute alongside their DB
legitimately need it (DOKS doesn't hairpin public LB traffic).

Tests:
- internal/handlers/internal_url_test.go::TestSetInternalURL —
  anonymous omits, hobby/pro/team/growth emit, empty URL omits on
  all tiers. Plus TestSetInternalURL_ReturnsSameMap for the chaining
  contract.
- e2e/w11_anon_internal_url_e2e_test.go — POST /cache/new from
  anonymous IP returns body with NO internal_url field, while
  connection_url stays populated.

Pre-existing failures NOT caused by this change (confirmed against
origin/master): TestTeamSelf_Get_ReturnsTeamRow,
TestTeamSelf_Patch_RenamesTeam (Scan column-count mismatch),
TestAll_ReturnsAllPlans / TestHobbyPlus_TierMatrix /
TestYearlyVariants_MirrorMonthly (plans.yaml drift from hobby_plus
addition in #92).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* api: W11 — lock X-Idempotent-Replay precedence vs fingerprint dedup

P1 reported `Idempotency-Key` appeared to be silently shadowed by
fingerprint dedup: same-key + same-body returned the fingerprint's
existing token AND no `X-Idempotent-Replay: true` header surfaced.

Investigation showed the middleware wiring in internal/router/router.go
already places Idempotency BEFORE the handler in the per-route chain
(OptionalAuth → RequireWritable → Idempotency → handler), so a cache
hit DOES short-circuit before the handler's fingerprint-dedup branch
runs, and idempotency.go:164 DOES set the replay header on the cached
path. The contract was already structurally correct — what was missing
was explicit black-box coverage that pins the precedence at the HTTP
boundary so a future per-route misconfig (e.g. middleware accidentally
moved AFTER the handler) fails loudly in CI rather than re-introducing
the silent regression.

This change:

1. Expands the idempotency.go header doc-block with an explicit
   precedence section covering the three branches:
   - key + cached     ⇒ replay cached token, X-Idempotent-Replay: true
   - key + no cache   ⇒ handler runs; response cached for next call
   - no key           ⇒ handler's fingerprint dedup; header NEVER set

2. Adds e2e/w11_idempotency_e2e_test.go with three black-box tests:
   - TestE2E_W11_Idempotency_ReplaysWithHeader: two calls same key +
     body return same token, second response carries the header.
   - TestE2E_W11_Idempotency_DifferentBody_Returns409: same key +
     different body → 409 idempotency_key_conflict.
   - TestE2E_W11_FingerprintDedup_NoIdempotencyKey_StillWorks:
     fingerprint dedup still functions when no key is sent, and the
     replay header is correctly absent on that path.

No middleware code-path changes — the existing unit tests in
internal/middleware/idempotency_test.go (TestIdempotency_ReplaySameBody_
CachedResponse, TestIdempotency_ReplayDifferentBody_Returns409, etc.)
continue to pin the same contracts at the unit layer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
mastermanas805 added a commit that referenced this pull request May 21, 2026
Anonymous /db/new, /cache/new, /nosql/new, /queue/new, /vector/new
responses leaked the cluster-internal proxy FQDN
(instant-pg-proxy.instant.svc.cluster.local, redis-proxy, mongo-proxy,
nats-proxy) to any unauthenticated curl. P1 (anon AI agent persona)
flagged this as infra topology disclosure with zero utility for the
caller — anon resources can't run /deploy/new workloads against the
proxy because POST /deploy/new requires a claimed team.

This change introduces setInternalURL(resp, tier, connectionURL, kind)
in internal_url.go as the single chokepoint for the omit-on-anon rule.
~12 handler callsites switch from inline `"internal_url":` map literals
to setInternalURL calls (or get the field stripped entirely on
known-anonymous response paths). Twin response paths (db.go, cache.go,
nosql.go) keep an inline defensive guard against tier=="anonymous"
even though twin requires an authenticated team in practice.

Paid tiers (hobby, hobby_plus, pro, growth, team) still receive
internal_url unchanged — Pro users running compute alongside their DB
legitimately need it (DOKS doesn't hairpin public LB traffic).

Tests:
- internal/handlers/internal_url_test.go::TestSetInternalURL —
  anonymous omits, hobby/pro/team/growth emit, empty URL omits on
  all tiers. Plus TestSetInternalURL_ReturnsSameMap for the chaining
  contract.
- e2e/w11_anon_internal_url_e2e_test.go — POST /cache/new from
  anonymous IP returns body with NO internal_url field, while
  connection_url stays populated.

Pre-existing failures NOT caused by this change (confirmed against
origin/master): TestTeamSelf_Get_ReturnsTeamRow,
TestTeamSelf_Patch_RenamesTeam (Scan column-count mismatch),
TestAll_ReturnsAllPlans / TestHobbyPlus_TierMatrix /
TestYearlyVariants_MirrorMonthly (plans.yaml drift from hobby_plus
addition in #92).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant