Skip to content

Commit 4759b67

Browse files
Merge pull request #185 from InstaNode-dev/fix/api-capabilities-terminal-tier-upgrade-url-2026-05-30
fix(api): /capabilities terminal-tier marker — null upgrade_url + is_terminal_tier (DOG-26)
2 parents 6d308c0 + 582e9a2 commit 4759b67

3 files changed

Lines changed: 89 additions & 6 deletions

File tree

internal/handlers/capabilities.go

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,19 @@ type tierCapabilities struct {
4949
RPOMinutes int `json:"rpo_minutes"`
5050
RTOMinutes int `json:"rto_minutes"`
5151
AnnualDiscountPercent int `json:"annual_discount_percent"`
52-
UpgradeURL string `json:"upgrade_url"`
52+
// UpgradeURL — pointer so the terminal tier (Team — there is nothing
53+
// to upgrade to) emits an explicit JSON `null` instead of the pricing
54+
// URL. DOG-26 (QA 2026-05-29): every tier including Team used to
55+
// return the pricing page, which would have SDKs / dashboards
56+
// rendering an "Upgrade" CTA on the Team plan with no destination.
57+
// `null` is the contract-stable terminal-tier marker; a non-null
58+
// string is the "click here to upgrade" signal.
59+
UpgradeURL *string `json:"upgrade_url"`
60+
// IsTerminalTier — explicit boolean so clients don't have to encode
61+
// the "is upgrade_url null" check at every render site. True for the
62+
// top tier (Team today), false for everything below. Pairs with
63+
// UpgradeURL — when IsTerminalTier=true, UpgradeURL is null.
64+
IsTerminalTier bool `json:"is_terminal_tier"`
5365
}
5466

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

143+
// terminalRank — the highest rank in the entries slice. Used to mark
144+
// the top tier as terminal (upgrade_url=null, is_terminal_tier=true).
145+
// Computed after the rank-sort so we don't have to re-walk the slice;
146+
// entries[len-1] has the highest rank by construction.
147+
terminalRank := -1
148+
if len(entries) > 0 {
149+
terminalRank = entries[len(entries)-1].rank
150+
}
151+
152+
// upgradeURLStr — pointer-to-string so we can emit explicit null for
153+
// the terminal tier. Hoisted to a local so we don't take the address
154+
// of a package-level const (Go doesn't allow that).
155+
upgradeURLStr := upgradeURL
156+
131157
out := make([]tierCapabilities, 0, len(entries))
132158
for _, e := range entries {
133159
storage := map[string]int{}
@@ -137,6 +163,15 @@ func (h *CapabilitiesHandler) Get(c *fiber.Ctx) error {
137163
conns[rt] = h.plans.ConnectionsLimit(e.name, rt)
138164
}
139165
priceUSD := e.plan.PriceMonthly / 100 // cents → dollars
166+
// DOG-26: terminal tier marker — top of the rank ladder has
167+
// nothing to upgrade to. upgrade_url is null + is_terminal_tier
168+
// is true so SDKs/dashboards rendering an "Upgrade" CTA can
169+
// suppress it without string-matching "team".
170+
isTerminal := e.rank == terminalRank
171+
var upgrade *string
172+
if !isTerminal {
173+
upgrade = &upgradeURLStr
174+
}
140175
out = append(out, tierCapabilities{
141176
Tier: e.name,
142177
DisplayName: e.plan.DisplayName,
@@ -151,7 +186,8 @@ func (h *CapabilitiesHandler) Get(c *fiber.Ctx) error {
151186
RPOMinutes: h.plans.RPOMinutes(e.name),
152187
RTOMinutes: h.plans.RTOMinutes(e.name),
153188
AnnualDiscountPercent: annualDiscountPercent(all, e.name),
154-
UpgradeURL: upgradeURL,
189+
UpgradeURL: upgrade,
190+
IsTerminalTier: isTerminal,
155191
})
156192
}
157193

internal/handlers/capabilities_test.go

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,9 @@ type capabilityTier struct {
3939
BackupRestoreEnabled bool `json:"backup_restore_enabled"`
4040
ManualBackupsPerDay int `json:"manual_backups_per_day"`
4141
AnnualDiscountPercent int `json:"annual_discount_percent"`
42-
UpgradeURL string `json:"upgrade_url"`
42+
// DOG-26: UpgradeURL is *string — terminal tier (Team) emits null.
43+
UpgradeURL *string `json:"upgrade_url"`
44+
IsTerminalTier bool `json:"is_terminal_tier"`
4345
}
4446

4547
// newCapabilitiesApp wires a minimal Fiber app with the /capabilities
@@ -165,7 +167,17 @@ func TestCapabilities_DerivesPriceFromPlanRegistry(t *testing.T) {
165167
assert.Equal(t, c.wantPriceUSD, got.PriceUSDMonthly, "%s price_usd_monthly", c.tier)
166168
assert.Equal(t, c.wantPaid, got.PaidFromDayOne, "%s paid_from_day_one", c.tier)
167169
assert.Equal(t, c.wantDisplay, got.DisplayName, "%s display_name", c.tier)
168-
assert.Equal(t, "https://instanode.dev/pricing/", got.UpgradeURL, "%s upgrade_url", c.tier)
170+
// DOG-26: terminal tier (Team — top of the rank ladder) emits
171+
// upgrade_url=null + is_terminal_tier=true. Every other tier
172+
// emits the pricing URL.
173+
if c.tier == "team" {
174+
assert.Nil(t, got.UpgradeURL, "%s upgrade_url must be null (terminal tier)", c.tier)
175+
assert.True(t, got.IsTerminalTier, "%s is_terminal_tier must be true", c.tier)
176+
} else {
177+
require.NotNil(t, got.UpgradeURL, "%s upgrade_url must be non-null", c.tier)
178+
assert.Equal(t, "https://instanode.dev/pricing/", *got.UpgradeURL, "%s upgrade_url", c.tier)
179+
assert.False(t, got.IsTerminalTier, "%s is_terminal_tier must be false (non-terminal)", c.tier)
180+
}
169181
}
170182
}
171183

