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

Billing (test page)

+

+ Direct passthrough to multica-cloud's /api/v1/billing/*. Not + a finished UI — just every endpoint stuffed onto one page so + we can verify the proxy + Stripe flow. +

+
+ + {sessionId && ( + { + // After we've shown the terminal status we strip + // session_id from the URL so a refresh doesn't re-poll a + // stale order. `replace` keeps the browser at the same + // pathname without adding history. + replace(pathname); + }} + /> + )} + + + + + + + + + + +
+ ); +} + +// ─── Stripe-success banner ─────────────────────────────────────────── + +// Polls /checkout-sessions/{id} every 2s until the order reaches a +// terminal state. Mounted only when ?session_id is in the URL. +function CheckoutSessionStatusBanner({ + sessionId, + onDismiss, +}: { + sessionId: string; + onDismiss: () => void; +}) { + const { data, isLoading, isError, error } = useQuery( + billingCheckoutSessionOptions(sessionId), + ); + + const status = data?.status ?? (isLoading ? "loading" : ""); + const terminal = + status === "credited" || status === "failed" || status === "canceled"; + + // When the polling reaches a terminal state, the rest of the page + // (balance, transactions, batches, topups) is still showing the + // pre-checkout snapshot. Without this effect the user would see + // "Final status: credited" up here while the balance card still + // displays the old number — the only signal that things were stale + // would be a manual refresh click. Invalidate the dependent + // queries so they re-fetch in the background. + // + // Dep list `[terminal, ...]`: `terminal` only flips from false→true + // once per session-id, so the invalidation fires exactly once. If + // the caller mounts this banner with a session that is already in a + // terminal state (e.g. user revisits the success URL after closing + // and reopening the tab), `terminal` flips false→true on the first + // data load and we still re-fetch — which is what we want, because + // the cached snapshot is just as stale in that case. + const invalidateBillingDataAfterCredit = useInvalidateBillingDataAfterCredit(); + useEffect(() => { + if (terminal) invalidateBillingDataAfterCredit(); + }, [terminal, invalidateBillingDataAfterCredit]); + + return ( + + + + Checkout session {sessionId.slice(0, 16)}… + + + {isLoading + ? "Loading order status…" + : isError + ? `Failed to fetch status: ${error instanceof Error ? error.message : "unknown error"}` + : terminal + ? `Final status: ${status}` + : `Polling status… current: ${status || "unknown"}`} + + + {data && ( + +
+
Order
+
{data.order_id}
+
Tier
+
{data.tier_id}
+
Charged
+
+ {formatMoney(data.amount_cents, data.currency)} ·{" "} + {data.credits.toLocaleString()} credits + {data.bonus_credits > 0 && + ` + ${data.bonus_credits.toLocaleString()} bonus`} +
+
+ {terminal && ( + + )} +
+ )} +
+ ); +} + +// ─── Balance ───────────────────────────────────────────────────────── + +function BalanceCard() { + const balance = useQuery(billingBalanceOptions()); + + return ( + + +
+ Balance + + GET /api/cloud-billing/balance + +
+ void balance.refetch()} + /> +
+ + {balance.isLoading ? ( + + ) : balance.isError ? ( + + ) : ( +
+
+ {balance.data?.balance_credit.toLocaleString() ?? 0} + + credits + +
+
+ raw micro: {balance.data?.balance_micro.toLocaleString() ?? 0} · + owner: {balance.data?.owner_id.slice(0, 8) ?? ""}… · updated{" "} + {formatDate(balance.data?.updated_at)} +
+
+ )} +
+
+ ); +} + +// ─── Buy + Portal ──────────────────────────────────────────────────── + +function BuyAndPortalSection() { + const tiers = useQuery(billingPriceTiersOptions()); + const createCheckout = useCreateCloudBillingCheckoutSession(); + const createPortal = useCreateCloudBillingPortalSession(); + const [busyTier, setBusyTier] = useState(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 ( + + + Buy credits / Manage billing + + GET /price-tiers · POST /checkout-sessions · POST /portal-sessions + + + + {tiers.isLoading ? ( + + ) : tiers.isError ? ( + + ) : tiers.data?.length ? ( +
+ {tiers.data.map((tier) => ( + void handleBuy(tier)} + /> + ))} +
+ ) : ( +

+ No price tiers configured. Cloud probably has no Stripe key set — + checkout will return 503. +

+ )} + +
+ +

+ 400 is expected for users who haven't paid yet (no Stripe + customer record exists). +

