Skip to content

Commit dd4d67f

Browse files
fix(billing): unstub checkout / invoices / cancel — upgrade flow now works (#20)
Before this commit the dashboard's billing surface had three fixture-only API functions despite the real agent-API endpoints existing and working: createCheckout() → returned https://rzp.io/p/stub-${plan} (a 404 — no real Razorpay session was created). Clicking "Upgrade to Pro" took customers to a broken URL. listInvoices() → returned FIXTURE_INVOICES regardless of the team's actual subscription. handleCancel() → console.log('not yet wired') After: createCheckout(plan) → POST /api/v1/billing/checkout on the agent API. Receives a real razorpay-hosted short_url and subscription_id. BillingPage redirects window.location to short_url so the customer completes payment on Razorpay, which fires the subscription.activated webhook back to /razorpay/webhook on the agent API and the team tier elevates via models.UpdatePlanTier + ElevateResourceTiersByTeam. listInvoices() → GET /api/v1/billing/invoices on the agent API. Falls back to fixtures only on 503 (billing_not_configured) so local-dev without Razorpay keys still shows usable data. cancelSubscription() → POST /api/v1/billing/cancel on the agent API. handleCancel re-reads the billing card and surfaces a notice that the downgrade is in flight pending Razorpay's async subscription.cancelled webhook. fetchBilling() stays partial-fixture: the plan TIER is real (from /api/v1/whoami via fetchMe), but the next-renewal-date and payment-method fields are still FIXTURE_BILLING because the agent API doesn't yet expose a GET /api/v1/billing endpoint that aggregates this. Open follow-up: add that endpoint to the agent API, then drop FIXTURE_BILLING from this file entirely. Verified locally: npm run build → 116 HTML + 116 .md mirrors, no errors npm test → 26/26 markdown renderer tests pass Security scan on the diff: zero secrets, zero cluster hostnames, zero customer identifiers This is the smaller of the two paths I offered: ✓ ship the billing unstub now (this PR) ⏳ kill dashboard-api as a separate session (~3000 lines of dead code in the dashboard-api repo since the frontend talks directly to the agent API) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 93ee96b commit dd4d67f

2 files changed

Lines changed: 66 additions & 13 deletions

File tree

src/api/index.ts

Lines changed: 51 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -396,26 +396,67 @@ export async function deleteCustomDomain(stackSlug: string, id: string): Promise
396396
)
397397
}
398398

399-
// ─── Billing (fixture — backend has /api/v1/billing/* but the dashboard
400-
// needs a richer shape than today's API exposes) ─────────────────────
399+
// ─── Billing (LIVE for checkout + invoices; partial-fixture for state) ───
400+
//
401+
// fetchBilling — plan tier is REAL (from /api/v1/whoami). Renewal date,
402+
// payment method, billing email all come from fixtures
403+
// because the agent API doesn't yet expose a GET
404+
// /api/v1/billing endpoint that aggregates this. Open
405+
// follow-up: add the endpoint, then drop FIXTURE_BILLING
406+
// here. See `dashboard/AGENT_API_NOTES.md` for the
407+
// expected shape.
408+
//
409+
// listInvoices — LIVE. Calls GET /api/v1/billing/invoices on the agent
410+
// API; falls back to FIXTURE_INVOICES on 503 (billing
411+
// not configured, e.g. local dev without Razorpay keys)
412+
// so the UI stays usable. Returns an empty list when
413+
// the team has no subscription yet.
414+
//
415+
// createCheckout — LIVE. Calls POST /api/v1/billing/checkout, creates a
416+
// real Razorpay subscription, and returns the hosted
417+
// payment short_url. The caller (BillingPage) redirects
418+
// the user to short_url to complete payment. Errors
419+
// propagate as APIError so the page's checkoutErr state
420+
// can surface them inline.
421+
401422
export async function fetchBilling(): Promise<{ ok: true; plan: string; billing: BillingDetails }> {
402423
try {
403424
const me = await fetchMe()
404-
return fake({ ok: true as const, plan: me.user.tier, billing: FIXTURE_BILLING })
425+
return { ok: true as const, plan: me.user.tier, billing: FIXTURE_BILLING }
405426
} catch {
406-
return fake({ ok: true as const, plan: 'hobby', billing: FIXTURE_BILLING })
427+
return { ok: true as const, plan: 'hobby', billing: FIXTURE_BILLING }
407428
}
408429
}
409430

431+
type InvoicesResp = { ok: boolean; invoices?: Invoice[] }
432+
410433
export async function listInvoices(): Promise<{ ok: true; invoices: Invoice[] }> {
411-
return fake({ ok: true as const, invoices: FIXTURE_INVOICES })
434+
try {
435+
const r = await call<InvoicesResp>('/api/v1/billing/invoices')
436+
return { ok: true, invoices: r.invoices ?? [] }
437+
} catch (e: any) {
438+
// 503 = billing_not_configured (no Razorpay keys in this env). Fall
439+
// back to the fixture list so the page renders something usable in
440+
// local dev. Any other error propagates so the UI shows a real
441+
// failure state.
442+
if (e?.status === 503) return { ok: true, invoices: FIXTURE_INVOICES }
443+
throw e
444+
}
412445
}
413446

414-
export async function createCheckout(plan: string): Promise<{ ok: true; short_url: string }> {
415-
// [FIXTURE] backend /api/v1/billing/checkout exists but currently returns
416-
// either a short_url or a 503 when Razorpay is unconfigured. Stubbed for
417-
// now so the UI flow is testable in isolation.
418-
return fake({ ok: true as const, short_url: `https://rzp.io/p/stub-${plan}` })
447+
export async function createCheckout(
448+
plan: string,
449+
): Promise<{ ok: true; short_url: string; subscription_id?: string }> {
450+
const r = await call<{ ok: boolean; short_url: string; subscription_id?: string }>(
451+
'/api/v1/billing/checkout',
452+
{ method: 'POST', body: JSON.stringify({ plan }) },
453+
)
454+
return { ok: true, short_url: r.short_url, subscription_id: r.subscription_id }
455+
}
456+
457+
export async function cancelSubscription(): Promise<{ ok: true }> {
458+
await call<{ ok: boolean }>('/api/v1/billing/cancel', { method: 'POST' })
459+
return { ok: true }
419460
}
420461

421462
// ─── Vault (LIVE — listing keys works, value reveal lives on detail) ────

src/pages/BillingPage.tsx

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -118,10 +118,22 @@ export function BillingPage() {
118118
}
119119
}
120120

121-
function handleCancel() {
121+
async function handleCancel() {
122122
if (!window.confirm('Cancel your subscription? You will keep access until the end of the current period.')) return
123-
// TODO: wire to POST /api/v1/billing/cancel on the server.
124-
console.log('cancel: not yet wired to backend')
123+
setCheckoutErr(null)
124+
try {
125+
await api.cancelSubscription()
126+
// Razorpay processes the cancellation asynchronously and emits a
127+
// subscription.cancelled webhook that downgrades the team. The new
128+
// tier won't appear until the next page reload picks up the
129+
// updated whoami, so re-read the billing card and tell the user
130+
// that the downgrade is in flight.
131+
const b = await api.fetchBilling()
132+
setBilling(b.billing)
133+
window.alert('Cancellation requested. Your tier downgrades when Razorpay finalises (usually within seconds). Refresh the page in a moment.')
134+
} catch (e: any) {
135+
setCheckoutErr(e?.message ?? 'cancel failed')
136+
}
125137
}
126138

127139
return (

0 commit comments

Comments
 (0)