diff --git a/apps/web/app/[workspaceSlug]/(dashboard)/billing/page.tsx b/apps/web/app/[workspaceSlug]/(dashboard)/billing/page.tsx
new file mode 100644
index 000000000..6978bd042
--- /dev/null
+++ b/apps/web/app/[workspaceSlug]/(dashboard)/billing/page.tsx
@@ -0,0 +1,10 @@
+import { BillingTestPage } from "@multica/views/billing";
+
+// Account-level test page for the cloud-billing API surface. Despite
+// living under [workspaceSlug] — that's where the dashboard layout
+// requires every page to sit — none of the data here is workspace-
+// scoped. The slug just keeps the route inside the authenticated
+// shell.
+export default function BillingRoute() {
+ return ;
+}
diff --git a/packages/core/api/client.ts b/packages/core/api/client.ts
index f76bd82b5..f02daeb12 100644
--- a/packages/core/api/client.ts
+++ b/packages/core/api/client.ts
@@ -102,6 +102,15 @@ import type {
Squad,
SquadMember,
SquadMemberStatusListResponse,
+ BillingBalance,
+ BillingTransactionsPage,
+ BillingBatchesPage,
+ BillingTopupsPage,
+ BillingPriceTier,
+ CreateBillingCheckoutSessionRequest,
+ CreateBillingCheckoutSessionResponse,
+ BillingCheckoutSessionStatus,
+ CreateBillingPortalSessionResponse,
} from "../types";
import type { OnboardingCompletionPath } from "../onboarding/types";
import type {
@@ -155,6 +164,22 @@ import {
TimelineEntriesSchema,
UserSchema,
WebhookDeliveryResponseSchema,
+ BillingBalanceSchema,
+ BillingTransactionsPageSchema,
+ BillingBatchesPageSchema,
+ BillingTopupsPageSchema,
+ BillingPriceTierListSchema,
+ CreateBillingCheckoutSessionResponseSchema,
+ BillingCheckoutSessionStatusSchema,
+ CreateBillingPortalSessionResponseSchema,
+ EMPTY_BILLING_BALANCE,
+ EMPTY_BILLING_TRANSACTIONS_PAGE,
+ EMPTY_BILLING_BATCHES_PAGE,
+ EMPTY_BILLING_TOPUPS_PAGE,
+ EMPTY_BILLING_PRICE_TIER_LIST,
+ EMPTY_CREATE_BILLING_CHECKOUT_SESSION_RESPONSE,
+ EMPTY_BILLING_CHECKOUT_SESSION_STATUS,
+ EMPTY_CREATE_BILLING_PORTAL_SESSION_RESPONSE,
} from "./schemas";
/** Identifies the calling client to the server.
@@ -864,6 +889,135 @@ export class ApiClient {
});
}
+ // ---------------------------------------------------------------------
+ // Cloud Billing — proxies to multica-cloud /api/v1/billing/*. The
+ // multica-api server stamps X-User-ID and forwards bytes; everything
+ // here is upstream-shaped. See packages/core/types/billing.ts for the
+ // response field documentation.
+ // ---------------------------------------------------------------------
+
+ async getCloudBillingBalance(): Promise {
+ const raw = await this.fetch("/api/cloud-billing/balance");
+ return parseWithFallback(raw, BillingBalanceSchema, EMPTY_BILLING_BALANCE, {
+ endpoint: "GET /api/cloud-billing/balance",
+ });
+ }
+
+ async listCloudBillingTransactions(
+ params?: { page?: number; page_size?: number },
+ ): Promise {
+ const search = new URLSearchParams();
+ if (params?.page !== undefined) search.set("page", String(params.page));
+ if (params?.page_size !== undefined) search.set("page_size", String(params.page_size));
+ const query = search.toString();
+ const raw = await this.fetch(
+ `/api/cloud-billing/transactions${query ? `?${query}` : ""}`,
+ );
+ return parseWithFallback(
+ raw,
+ BillingTransactionsPageSchema,
+ EMPTY_BILLING_TRANSACTIONS_PAGE,
+ { endpoint: "GET /api/cloud-billing/transactions" },
+ );
+ }
+
+ async listCloudBillingBatches(
+ params?: { page?: number; page_size?: number },
+ ): Promise {
+ const search = new URLSearchParams();
+ if (params?.page !== undefined) search.set("page", String(params.page));
+ if (params?.page_size !== undefined) search.set("page_size", String(params.page_size));
+ const query = search.toString();
+ const raw = await this.fetch(
+ `/api/cloud-billing/batches${query ? `?${query}` : ""}`,
+ );
+ return parseWithFallback(
+ raw,
+ BillingBatchesPageSchema,
+ EMPTY_BILLING_BATCHES_PAGE,
+ { endpoint: "GET /api/cloud-billing/batches" },
+ );
+ }
+
+ async listCloudBillingTopups(
+ params?: { page?: number; page_size?: number },
+ ): Promise {
+ const search = new URLSearchParams();
+ if (params?.page !== undefined) search.set("page", String(params.page));
+ if (params?.page_size !== undefined) search.set("page_size", String(params.page_size));
+ const query = search.toString();
+ const raw = await this.fetch(
+ `/api/cloud-billing/topups${query ? `?${query}` : ""}`,
+ );
+ return parseWithFallback(
+ raw,
+ BillingTopupsPageSchema,
+ EMPTY_BILLING_TOPUPS_PAGE,
+ { endpoint: "GET /api/cloud-billing/topups" },
+ );
+ }
+
+ async listCloudBillingPriceTiers(): Promise {
+ const raw = await this.fetch("/api/cloud-billing/price-tiers");
+ return parseWithFallback(
+ raw,
+ BillingPriceTierListSchema,
+ EMPTY_BILLING_PRICE_TIER_LIST,
+ { endpoint: "GET /api/cloud-billing/price-tiers" },
+ );
+ }
+
+ async createCloudBillingCheckoutSession(
+ data: CreateBillingCheckoutSessionRequest,
+ ): Promise {
+ const res = await this.fetchRaw("/api/cloud-billing/checkout-sessions", {
+ method: "POST",
+ body: JSON.stringify(data),
+ extraHeaders: { "Content-Type": "application/json" },
+ });
+ const raw = (await res.json()) as unknown;
+ return parseWithFallback(
+ raw,
+ CreateBillingCheckoutSessionResponseSchema,
+ EMPTY_CREATE_BILLING_CHECKOUT_SESSION_RESPONSE,
+ { endpoint: "POST /api/cloud-billing/checkout-sessions" },
+ );
+ }
+
+ async getCloudBillingCheckoutSession(
+ sessionId: string,
+ ): Promise {
+ // Stripe session ids are `cs_` so they're URL-safe by
+ // construction; encodeURIComponent is paranoia for the case where a
+ // future Stripe format change adds a non-alphanumeric character. The
+ // server has its own allow-list rejection for unsafe ids.
+ const raw = await this.fetch(
+ `/api/cloud-billing/checkout-sessions/${encodeURIComponent(sessionId)}`,
+ );
+ return parseWithFallback(
+ raw,
+ BillingCheckoutSessionStatusSchema,
+ EMPTY_BILLING_CHECKOUT_SESSION_STATUS,
+ { endpoint: "GET /api/cloud-billing/checkout-sessions/{sessionId}" },
+ );
+ }
+
+ async createCloudBillingPortalSession(): Promise {
+ const res = await this.fetchRaw("/api/cloud-billing/portal-sessions", {
+ method: "POST",
+ // Body is intentionally absent — the upstream endpoint requires no
+ // payload today. fetchRaw with no body skips the Content-Type
+ // default; that's fine because there's nothing to declare.
+ });
+ const raw = (await res.json()) as unknown;
+ return parseWithFallback(
+ raw,
+ CreateBillingPortalSessionResponseSchema,
+ EMPTY_CREATE_BILLING_PORTAL_SESSION_RESPONSE,
+ { endpoint: "POST /api/cloud-billing/portal-sessions" },
+ );
+ }
+
async deleteRuntime(runtimeId: string): Promise {
await this.fetch(`/api/runtimes/${runtimeId}`, { method: "DELETE" });
}
diff --git a/packages/core/api/schemas.ts b/packages/core/api/schemas.ts
index 9b2ba0548..de430753b 100644
--- a/packages/core/api/schemas.ts
+++ b/packages/core/api/schemas.ts
@@ -4,7 +4,15 @@ import type {
AgentTemplate,
AgentTemplateSummary,
Attachment,
+ BillingBalance,
+ BillingBatchesPage,
+ BillingCheckoutSessionStatus,
+ BillingPriceTier,
+ BillingTopupsPage,
+ BillingTransactionsPage,
CreateAgentFromTemplateResponse,
+ CreateBillingCheckoutSessionResponse,
+ CreateBillingPortalSessionResponse,
GroupedIssuesResponse,
ListIssuesResponse,
ListWebhookDeliveriesResponse,
@@ -655,3 +663,173 @@ export const EMPTY_USER: User = {
created_at: "",
updated_at: "",
};
+
+// ---------------------------------------------------------------------------
+// Billing schemas (cloud-billing proxy surface)
+//
+// All billing JSON we receive comes from multica-cloud verbatim — we proxy
+// the bytes without re-shaping. These schemas use `loose()` so a future
+// non-breaking field addition on the cloud side doesn't crash us; required
+// fields are still strictly enforced. EMPTY_* constants supply the
+// fallback parseWithFallback uses when the upstream response is malformed
+// or unparseable.
+
+export const BillingBalanceSchema = z.object({
+ owner_id: z.string(),
+ balance_micro: z.number(),
+ balance_credit: z.number(),
+ updated_at: z.string(),
+}).loose();
+
+export const EMPTY_BILLING_BALANCE: BillingBalance = {
+ owner_id: "",
+ balance_micro: 0,
+ balance_credit: 0,
+ updated_at: "",
+};
+
+// `tx_type` and `source` are kept as plain strings here; the cloud doc
+// enumerates the canonical values but the frontend display tolerates
+// unknown ones gracefully. Strict enums would crash the page on a future
+// addition (e.g. a new `topup` source kind).
+export const BillingTransactionSchema = z.object({
+ id: z.string(),
+ owner_id: z.string(),
+ idempotency_key: z.string().default(""),
+ tx_type: z.string(),
+ source: z.string(),
+ amount_micro: z.number(),
+ balance_after: z.number(),
+ reference_id: z.string().default(""),
+ description: z.string().default(""),
+ metadata: z.record(z.string(), z.unknown()).default({}),
+ created_at: z.string(),
+}).loose();
+
+export const BillingTransactionsPageSchema = z.object({
+ items: z.array(BillingTransactionSchema).default([]),
+ total: z.number().default(0),
+ page: z.number().default(1),
+ page_size: z.number().default(20),
+}).loose();
+
+export const EMPTY_BILLING_TRANSACTIONS_PAGE: BillingTransactionsPage = {
+ items: [],
+ total: 0,
+ page: 1,
+ page_size: 20,
+};
+
+export const BillingBatchSchema = z.object({
+ id: z.string(),
+ owner_id: z.string(),
+ source_tx_id: z.string().default(""),
+ source_type: z.string(),
+ total_micro: z.number(),
+ remaining_micro: z.number(),
+ // Cloud either omits the key (never expires) or sends a string
+ // timestamp. Null is also tolerated since some serializers emit
+ // explicit nulls for absent timestamps.
+ expires_at: z.string().nullable().optional(),
+ created_at: z.string(),
+ updated_at: z.string(),
+}).loose();
+
+export const BillingBatchesPageSchema = z.object({
+ items: z.array(BillingBatchSchema).default([]),
+ total: z.number().default(0),
+ page: z.number().default(1),
+ page_size: z.number().default(20),
+}).loose();
+
+export const EMPTY_BILLING_BATCHES_PAGE: BillingBatchesPage = {
+ items: [],
+ total: 0,
+ page: 1,
+ page_size: 20,
+};
+
+export const BillingTopupSchema = z.object({
+ id: z.string(),
+ owner_id: z.string(),
+ amount_cents: z.number(),
+ currency: z.string().default("usd"),
+ credits: z.number(),
+ bonus_credits: z.number().default(0),
+ status: z.string(),
+ tier_id: z.string().default(""),
+ stripe_checkout_id: z.string().default(""),
+ // Only set after status reaches `credited` — leave optional rather
+ // than coerce to "" so a UI can branch on existence.
+ purchase_batch_id: z.string().optional(),
+ created_at: z.string(),
+ updated_at: z.string(),
+}).loose();
+
+export const BillingTopupsPageSchema = z.object({
+ items: z.array(BillingTopupSchema).default([]),
+ total: z.number().default(0),
+ page: z.number().default(1),
+ page_size: z.number().default(20),
+}).loose();
+
+export const EMPTY_BILLING_TOPUPS_PAGE: BillingTopupsPage = {
+ items: [],
+ total: 0,
+ page: 1,
+ page_size: 20,
+};
+
+export const BillingPriceTierSchema = z.object({
+ id: z.string(),
+ // Cloud doc says display_name falls back to id; tolerate empty too.
+ display_name: z.string().default(""),
+ amount_cents: z.number(),
+ credits: z.number(),
+ bonus_credits: z.number().optional(),
+ bonus_expires_in: z.string().optional(),
+}).loose();
+
+export const BillingPriceTierListSchema = z.array(BillingPriceTierSchema);
+
+export const EMPTY_BILLING_PRICE_TIER_LIST: BillingPriceTier[] = [];
+
+export const CreateBillingCheckoutSessionResponseSchema = z.object({
+ order_id: z.string(),
+ session_id: z.string(),
+ url: z.string(),
+}).loose();
+
+export const EMPTY_CREATE_BILLING_CHECKOUT_SESSION_RESPONSE: CreateBillingCheckoutSessionResponse = {
+ order_id: "",
+ session_id: "",
+ url: "",
+};
+
+export const BillingCheckoutSessionStatusSchema = z.object({
+ order_id: z.string(),
+ status: z.string(),
+ amount_cents: z.number(),
+ credits: z.number(),
+ bonus_credits: z.number().default(0),
+ currency: z.string().default("usd"),
+ tier_id: z.string().default(""),
+}).loose();
+
+export const EMPTY_BILLING_CHECKOUT_SESSION_STATUS: BillingCheckoutSessionStatus = {
+ order_id: "",
+ status: "pending",
+ amount_cents: 0,
+ credits: 0,
+ bonus_credits: 0,
+ currency: "usd",
+ tier_id: "",
+};
+
+export const CreateBillingPortalSessionResponseSchema = z.object({
+ url: z.string(),
+}).loose();
+
+export const EMPTY_CREATE_BILLING_PORTAL_SESSION_RESPONSE: CreateBillingPortalSessionResponse = {
+ url: "",
+};
diff --git a/packages/core/billing/index.ts b/packages/core/billing/index.ts
new file mode 100644
index 000000000..868bf87e5
--- /dev/null
+++ b/packages/core/billing/index.ts
@@ -0,0 +1,2 @@
+export * from "./queries";
+export * from "./mutations";
diff --git a/packages/core/billing/mutations.ts b/packages/core/billing/mutations.ts
new file mode 100644
index 000000000..4e419436b
--- /dev/null
+++ b/packages/core/billing/mutations.ts
@@ -0,0 +1,79 @@
+import { useCallback } from "react";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { api } from "../api";
+import type { CreateBillingCheckoutSessionRequest } from "../types";
+import { billingKeys } from "./queries";
+
+// Both mutations here trigger a hop OUT of the SPA — Stripe Checkout
+// and Stripe Billing Portal are hosted pages. The mutation completes
+// once the URL is in our hands; the caller is responsible for the
+// `window.location.href = url` redirect (or in newer flows,
+// `window.open` + tab-aware polling).
+//
+// We invalidate the topup list on settle so when the user returns
+// from Stripe the new `pending` order shows up immediately. The
+// balance and transactions are NOT invalidated here — they only flip
+// after Stripe + the cloud webhook actually credit, which is a
+// post-redirect concern.
+
+export function useCreateCloudBillingCheckoutSession() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: (data: CreateBillingCheckoutSessionRequest) =>
+ api.createCloudBillingCheckoutSession(data),
+ onSettled: () => {
+ // The new pending topup row is visible to the topups list as
+ // soon as Cloud writes it. Invalidate so the user sees the new
+ // pending entry without a page refresh.
+ qc.invalidateQueries({ queryKey: [...billingKeys.all(), "topups"] });
+ },
+ });
+}
+
+export function useCreateCloudBillingPortalSession() {
+ return useMutation({
+ mutationFn: () => api.createCloudBillingPortalSession(),
+ // No cache invalidation — the portal opens, the user does whatever,
+ // and any state changes Stripe-side propagate back via webhook.
+ // The next React Query refetch picks them up at its own cadence.
+ });
+}
+
+/**
+ * useInvalidateBillingDataAfterCredit returns a callback that flushes
+ * the cached balance / transactions / batches / topups so the page
+ * re-fetches them. Used by the Stripe-success polling banner: once it
+ * detects the topup status flipped to a terminal value (credited /
+ * failed / canceled), the banner is the only query that's been
+ * polling — every other card on the page is still showing its
+ * pre-checkout snapshot. Without this invalidation the user sees
+ * "Final status: credited" while the balance card still displays the
+ * old number until they click refresh.
+ *
+ * Scope of the invalidation:
+ *
+ * - balance + transactions + batches: only ever change at the
+ * `credited` transition (the cloud writes the credit ledger and
+ * batch row in the same DB transaction as the wallet update).
+ * For `failed` / `canceled` they do NOT change, so technically we
+ * over-fetch in those cases — three extra cheap round-trips that
+ * simplify the call site and are negligible on a test page.
+ *
+ * - topups: changes on every terminal transition (the order row
+ * flips status), so it always needs invalidating.
+ *
+ * - the checkout-session query itself is intentionally NOT in this
+ * sweep. Its `refetchInterval` already returned `false` when
+ * status went terminal; refetching would just confirm the same
+ * value we already hold and wake the polling cycle back up for
+ * no benefit.
+ */
+export function useInvalidateBillingDataAfterCredit() {
+ const qc = useQueryClient();
+ return useCallback(() => {
+ qc.invalidateQueries({ queryKey: billingKeys.balance() });
+ qc.invalidateQueries({ queryKey: [...billingKeys.all(), "transactions"] });
+ qc.invalidateQueries({ queryKey: [...billingKeys.all(), "batches"] });
+ qc.invalidateQueries({ queryKey: [...billingKeys.all(), "topups"] });
+ }, [qc]);
+}
diff --git a/packages/core/billing/queries.ts b/packages/core/billing/queries.ts
new file mode 100644
index 000000000..16a67fdb8
--- /dev/null
+++ b/packages/core/billing/queries.ts
@@ -0,0 +1,108 @@
+import { queryOptions } from "@tanstack/react-query";
+import { api } from "../api";
+
+// Billing data is account-level (single owner per X-User-ID), so the
+// React Query keys are NOT scoped to a workspace — keying on workspace
+// would force a refetch every time the user navigates to a different
+// workspace, even though the backing data is identical.
+//
+// Query-level staleness: the cloud's billing module is the source of
+// truth. Topup status flips from `pending` → `paid` → `credited` after
+// Stripe + the cloud-side webhook handler do their thing, so a returning-
+// from-Stripe page needs prompt freshness. We rely on the page-level
+// hooks (refetchInterval / invalidate-on-mount) rather than baking a
+// stale-time here, so consumers can tune polling per surface.
+
+export const billingKeys = {
+ all: () => ["billing"] as const,
+ balance: () => [...billingKeys.all(), "balance"] as const,
+ transactions: (params?: { page?: number; page_size?: number }) =>
+ [...billingKeys.all(), "transactions", params ?? {}] as const,
+ batches: (params?: { page?: number; page_size?: number }) =>
+ [...billingKeys.all(), "batches", params ?? {}] as const,
+ topups: (params?: { page?: number; page_size?: number }) =>
+ [...billingKeys.all(), "topups", params ?? {}] as const,
+ priceTiers: () => [...billingKeys.all(), "price-tiers"] as const,
+ checkoutSession: (sessionId: string) =>
+ [...billingKeys.all(), "checkout-session", sessionId] as const,
+};
+
+export function billingBalanceOptions() {
+ return queryOptions({
+ queryKey: billingKeys.balance(),
+ queryFn: () => api.getCloudBillingBalance(),
+ // 30s stale-time: balance changes only when a topup credits or a
+ // deduction happens. For the test page the user's main interest is
+ // post-checkout state; we let the page invalidate explicitly.
+ staleTime: 30 * 1000,
+ });
+}
+
+export function billingTransactionsOptions(params?: {
+ page?: number;
+ page_size?: number;
+}) {
+ return queryOptions({
+ queryKey: billingKeys.transactions(params),
+ queryFn: () => api.listCloudBillingTransactions(params),
+ staleTime: 30 * 1000,
+ });
+}
+
+export function billingBatchesOptions(params?: {
+ page?: number;
+ page_size?: number;
+}) {
+ return queryOptions({
+ queryKey: billingKeys.batches(params),
+ queryFn: () => api.listCloudBillingBatches(params),
+ staleTime: 30 * 1000,
+ });
+}
+
+export function billingTopupsOptions(params?: {
+ page?: number;
+ page_size?: number;
+}) {
+ return queryOptions({
+ queryKey: billingKeys.topups(params),
+ queryFn: () => api.listCloudBillingTopups(params),
+ staleTime: 30 * 1000,
+ });
+}
+
+export function billingPriceTiersOptions() {
+ return queryOptions({
+ queryKey: billingKeys.priceTiers(),
+ queryFn: () => api.listCloudBillingPriceTiers(),
+ // Price tiers come from server config and basically never change at
+ // runtime — once we've fetched once we can keep it for the whole
+ // session. 5 minutes is more than enough for a test page.
+ staleTime: 5 * 60 * 1000,
+ });
+}
+
+// Stripe-success-redirect polling: when the page loads with
+// `?session_id=...` in the URL, the user just came back from Stripe and
+// the topup is racing through `pending → paid → credited`. Poll until
+// it's terminal so the UI can show a final outcome before the user
+// closes the tab.
+//
+// Caller is expected to short-circuit by passing `enabled: !!sessionId`
+// — that's why we don't gate inside queryOptions itself.
+export function billingCheckoutSessionOptions(sessionId: string) {
+ return queryOptions({
+ queryKey: billingKeys.checkoutSession(sessionId),
+ queryFn: () => api.getCloudBillingCheckoutSession(sessionId),
+ // Refetch every 2s while we're still in a non-terminal state,
+ // stop once we land in `credited` / `failed` / `canceled`.
+ refetchInterval: (query) => {
+ const status = query.state.data?.status;
+ if (status === "credited" || status === "failed" || status === "canceled") {
+ return false;
+ }
+ return 2000;
+ },
+ staleTime: 0,
+ });
+}
diff --git a/packages/core/package.json b/packages/core/package.json
index da786517c..b84d72b95 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -75,6 +75,9 @@
"./pins": "./pins/index.ts",
"./pins/queries": "./pins/queries.ts",
"./pins/mutations": "./pins/mutations.ts",
+ "./billing": "./billing/index.ts",
+ "./billing/queries": "./billing/queries.ts",
+ "./billing/mutations": "./billing/mutations.ts",
"./github": "./github/index.ts",
"./github/queries": "./github/queries.ts",
"./feedback": "./feedback/index.ts",
diff --git a/packages/core/types/billing.ts b/packages/core/types/billing.ts
new file mode 100644
index 000000000..401779d8b
--- /dev/null
+++ b/packages/core/types/billing.ts
@@ -0,0 +1,182 @@
+// Mirrors the multica-cloud Billing module response shapes
+// (multica-cloud/docs/api/billing.md). These types are the contract our
+// frontend consumes via /api/cloud-billing/* — multica-api itself does
+// not own the schema, it just proxies bytes. Keep field names verbatim
+// with what the cloud sends.
+//
+// Unit convention (from the cloud doc):
+// - micro-credit (BIGINT): internal storage unit; 1 credit = 1_000_000 micro
+// - credit: user-facing display unit; 1 USD = 1000 credit
+// Always show users `*_credit` fields when present; only do math on
+// `*_micro` to avoid float drift.
+
+// GET /balance
+export interface BillingBalance {
+ owner_id: string;
+ balance_micro: number;
+ balance_credit: number;
+ updated_at: string;
+}
+
+// `tx_type` values per the cloud doc's enum. Exported as a union for
+// reference / display switches; the actual interface field below is
+// typed as plain `string` so a future cloud-side enum widening doesn't
+// crash the parser. Frontend should switch on these values when known
+// and fall back to a generic display otherwise.
+export type BillingTxType =
+ | "topup"
+ | "deduction"
+ | "refund"
+ | "expire"
+ | "adjustment";
+
+// `source` values per the cloud doc's enum. Same loosening rationale
+// as BillingTxType above.
+export type BillingTxSource =
+ | "gateway"
+ | "fleet"
+ | "topup"
+ | "refund"
+ | "admin"
+ | "system";
+
+export interface BillingTransaction {
+ id: string;
+ owner_id: string;
+ idempotency_key: string;
+ // tx_type / source are widened to string here even though the cloud
+ // doc enumerates a fixed set; see comment on BillingTxType. UIs that
+ // care should switch on the value and gracefully default.
+ tx_type: string;
+ source: string;
+ // amount_micro is positive for credits, negative for deductions.
+ amount_micro: number;
+ balance_after: number;
+ reference_id: string;
+ description: string;
+ metadata: Record;
+ created_at: string;
+}
+
+export interface BillingTransactionsPage {
+ items: BillingTransaction[];
+ total: number;
+ page: number;
+ page_size: number;
+}
+
+// `source_type` of a credit batch. `purchase` = paid topup,
+// `bonus` = subscription / promo gift, `adjustment` = ops fix.
+// Exported as a union for documentation; the field is widened to
+// string in BillingBatch (same rationale as BillingTxType).
+export type BillingBatchSourceType = "purchase" | "bonus" | "adjustment";
+
+export interface BillingBatch {
+ id: string;
+ owner_id: string;
+ source_tx_id: string;
+ // Widened to string; see BillingBatchSourceType comment above.
+ source_type: string;
+ total_micro: number;
+ remaining_micro: number;
+ // expires_at omitted means the batch never expires. Bonus batches
+ // typically carry an expiry; purchase batches typically don't.
+ expires_at?: string | null;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface BillingBatchesPage {
+ items: BillingBatch[];
+ total: number;
+ page: number;
+ page_size: number;
+}
+
+// Topup order lifecycle. `pending` = checkout open, `paid` = Stripe
+// confirmed payment but credit not yet booked, `credited` = wallet
+// updated, `failed`/`canceled` = terminal failures. Exported as a
+// union for documentation; the field is widened to string in
+// BillingTopup / BillingCheckoutSessionStatus.
+export type BillingTopupStatus =
+ | "pending"
+ | "paid"
+ | "credited"
+ | "failed"
+ | "canceled";
+
+export interface BillingTopup {
+ id: string;
+ owner_id: string;
+ amount_cents: number;
+ currency: string;
+ credits: number;
+ bonus_credits: number;
+ // Widened to string; see BillingTopupStatus comment.
+ status: string;
+ tier_id: string;
+ stripe_checkout_id: string;
+ // Set when status reaches `credited` and the purchase batch was
+ // minted; before that the field is empty.
+ purchase_batch_id?: string;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface BillingTopupsPage {
+ items: BillingTopup[];
+ total: number;
+ page: number;
+ page_size: number;
+}
+
+// GET /price-tiers — returns server-authoritative purchasable tiers.
+// Frontend should NEVER hard-code amount/credits; the upstream is the
+// source of truth so prices can be updated without a frontend ship.
+export interface BillingPriceTier {
+ id: string;
+ display_name: string;
+ amount_cents: number;
+ credits: number;
+ // Both bonus fields are optional: when omitted, no bonus is granted.
+ // When `bonus_expires_in` is omitted but `bonus_credits` is present,
+ // the bonus credits never expire.
+ bonus_credits?: number;
+ // Go time.Duration string, e.g. "720h0m0s" = 30 days.
+ bonus_expires_in?: string;
+}
+
+// POST /checkout-sessions
+export interface CreateBillingCheckoutSessionRequest {
+ tier_id: string;
+ // Optional caller-provided email; only used by Stripe Checkout when
+ // the owner doesn't yet have a Stripe customer record. Pass it when
+ // we already know the user's email and want it pre-filled.
+ customer_email?: string;
+}
+
+export interface CreateBillingCheckoutSessionResponse {
+ order_id: string;
+ session_id: string;
+ // Stripe-hosted Checkout URL. Frontend redirects the browser here.
+ url: string;
+}
+
+// GET /checkout-sessions/{session_id} — frontend polls this after
+// returning from Stripe with `?session_id=...` to surface the credit
+// status before the user navigates away.
+export interface BillingCheckoutSessionStatus {
+ order_id: string;
+ // Widened to string; see BillingTopupStatus comment.
+ status: string;
+ amount_cents: number;
+ credits: number;
+ bonus_credits: number;
+ currency: string;
+ tier_id: string;
+}
+
+// POST /portal-sessions
+export interface CreateBillingPortalSessionResponse {
+ url: string;
+}
diff --git a/packages/core/types/index.ts b/packages/core/types/index.ts
index 9ad5c24c1..e1f8cc893 100644
--- a/packages/core/types/index.ts
+++ b/packages/core/types/index.ts
@@ -134,3 +134,21 @@ export type {
SquadMemberStatus,
SquadMemberStatusListResponse,
} from "./squad";
+export type {
+ BillingBalance,
+ BillingTransaction,
+ BillingTransactionsPage,
+ BillingTxType,
+ BillingTxSource,
+ BillingBatch,
+ BillingBatchesPage,
+ BillingBatchSourceType,
+ BillingTopup,
+ BillingTopupsPage,
+ BillingTopupStatus,
+ BillingPriceTier,
+ CreateBillingCheckoutSessionRequest,
+ CreateBillingCheckoutSessionResponse,
+ BillingCheckoutSessionStatus,
+ CreateBillingPortalSessionResponse,
+} from "./billing";
diff --git a/packages/views/billing/billing-test-page.tsx b/packages/views/billing/billing-test-page.tsx
new file mode 100644
index 000000000..27fb6009f
--- /dev/null
+++ b/packages/views/billing/billing-test-page.tsx
@@ -0,0 +1,659 @@
+"use client";
+
+// Test-quality billing page. Stuffs every /api/cloud-billing/* surface
+// onto a single screen so we can verify the proxy + Stripe flow
+// end-to-end without a designed UI. Sections:
+//
+// 1. Balance card
+// 2. Stripe-success banner (visible only when the URL carries a
+// ?session_id=... — the user just came back from Stripe Checkout
+// and we poll the upstream until the topup is terminal).
+// 3. Buy section: server-authoritative price tier buttons that POST
+// a checkout-session and redirect.window.location.href = url.
+// 4. Billing Portal button.
+// 5. Three lists: transactions / batches / topups.
+//
+// Anything past "make the API talk to Stripe and surface results" is
+// out of scope here on purpose — when the real billing UI ships it
+// will live elsewhere and this whole page can be deleted.
+
+import { useEffect, useState } from "react";
+import { useQuery } from "@tanstack/react-query";
+import { Loader2, RefreshCw, ExternalLink } from "lucide-react";
+import { toast } from "sonner";
+import {
+ billingBalanceOptions,
+ billingBatchesOptions,
+ billingCheckoutSessionOptions,
+ billingPriceTiersOptions,
+ billingTopupsOptions,
+ billingTransactionsOptions,
+ useCreateCloudBillingCheckoutSession,
+ useCreateCloudBillingPortalSession,
+ useInvalidateBillingDataAfterCredit,
+} from "@multica/core/billing";
+import type {
+ BillingBatch,
+ BillingPriceTier,
+ BillingTopup,
+ BillingTransaction,
+} from "@multica/core/types";
+import { Button } from "@multica/ui/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@multica/ui/components/ui/card";
+import { useNavigation } from "../navigation";
+
+// 1 credit = 1_000_000 micro-credit; cents → dollars factor for the
+// Stripe-side display column. Documented at the top of the cloud
+// billing.md so we don't sprinkle magic numbers through the UI.
+const MICRO_PER_CREDIT = 1_000_000;
+const CENTS_PER_DOLLAR = 100;
+
+export function BillingTestPage() {
+ const { searchParams, replace, pathname } = useNavigation();
+
+ // The Stripe success URL on the cloud side has the literal
+ // {CHECKOUT_SESSION_ID} placeholder which Stripe substitutes before
+ // redirecting the browser. So when we land here, the param is real.
+ const sessionId = searchParams.get("session_id") ?? "";
+
+ return (
+
+
+
Billing (test page)
+
+ Direct passthrough to multica-cloud's /api/v1/billing/*. Not
+ a finished UI — just every endpoint stuffed onto one page so
+ we can verify the proxy + Stripe flow.
+
+
+
+ {sessionId && (
+ {
+ // After we've shown the terminal status we strip
+ // session_id from the URL so a refresh doesn't re-poll a
+ // stale order. `replace` keeps the browser at the same
+ // pathname without adding history.
+ replace(pathname);
+ }}
+ />
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+// ─── Stripe-success banner ───────────────────────────────────────────
+
+// Polls /checkout-sessions/{id} every 2s until the order reaches a
+// terminal state. Mounted only when ?session_id is in the URL.
+function CheckoutSessionStatusBanner({
+ sessionId,
+ onDismiss,
+}: {
+ sessionId: string;
+ onDismiss: () => void;
+}) {
+ const { data, isLoading, isError, error } = useQuery(
+ billingCheckoutSessionOptions(sessionId),
+ );
+
+ const status = data?.status ?? (isLoading ? "loading" : "");
+ const terminal =
+ status === "credited" || status === "failed" || status === "canceled";
+
+ // When the polling reaches a terminal state, the rest of the page
+ // (balance, transactions, batches, topups) is still showing the
+ // pre-checkout snapshot. Without this effect the user would see
+ // "Final status: credited" up here while the balance card still
+ // displays the old number — the only signal that things were stale
+ // would be a manual refresh click. Invalidate the dependent
+ // queries so they re-fetch in the background.
+ //
+ // Dep list `[terminal, ...]`: `terminal` only flips from false→true
+ // once per session-id, so the invalidation fires exactly once. If
+ // the caller mounts this banner with a session that is already in a
+ // terminal state (e.g. user revisits the success URL after closing
+ // and reopening the tab), `terminal` flips false→true on the first
+ // data load and we still re-fetch — which is what we want, because
+ // the cached snapshot is just as stale in that case.
+ const invalidateBillingDataAfterCredit = useInvalidateBillingDataAfterCredit();
+ useEffect(() => {
+ if (terminal) invalidateBillingDataAfterCredit();
+ }, [terminal, invalidateBillingDataAfterCredit]);
+
+ return (
+
+
+
+ Checkout session {sessionId.slice(0, 16)}…
+
+
+ {isLoading
+ ? "Loading order status…"
+ : isError
+ ? `Failed to fetch status: ${error instanceof Error ? error.message : "unknown error"}`
+ : terminal
+ ? `Final status: ${status}`
+ : `Polling status… current: ${status || "unknown"}`}
+
+
+ {data && (
+
+