feat(billing): test page consuming /api/cloud-billing/* (#3442)

* feat(billing): test page consuming /api/cloud-billing/*

Stuffs every cloud-billing endpoint onto a single dev page so we can
verify the proxy + Stripe end-to-end flow without a designed UI.
Reachable at /<workspaceSlug>/billing — the page is account-level
data but lives under the workspace dashboard layout because that's
where the authenticated shell sits. No sidebar entry on purpose;
this is test-quality and meant to be deleted when the real billing
UI ships.

What's there:

  * Balance card (GET /balance)
  * Stripe-success polling banner — visible only when ?session_id=
    is in the URL (Stripe substitutes it into checkout_success_url
    on its way back). React Query refetchInterval polls every 2s
    until the topup status reaches credited / failed / canceled,
    then a 'Clear from URL' button calls navigation.replace(pathname).
  * Buy section: server-authoritative price tier buttons (GET
    /price-tiers) → POST /checkout-sessions → window.location to the
    Stripe URL. We do NOT hard-code amounts on the frontend; tier
    config lives in cloud's billing.price_tiers.
  * Stripe Billing Portal button (POST /portal-sessions). Opens in a
    new tab so the originating page stays put for easy verification.
    Documented behaviour: 400 is expected for users with no Stripe
    customer record yet.
  * Three lists: transactions / batches / topups.

Plumbing:

  * packages/core/types/billing.ts — interfaces mirroring the cloud
    response shapes. Status / source / tx_type fields are typed
    'string' rather than enum unions to match the schemas' z.string()
    parsing (same convention as CloudRuntimeNode); the canonical
    enum values are exported as separate type aliases for callers
    that want to switch on them.
  * packages/core/api/schemas.ts — 9 zod schemas + 7 EMPTY_ fallbacks,
    all .loose() so a non-breaking cloud-side field addition doesn't
    crash the parser.
  * packages/core/api/client.ts — 8 methods using parseWithFallback,
    matching the existing cloud-runtime shape.
  * packages/core/billing/{queries,mutations,index}.ts — React Query
    queryOptions + mutations. Notable choices: balance / lists are
    NOT keyed on workspace (account-level data), and the
    checkout-session polling stops automatically when status is
    terminal so we don't poll forever after a user closes the tab.
  * packages/core/package.json + packages/views/package.json — exports
    map updated for @multica/core/billing and @multica/views/billing.

Verification:

  * pnpm --filter @multica/core typecheck clean
  * pnpm --filter @multica/views typecheck — only pre-existing
    hast-util-to-html error in editor code (exists on main)
  * pnpm --filter @multica/core test — 412 passing
  * pnpm --filter @multica/views test — 877 passing, 1 failure
    (editor/readonly-content) is also pre-existing on main, not
    caused by this change

Out of scope: real production-quality billing UI; sidebar entry; i18n
strings; mobile app. This is a single test page; it gets replaced
when the real UI ships.

* fix(billing): refetch balance/lists when checkout polling reaches terminal

Closes the second-half of the Stripe-return race the previous commit
left dangling.

Symptom:
  After Stripe redirects back with ?session_id=..., the banner polls
  /checkout-sessions/{id} every 2s and the rest of the page (balance,
  transactions, batches, topups) is fetched once on mount. The
  webhook race means those four queries usually see pre-credit state
  — but the banner is the only thing that keeps polling, so once it
  reads 'credited' nothing else on the page knows. The user would
  see 'Final status: credited' next to a stale balance card until
  they manually refresh.

Fix:
  Add useInvalidateBillingDataAfterCredit() in @multica/core/billing —
  a hook returning a callback that flushes balance / transactions /
  batches / topups (NOT the checkout-session itself; its
  refetchInterval already terminated, refetching would just confirm
  the same value). The Stripe-success banner runs this callback in
  a useEffect keyed on terminal-status transition, so it fires
  exactly once when the polling lands.

  Strict scope is documented in the hook's JSDoc:
    - balance/transactions/batches: only change at the 'credited'
      transition (cloud writes ledger + batch + wallet in one DB tx)
    - topups: changes on every terminal transition
    - For 'failed' / 'canceled' we technically over-fetch the first
      three; three cheap round-trips, simplifies the call site, fine
      on a test page.

  Effect dep is . terminal flips false→true at most once
  per session id (the polling stops when terminal is true so the
  data won't change again). If the user lands here with a session
  that is already terminal (re-opened tab on a credited URL), the
  effect still fires on first data load and we still re-fetch —
  correct, the cached snapshot is just as stale in that case.

go build / pnpm typecheck / pnpm test clean (core 412 passing; only
pre-existing hast-util-to-html error in unrelated editor code on
views, same as on main).
This commit is contained in:
LinYushen
2026-05-28 16:48:04 +08:00
committed by GitHub
parent 90ddfb04e2
commit 7bbca54e92
12 changed files with 1395 additions and 0 deletions

View File

@@ -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 <BillingTestPage />;
}

View File

@@ -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<BillingBalance> {
const raw = await this.fetch<unknown>("/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<BillingTransactionsPage> {
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<unknown>(
`/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<BillingBatchesPage> {
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<unknown>(
`/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<BillingTopupsPage> {
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<unknown>(
`/api/cloud-billing/topups${query ? `?${query}` : ""}`,
);
return parseWithFallback(
raw,
BillingTopupsPageSchema,
EMPTY_BILLING_TOPUPS_PAGE,
{ endpoint: "GET /api/cloud-billing/topups" },
);
}
async listCloudBillingPriceTiers(): Promise<BillingPriceTier[]> {
const raw = await this.fetch<unknown>("/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<CreateBillingCheckoutSessionResponse> {
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<BillingCheckoutSessionStatus> {
// Stripe session ids are `cs_<base62>` 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<unknown>(
`/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<CreateBillingPortalSessionResponse> {
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<void> {
await this.fetch(`/api/runtimes/${runtimeId}`, { method: "DELETE" });
}

View File

@@ -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: "",
};

View File

@@ -0,0 +1,2 @@
export * from "./queries";
export * from "./mutations";

View File

@@ -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]);
}

View File

@@ -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,
});
}

View File

@@ -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",

View File

@@ -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<string, unknown>;
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;
}

View File

@@ -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";

View File

@@ -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 (
<div className="space-y-6 p-6">
<header>
<h1 className="text-xl font-semibold">Billing (test page)</h1>
<p className="mt-1 text-sm text-muted-foreground">
Direct passthrough to multica-cloud&apos;s /api/v1/billing/*. Not
a finished UI just every endpoint stuffed onto one page so
we can verify the proxy + Stripe flow.
</p>
</header>
{sessionId && (
<CheckoutSessionStatusBanner
sessionId={sessionId}
onDismiss={() => {
// 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);
}}
/>
)}
<BalanceCard />
<BuyAndPortalSection />
<TransactionsCard />
<BatchesCard />
<TopupsCard />
</div>
);
}
// ─── 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 (
<Card className="border-primary/40 bg-primary/5">
<CardHeader>
<CardTitle className="text-sm">
Checkout session {sessionId.slice(0, 16)}
</CardTitle>
<CardDescription className="text-xs">
{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"}`}
</CardDescription>
</CardHeader>
{data && (
<CardContent className="text-xs">
<dl className="grid grid-cols-[120px_1fr] gap-y-1">
<dt className="text-muted-foreground">Order</dt>
<dd className="font-mono">{data.order_id}</dd>
<dt className="text-muted-foreground">Tier</dt>
<dd>{data.tier_id}</dd>
<dt className="text-muted-foreground">Charged</dt>
<dd>
{formatMoney(data.amount_cents, data.currency)} ·{" "}
{data.credits.toLocaleString()} credits
{data.bonus_credits > 0 &&
` + ${data.bonus_credits.toLocaleString()} bonus`}
</dd>
</dl>
{terminal && (
<Button
variant="outline"
size="sm"
className="mt-3"
onClick={onDismiss}
>
Clear from URL
</Button>
)}
</CardContent>
)}
</Card>
);
}
// ─── Balance ─────────────────────────────────────────────────────────
function BalanceCard() {
const balance = useQuery(billingBalanceOptions());
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="text-sm">Balance</CardTitle>
<CardDescription className="text-xs">
GET /api/cloud-billing/balance
</CardDescription>
</div>
<RefreshButton
isLoading={balance.isFetching}
onClick={() => void balance.refetch()}
/>
</CardHeader>
<CardContent>
{balance.isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : balance.isError ? (
<ErrorText error={balance.error} />
) : (
<div className="space-y-1 text-sm">
<div className="text-2xl font-semibold tabular-nums">
{balance.data?.balance_credit.toLocaleString() ?? 0}
<span className="ml-1 text-sm font-normal text-muted-foreground">
credits
</span>
</div>
<div className="text-xs text-muted-foreground">
raw micro: {balance.data?.balance_micro.toLocaleString() ?? 0} ·
owner: {balance.data?.owner_id.slice(0, 8) ?? ""} · updated{" "}
{formatDate(balance.data?.updated_at)}
</div>
</div>
)}
</CardContent>
</Card>
);
}
// ─── Buy + Portal ────────────────────────────────────────────────────
function BuyAndPortalSection() {
const tiers = useQuery(billingPriceTiersOptions());
const createCheckout = useCreateCloudBillingCheckoutSession();
const createPortal = useCreateCloudBillingPortalSession();
const [busyTier, setBusyTier] = useState<string | null>(null);
const handleBuy = async (tier: BillingPriceTier) => {
setBusyTier(tier.id);
try {
const { url } = await createCheckout.mutateAsync({ tier_id: tier.id });
if (!url) {
toast.error("Cloud returned no checkout URL");
return;
}
// Redirect via window.location instead of window.open so the
// browser back button returns the user to this page after
// Stripe redirects out. Stripe-hosted pages handle their own
// SPA-like behaviour from there.
window.location.href = url;
} catch (err) {
toast.error(err instanceof Error ? err.message : "Checkout failed");
} finally {
setBusyTier(null);
}
};
const handlePortal = async () => {
try {
const { url } = await createPortal.mutateAsync();
if (!url) {
toast.error("No portal URL returned");
return;
}
// Open in a new tab — the portal is a customer self-service
// surface and keeping our session in this tab makes it easy to
// come back and verify the resulting state via this same page.
window.open(url, "_blank", "noopener,noreferrer");
} catch (err) {
// 400 is the documented "no Stripe customer yet" case from
// upstream. Surface the body verbatim — it's the most useful
// signal during testing.
toast.error(err instanceof Error ? err.message : "Portal failed");
}
};
return (
<Card>
<CardHeader>
<CardTitle className="text-sm">Buy credits / Manage billing</CardTitle>
<CardDescription className="text-xs">
GET /price-tiers · POST /checkout-sessions · POST /portal-sessions
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{tiers.isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : tiers.isError ? (
<ErrorText error={tiers.error} />
) : tiers.data?.length ? (
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{tiers.data.map((tier) => (
<TierButton
key={tier.id}
tier={tier}
busy={busyTier === tier.id}
disabled={busyTier !== null}
onClick={() => void handleBuy(tier)}
/>
))}
</div>
) : (
<p className="text-xs text-muted-foreground">
No price tiers configured. Cloud probably has no Stripe key set
checkout will return 503.
</p>
)}
<div className="border-t pt-4">
<Button
type="button"
variant="outline"
size="sm"
disabled={createPortal.isPending}
onClick={() => void handlePortal()}
>
{createPortal.isPending ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<ExternalLink className="mr-1.5 h-3.5 w-3.5" />
)}
Open Stripe Billing Portal
</Button>
<p className="mt-1 text-xs text-muted-foreground">
400 is expected for users who haven&apos;t paid yet (no Stripe
customer record exists).
</p>
</div>
</CardContent>
</Card>
);
}
function TierButton({
tier,
busy,
disabled,
onClick,
}: {
tier: BillingPriceTier;
busy: boolean;
disabled: boolean;
onClick: () => void;
}) {
const display = tier.display_name || tier.id;
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
className="rounded-md border bg-background p-3 text-left transition hover:border-primary disabled:cursor-not-allowed disabled:opacity-50"
>
<div className="flex items-center justify-between">
<div className="text-sm font-medium">{display}</div>
{busy && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
</div>
<div className="mt-1 text-xs text-muted-foreground">
{formatMoney(tier.amount_cents, "usd")} {" "}
{tier.credits.toLocaleString()} credits
{tier.bonus_credits ? (
<>
{" "}
+ {tier.bonus_credits.toLocaleString()} bonus
{tier.bonus_expires_in ? ` (${tier.bonus_expires_in})` : ""}
</>
) : null}
</div>
<div className="mt-1 font-mono text-[10px] text-muted-foreground/70">
id: {tier.id}
</div>
</button>
);
}
// ─── Lists ───────────────────────────────────────────────────────────
function TransactionsCard() {
const txs = useQuery(billingTransactionsOptions({ page: 1, page_size: 20 }));
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="text-sm">Transactions</CardTitle>
<CardDescription className="text-xs">
GET /api/cloud-billing/transactions
</CardDescription>
</div>
<RefreshButton
isLoading={txs.isFetching}
onClick={() => void txs.refetch()}
/>
</CardHeader>
<CardContent>
{txs.isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : txs.isError ? (
<ErrorText error={txs.error} />
) : txs.data?.items.length ? (
<ul className="space-y-2 text-xs">
{txs.data.items.map((row) => (
<TransactionRow key={row.id} row={row} />
))}
</ul>
) : (
<EmptyText>No transactions yet.</EmptyText>
)}
<PagingFooter
page={txs.data?.page ?? 1}
pageSize={txs.data?.page_size ?? 20}
total={txs.data?.total ?? 0}
/>
</CardContent>
</Card>
);
}
function TransactionRow({ row }: { row: BillingTransaction }) {
const credit = row.amount_micro / MICRO_PER_CREDIT;
return (
<li className="rounded-md border bg-background p-2.5">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium">
{row.tx_type}
<span className="ml-1.5 rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
{row.source}
</span>
</span>
<span
className={`text-sm tabular-nums ${
credit >= 0 ? "text-green-700 dark:text-green-400" : "text-red-700 dark:text-red-400"
}`}
>
{credit >= 0 ? "+" : ""}
{credit.toLocaleString()} credits
</span>
</div>
{row.description && (
<div className="mt-1 text-xs text-muted-foreground">{row.description}</div>
)}
<div className="mt-1 font-mono text-[10px] text-muted-foreground/70">
{formatDate(row.created_at)} · balance after:{" "}
{(row.balance_after / MICRO_PER_CREDIT).toLocaleString()} credits · ref:{" "}
{row.reference_id || "—"}
</div>
</li>
);
}
function BatchesCard() {
const batches = useQuery(billingBatchesOptions({ page: 1, page_size: 20 }));
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="text-sm">Credit batches</CardTitle>
<CardDescription className="text-xs">
GET /api/cloud-billing/batches
</CardDescription>
</div>
<RefreshButton
isLoading={batches.isFetching}
onClick={() => void batches.refetch()}
/>
</CardHeader>
<CardContent>
{batches.isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : batches.isError ? (
<ErrorText error={batches.error} />
) : batches.data?.items.length ? (
<ul className="space-y-2 text-xs">
{batches.data.items.map((row) => (
<BatchRow key={row.id} row={row} />
))}
</ul>
) : (
<EmptyText>No batches yet.</EmptyText>
)}
<PagingFooter
page={batches.data?.page ?? 1}
pageSize={batches.data?.page_size ?? 20}
total={batches.data?.total ?? 0}
/>
</CardContent>
</Card>
);
}
function BatchRow({ row }: { row: BillingBatch }) {
const total = row.total_micro / MICRO_PER_CREDIT;
const remaining = row.remaining_micro / MICRO_PER_CREDIT;
const consumed = total - remaining;
return (
<li className="rounded-md border bg-background p-2.5">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium">
{row.source_type}
<span className="ml-1.5 font-mono text-[10px] text-muted-foreground">
{row.id.slice(0, 8)}
</span>
</span>
<span className="text-sm tabular-nums">
{remaining.toLocaleString()} / {total.toLocaleString()} credits
</span>
</div>
<div className="mt-1 text-xs text-muted-foreground">
Consumed: {consumed.toLocaleString()} credits
{row.expires_at ? ` · expires ${formatDate(row.expires_at)}` : " · never expires"}
</div>
</li>
);
}
function TopupsCard() {
const topups = useQuery(billingTopupsOptions({ page: 1, page_size: 20 }));
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="text-sm">Topup orders</CardTitle>
<CardDescription className="text-xs">
GET /api/cloud-billing/topups
</CardDescription>
</div>
<RefreshButton
isLoading={topups.isFetching}
onClick={() => void topups.refetch()}
/>
</CardHeader>
<CardContent>
{topups.isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : topups.isError ? (
<ErrorText error={topups.error} />
) : topups.data?.items.length ? (
<ul className="space-y-2 text-xs">
{topups.data.items.map((row) => (
<TopupRow key={row.id} row={row} />
))}
</ul>
) : (
<EmptyText>No topup orders yet.</EmptyText>
)}
<PagingFooter
page={topups.data?.page ?? 1}
pageSize={topups.data?.page_size ?? 20}
total={topups.data?.total ?? 0}
/>
</CardContent>
</Card>
);
}
function TopupRow({ row }: { row: BillingTopup }) {
return (
<li className="rounded-md border bg-background p-2.5">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium">
{row.tier_id || row.id.slice(0, 8)}
<span
className={`ml-1.5 rounded px-1.5 py-0.5 font-mono text-[10px] ${
row.status === "credited"
? "bg-green-500/10 text-green-700 dark:text-green-400"
: row.status === "failed" || row.status === "canceled"
? "bg-red-500/10 text-red-700 dark:text-red-400"
: "bg-amber-500/10 text-amber-700 dark:text-amber-400"
}`}
>
{row.status}
</span>
</span>
<span className="text-sm tabular-nums">
{formatMoney(row.amount_cents, row.currency)} {" "}
{row.credits.toLocaleString()} credits
{row.bonus_credits > 0 ? ` + ${row.bonus_credits} bonus` : ""}
</span>
</div>
<div className="mt-1 font-mono text-[10px] text-muted-foreground/70">
{formatDate(row.created_at)} · stripe: {row.stripe_checkout_id || "—"}
</div>
</li>
);
}
// ─── Shared bits ─────────────────────────────────────────────────────
function PagingFooter({
page,
pageSize,
total,
}: {
page: number;
pageSize: number;
total: number;
}) {
if (total === 0) return null;
return (
<div className="mt-3 text-[10px] text-muted-foreground">
page {page} / {Math.max(1, Math.ceil(total / pageSize))} · {total} total
</div>
);
}
function RefreshButton({
isLoading,
onClick,
}: {
isLoading: boolean;
onClick: () => void;
}) {
return (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={onClick}
disabled={isLoading}
aria-label="Refresh"
>
<RefreshCw className={`h-3.5 w-3.5 ${isLoading ? "animate-spin" : ""}`} />
</Button>
);
}
function ErrorText({ error }: { error: unknown }) {
return (
<p className="text-xs text-destructive">
{error instanceof Error ? error.message : "Request failed"}
</p>
);
}
function EmptyText({ children }: { children: React.ReactNode }) {
return <p className="text-xs text-muted-foreground">{children}</p>;
}
function formatMoney(amountCents: number, currency: string): string {
// Intl is fine here — no currency conversion happening, just
// canonical display. Defaults to en-US to match the rest of the
// dev UI.
try {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: currency.toUpperCase(),
}).format(amountCents / CENTS_PER_DOLLAR);
} catch {
return `${(amountCents / CENTS_PER_DOLLAR).toFixed(2)} ${currency.toUpperCase()}`;
}
}
function formatDate(value: string | undefined): string {
if (!value) return "—";
const d = new Date(value);
if (Number.isNaN(d.getTime())) return value;
return d.toLocaleString();
}

View File

@@ -0,0 +1 @@
export { BillingTestPage } from "./billing-test-page";

View File

@@ -28,6 +28,7 @@
"./members": "./members/index.ts",
"./inbox": "./inbox/index.ts",
"./runtimes": "./runtimes/index.ts",
"./billing": "./billing/index.ts",
"./dashboard": "./dashboard/index.ts",
"./squads": "./squads/index.ts",
"./squads/components": "./squads/components/index.ts",