mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 03:38:32 +02:00
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:
10
apps/web/app/[workspaceSlug]/(dashboard)/billing/page.tsx
Normal file
10
apps/web/app/[workspaceSlug]/(dashboard)/billing/page.tsx
Normal 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 />;
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
@@ -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: "",
|
||||
};
|
||||
|
||||
2
packages/core/billing/index.ts
Normal file
2
packages/core/billing/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./queries";
|
||||
export * from "./mutations";
|
||||
79
packages/core/billing/mutations.ts
Normal file
79
packages/core/billing/mutations.ts
Normal 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]);
|
||||
}
|
||||
108
packages/core/billing/queries.ts
Normal file
108
packages/core/billing/queries.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
182
packages/core/types/billing.ts
Normal file
182
packages/core/types/billing.ts
Normal 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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
659
packages/views/billing/billing-test-page.tsx
Normal file
659
packages/views/billing/billing-test-page.tsx
Normal 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'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'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();
|
||||
}
|
||||
1
packages/views/billing/index.ts
Normal file
1
packages/views/billing/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { BillingTestPage } from "./billing-test-page";
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user