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
40 changes: 38 additions & 2 deletions internal/handlers/capabilities.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,19 @@ type tierCapabilities struct {
RPOMinutes int `json:"rpo_minutes"`
RTOMinutes int `json:"rto_minutes"`
AnnualDiscountPercent int `json:"annual_discount_percent"`
UpgradeURL string `json:"upgrade_url"`
// UpgradeURL — pointer so the terminal tier (Team — there is nothing
// to upgrade to) emits an explicit JSON `null` instead of the pricing
// URL. DOG-26 (QA 2026-05-29): every tier including Team used to
// return the pricing page, which would have SDKs / dashboards
// rendering an "Upgrade" CTA on the Team plan with no destination.
// `null` is the contract-stable terminal-tier marker; a non-null
// string is the "click here to upgrade" signal.
UpgradeURL *string `json:"upgrade_url"`
// IsTerminalTier — explicit boolean so clients don't have to encode
// the "is upgrade_url null" check at every render site. True for the
// top tier (Team today), false for everything below. Pairs with
// UpgradeURL — when IsTerminalTier=true, UpgradeURL is null.
IsTerminalTier bool `json:"is_terminal_tier"`
}

// capabilityResourceTypes is the list of service types the /capabilities
Expand Down Expand Up @@ -128,6 +140,20 @@ func (h *CapabilitiesHandler) Get(c *fiber.Ctx) error {
return entries[i].name < entries[j].name
})

// terminalRank — the highest rank in the entries slice. Used to mark
// the top tier as terminal (upgrade_url=null, is_terminal_tier=true).
// Computed after the rank-sort so we don't have to re-walk the slice;
// entries[len-1] has the highest rank by construction.
terminalRank := -1
if len(entries) > 0 {
terminalRank = entries[len(entries)-1].rank
}

// upgradeURLStr — pointer-to-string so we can emit explicit null for
// the terminal tier. Hoisted to a local so we don't take the address
// of a package-level const (Go doesn't allow that).
upgradeURLStr := upgradeURL

out := make([]tierCapabilities, 0, len(entries))
for _, e := range entries {
storage := map[string]int{}
Expand All @@ -137,6 +163,15 @@ func (h *CapabilitiesHandler) Get(c *fiber.Ctx) error {
conns[rt] = h.plans.ConnectionsLimit(e.name, rt)
}
priceUSD := e.plan.PriceMonthly / 100 // cents → dollars
// DOG-26: terminal tier marker — top of the rank ladder has
// nothing to upgrade to. upgrade_url is null + is_terminal_tier
// is true so SDKs/dashboards rendering an "Upgrade" CTA can
// suppress it without string-matching "team".
isTerminal := e.rank == terminalRank
var upgrade *string
if !isTerminal {
upgrade = &upgradeURLStr
}
out = append(out, tierCapabilities{
Tier: e.name,
DisplayName: e.plan.DisplayName,
Expand All @@ -151,7 +186,8 @@ func (h *CapabilitiesHandler) Get(c *fiber.Ctx) error {
RPOMinutes: h.plans.RPOMinutes(e.name),
RTOMinutes: h.plans.RTOMinutes(e.name),
AnnualDiscountPercent: annualDiscountPercent(all, e.name),
UpgradeURL: upgradeURL,
UpgradeURL: upgrade,
IsTerminalTier: isTerminal,
})
}

Expand Down
50 changes: 48 additions & 2 deletions internal/handlers/capabilities_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@ type capabilityTier struct {
BackupRestoreEnabled bool `json:"backup_restore_enabled"`
ManualBackupsPerDay int `json:"manual_backups_per_day"`
AnnualDiscountPercent int `json:"annual_discount_percent"`
UpgradeURL string `json:"upgrade_url"`
// DOG-26: UpgradeURL is *string — terminal tier (Team) emits null.
UpgradeURL *string `json:"upgrade_url"`
IsTerminalTier bool `json:"is_terminal_tier"`
}

// newCapabilitiesApp wires a minimal Fiber app with the /capabilities
Expand Down Expand Up @@ -165,7 +167,17 @@ func TestCapabilities_DerivesPriceFromPlanRegistry(t *testing.T) {
assert.Equal(t, c.wantPriceUSD, got.PriceUSDMonthly, "%s price_usd_monthly", c.tier)
assert.Equal(t, c.wantPaid, got.PaidFromDayOne, "%s paid_from_day_one", c.tier)
assert.Equal(t, c.wantDisplay, got.DisplayName, "%s display_name", c.tier)
assert.Equal(t, "https://instanode.dev/pricing/", got.UpgradeURL, "%s upgrade_url", c.tier)
// DOG-26: terminal tier (Team — top of the rank ladder) emits
// upgrade_url=null + is_terminal_tier=true. Every other tier
// emits the pricing URL.
if c.tier == "team" {
assert.Nil(t, got.UpgradeURL, "%s upgrade_url must be null (terminal tier)", c.tier)
assert.True(t, got.IsTerminalTier, "%s is_terminal_tier must be true", c.tier)
} else {
require.NotNil(t, got.UpgradeURL, "%s upgrade_url must be non-null", c.tier)
assert.Equal(t, "https://instanode.dev/pricing/", *got.UpgradeURL, "%s upgrade_url", c.tier)
assert.False(t, got.IsTerminalTier, "%s is_terminal_tier must be false (non-terminal)", c.tier)
}
}
}