+
+
+
+ ); +} + +function TierButton({ + tier, + busy, + disabled, + onClick, +}: { + tier: BillingPriceTier; + busy: boolean; + disabled: boolean; + onClick: () => void; +}) { + const display = tier.display_name || tier.id; + return ( + + ); +} + +// ─── Lists ─────────────────────────────────────────────────────────── + +function TransactionsCard() { + const txs = useQuery(billingTransactionsOptions({ page: 1, page_size: 20 })); + return ( + + +
+ Transactions + + GET /api/cloud-billing/transactions + +
+ void txs.refetch()} + /> +
+ + {txs.isLoading ? ( + + ) : txs.isError ? ( + + ) : txs.data?.items.length ? ( +
    + {txs.data.items.map((row) => ( + + ))} +
+ ) : ( + No transactions yet. + )} + +
+
+ ); +} + +function TransactionRow({ row }: { row: BillingTransaction }) { + const credit = row.amount_micro / MICRO_PER_CREDIT; + return ( +
  • +
    + + {row.tx_type} + + {row.source} + + + = 0 ? "text-green-700 dark:text-green-400" : "text-red-700 dark:text-red-400" + }`} + > + {credit >= 0 ? "+" : ""} + {credit.toLocaleString()} credits + +
    + {row.description && ( +
    {row.description}
    + )} +
    + {formatDate(row.created_at)} · balance after:{" "} + {(row.balance_after / MICRO_PER_CREDIT).toLocaleString()} credits · ref:{" "} + {row.reference_id || "—"} +
    +
  • + ); +} + +function BatchesCard() { + const batches = useQuery(billingBatchesOptions({ page: 1, page_size: 20 })); + return ( + + +
    + Credit batches + + GET /api/cloud-billing/batches + +
    + void batches.refetch()} + /> +
    + + {batches.isLoading ? ( + + ) : batches.isError ? ( + + ) : batches.data?.items.length ? ( +
      + {batches.data.items.map((row) => ( + + ))} +
    + ) : ( + No batches yet. + )} + +
    +
    + ); +} + +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 ( +
  • +
    + + {row.source_type} + + {row.id.slice(0, 8)}… + + + + {remaining.toLocaleString()} / {total.toLocaleString()} credits + +
    +
    + Consumed: {consumed.toLocaleString()} credits + {row.expires_at ? ` · expires ${formatDate(row.expires_at)}` : " · never expires"} +
    +
  • + ); +} + +function TopupsCard() { + const topups = useQuery(billingTopupsOptions({ page: 1, page_size: 20 })); + return ( + + +
    + Topup orders + + GET /api/cloud-billing/topups + +
    + void topups.refetch()} + /> +
    + + {topups.isLoading ? ( + + ) : topups.isError ? ( + + ) : topups.data?.items.length ? ( +
      + {topups.data.items.map((row) => ( + + ))} +
    + ) : ( + No topup orders yet. + )} + +
    +
    + ); +} + +function TopupRow({ row }: { row: BillingTopup }) { + return ( +
  • +
    + + {row.tier_id || row.id.slice(0, 8)} + + {row.status} + + + + {formatMoney(row.amount_cents, row.currency)} →{" "} + {row.credits.toLocaleString()} credits + {row.bonus_credits > 0 ? ` + ${row.bonus_credits} bonus` : ""} + +
    +
    + {formatDate(row.created_at)} · stripe: {row.stripe_checkout_id || "—"} +
    +
  • + ); +} + +// ─── Shared bits ───────────────────────────────────────────────────── + +function PagingFooter({ + page, + pageSize, + total, +}: { + page: number; + pageSize: number; + total: number; +}) { + if (total === 0) return null; + return ( +
    + page {page} / {Math.max(1, Math.ceil(total / pageSize))} · {total} total +
    + ); +} + +function RefreshButton({ + isLoading, + onClick, +}: { + isLoading: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +function ErrorText({ error }: { error: unknown }) { + return ( +

    + {error instanceof Error ? error.message : "Request failed"} +

    + ); +} + +function EmptyText({ children }: { children: React.ReactNode }) { + return

    {children}

    ; +} + +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(); +} diff --git a/packages/views/billing/index.ts b/packages/views/billing/index.ts new file mode 100644 index 000000000..a52179991 --- /dev/null +++ b/packages/views/billing/index.ts @@ -0,0 +1 @@ +export { BillingTestPage } from "./billing-test-page"; diff --git a/packages/views/package.json b/packages/views/package.json index a989c68e3..90ee49dd5 100644 --- a/packages/views/package.json +++ b/packages/views/package.json @@ -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",