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
61 changes: 51 additions & 10 deletions src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -396,26 +396,67 @@ export async function deleteCustomDomain(stackSlug: string, id: string): Promise
)
}

// ─── Billing (fixture — backend has /api/v1/billing/* but the dashboard
// needs a richer shape than today's API exposes) ─────────────────────
// ─── Billing (LIVE for checkout + invoices; partial-fixture for state) ───
//
// fetchBilling — plan tier is REAL (from /api/v1/whoami). Renewal date,
// payment method, billing email all come from fixtures
// because the agent API doesn't yet expose a GET
// /api/v1/billing endpoint that aggregates this. Open
// follow-up: add the endpoint, then drop FIXTURE_BILLING
// here. See `dashboard/AGENT_API_NOTES.md` for the
// expected shape.
//
// listInvoices — LIVE. Calls GET /api/v1/billing/invoices on the agent
// API; falls back to FIXTURE_INVOICES on 503 (billing
// not configured, e.g. local dev without Razorpay keys)
// so the UI stays usable. Returns an empty list when
// the team has no subscription yet.
//
// createCheckout — LIVE. Calls POST /api/v1/billing/checkout, creates a
// real Razorpay subscription, and returns the hosted
// payment short_url. The caller (BillingPage) redirects
// the user to short_url to complete payment. Errors
// propagate as APIError so the page's checkoutErr state
// can surface them inline.

export async function fetchBilling(): Promise<{ ok: true; plan: string; billing: BillingDetails }> {
try {
const me = await fetchMe()
return fake({ ok: true as const, plan: me.user.tier, billing: FIXTURE_BILLING })
return { ok: true as const, plan: me.user.tier, billing: FIXTURE_BILLING }
} catch {
return fake({ ok: true as const, plan: 'hobby', billing: FIXTURE_BILLING })
return { ok: true as const, plan: 'hobby', billing: FIXTURE_BILLING }
}
}

type InvoicesResp = { ok: boolean; invoices?: Invoice[] }

export async function listInvoices(): Promise<{ ok: true; invoices: Invoice[] }> {
return fake({ ok: true as const, invoices: FIXTURE_INVOICES })
try {
const r = await call<InvoicesResp>('/api/v1/billing/invoices')
return { ok: true, invoices: r.invoices ?? [] }
} catch (e: any) {
// 503 = billing_not_configured (no Razorpay keys in this env). Fall
// back to the fixture list so the page renders something usable in
// local dev. Any other error propagates so the UI shows a real
// failure state.
if (e?.status === 503) return { ok: true, invoices: FIXTURE_INVOICES }
throw e
}
}

export async function createCheckout(plan: string): Promise<{ ok: true; short_url: string }> {
// [FIXTURE] backend /api/v1/billing/checkout exists but currently returns
// either a short_url or a 503 when Razorpay is unconfigured. Stubbed for
// now so the UI flow is testable in isolation.
return fake({ ok: true as const, short_url: `https://rzp.io/p/stub-${plan}` })
export async function createCheckout(
plan: string,
): Promise<{ ok: true; short_url: string; subscription_id?: string }> {
const r = await call<{ ok: boolean; short_url: string; subscription_id?: string }>(
'/api/v1/billing/checkout',
{ method: 'POST', body: JSON.stringify({ plan }) },
)
return { ok: true, short_url: r.short_url, subscription_id: r.subscription_id }
}

export async function cancelSubscription(): Promise<{ ok: true }> {
await call<{ ok: boolean }>('/api/v1/billing/cancel', { method: 'POST' })
return { ok: true }
}

// ─── Vault (LIVE — listing keys works, value reveal lives on detail) ────
Expand Down
18 changes: 15 additions & 3 deletions src/pages/BillingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,10 +118,22 @@ export function BillingPage() {
}
}

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

return (
Expand Down
Loading