Expand Down Expand Up @@ -237,6 +249,40 @@ func TestCapabilities_SkipsYearlyVariants(t *testing.T) {
}
}

// TestCapabilities_TerminalTierUpgradeURLIsNull pins DOG-26: the top tier
// in the rank ladder (Team today) emits upgrade_url=null + is_terminal_tier=
// true. Every non-terminal tier emits the pricing URL + is_terminal_tier=false.
//
// Registry-iterating per CLAUDE.md rule 18: tomorrow's plans.yaml + rank.go
// addition (e.g. an `enterprise` tier above team) automatically shifts the
// terminal marker — this test reads the live rank ordering rather than
// hardcoding "team" as the terminal name, so adding a new top tier doesn't
// require touching this assertion.
func TestCapabilities_TerminalTierUpgradeURLIsNull(t *testing.T) {
reg := plans.Default()
app := newCapabilitiesApp(t, reg)
_, body := callCapabilities(t, app)
require.NotEmpty(t, body.Tiers, "expected at least one tier")

// Last row in the rank-sorted slice is the terminal tier by
// construction (capabilities.go sorts entries by rank ascending).
terminal := body.Tiers[len(body.Tiers)-1]
assert.True(t, terminal.IsTerminalTier,
"DOG-26: top-of-ladder tier (%q) must have is_terminal_tier=true", terminal.Tier)
assert.Nil(t, terminal.UpgradeURL,
"DOG-26: top-of-ladder tier (%q) must have upgrade_url=null — nothing to upgrade to", terminal.Tier)

// Every other row must be non-terminal with a populated URL.
for _, tr := range body.Tiers[:len(body.Tiers)-1] {
assert.False(t, tr.IsTerminalTier,
"DOG-26: non-terminal tier %q must have is_terminal_tier=false", tr.Tier)
require.NotNil(t, tr.UpgradeURL,
"DOG-26: non-terminal tier %q must have a non-null upgrade_url", tr.Tier)
assert.Equal(t, "https://instanode.dev/pricing/", *tr.UpgradeURL,
"non-terminal tier %q upgrade_url", tr.Tier)
}
}

// TestCapabilities_AnnualDiscountFromYAML — when a {tier}_yearly variant
// exists in the registry, the canonical tier reports a non-zero
// annual_discount_percent computed from (1 - yearly/(monthly*12)).
Expand Down
5 changes: 3 additions & 2 deletions internal/handlers/openapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -3246,9 +3246,10 @@ const openAPISpec = `{
"rpo_minutes": { "type": "integer", "description": "Recovery Point Objective in minutes — the maximum window of data loss a restore can incur. 0 means no backup/RPO guarantee for the tier." },
"rto_minutes": { "type": "integer", "description": "Recovery Time Objective in minutes — the target time to restore service after an incident. 0 means no RTO guarantee for the tier." },
"annual_discount_percent": { "type": "integer", "description": "Discount percent of the {tier}_yearly variant vs 12x the monthly. 0 when no yearly variant exists." },
"upgrade_url": { "type": "string", "format": "uri" }
"upgrade_url": { "type": ["string", "null"], "format": "uri", "description": "Pricing/upgrade URL for non-terminal tiers. null for the terminal tier (Team today) — there is nothing to upgrade to. Pairs with is_terminal_tier; SDKs/dashboards rendering an Upgrade CTA should suppress when null. DOG-26 (QA 2026-05-29)." },
"is_terminal_tier": { "type": "boolean", "description": "True for the top tier in the rank ladder (Team today). When true, upgrade_url is null. Lets clients render an Upgrade CTA conditionally without string-matching tier names. DOG-26." }
},
"required": ["tier", "display_name", "price_usd_monthly", "paid_from_day_one", "storage_limit_mb", "connections_limit", "deployments_apps", "upgrade_url"]
"required": ["tier", "display_name", "price_usd_monthly", "paid_from_day_one", "storage_limit_mb", "connections_limit", "deployments_apps", "upgrade_url", "is_terminal_tier"]
},
"CapabilitiesResponse": {
"type": "object",
Expand Down
Loading