@@ -237,6 +249,40 @@ func TestCapabilities_SkipsYearlyVariants(t *testing.T) {
237249
}
238250
}
239251

252+
// TestCapabilities_TerminalTierUpgradeURLIsNull pins DOG-26: the top tier
253+
// in the rank ladder (Team today) emits upgrade_url=null + is_terminal_tier=
254+
// true. Every non-terminal tier emits the pricing URL + is_terminal_tier=false.
255+
//
256+
// Registry-iterating per CLAUDE.md rule 18: tomorrow's plans.yaml + rank.go
257+
// addition (e.g. an `enterprise` tier above team) automatically shifts the
258+
// terminal marker — this test reads the live rank ordering rather than
259+
// hardcoding "team" as the terminal name, so adding a new top tier doesn't
260+
// require touching this assertion.
261+
func TestCapabilities_TerminalTierUpgradeURLIsNull(t *testing.T) {
262+
reg := plans.Default()
263+
app := newCapabilitiesApp(t, reg)
264+
_, body := callCapabilities(t, app)
265+
require.NotEmpty(t, body.Tiers, "expected at least one tier")
266+
267+
// Last row in the rank-sorted slice is the terminal tier by
268+
// construction (capabilities.go sorts entries by rank ascending).
269+
terminal := body.Tiers[len(body.Tiers)-1]
270+
assert.True(t, terminal.IsTerminalTier,
271+
"DOG-26: top-of-ladder tier (%q) must have is_terminal_tier=true", terminal.Tier)
272+
assert.Nil(t, terminal.UpgradeURL,
273+
"DOG-26: top-of-ladder tier (%q) must have upgrade_url=null — nothing to upgrade to", terminal.Tier)
274+
275+
// Every other row must be non-terminal with a populated URL.
276+
for _, tr := range body.Tiers[:len(body.Tiers)-1] {
277+
assert.False(t, tr.IsTerminalTier,
278+
"DOG-26: non-terminal tier %q must have is_terminal_tier=false", tr.Tier)
279+
require.NotNil(t, tr.UpgradeURL,
280+
"DOG-26: non-terminal tier %q must have a non-null upgrade_url", tr.Tier)
281+
assert.Equal(t, "https://instanode.dev/pricing/", *tr.UpgradeURL,
282+
"non-terminal tier %q upgrade_url", tr.Tier)
283+
}
284+
}
285+
240286
// TestCapabilities_AnnualDiscountFromYAML — when a {tier}_yearly variant
241287
// exists in the registry, the canonical tier reports a non-zero
242288
// annual_discount_percent computed from (1 - yearly/(monthly*12)).

internal/handlers/openapi.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3246,9 +3246,10 @@ const openAPISpec = `{
32463246
"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." },
32473247
"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." },
32483248
"annual_discount_percent": { "type": "integer", "description": "Discount percent of the {tier}_yearly variant vs 12x the monthly. 0 when no yearly variant exists." },
3249-
"upgrade_url": { "type": "string", "format": "uri" }
3249+
"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)." },
3250+
"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." }
32503251
},
3251-
"required": ["tier", "display_name", "price_usd_monthly", "paid_from_day_one", "storage_limit_mb", "connections_limit", "deployments_apps", "upgrade_url"]
3252+
"required": ["tier", "display_name", "price_usd_monthly", "paid_from_day_one", "storage_limit_mb", "connections_limit", "deployments_apps", "upgrade_url", "is_terminal_tier"]
32523253
},
32533254
"CapabilitiesResponse": {
32543255
"type": "object",

0 commit comments

Comments
 (0)