AUTH4D-46 console billing screen and checkout return page

The console adds a Billing section at /projects/{tenantId}/billing (navigation group “Project”) and the checkout return page at /billing/return. Both live in apps/console/src/features/billing/. Every amount, limit and entitlement shown comes from the API. The console never computes a price. Checkout sends only the plan id and receives a redirect URL.

BFF proxy additions

apps/console/worker/app.ts now also proxies these management routes, with the same session, token and same-origin write checks as the other management routes:

Console request Upstream (AUTH4D ticket) Who can use it in the UI
GET /api/management/tenants/{tenantId}/usage AUTH4D-39 any member
GET /api/management/tenants/{tenantId}/billing/plans AUTH4D-45 any member
GET /api/management/tenants/{tenantId}/billing/subscription AUTH4D-45 any member
GET /api/management/tenants/{tenantId}/billing/invoices AUTH4D-45 owner, admin
GET /api/management/tenants/{tenantId}/billing/payments AUTH4D-45 owner, admin
POST /api/management/tenants/{tenantId}/billing/checkout AUTH4D-40 owner, admin
POST /api/management/tenants/{tenantId}/billing/subscription/cancel AUTH4D-45 owner, admin
  • The Idempotency-Key request header is forwarded only when it matches [A-Za-z0-9_.:-]{16,128}.
  • Retry-After and Idempotent-Replayed response headers are passed back with Content-Type, WWW-Authenticate and X-Request-Id. No other upstream headers are passed back.
  • Other billing paths, including the Stitch webhook, still return 404 from the BFF.

Billing screen

  • Role. The screen reads role from GET /api/management/tenants/{tenantId}. Owners and admins can manage billing. Any other or unknown role gets a read-only view, and the API still authorizes every request. Read-only users see the current plan, usage and the plan catalog as a list. They do not see the picker, checkout, cancel, invoices or payments, and none of those requests is sent.
  • Current plan. This shows GET billing/subscription: plan, status, renewal or end date and entitlements. A past-due subscription and a pending cancellation each show a notice.
  • Usage. GET usage is shown as a captioned table: monthly active users and enabled applications, each with its plan limit and whether it is over that limit. A grace period shows a notice. MAU over the limit is informational only.
  • Plan picker. A fieldset and legend radio group lists the catalog from GET billing/plans with display-only prices (amountMinor formatted for the currency). The free plan and the current plan cannot be selected. If a paid plan is already entitled, the picker is disabled and explains why (the API would answer 409 subscription_exists).
  • Checkout. POST billing/checkout sends { "planId": "indie" | "studio" } and an Idempotency-Key (a random UUID). A retry of the same plan reuses that key, as AUTH4D-40 requires after 502 payment_provider_unavailable. After checkout_closed, idempotency_key_reused or subscription_exists the key is dropped. The browser follows redirectUrl only if it is HTTPS on stitch.money or a subdomain, or loopback HTTP for the local mock, with no credentials. Any other URL is treated as an invalid response.
  • Invoices and payments. These are captioned tables, newest first. “Load more” follows nextCursor (limit=25) and appends rows. A live status line reports how many rows are shown.
  • Cancel. A modal dialog confirms “Cancel at period end” and sends POST billing/subscription/cancel with { "atPeriodEnd": true }. On success the returned state replaces the current plan, a status notice is announced, and focus moves to the “Current plan” heading.
  • Error codes from AUTH4D-40/45 (billing_unavailable, checkout_in_progress, payment_provider_unavailable, no_active_subscription, subscription_changed, insufficient_role, …) get specific messages, shown with their request ID.

Checkout return page

AUTH4D-40 builds the return URL as {CONSOLE_ORIGIN}/billing/return?tenant_id=…&payment_id=….

  • A tenant_id that is not a project id is a “Page not found”. A missing or malformed payment_id shows an explanation with a link to billing. The project must be in the user’s project list, as for every project route.
  • The page is titled “Payment status” and highlights Billing in the navigation.
  • A status query value appended by the provider is used only for the waiting message (for example “you left the payment page”). It never decides the result.
  • The page polls GET billing/payments?limit=10 (up to 2 pages per poll) until the payment is completed, failed, canceled or expired. The first wait is 2 s, growing by 25% per poll up to 8 s. Rate limits and transient errors double the interval. Polling stops after 90 s, which keeps it far below the 120-per-minute management budget. 401, 403, 404, 400 and invalid responses stop it at once.
  • Results: Payment complete (and the new plan from GET billing/subscription), Payment failed (with the failure reason), Payment cancelled, Checkout expired, or Payment still processing after the time-out, which has a “Check again” button. Focus moves to the result heading. Leaving the page aborts polling.

Tests

  • tests/integration/auth4d-46.console-billing.test.ts (Vitest): the API client (checkout body and header, untrusted redirects, paths and cursors, roles), polling (final states, paging, back-off, time-out, permanent errors, abort), return-route parsing, BFF proxying, and static renders of the components (labels, table header scopes, read-only views, escaping).
  • tests/e2e/auth4d-46.console-billing.spec.ts (Playwright, mocked BFF and mocked Stitch page, port 5196): pnpm exec playwright test -c tests/e2e/auth4d-46.playwright.config.ts.

Source: docs/api-spec/auth4d-46.md