mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-17 07:09:53 +02:00
* 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).
109 lines
4.0 KiB
TypeScript
109 lines
4.0 KiB
TypeScript
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,
|
|
});
|
|
}
|