Skip to content

Commit 1a7dab9

Browse files
fix(wave-d): repair dead routes, tier-rank inversion, invoice + limit drift (#96)
Wave-D frontend findings from BUGHUNT-REPORT-2026-05-18. D1 (P1-W5-18, L-02): the nav "Get token →" CTA pointed at /get-token, a route that doesn't exist — the SPA catch-all silently redirected the conversion funnel's headline button to /. Repointed at /login, the same route MarketingPage's "Get a token" CTA already uses. D2 (P2-W5-02): the footer "Security" link pointed at /security (no such route → 404 → catch-all to /). Repointed at /docs/public/security.md, the real security doc, matching how ChangelogPage links /docs/public/dpa.md. D3 (P1-W4-10): ChangePlanModal and TierChangeModal each carried a private TIER_RANK with the inverted order growth:4, pro:5 — the admin console showed "DEMOTE" for a pro→growth upgrade. Added one canonical TIER_RANK table (pro:4, growth:5) to src/api/index.ts, aligned with the backend's common/plans/rank.go; both modals now import it. Added a unit test pinning the ladder ordering. D4 (P1-W4-09): listInvoices did a blind cast of the API response. The invoices endpoint emits the Razorpay wire shape {id,amount,currency, status,date,pdf_url} but the Invoice type expected Stripe-style {period_start,period_end,plan,amount_cents}, so every row rendered "Invalid Date"/"$NaN"/blank tier. Added an explicit mapInvoice mapper: amount→amount_cents (direct copy — already smallest-unit, no ×100), date→issued_at. period_start/period_end/plan are not on the wire and stay optional; BillingPage renders only what is present. D5 (P1-W2-06): BillingPage LIMITS had no growth row, so a growth-tier team fell through to LIMITS.hobby and saw red over-quota bars on a plan they were within. Added the growth row with api/plans.yaml values. D6 (P2-W2-17/18): OverviewPage's hardcoded TIER_LIMIT_GB table drifted from plans.yaml, and the storage tile mixed a decimal-MB numerator with a binary-MiB denominator. Dropped TIER_LIMIT_GB in favour of summing the server-supplied per-resource storage_limit_bytes; all storage math now uses one binary base. MetricsPanel's formatBytes relabelled KB/MB/GB → KiB/MiB/GiB to match its /1024 divisors. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5cd8b5f commit 1a7dab9

10 files changed

Lines changed: 248 additions & 89 deletions

File tree

src/api/index.test.ts

Lines changed: 60 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838
createStack,
3939
fetchStackStatus,
4040
registerLogoutHook,
41+
TIER_RANK,
4142
} from './index'
4243
// §10.21: FIXTURE_BILLING / FIXTURE_INVOICES imports retired. The 503
4344
// fallback paths in fetchBilling() and listInvoices() were removed —
@@ -330,16 +331,36 @@ describe('fetchBilling()', () => {
330331

331332
// ─── listInvoices() ──────────────────────────────────────────────────────
332333
describe('listInvoices()', () => {
333-
it('returns the API invoices on a successful response', async () => {
334-
const m = installFetch()
335-
const sample = [
336-
{ id: 'inv_a', period_start: '2026-04-01', period_end: '2026-05-01', plan: 'pro', amount_cents: 4900, currency: 'USD', status: 'paid' },
337-
{ id: 'inv_b', period_start: '2026-03-01', period_end: '2026-04-01', plan: 'pro', amount_cents: 4900, currency: 'USD', status: 'paid' },
334+
// D4 (P1-W4-09): the agent API emits each invoice as the Razorpay wire
335+
// shape { id, amount, currency, status, date, pdf_url } — `amount` is
336+
// already in the smallest currency unit (paise/cents) and there is no
337+
// billing period or plan tier. listInvoices() now maps that into the
338+
// dashboard's normalized Invoice type instead of a blind cast.
339+
it('maps the Razorpay wire shape into the normalized Invoice type', async () => {
340+
const m = installFetch()
341+
const wire = [
342+
{ id: 'inv_a', amount: 4900, currency: 'USD', status: 'paid', date: '2026-05-01T00:00:00Z', pdf_url: 'https://x/a.pdf' },
343+
{ id: 'inv_b', amount: 4900, currency: 'USD', status: 'paid', date: '2026-04-01T00:00:00Z' },
338344
]
339-
m.mockResolvedValueOnce(jsonResponse({ ok: true, invoices: sample }))
345+
m.mockResolvedValueOnce(jsonResponse({ ok: true, invoices: wire }))
340346
const r = await listInvoices()
341347
expect(r.ok).toBe(true)
342-
expect(r.invoices).toEqual(sample)
348+
// `amount` → `amount_cents` is a direct copy (NOT ×100); `date` →
349+
// `issued_at`; period/plan are absent and stay undefined.
350+
expect(r.invoices).toEqual([
351+
{ id: 'inv_a', issued_at: '2026-05-01T00:00:00Z', amount_cents: 4900, currency: 'USD', status: 'paid', pdf_url: 'https://x/a.pdf' },
352+
{ id: 'inv_b', issued_at: '2026-04-01T00:00:00Z', amount_cents: 4900, currency: 'USD', status: 'paid', pdf_url: undefined },
353+
])
354+
})
355+
356+
it('collapses an unknown invoice status to "pending"', async () => {
357+
const m = installFetch()
358+
m.mockResolvedValueOnce(jsonResponse({
359+
ok: true,
360+
invoices: [{ id: 'inv_c', amount: 900, currency: 'INR', status: 'issued', date: '2026-05-10T00:00:00Z' }],
361+
}))
362+
const r = await listInvoices()
363+
expect(r.invoices[0].status).toBe('pending')
343364
})
344365

345366
it('hits GET /api/v1/billing/invoices', async () => {
@@ -378,6 +399,38 @@ describe('listInvoices()', () => {
378399
})
379400
})
380401

402+
// ─── TIER_RANK ordering (D3 / P1-W4-10) ──────────────────────────────────
403+
// Pins the canonical tier ladder. The bug this guards against: an inverted
404+
// copy with growth:4, pro:5 lived independently in ChangePlanModal and
405+
// TierChangeModal, so the admin console showed "DEMOTE" for a pro→growth
406+
// upgrade. growth ($99) must rank strictly ABOVE pro ($49). This table is
407+
// the single source both modals import — kept aligned with the backend's
408+
// common/plans/rank.go.
409+
describe('TIER_RANK — canonical tier ladder (D3)', () => {
410+
it('orders tiers low → high by price', () => {
411+
expect(TIER_RANK).toEqual({
412+
anonymous: 0,
413+
free: 1,
414+
hobby: 2,
415+
hobby_plus: 3,
416+
pro: 4,
417+
growth: 5,
418+
team: 6,
419+
})
420+
})
421+
422+
it('ranks growth strictly above pro (the inversion the bug had backwards)', () => {
423+
expect(TIER_RANK.growth).toBeGreaterThan(TIER_RANK.pro)
424+
})
425+
426+
it('is strictly monotonic across the full ladder', () => {
427+
const ladder = ['anonymous', 'free', 'hobby', 'hobby_plus', 'pro', 'growth', 'team']
428+
for (let i = 1; i < ladder.length; i++) {
429+
expect(TIER_RANK[ladder[i]]).toBeGreaterThan(TIER_RANK[ladder[i - 1]])
430+
}
431+
})
432+
})
433+
381434
// ─── createCheckout() ────────────────────────────────────────────────────
382435
describe('createCheckout()', () => {
383436
it('returns {ok, short_url, subscription_id} on success', async () => {

src/api/index.ts

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1599,14 +1599,52 @@ export async function fetchBilling(): Promise<{ ok: true; plan: string; billing:
15991599
return { ok: true as const, plan: r.tier, billing: mapBillingState(r) }
16001600
}
16011601

1602-
type InvoicesResp = { ok: boolean; invoices?: Invoice[] }
1602+
// Wire shape of one invoice row from GET /api/v1/billing/invoices. Mirrors
1603+
// api/internal/handlers/billing.go::ListInvoicesAPI exactly: a Razorpay
1604+
// invoice has a single `amount` (in the currency's smallest unit — paise
1605+
// for INR) and a single `date`, with no billing period or plan tier.
1606+
type InvoiceWire = {
1607+
id: string
1608+
amount: number
1609+
currency: string
1610+
status: string
1611+
date: string
1612+
pdf_url?: string
1613+
}
1614+
type InvoicesResp = { ok: boolean; invoices?: InvoiceWire[] }
1615+
1616+
// VALID_INVOICE_STATUSES — the three states the dashboard's Invoice type
1617+
// models. Razorpay can also emit 'issued' / 'expired' / 'cancelled'; any
1618+
// status outside this set collapses to 'pending' so the UI renders a
1619+
// neutral pill instead of an unstyled raw string.
1620+
const VALID_INVOICE_STATUSES: ReadonlySet<string> = new Set(['paid', 'pending', 'failed'])
1621+
1622+
// mapInvoice — converts the agent API's wire shape into the dashboard's
1623+
// normalized Invoice type. The previous `r.invoices ?? []` blind cast let
1624+
// the wire's {amount,date} reach a UI expecting {amount_cents,period_*},
1625+
// rendering "Invalid Date" / "$NaN" on every row. `amount` is already in
1626+
// the smallest currency unit (paise/cents) — it maps to `amount_cents`
1627+
// directly, NO ×100. `period_start` / `period_end` / `plan` are not on
1628+
// the wire and stay undefined; the BillingPage renders around them.
1629+
function mapInvoice(w: InvoiceWire): Invoice {
1630+
return {
1631+
id: w.id,
1632+
issued_at: w.date,
1633+
amount_cents: w.amount,
1634+
currency: w.currency,
1635+
status: VALID_INVOICE_STATUSES.has(w.status)
1636+
? (w.status as Invoice['status'])
1637+
: 'pending',
1638+
pdf_url: w.pdf_url,
1639+
}
1640+
}
16031641

16041642
export async function listInvoices(): Promise<{ ok: true; invoices: Invoice[] }> {
16051643
// §10.21: errors propagate. The previous 503 fallback returned three
16061644
// mock "paid" invoices that didn't correspond to any real payment;
16071645
// BillingPage now surfaces the failure honestly.
16081646
const r = await call<InvoicesResp>('/api/v1/billing/invoices')
1609-
return { ok: true, invoices: r.invoices ?? [] }
1647+
return { ok: true, invoices: (r.invoices ?? []).map(mapInvoice) }
16101648
}
16111649

16121650
// PlanFrequency selects between the monthly and yearly Razorpay plan_id at
@@ -1752,6 +1790,26 @@ export async function updatePaymentMethod(): Promise<{ ok: true; short_url: stri
17521790
// the grid's four columns for an unrelated UI reason).
17531791
export type ChangePlanTier = 'hobby' | 'hobby_plus' | 'pro' | 'team' | 'growth'
17541792

1793+
// TIER_RANK — the single canonical totally-ordered rank of plan tiers for
1794+
// the dashboard. Higher rank = more capacity. Anchored to api/plans.yaml
1795+
// pricing (hobby $9 < hobby_plus $19 < pro $49 < growth $99 < team $199)
1796+
// and kept byte-for-byte aligned with the backend's common/plans/rank.go.
1797+
//
1798+
// IMPORTANT: pro sits strictly BELOW growth. An earlier inverted copy
1799+
// (growth:4, pro:5) lived independently in ChangePlanModal and
1800+
// TierChangeModal — the admin console then showed "DEMOTE" for a
1801+
// pro→growth upgrade. Both modals now import this one table so they can't
1802+
// re-diverge. If the tier ladder changes, edit here AND rank.go together.
1803+
export const TIER_RANK: Record<string, number> = {
1804+
anonymous: 0,
1805+
free: 1,
1806+
hobby: 2,
1807+
hobby_plus: 3,
1808+
pro: 4,
1809+
growth: 5,
1810+
team: 6,
1811+
}
1812+
17551813
// changePlan — LIVE. POST /api/v1/billing/change-plan upgrades an *existing*
17561814
// Razorpay subscription to a different plan tier in-place, rather than
17571815
// creating a fresh subscription via the checkout flow. Used by the

src/api/types.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -247,11 +247,31 @@ export interface BillingDetails {
247247
cancel_at_period_end?: boolean
248248
}
249249

250+
// Invoice — a normalized subscription invoice row for the Billing page.
251+
//
252+
// The agent API's GET /api/v1/billing/invoices emits each Razorpay invoice
253+
// as { id, amount, currency, status, date, pdf_url } — a single charge with
254+
// one timestamp, an amount in the currency's smallest unit (paise/cents),
255+
// and NO billing period or plan tier. The dashboard previously expected a
256+
// Stripe-style { period_start, period_end, plan, amount_cents } shape, so
257+
// every row rendered "Invalid Date" / "$NaN" / blank tier.
258+
//
259+
// `listInvoices` in api/index.ts maps the wire shape into this type:
260+
// amount → amount_cents (already smallest-unit; a direct copy, no ×100)
261+
// date → issued_at (the single charge timestamp)
262+
// `period_start` / `period_end` / `plan` are NOT on the wire — they stay
263+
// optional and the BillingPage renders only what is actually present
264+
// rather than fabricating a billing window.
250265
export interface Invoice {
251266
id: string
252-
period_start: string
253-
period_end: string
254-
plan: Tier
267+
/** Single charge timestamp from Razorpay (the wire `date` field). */
268+
issued_at: string
269+
/** Billing window — only present if a future API revision sends it. */
270+
period_start?: string
271+
period_end?: string
272+
/** Plan tier — not emitted by the current invoices endpoint. */
273+
plan?: Tier
274+
/** Amount in the currency's smallest unit (paise for INR, cents for USD). */
255275
amount_cents: number
256276
currency: string
257277
status: 'paid' | 'pending' | 'failed'

src/components/ChangePlanModal.tsx

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -27,22 +27,15 @@
2727

2828
import { useEffect, useRef, useState } from 'react'
2929
import * as api from '../api'
30+
import { TIER_RANK } from '../api'
3031
import type { ChangePlanTier, PlanFrequency, Tier } from '../api'
3132

32-
// Ranks for "is this an upgrade?" math. Source of truth: api/plans.yaml.
33-
// `hobby_plus` sits between hobby and pro at $19/mo (W11 mid-tier insertion,
34-
// 2026-05-13). The api enum on /billing/checkout + /billing/change-plan now
35-
// accepts it — see FIX-A6/R8/R9 — so the dashboard exposes it as a real
36-
// upgrade target alongside the legacy tiers.
37-
const TIER_RANK: Record<string, number> = {
38-
anonymous: 0,
39-
free: 1,
40-
hobby: 2,
41-
hobby_plus: 3,
42-
growth: 4,
43-
pro: 5,
44-
team: 6,
45-
}
33+
// Ranks for "is this an upgrade?" math come from the single canonical
34+
// TIER_RANK table in src/api/index.ts (kept aligned with the backend's
35+
// common/plans/rank.go). `hobby_plus` sits between hobby and pro at $19/mo,
36+
// and growth ($99) sits strictly ABOVE pro ($49) — see the table's comment.
37+
// Importing the shared table keeps this modal from re-diverging into the
38+
// old inverted growth/pro ordering.
4639

4740
// Human-readable label for the modal title + selector. Derived once so
4841
// translations / brand renames live in one place.

src/components/MetricsPanel.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -296,11 +296,15 @@ function StorageTile({ values }: { values: number[] }) {
296296
)
297297
}
298298

299+
// formatBytes — renders a byte count with binary-prefix units. The divisors
300+
// are powers of 1024, so the suffixes must be the binary KiB/MiB/GiB, not
301+
// the decimal-SI KB/MB/GB (D6: the old labels said "MB" for a /1048576
302+
// conversion, which is a MiB).
299303
function formatBytes(n: number): string {
300304
if (n < 1024) return `${n.toFixed(0)} B`
301-
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
302-
if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`
303-
return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`
305+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KiB`
306+
if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MiB`
307+
return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GiB`
304308
}
305309

306310
function fmt(n: number): string {

src/components/TierChangeModal.tsx

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
import { useEffect, useRef, useState } from 'react'
1717
import * as api from '../api'
18+
import { TIER_RANK } from '../api'
1819
import type { AdminSetTierInput, Tier } from '../api/types'
1920

2021
export const ADMIN_TIER_CHOICES: Tier[] = [
@@ -26,20 +27,13 @@ export const ADMIN_TIER_CHOICES: Tier[] = [
2627
'growth',
2728
]
2829

29-
// Ranked low → high so we can compute "is this a promotion or demotion?"
30-
// and pick the correct confirmation word.
31-
const TIER_RANK: Record<Tier, number> = {
32-
anonymous: 0,
33-
free: 1,
34-
hobby: 2,
35-
hobby_plus: 3,
36-
growth: 4,
37-
pro: 5,
38-
team: 6,
39-
}
40-
30+
// Promotion/demotion math reads the single canonical TIER_RANK table from
31+
// src/api/index.ts (kept aligned with the backend's common/plans/rank.go).
32+
// growth ($99) ranks strictly ABOVE pro ($49) — a pro→growth change is a
33+
// PROMOTE. An earlier private copy here had the inverted order, which made
34+
// the admin console show "DEMOTE" for that upgrade.
4135
export function confirmationWord(currentTier: Tier, nextTier: Tier): 'PROMOTE' | 'DEMOTE' {
42-
return TIER_RANK[nextTier] >= TIER_RANK[currentTier] ? 'PROMOTE' : 'DEMOTE'
36+
return (TIER_RANK[nextTier] ?? -1) >= (TIER_RANK[currentTier] ?? -1) ? 'PROMOTE' : 'DEMOTE'
4337
}
4438

4539
interface Props {

src/layout/PublicShell.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ function PublicNav() {
4646
<a href="/login" className="public-nav-link public-nav-link--muted">
4747
Sign in
4848
</a>
49-
<a href="/get-token" className="public-cta-pill">
49+
<a href="/login" className="public-cta-pill">
5050
Get token <span aria-hidden="true"></span>
5151
</a>
5252
</div>
@@ -79,7 +79,7 @@ function PublicFooter() {
7979
<div className="public-footer-h">Legal</div>
8080
<a href="/terms">Terms</a>
8181
<a href="/privacy">Privacy</a>
82-
<a href="/security">Security</a>
82+
<a href="/docs/public/security.md">Security</a>
8383
<a href="mailto:hello@instanode.dev">Contact</a>
8484
</div>
8585
</div>

src/pages/BillingPage.test.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,12 @@ const FIXTURE_BILLING: BillingDetails = {
5757
cancel_at_period_end: false,
5858
}
5959

60+
// D4: post-mapper Invoice shape — `issued_at` is the single Razorpay charge
61+
// date; the invoices endpoint emits no billing period or plan tier.
6062
const FIXTURE_INVOICES: Invoice[] = [
61-
{ id: 'inv_QzN8bD', period_start: '2026-04-22', period_end: '2026-05-22', plan: 'pro', amount_cents: 4900, currency: 'USD', status: 'paid' },
62-
{ id: 'inv_Pp7K2c', period_start: '2026-03-22', period_end: '2026-04-22', plan: 'pro', amount_cents: 4900, currency: 'USD', status: 'paid' },
63-
{ id: 'inv_Lm4F9a', period_start: '2026-02-20', period_end: '2026-03-22', plan: 'hobby', amount_cents: 900, currency: 'USD', status: 'paid' },
63+
{ id: 'inv_QzN8bD', issued_at: '2026-05-22T00:00:00Z', amount_cents: 4900, currency: 'USD', status: 'paid' },
64+
{ id: 'inv_Pp7K2c', issued_at: '2026-04-22T00:00:00Z', amount_cents: 4900, currency: 'USD', status: 'paid' },
65+
{ id: 'inv_Lm4F9a', issued_at: '2026-03-22T00:00:00Z', amount_cents: 900, currency: 'USD', status: 'paid' },
6466
]
6567

6668
// ─── Module-level mocks ──────────────────────────────────────────────────

src/pages/BillingPage.tsx

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,19 @@ const LIMITS: Record<string, { label: string; limits: PlanLimits; nextTier?: Tie
115115
// FIX-K (2026-05-16): 2026-05-15 storage bump. Was: postgres_mb: 5120, redis_mb: 256, mongodb_mb: 2048.
116116
limits: { postgres_mb: 10240, redis_mb: 512, mongodb_mb: 5120, deployments: 10, webhooks: 10000, team_seats: 5 },
117117
},
118+
// D5 (2026-05-18): growth row was missing — a growth-tier team fell
119+
// through to `LIMITS.hobby`, painting red over-quota bars on a plan they
120+
// were well within. Numbers mirror api/plans.yaml growth: postgres 20480
121+
// MB, redis 1024 MB, mongodb / webhooks unlimited (-1 in plans.yaml →
122+
// Infinity here), 5 deployments, 10 team_members.
123+
growth: {
124+
label: 'Growth',
125+
nextTier: 'team',
126+
limits: {
127+
postgres_mb: 20480, redis_mb: 1024, mongodb_mb: Infinity,
128+
deployments: 5, webhooks: Infinity, team_seats: 10,
129+
},
130+
},
118131
team: {
119132
label: 'Team',
120133
limits: {
@@ -549,7 +562,9 @@ export function BillingPage() {
549562
<div className="card" style={{ padding: 0 }}>
550563
<div className="invoice-row head">
551564
<span>id</span>
552-
<span>period</span>
565+
{/* "date" not "period": the Razorpay invoices endpoint emits a
566+
single charge timestamp, not a billing window. */}
567+
<span>date</span>
553568
<span>plan</span>
554569
<span>status</span>
555570
<span>amount</span>
@@ -558,9 +573,15 @@ export function BillingPage() {
558573
<div key={i.id} className="invoice-row">
559574
<span className="id">{i.id}</span>
560575
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11.5, color: 'var(--text-dim)' }}>
561-
{new Date(i.period_start).toLocaleDateString()}{new Date(i.period_end).toLocaleDateString()}
576+
{/* Prefer a real billing window if a future API revision
577+
sends one; otherwise show the single charge date. */}
578+
{i.period_start && i.period_end
579+
? `${new Date(i.period_start).toLocaleDateString()}${new Date(i.period_end).toLocaleDateString()}`
580+
: new Date(i.issued_at).toLocaleDateString()}
562581
</span>
563-
<TierPill tier={i.plan} />
582+
{/* The invoices endpoint doesn't carry a plan tier — render the
583+
pill only when one is actually present, never fabricate. */}
584+
{i.plan ? <TierPill tier={i.plan} /> : <span className="dim"></span>}
564585
{/* Show the real invoice status (paid/pending/failed), not a
565586
hardcoded "running" pill. StatusPill doesn't style these
566587
three, so render a plain mono span. (§10.8.) */}

0 commit comments

Comments
 (0)