diff --git a/app/api/pymthouse/plans/route.ts b/app/api/pymthouse/plans/route.ts
new file mode 100644
index 0000000..cf483ba
--- /dev/null
+++ b/app/api/pymthouse/plans/route.ts
@@ -0,0 +1,20 @@
+import { NextResponse } from "next/server";
+import { listDashboardBillingPlans } from "@/lib/console/pymthouse-billing-bff";
+import {
+ PYMTHOUSE_NO_STORE_HEADERS,
+ pymthouseErrorResponse,
+} from "@/app/api/pymthouse/route-helpers";
+
+export const runtime = "nodejs";
+
+export async function GET() {
+ try {
+ const plans = await listDashboardBillingPlans();
+ return NextResponse.json(
+ { plans },
+ { headers: PYMTHOUSE_NO_STORE_HEADERS }
+ );
+ } catch (error) {
+ return pymthouseErrorResponse(error, "Failed to list billing plans");
+ }
+}
diff --git a/app/api/pymthouse/subscribe/route.ts b/app/api/pymthouse/subscribe/route.ts
new file mode 100644
index 0000000..9637a2e
--- /dev/null
+++ b/app/api/pymthouse/subscribe/route.ts
@@ -0,0 +1,42 @@
+import { NextRequest, NextResponse } from "next/server";
+import { startDashboardBillingCheckout } from "@/lib/console/pymthouse-billing-bff";
+import { requireConsoleSession } from "@/lib/console/session-user";
+import {
+ checkoutReturnOrigin,
+ PYMTHOUSE_NO_STORE_HEADERS,
+ pymthouseErrorResponse,
+} from "@/app/api/pymthouse/route-helpers";
+
+export const runtime = "nodejs";
+
+export async function POST(request: NextRequest) {
+ let body: { planId?: string; successUrl?: string; cancelUrl?: string };
+ try {
+ body = (await request.json()) as typeof body;
+ } catch {
+ return NextResponse.json({ error: "invalid_json" }, { status: 400 });
+ }
+
+ const planId = body.planId?.trim();
+ if (!planId) {
+ return NextResponse.json({ error: "planId is required" }, { status: 400 });
+ }
+
+ const origin = checkoutReturnOrigin(request);
+ const successUrl =
+ body.successUrl?.trim() || `${origin}/usage?checkout=success`;
+ const cancelUrl = body.cancelUrl?.trim() || `${origin}/usage?checkout=cancel`;
+
+ try {
+ const session = await requireConsoleSession();
+ const result = await startDashboardBillingCheckout({
+ planId,
+ externalUserId: session.externalUserId,
+ successUrl,
+ cancelUrl,
+ });
+ return NextResponse.json(result, { headers: PYMTHOUSE_NO_STORE_HEADERS });
+ } catch (error) {
+ return pymthouseErrorResponse(error, "Failed to start checkout");
+ }
+}
diff --git a/app/api/pymthouse/subscription/cancel/route.ts b/app/api/pymthouse/subscription/cancel/route.ts
new file mode 100644
index 0000000..e986bcc
--- /dev/null
+++ b/app/api/pymthouse/subscription/cancel/route.ts
@@ -0,0 +1,32 @@
+import { NextRequest, NextResponse } from "next/server";
+import { cancelDashboardUserSubscription } from "@/lib/console/pymthouse-billing-bff";
+import { requireConsoleSession } from "@/lib/console/session-user";
+import {
+ PYMTHOUSE_NO_STORE_HEADERS,
+ pymthouseErrorResponse,
+} from "@/app/api/pymthouse/route-helpers";
+
+export const runtime = "nodejs";
+
+export async function POST(request: NextRequest) {
+ let body: { timing?: string; effectiveAt?: string };
+ try {
+ body = (await request.json()) as typeof body;
+ } catch {
+ return NextResponse.json({ error: "invalid_json" }, { status: 400 });
+ }
+
+ try {
+ const session = await requireConsoleSession();
+ await cancelDashboardUserSubscription(session.externalUserId, {
+ timing: body.timing?.trim(),
+ effectiveAt: body.effectiveAt?.trim(),
+ });
+ return NextResponse.json(
+ { ok: true },
+ { headers: PYMTHOUSE_NO_STORE_HEADERS }
+ );
+ } catch (error) {
+ return pymthouseErrorResponse(error, "Failed to cancel subscription");
+ }
+}
diff --git a/app/api/pymthouse/subscription/change/route.ts b/app/api/pymthouse/subscription/change/route.ts
new file mode 100644
index 0000000..89780bd
--- /dev/null
+++ b/app/api/pymthouse/subscription/change/route.ts
@@ -0,0 +1,59 @@
+import { NextRequest, NextResponse } from "next/server";
+import { PmtHouseError } from "@pymthouse/builder-sdk";
+import { changeDashboardBillingSubscription } from "@/lib/console/pymthouse-billing-bff";
+import { requireConsoleSession } from "@/lib/console/session-user";
+import {
+ PYMTHOUSE_NO_STORE_HEADERS,
+ pymthouseErrorResponse,
+} from "@/app/api/pymthouse/route-helpers";
+
+export const runtime = "nodejs";
+
+export async function POST(request: NextRequest) {
+ let body: {
+ planId?: string;
+ successUrl?: string;
+ cancelUrl?: string;
+ timing?: string;
+ effectiveAt?: string;
+ confirmReplaceScheduled?: boolean;
+ };
+ try {
+ body = (await request.json()) as typeof body;
+ } catch {
+ return NextResponse.json({ error: "invalid_json" }, { status: 400 });
+ }
+
+ const planId = body.planId?.trim();
+ if (!planId) {
+ return NextResponse.json({ error: "planId is required" }, { status: 400 });
+ }
+
+ try {
+ const session = await requireConsoleSession();
+ const result = await changeDashboardBillingSubscription({
+ planId,
+ externalUserId: session.externalUserId,
+ successUrl: body.successUrl?.trim(),
+ cancelUrl: body.cancelUrl?.trim(),
+ timing: body.timing?.trim(),
+ effectiveAt: body.effectiveAt?.trim(),
+ confirmReplaceScheduled: body.confirmReplaceScheduled === true,
+ });
+ return NextResponse.json(result, { headers: PYMTHOUSE_NO_STORE_HEADERS });
+ } catch (error) {
+ if (error instanceof PmtHouseError) {
+ return NextResponse.json(
+ {
+ error: error.message,
+ code: error.code,
+ ...(error.details && typeof error.details === "object"
+ ? (error.details as Record)
+ : {}),
+ },
+ { status: error.status, headers: PYMTHOUSE_NO_STORE_HEADERS }
+ );
+ }
+ return pymthouseErrorResponse(error, "Failed to change subscription");
+ }
+}
diff --git a/app/api/pymthouse/subscription/resume/route.ts b/app/api/pymthouse/subscription/resume/route.ts
new file mode 100644
index 0000000..2aeab4d
--- /dev/null
+++ b/app/api/pymthouse/subscription/resume/route.ts
@@ -0,0 +1,38 @@
+import { NextResponse } from "next/server";
+import { PmtHouseError } from "@pymthouse/builder-sdk";
+import { resumeDashboardUserSubscription } from "@/lib/console/pymthouse-billing-bff";
+import { requireConsoleSession } from "@/lib/console/session-user";
+import {
+ PYMTHOUSE_NO_STORE_HEADERS,
+ pymthouseErrorResponse,
+} from "@/app/api/pymthouse/route-helpers";
+
+export const runtime = "nodejs";
+
+function upstreamCode(error: PmtHouseError): string {
+ const details = error.details;
+ if (details && typeof details === "object" && "code" in details) {
+ const code = (details as { code?: unknown }).code;
+ if (typeof code === "string" && code.trim()) return code;
+ }
+ return error.code;
+}
+
+export async function POST() {
+ try {
+ const session = await requireConsoleSession();
+ await resumeDashboardUserSubscription(session.externalUserId);
+ return NextResponse.json(
+ { ok: true },
+ { headers: PYMTHOUSE_NO_STORE_HEADERS }
+ );
+ } catch (error) {
+ if (error instanceof PmtHouseError) {
+ return NextResponse.json(
+ { error: error.message, code: upstreamCode(error) },
+ { status: error.status, headers: PYMTHOUSE_NO_STORE_HEADERS }
+ );
+ }
+ return pymthouseErrorResponse(error, "Failed to resume subscription");
+ }
+}
diff --git a/app/api/pymthouse/subscription/route.ts b/app/api/pymthouse/subscription/route.ts
new file mode 100644
index 0000000..233147a
--- /dev/null
+++ b/app/api/pymthouse/subscription/route.ts
@@ -0,0 +1,24 @@
+import { NextResponse } from "next/server";
+import { getDashboardUserSubscription } from "@/lib/console/pymthouse-billing-bff";
+import { requireConsoleSession } from "@/lib/console/session-user";
+import {
+ PYMTHOUSE_NO_STORE_HEADERS,
+ pymthouseErrorResponse,
+} from "@/app/api/pymthouse/route-helpers";
+
+export const runtime = "nodejs";
+
+export async function GET() {
+ try {
+ const session = await requireConsoleSession();
+ const subscription = await getDashboardUserSubscription(
+ session.externalUserId
+ );
+ return NextResponse.json(
+ { subscription },
+ { headers: PYMTHOUSE_NO_STORE_HEADERS }
+ );
+ } catch (error) {
+ return pymthouseErrorResponse(error, "Failed to load subscription");
+ }
+}
diff --git a/app/api/pymthouse/subscriptions/route.ts b/app/api/pymthouse/subscriptions/route.ts
new file mode 100644
index 0000000..0117762
--- /dev/null
+++ b/app/api/pymthouse/subscriptions/route.ts
@@ -0,0 +1,19 @@
+import { NextResponse } from "next/server";
+import { listDashboardUserSubscriptions } from "@/lib/console/pymthouse-billing-bff";
+import { requireConsoleSession } from "@/lib/console/session-user";
+import {
+ PYMTHOUSE_NO_STORE_HEADERS,
+ pymthouseErrorResponse,
+} from "@/app/api/pymthouse/route-helpers";
+
+export const runtime = "nodejs";
+
+export async function GET() {
+ try {
+ const session = await requireConsoleSession();
+ const result = await listDashboardUserSubscriptions(session.externalUserId);
+ return NextResponse.json(result, { headers: PYMTHOUSE_NO_STORE_HEADERS });
+ } catch (error) {
+ return pymthouseErrorResponse(error, "Failed to load subscription history");
+ }
+}
diff --git a/components/console/PlansPanel.tsx b/components/console/PlansPanel.tsx
new file mode 100644
index 0000000..922427a
--- /dev/null
+++ b/components/console/PlansPanel.tsx
@@ -0,0 +1,418 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import Button from "@/components/design-system/Button";
+import Dialog from "@/components/design-system/Dialog";
+import TimingChoicePanel from "@/components/console/TimingChoicePanel";
+import type {
+ DashboardBillingPlan,
+ DashboardScheduledChangeConflict,
+} from "@/lib/console/pymthouse-billing";
+import {
+ defaultCancelTimingChoice,
+ formatBillingPlanPrice,
+ resolveTimingPayload,
+ toDateInputValue,
+ type SubscriptionTimingChoice,
+} from "@/lib/console/billing-subscription-state";
+import {
+ ScheduledChangeConflictError,
+ useBillingPlans,
+} from "@/lib/console/useBillingPlans";
+import { redirectToCheckout } from "@/lib/console/checkout-redirect";
+import { useAuth } from "@/components/console/AuthContext";
+
+function isUsagePlan(
+ plan: Pick
+): boolean {
+ if (plan.isStarterDefault) return false;
+ return plan.type.trim().toLowerCase() === "usage";
+}
+
+function formatPrice(plan: DashboardBillingPlan): string {
+ const { price, priceSub } = formatBillingPlanPrice(plan);
+ return `${price}${priceSub}`;
+}
+
+function resolvedPayPerUseBehavior(plan: DashboardBillingPlan): string {
+ const resolved = plan.resolvedBehavior?.trim();
+ if (resolved) return resolved;
+
+ return "Usage draws down included usage first, then prepaid credits, then is invoiced automatically as it accrues.";
+}
+
+function readCheckoutFlash(): "success" | "cancel" | null {
+ if (typeof window === "undefined") return null;
+ const value = new URLSearchParams(window.location.search).get("checkout");
+ if (value === "success" || value === "cancel") return value;
+ return null;
+}
+
+function readResumePlanChange(): string | null {
+ if (typeof window === "undefined") return null;
+ const value = new URLSearchParams(window.location.search)
+ .get("changePlan")
+ ?.trim();
+ return value || null;
+}
+
+function clearCheckoutQueryParam(): void {
+ if (typeof window === "undefined") return;
+ const url = new URL(window.location.href);
+ let changed = false;
+ for (const key of ["checkout", "changePlan"] as const) {
+ if (url.searchParams.has(key)) {
+ url.searchParams.delete(key);
+ changed = true;
+ }
+ }
+ if (!changed) return;
+ window.history.replaceState(
+ {},
+ "",
+ `${url.pathname}${url.search}${url.hash}`
+ );
+}
+
+export default function PlansPanel() {
+ const { isConnected } = useAuth();
+ const { state, reload, subscribe, changePlan } = useBillingPlans(isConnected);
+ const [busyPlanId, setBusyPlanId] = useState(null);
+ const [error, setError] = useState(null);
+ const [flash, setFlash] = useState<"success" | "cancel" | null>(null);
+ const [changeDialog, setChangeDialog] = useState<{
+ planId: string;
+ conflict: DashboardScheduledChangeConflict | null;
+ } | null>(null);
+ const [changeChoice, setChangeChoice] = useState(
+ defaultCancelTimingChoice()
+ );
+ const [changeCustomDate, setChangeCustomDate] = useState("");
+
+ useEffect(() => {
+ const next = readCheckoutFlash();
+ const resumePlanId = readResumePlanChange();
+ if (!next && !resumePlanId) return;
+ if (resumePlanId && !isConnected) return;
+
+ if (next) setFlash(next);
+ clearCheckoutQueryParam();
+ if (next !== "success") return;
+
+ void (async () => {
+ if (resumePlanId && isConnected) {
+ setBusyPlanId(resumePlanId);
+ try {
+ const result = await changePlan({
+ planId: resumePlanId,
+ successUrl: `${window.location.origin}/usage?checkout=success&changePlan=${encodeURIComponent(resumePlanId)}`,
+ cancelUrl: `${window.location.origin}/usage?checkout=cancel`,
+ });
+ if (result.checkoutUrl) {
+ redirectToCheckout(result.checkoutUrl);
+ return;
+ }
+ await reload();
+ setError("Plan updated.");
+ } catch (err) {
+ setError(
+ err instanceof Error
+ ? err.message
+ : "Could not finish plan change after adding a card"
+ );
+ } finally {
+ setBusyPlanId(null);
+ }
+ return;
+ }
+ void reload();
+ })();
+ }, [reload, changePlan, isConnected]);
+
+ function openChangeTimingDialog(
+ planId: string,
+ conflict: DashboardScheduledChangeConflict | null = null
+ ) {
+ setChangeChoice(defaultCancelTimingChoice());
+ setChangeCustomDate(
+ toDateInputValue(
+ conflict?.timingOptions?.minEffectiveAt ??
+ (state.status === "ready"
+ ? state.subscription?.timingOptions?.change.minEffectiveAt
+ : undefined)
+ )
+ );
+ setChangeDialog({ planId, conflict });
+ }
+
+ async function runChangePlan(
+ planId: string,
+ timing?: {
+ timing?: string;
+ effectiveAt?: string;
+ confirmReplaceScheduled?: boolean;
+ }
+ ) {
+ if (!isConnected) return;
+ const result = await changePlan({
+ planId,
+ successUrl: `${window.location.origin}/usage?checkout=success&changePlan=${encodeURIComponent(planId)}`,
+ cancelUrl: `${window.location.origin}/usage?checkout=cancel`,
+ ...timing,
+ });
+ if (result.checkoutUrl) {
+ redirectToCheckout(result.checkoutUrl);
+ return;
+ }
+ await reload();
+ const plans = state.status === "ready" ? state.plans : [];
+ const targetPlan = plans.find((p) => p.id === planId);
+ setError(
+ targetPlan && isUsagePlan(targetPlan)
+ ? "Plan updated. Add a payment method in Settings → Billing for pay-per-use auto-debit."
+ : "Plan updated."
+ );
+ }
+
+ async function onSubscribe(planId: string) {
+ if (!isConnected) {
+ setError("Sign in to subscribe.");
+ return;
+ }
+ setError(null);
+ setBusyPlanId(planId);
+ try {
+ const plans = state.status === "ready" ? state.plans : [];
+ const targetPlan = plans.find((p) => p.id === planId);
+ const activePlanId =
+ state.status === "ready" ? state.subscription?.planId : null;
+ const activeStatus =
+ state.status === "ready"
+ ? (state.subscription?.status?.toLowerCase() ?? "")
+ : "";
+ const hasActiveSubscription =
+ Boolean(activePlanId) &&
+ (activeStatus === "active" ||
+ activeStatus === "pending" ||
+ activeStatus === "trialing" ||
+ activeStatus === "scheduled");
+
+ if (hasActiveSubscription && targetPlan?.isStarterDefault === true) {
+ setBusyPlanId(null);
+ openChangeTimingDialog(planId);
+ return;
+ }
+
+ // Starter/default users already have a subscription — switch instead of
+ // create, so pay-per-use can still collect a setup Checkout card.
+ try {
+ if (hasActiveSubscription) {
+ await runChangePlan(planId);
+ } else {
+ const result = await subscribe({
+ planId,
+ successUrl: `${window.location.origin}/usage?checkout=success&changePlan=${encodeURIComponent(planId)}`,
+ cancelUrl: `${window.location.origin}/usage?checkout=cancel`,
+ });
+ if (result.checkoutUrl) {
+ redirectToCheckout(result.checkoutUrl);
+ return;
+ }
+ await reload();
+ setError(
+ targetPlan && isUsagePlan(targetPlan)
+ ? "Plan updated. Add a payment method in Settings → Billing for pay-per-use auto-debit."
+ : "Plan updated."
+ );
+ }
+ } catch (err) {
+ if (err instanceof ScheduledChangeConflictError) {
+ openChangeTimingDialog(planId, err.conflict);
+ return;
+ }
+ throw err;
+ }
+ setBusyPlanId(null);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Checkout failed");
+ setBusyPlanId(null);
+ }
+ }
+
+ async function onConfirmChangeTiming() {
+ if (!isConnected || !changeDialog) return;
+ setError(null);
+ setBusyPlanId(changeDialog.planId);
+ try {
+ const payload = resolveTimingPayload({
+ choice: changeChoice,
+ customDateYmd: changeCustomDate,
+ });
+ await runChangePlan(changeDialog.planId, {
+ ...payload,
+ ...(changeDialog.conflict ? { confirmReplaceScheduled: true } : {}),
+ });
+ setChangeDialog(null);
+ } catch (err) {
+ if (err instanceof ScheduledChangeConflictError) {
+ openChangeTimingDialog(changeDialog.planId, err.conflict);
+ return;
+ }
+ setError(
+ err instanceof Error ? err.message : "Could not change subscription"
+ );
+ } finally {
+ setBusyPlanId(null);
+ }
+ }
+
+ if (state.status === "loading" || state.status === "idle") {
+ return (
+
+ );
+ }
+
+ if (state.status === "error") {
+ return (
+
+
Could not load plans.
+
{state.message}
+
void reload()}
+ >
+ Retry
+
+
+ );
+ }
+
+ if (state.plans.length === 0) {
+ return null;
+ }
+
+ const activePlanId = state.subscription?.planId ?? null;
+ const activeStatus = state.subscription?.status?.toLowerCase() ?? "";
+ const hasActiveSubscription =
+ Boolean(activePlanId) &&
+ (activeStatus === "active" ||
+ activeStatus === "pending" ||
+ activeStatus === "trialing" ||
+ activeStatus === "scheduled");
+
+ return (
+
+
+
Plans
+
+ Subscribe via PymtHouse → Stripe Checkout
+
+ {flash === "success" ? (
+
+ Payment method saved
+ {hasActiveSubscription && state.subscription?.planName
+ ? ` · on ${state.subscription.planName}`
+ : ""}
+ .
+
+ ) : null}
+ {flash === "cancel" ? (
+
Checkout canceled.
+ ) : null}
+
+
+ {state.plans.map((plan) => {
+ const isCurrent = hasActiveSubscription && plan.id === activePlanId;
+ return (
+
+
+
+ {plan.name || plan.id}
+
+
+ {formatPrice(plan)}
+ {plan.capabilityCount > 0
+ ? ` · ${plan.capabilityCount} capabilities`
+ : ""}
+
+ {isUsagePlan(plan) ? (
+
+ {resolvedPayPerUseBehavior(plan)}
+
+ ) : null}
+
+ {isCurrent ? (
+
+ Current plan
+
+ ) : (
+ void onSubscribe(plan.id)}
+ >
+ {busyPlanId === plan.id
+ ? "Redirecting…"
+ : plan.isStarterDefault
+ ? hasActiveSubscription && !isCurrent
+ ? "Switch to Starter"
+ : "Choose Starter"
+ : isUsagePlan(plan)
+ ? "Enable pay-per-use"
+ : "Subscribe"}
+
+ )}
+
+ );
+ })}
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+
{
+ if (busyPlanId === null) setChangeDialog(null);
+ }}
+ maxWidth="max-w-[420px]"
+ >
+ void onConfirmChangeTiming()}
+ onClose={() => setChangeDialog(null)}
+ />
+
+
+ );
+}
diff --git a/components/console/TimingChoicePanel.tsx b/components/console/TimingChoicePanel.tsx
new file mode 100644
index 0000000..a719a54
--- /dev/null
+++ b/components/console/TimingChoicePanel.tsx
@@ -0,0 +1,105 @@
+"use client";
+
+import {
+ formatPendingCancelDate,
+ toDateInputValue,
+ type SubscriptionTimingChoice,
+ type SubscriptionTimingOptions,
+} from "@/lib/console/billing-subscription-state";
+
+export default function TimingChoicePanel(props: {
+ title: string;
+ description: string;
+ options: SubscriptionTimingOptions | null | undefined;
+ choice: SubscriptionTimingChoice;
+ customDate: string;
+ confirmLabel: string;
+ busy: boolean;
+ onChoice: (choice: SubscriptionTimingChoice) => void;
+ onCustomDate: (ymd: string) => void;
+ onConfirm: () => void;
+ onClose: () => void;
+}) {
+ const min = toDateInputValue(props.options?.minEffectiveAt);
+ const max = toDateInputValue(props.options?.maxEffectiveAt);
+ return (
+
+
{props.title}
+
{props.description}
+
+ {(
+ [
+ {
+ id: "immediate" as const,
+ label: "Immediately",
+ hint: "Takes effect right away",
+ },
+ {
+ id: "next_billing_cycle" as const,
+ label: "End of current period",
+ hint: props.options?.maxEffectiveAt
+ ? formatPendingCancelDate(props.options.maxEffectiveAt)
+ : "Keep access until the period ends",
+ },
+ {
+ id: "custom" as const,
+ label: "Pick a date",
+ hint: min && max ? `${min} – ${max}` : "Choose a date in range",
+ },
+ ] as const
+ ).map((opt) => (
+
+ props.onChoice(opt.id)}
+ />
+
+
+ {opt.label}
+
+
+ {opt.hint}
+
+
+
+ ))}
+
+ {props.choice === "custom" ? (
+
props.onCustomDate(e.target.value)}
+ />
+ ) : null}
+
+
+ Cancel
+
+
+ {props.busy ? "Working…" : props.confirmLabel}
+
+
+
+ );
+}
diff --git a/components/console/UsageView.tsx b/components/console/UsageView.tsx
index 5414b06..e31a466 100644
--- a/components/console/UsageView.tsx
+++ b/components/console/UsageView.tsx
@@ -15,6 +15,7 @@ import {
type UsageCapabilityRow,
} from "@/lib/console/usage-capability-display";
import ConsolePageSkeleton from "@/components/console/ConsolePageSkeleton";
+import PlansPanel from "@/components/console/PlansPanel";
type IncludedUsageSummary = {
planName?: string;
@@ -309,6 +310,8 @@ export default function UsageView() {
Account{user?.id ? ` · ${user.id}` : ""}
+
+
+): boolean {
+ if (plan.isStarterDefault) return false;
+ return plan.type.trim().toLowerCase() === "usage";
+}
+
+function resolvedPayPerUseBehavior(plan: DashboardBillingPlan): string {
+ const resolved = plan.resolvedBehavior?.trim();
+ if (resolved) {
+ return resolved;
+ }
+
+ return "Pay-per-use — usage draws down prepaid credits first, then is invoiced automatically as it accrues.";
+}
+
+function formatInvoiceAmount(totalAmount: string, currency: string): string {
+ const n = Number(totalAmount);
+ if (!Number.isFinite(n)) return `${totalAmount} ${currency}`;
+ // OpenMeter invoice totals are decimal dollar strings (e.g. "2.50").
+ return new Intl.NumberFormat("en-US", {
+ style: "currency",
+ currency: currency || "USD",
+ }).format(n);
+}
+
+function formatInvoiceDate(iso: string | undefined): string {
+ if (!iso) return "—";
+ const d = new Date(iso);
+ if (Number.isNaN(d.getTime())) return iso;
+ return d.toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ });
+}
+
+function formatSubscriptionHistoryStatus(input: {
+ status: string;
+ current: boolean;
+}): string {
+ if (input.current) return "Current";
+ const status = input.status.trim().toLowerCase();
+ if (status === "scheduled" || status === "pending") return "Scheduled";
+ if (
+ status === "inactive" ||
+ status === "canceled" ||
+ status === "cancelled"
+ ) {
+ return "Ended";
+ }
+ return input.status || "—";
+}
+
+function readCheckoutFlash(): "success" | "cancel" | null {
+ if (typeof window === "undefined") return null;
+ const value = new URLSearchParams(window.location.search).get("checkout");
+ if (value === "success" || value === "cancel") return value;
+ return null;
+}
+
+/** Plan id to finish switching after setup-mode Checkout returns. */
+function readResumePlanChange(): string | null {
+ if (typeof window === "undefined") return null;
+ const value = new URLSearchParams(window.location.search)
+ .get("changePlan")
+ ?.trim();
+ return value || null;
+}
+
+function clearCheckoutQueryParam(): void {
+ if (typeof window === "undefined") return;
+ const url = new URL(window.location.href);
+ let changed = false;
+ for (const key of ["checkout", "changePlan"] as const) {
+ if (url.searchParams.has(key)) {
+ url.searchParams.delete(key);
+ changed = true;
+ }
+ }
+ if (!changed) return;
+ window.history.replaceState(
+ {},
+ "",
+ `${url.pathname}${url.search}${url.hash}`
+ );
+}
+
+function billingChangePlanSuccessUrl(planId: string): string {
+ const url = new URL("/settings", window.location.origin);
+ url.searchParams.set("tab", "billing");
+ url.searchParams.set("checkout", "success");
+ url.searchParams.set("changePlan", planId);
+ return url.toString();
+}
+
+function billingChangePlanCancelUrl(): string {
+ return `${window.location.origin}/settings?tab=billing&checkout=cancel`;
+}
/**
- * Organization · Billing — `?tab=billing` per the v7 prototype.
- *
- * Four blocks:
- * 1. Plan — three plan cards side by side (Free, Pro, Scale)
- * 2. Payment method — empty state ("No payment method · Add a card…")
- * 3. Billing details — company / email / tax ID / address fields
- * 4. Invoices — table of historical invoices
+ * Organization · Billing — live plan, payment method, and invoices.
+ * Fake company/tax/address “Billing details” removed (no API).
*/
export default function BillingSection() {
- // Billing view is rendered behind a blur with a "Work in progress" notice
- // on top — reviewers can see the surface area without mistaking it for a
- // finalized flow. Real treatment is still being designed.
+ const { isConnected } = useAuth();
+ const {
+ state: plansState,
+ reload: reloadPlans,
+ subscribe,
+ changePlan,
+ cancelSubscription,
+ resumeSubscription,
+ } = useBillingPlans(isConnected);
+ const {
+ state: accountState,
+ reload: reloadAccount,
+ startPaymentMethodCheckout,
+ openInvoice,
+ setDefaultPaymentMethod,
+ ensureDefaultPaymentMethod,
+ removePaymentMethod,
+ } = useBillingAccount(isConnected);
+ const wallet = useWalletBillingState(isConnected);
+ const included =
+ wallet.state.status === "ready"
+ ? includedUsageSummary(wallet.state.wallet.billingState)
+ : null;
+
+ const [busyPlanId, setBusyPlanId] = useState(null);
+ const [pmBusy, setPmBusy] = useState(false);
+ const [lifecycleBusy, setLifecycleBusy] = useState(false);
+ const [paymentMethodActionId, setPaymentMethodActionId] = useState<
+ string | null
+ >(null);
+ const [invoiceBusyId, setInvoiceBusyId] = useState(null);
+ const [error, setError] = useState(null);
+ const [billingNotice, setBillingNotice] = useState(null);
+ const [flash, setFlash] = useState<"success" | "cancel" | null>(null);
+
+ const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
+ const [cancelChoice, setCancelChoice] = useState(
+ defaultCancelTimingChoice()
+ );
+ const [cancelCustomDate, setCancelCustomDate] = useState("");
+ const [changeDialog, setChangeDialog] = useState<{
+ planId: string;
+ conflict: DashboardScheduledChangeConflict | null;
+ } | null>(null);
+ const [changeChoice, setChangeChoice] =
+ useState("immediate");
+ const [changeCustomDate, setChangeCustomDate] = useState("");
+
+ useEffect(() => {
+ const next = readCheckoutFlash();
+ const resumePlanId = readResumePlanChange();
+ if (!next && !resumePlanId) return;
+ // Wait for auth before consuming a resume intent from the return URL.
+ if (resumePlanId && !isConnected) return;
+
+ if (next) setFlash(next);
+ clearCheckoutQueryParam();
+ if (next === "success") {
+ void (async () => {
+ if (isConnected) {
+ try {
+ await ensureDefaultPaymentMethod();
+ } catch {
+ // Webhook may already have promoted; list/UI still refreshes.
+ }
+ }
+ if (resumePlanId && isConnected) {
+ setBusyPlanId(resumePlanId);
+ try {
+ await runChangePlan(resumePlanId);
+ } catch (err) {
+ setError(
+ err instanceof Error
+ ? err.message
+ : "Could not finish plan change after adding a card"
+ );
+ } finally {
+ setBusyPlanId(null);
+ }
+ return;
+ }
+ void reloadPlans();
+ void reloadAccount();
+ })();
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- resume once from return URL
+ }, [isConnected, ensureDefaultPaymentMethod, reloadPlans, reloadAccount]);
+
+ async function ensurePaymentMethodForUsagePlan(planId: string) {
+ if (!isConnected) return;
+ const plan = (plansState.status === "ready" ? plansState.plans : []).find(
+ (p) => p.id === planId
+ );
+ if (!plan || !isUsagePlan(plan)) return;
+
+ // Pay-per-use needs a card for threshold auto-debit. If plan change did
+ // not return Checkout (older pymthouse), start setup-mode Checkout here.
+ try {
+ const { checkoutUrl } = await startPaymentMethodCheckout();
+ redirectToCheckout(checkoutUrl);
+ } catch (err) {
+ const message =
+ err instanceof Error ? err.message : "Payment method checkout failed";
+ setBillingNotice(
+ "Pay-per-use plan is active. Add a card below so usage can auto-debit after prepaid credits."
+ );
+ setError(message);
+ }
+ }
+
+ async function runChangePlan(
+ planId: string,
+ timing?: {
+ timing?: string;
+ effectiveAt?: string;
+ confirmReplaceScheduled?: boolean;
+ }
+ ) {
+ if (!isConnected) return;
+ const result = await changePlan({
+ planId,
+ successUrl: billingChangePlanSuccessUrl(planId),
+ cancelUrl: billingChangePlanCancelUrl(),
+ ...timing,
+ });
+ if (result.checkoutUrl) {
+ redirectToCheckout(result.checkoutUrl);
+ return;
+ }
+ await reloadPlans();
+ setBillingNotice("Your plan has been updated.");
+ await ensurePaymentMethodForUsagePlan(planId);
+ }
+
+ function openChangeTimingDialog(
+ planId: string,
+ conflict: DashboardScheduledChangeConflict | null = null
+ ) {
+ setChangeChoice(defaultCancelTimingChoice());
+ setChangeCustomDate(
+ toDateInputValue(
+ conflict?.timingOptions?.minEffectiveAt ??
+ subscription?.timingOptions?.change.minEffectiveAt
+ )
+ );
+ setChangeDialog({ planId, conflict });
+ }
+
+ async function onPlanAction(planId: string, action: BillingPlanAction) {
+ if (!isConnected) {
+ setError("Sign in to subscribe.");
+ return;
+ }
+ if (action === "current") {
+ return;
+ }
+
+ setError(null);
+ setBillingNotice(null);
+ setBusyPlanId(planId);
+ try {
+ if (action === "change_plan") {
+ const catalog = plansState.status === "ready" ? plansState.plans : [];
+ const liveSubscription =
+ plansState.status === "ready" ? plansState.subscription : null;
+ const targetPlan = withCurrentPlanInDisplayList(
+ catalog,
+ liveSubscription
+ ).find((p) => p.id === planId);
+ // Starter downgrades schedule silently without timing — prompt first.
+ if (targetPlan?.isStarterDefault === true) {
+ setBusyPlanId(null);
+ openChangeTimingDialog(planId);
+ return;
+ }
+ try {
+ await runChangePlan(planId);
+ } catch (err) {
+ if (err instanceof ScheduledChangeConflictError) {
+ openChangeTimingDialog(planId, err.conflict);
+ return;
+ }
+ throw err;
+ }
+ return;
+ }
+
+ const input = {
+ planId:
+ action === "retry_checkout"
+ ? (subscriptionUiState.planId ?? planId)
+ : planId,
+ successUrl: billingChangePlanSuccessUrl(
+ action === "retry_checkout"
+ ? (subscriptionUiState.planId ?? planId)
+ : planId
+ ),
+ cancelUrl: billingChangePlanCancelUrl(),
+ };
+ const result = await subscribe(input);
+
+ if (result.checkoutUrl) {
+ redirectToCheckout(result.checkoutUrl);
+ return;
+ }
+
+ await reloadPlans();
+ setBillingNotice("Your plan has been updated.");
+ } catch (err) {
+ const message = err instanceof Error ? err.message : "Checkout failed";
+ if (isActiveSubscriptionConflict(message)) {
+ setBillingNotice(
+ "You already have a subscription. Choose another plan to switch, or complete payment for your current plan."
+ );
+ await reloadPlans();
+ } else {
+ setError(message);
+ }
+ } finally {
+ setBusyPlanId(null);
+ }
+ }
+
+ function openCancelDialog() {
+ setCancelChoice(defaultCancelTimingChoice());
+ setCancelCustomDate(
+ toDateInputValue(subscription?.timingOptions?.cancel.minEffectiveAt)
+ );
+ setCancelDialogOpen(true);
+ }
+
+ async function onConfirmCancel() {
+ if (!isConnected) {
+ setError("Sign in to cancel.");
+ return;
+ }
+ setError(null);
+ setBillingNotice(null);
+ setLifecycleBusy(true);
+ try {
+ const payload = resolveTimingPayload({
+ choice: cancelChoice,
+ customDateYmd: cancelCustomDate,
+ });
+ await cancelSubscription(payload);
+ setCancelDialogOpen(false);
+ await reloadPlans();
+ setBillingNotice(
+ cancelChoice === "immediate"
+ ? "Your subscription has been canceled."
+ : `Cancellation scheduled${
+ payload.effectiveAt
+ ? ` for ${formatPendingCancelDate(payload.effectiveAt)}`
+ : " for the end of this period"
+ }.`
+ );
+ } catch (err) {
+ setError(
+ err instanceof Error ? err.message : "Could not cancel subscription"
+ );
+ } finally {
+ setLifecycleBusy(false);
+ }
+ }
+
+ async function onConfirmChangeTiming() {
+ if (!isConnected || !changeDialog) return;
+ setError(null);
+ setBillingNotice(null);
+ setBusyPlanId(changeDialog.planId);
+ try {
+ const payload = resolveTimingPayload({
+ choice: changeChoice,
+ customDateYmd: changeCustomDate,
+ });
+ await runChangePlan(changeDialog.planId, {
+ ...payload,
+ ...(changeDialog.conflict ? { confirmReplaceScheduled: true } : {}),
+ });
+ setChangeDialog(null);
+ } catch (err) {
+ if (err instanceof ScheduledChangeConflictError) {
+ openChangeTimingDialog(changeDialog.planId, err.conflict);
+ return;
+ }
+ setError(
+ err instanceof Error ? err.message : "Could not change subscription"
+ );
+ } finally {
+ setBusyPlanId(null);
+ }
+ }
+
+ async function onCancelSubscription() {
+ openCancelDialog();
+ }
+
+ async function onResumeSubscription() {
+ if (!isConnected) {
+ setError("Sign in to restore your plan.");
+ return;
+ }
+ setError(null);
+ setBillingNotice(null);
+ setFlash(null);
+ setLifecycleBusy(true);
+ try {
+ await resumeSubscription();
+ await reloadPlans();
+ setBillingNotice("Your plan will continue — cancellation removed.");
+ } catch (err) {
+ // Nothing left to undo upstream — the local snapshot is stale, so reload
+ // it and drop the banner rather than stranding an error.
+ if (
+ err instanceof ResumeSubscriptionError &&
+ isNothingToResumeError(err.code)
+ ) {
+ await reloadPlans();
+ setBillingNotice(
+ "No scheduled cancellation is pending — your plan is up to date."
+ );
+ return;
+ }
+ setError(
+ err instanceof Error ? err.message : "Could not restore subscription"
+ );
+ } finally {
+ setLifecycleBusy(false);
+ }
+ }
+
+ async function onAddCard() {
+ if (!isConnected) {
+ setError("Sign in to add a payment method.");
+ return;
+ }
+ setError(null);
+ setPmBusy(true);
+ try {
+ const { checkoutUrl } = await startPaymentMethodCheckout();
+ redirectToCheckout(checkoutUrl);
+ } catch (err) {
+ setError(
+ err instanceof Error ? err.message : "Payment method checkout failed"
+ );
+ setPmBusy(false);
+ }
+ }
+
+ async function onOpenInvoice(invoiceId: string, prefer: "hosted" | "pdf") {
+ if (!isConnected) return;
+ setError(null);
+ setInvoiceBusyId(invoiceId);
+ try {
+ const links = await openInvoice({ invoiceId });
+ const url =
+ prefer === "pdf"
+ ? links.invoicePdf || links.hostedInvoiceUrl
+ : links.hostedInvoiceUrl || links.invoicePdf;
+ if (!url) {
+ throw new Error(
+ invoiceId.startsWith("pi_")
+ ? "No Stripe receipt for this top-up yet."
+ : "No Stripe invoice page for this invoice yet."
+ );
+ }
+ window.open(url, "_blank", "noopener,noreferrer");
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Could not open invoice");
+ } finally {
+ setInvoiceBusyId(null);
+ }
+ }
+
+ async function onSetDefaultPaymentMethod(paymentMethodId: string) {
+ if (!isConnected) return;
+ setError(null);
+ setPaymentMethodActionId(paymentMethodId);
+ try {
+ await setDefaultPaymentMethod({ paymentMethodId });
+ } catch (err) {
+ setError(
+ err instanceof Error
+ ? err.message
+ : "Could not set default payment method"
+ );
+ } finally {
+ setPaymentMethodActionId(null);
+ }
+ }
+
+ async function onRemovePaymentMethod(paymentMethodId: string) {
+ if (!isConnected) return;
+ if (!window.confirm("Remove this payment method?")) return;
+ setError(null);
+ setPaymentMethodActionId(paymentMethodId);
+ try {
+ await removePaymentMethod({ paymentMethodId });
+ } catch (err) {
+ setError(
+ err instanceof Error ? err.message : "Could not remove payment method"
+ );
+ } finally {
+ setPaymentMethodActionId(null);
+ }
+ }
+
+ const plansLoading =
+ plansState.status === "loading" || plansState.status === "idle";
+ const accountLoading =
+ accountState.status === "loading" || accountState.status === "idle";
+
+ const catalogPlans = plansState.status === "ready" ? plansState.plans : [];
+ const subscription =
+ plansState.status === "ready" ? plansState.subscription : null;
+ const plans = withCurrentPlanInDisplayList(
+ catalogPlans,
+ subscription
+ ) as DashboardBillingPlan[];
+ const subscriptionUiState = deriveBillingSubscriptionUiState(subscription);
+ const paymentMethods =
+ accountState.status === "ready" ? accountState.paymentMethods : [];
+ const invoices = accountState.status === "ready" ? accountState.invoices : [];
+ const subscriptions =
+ accountState.status === "ready" ? accountState.subscriptions : [];
+ const paymentMethodsError =
+ accountState.status === "ready" ? accountState.paymentMethodsError : null;
+ const invoicesError =
+ accountState.status === "ready" ? accountState.invoicesError : null;
+ const subscriptionsError =
+ accountState.status === "ready" ? accountState.subscriptionsError : null;
+
+ const cancelingPlanName = resolveCancelingPlanName(subscription);
+ const cancelingEndsAt = resolveCancelingEffectiveAt(subscription);
+ const cancelingEndsLabel = formatPendingCancelDate(cancelingEndsAt);
+
+ const planSub =
+ subscriptionUiState.kind === "canceling"
+ ? `${cancelingPlanName} ends ${cancelingEndsLabel}`
+ : included
+ ? includedUsageRemainingLabel(included)
+ : subscription?.planName?.trim() ||
+ (subscriptionUiState.kind === "pending"
+ ? "Payment needs to be completed"
+ : subscriptionUiState.kind === "active"
+ ? "Current subscription"
+ : "Choose a plan to get started");
+
+ // Starter is the floor — cancel is only for paid catalog plans.
+ const canCancel = canCancelBillingSubscription(
+ subscriptionUiState,
+ paidCatalogPlanIds(catalogPlans),
+ Boolean(isConnected)
+ );
+ const canResume =
+ Boolean(isConnected) &&
+ Boolean(resolveApplicablePendingCancel(subscription));
+ const showCancelingBanner = subscriptionUiState.kind === "canceling";
+
return (
-
-
-
+
+ {flash === "success" ? (
+
+ Checkout completed — billing details refreshed.
+
+ ) : null}
+ {flash === "cancel" ? (
+
Checkout canceled.
+ ) : null}
+ {billingNotice ? (
+
+ {billingNotice}
+
+ ) : null}
+ {error ? (
+
+ {error}
+
+ ) : null}
-
-
- {/* Free — current plan: 2px accent rail on the left + a vertical
- `rgba(64,191,134,0.06) → transparent` wash, per the v7
- prototype's `.plan-active` rule. The gradient subtly tints the
- top of the card so the "current plan" reads at a glance even
- before the eyebrow is parsed. */}
-
+
+ Ends at end of current period
+
+
+ {cancelingPlanName} stays active until {cancelingEndsLabel}. You
+ chose to let it expire at the end of the current period — access
+ continues until then. Switching to another plan replaces this
+ remaining period.
+
+ {canResume ? (
+
void onResumeSubscription()}
>
-
-
- Current plan
-
- Free
-
-
- $0
-
- / month
-
-
- {[
- "10,000 jobs / month",
- "3 concurrent streams",
- "5 GB storage retention",
- "Community support",
- ].map((line) => (
-
-
- {line}
-
- ))}
-
-
+ {lifecycleBusy ? "Restoring…" : `Keep ${cancelingPlanName}`}
+
+ ) : null}
+
+ ) : null}
- {/* Pro — upgrade option */}
-
+ void onCancelSubscription()}
+ >
+ {lifecycleBusy ? "Canceling…" : "Cancel subscription"}
+
+ ) : canResume ? (
+ void onResumeSubscription()}
+ >
+ {lifecycleBusy ? "Restoring…" : "Restore plan"}
+
+ ) : undefined
+ }
+ />
+
+ {plansLoading ? (
+
+ ) : plansState.status === "error" ? (
+
+
Could not load plans.
+
+ {plansState.message}
+
+
void reloadPlans()}
+ >
+ Retry
+
+
+ ) : plans.length === 0 ? (
+
+
+ No paid plans are published for this app yet.
+
+
+ ) : (
+
+ {plans.map((plan, index) => {
+ const action = deriveBillingPlanAction(
+ subscriptionUiState,
+ plan.id
+ );
+ const isCurrent = action === "current";
+ const isPending =
+ subscriptionUiState.kind === "pending" &&
+ subscriptionUiState.planId === plan.id;
+ const { price, priceSub } = formatBillingPlanPrice(plan);
+ const isStarter =
+ plan.isStarterDefault === true ||
+ plan.type.trim().toLowerCase() === "free";
+ const includedUsage = includedUsageFeatureLabel(plan);
+ const features: string[] = [];
+ if (
+ isCurrent &&
+ included &&
+ (included.planId === plan.id || !included.planId)
+ ) {
+ features.push(
+ `$${included.remainingUsd} of $${included.totalUsd} included left`
+ );
+ }
+ if (isUsagePlan(plan)) {
+ features.push(resolvedPayPerUseBehavior(plan));
+ } else {
+ if (includedUsage) {
+ features.push(includedUsage);
+ } else if (isStarter) {
+ features.push("Free included usage");
+ }
+ if (!isStarter) {
+ features.push(
+ plan.billingCycle
+ ? `${plan.billingCycle} billing`
+ : "Usage-based billing"
+ );
+ }
+ }
+ features.push(
+ plan.capabilityCount > 0
+ ? `${plan.capabilityCount} capabilities`
+ : "All included capabilities"
+ );
+ return (
+ void onPlanAction(plan.id, action)}
+ />
+ );
+ })}
+
+ )}
+
- {/* Scale — enterprise */}
-
+ void onAddCard()}
+ disabled={pmBusy || !isConnected}
+ >
+
+ {pmBusy ? "Starting…" : "Add card"}
+
+ }
+ />
+
+ {accountLoading ? (
+
-
-
-
-
- Add card
-
- }
- />
-
+ ) : paymentMethodsError ? (
+
+
+ Could not load payment methods.
+
+
+ {paymentMethodsError}
+
+
void reloadAccount()}
+ >
+ Retry
+
+
+ ) : paymentMethods.length === 0 ? (
- Add a card to keep capabilities running past the free quota.
+ Add a card via Stripe Checkout for subscription and usage charges.
+ Completing Checkout updates your card on file even if you do not
+ return to this page.
-
+ ) : (
+
+ {paymentMethods.map((pm) => {
+ const isBusy = paymentMethodActionId === pm.id;
+ return (
+
+
+
+
+ {(pm.brand || pm.type || "Card").toUpperCase()}
+ {pm.last4 ? ` ···· ${pm.last4}` : ""}
+
+
+ {pm.expMonth && pm.expYear
+ ? `Expires ${String(pm.expMonth).padStart(2, "0")}/${pm.expYear}`
+ : pm.type}
+ {pm.isDefault ? " · Default" : ""}
+
+
+ {!pm.isDefault ? (
+ void onSetDefaultPaymentMethod(pm.id)}
+ >
+ {isBusy ? "Saving…" : "Set default"}
+
+ ) : null}
+ void onRemovePaymentMethod(pm.id)}
+ aria-label={`Remove ${(pm.brand || pm.type || "payment method").toLowerCase()} ending ${pm.last4 ?? ""}`}
+ title="Remove payment method"
+ >
+ {isBusy ? (
+ "…"
+ ) : (
+
+ )}
+
+
+ );
+ })}
+
+ )}
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Download all
-
- }
- />
-
-
-
Invoice
-
Date
-
Amount
-
Description
-
+
+
+ {accountLoading ? (
+
+ ) : subscriptionsError ? (
+
+
+ Could not load plan history.
+
+
+ {subscriptionsError}
+
+
void reloadAccount()}
+ >
+ Retry
+
- {[
- {
- id: "INV-2024-04",
- date: "Apr 1, 2025",
- amount: "$0.00",
- desc: "Free tier · 9,127 jobs",
- },
- {
- id: "INV-2024-03",
- date: "Mar 1, 2025",
- amount: "$0.00",
- desc: "Free tier · 4,820 jobs",
- },
- {
- id: "INV-2024-02",
- date: "Feb 1, 2025",
- amount: "$0.00",
- desc: "Free tier · 1,602 jobs",
- },
- ].map((inv) => (
+ ) : subscriptions.length === 0 ? (
+
+
+ No subscription history yet.
+
+
+ ) : (
+ <>
-
{inv.id}
-
{inv.date}
-
- {inv.amount}
-
-
{inv.desc}
-
-
- View
-
- Plan
+ Status
+ Started
+ Ended
+
+ {subscriptions.map((sub) => (
+
+
+
+ {sub.planName?.trim() || sub.planKey?.trim() || "Plan"}
+
+
+ {sub.id}
+
+
+
-
- PDF
-
+ {formatSubscriptionHistoryStatus(sub)}
+
+
+ {formatInvoiceDate(sub.activeFrom ?? undefined)}
+
+
+ {sub.current
+ ? "—"
+ : formatInvoiceDate(sub.activeTo ?? undefined)}
+
+ ))}
+ >
+ )}
+
+
+
+
+ {accountLoading ? (
+
+ ) : invoicesError ? (
+
+
+ Could not load billing history.
+
+
+ {invoicesError}
+
+
void reloadAccount()}
+ >
+ Retry
+
+
+ ) : invoices.length === 0 ? (
+
+
+ No invoices or top-ups yet.
+
+
+ ) : (
+ <>
+
+ Item
+ Date
+ Amount
+ Status
+
- ))}
-
-
-
- {/* WIP overlay — sits above the blurred content. Pointer-events-none
- on the wrapper so the blur layer below stays inert; the notice
- itself re-enables pointer-events so it's selectable text. */}
-
-
-
- Work in progress
-
-
- Billing UX is not finalized
-
-
- This view is still in flux while we settle on the payment-provider
- model.
-
-
-
+ {invoices.map((inv) => {
+ const isReceiptOnly =
+ inv.invoiceType === "auto_topup" ||
+ inv.invoiceType === "payment";
+ const statusLabel =
+ inv.invoiceType === "auto_topup"
+ ? "Top-up"
+ : inv.invoiceType === "payment"
+ ? "Paid"
+ : inv.status;
+ return (
+
+
+ {inv.number?.trim() || inv.id}
+
+
+ {formatInvoiceDate(inv.issuedAt || inv.periodStart)}
+
+
+ {formatInvoiceAmount(inv.totalAmount, inv.currency)}
+
+
+ {statusLabel}
+
+
+ void onOpenInvoice(inv.id, "hosted")}
+ >
+ {isReceiptOnly ? "Receipt" : "View"}
+
+ {isReceiptOnly ? null : (
+ void onOpenInvoice(inv.id, "pdf")}
+ >
+
+ PDF
+
+ )}
+
+
+ );
+ })}
+ >
+ )}
+
+
+
{
+ if (!lifecycleBusy) setCancelDialogOpen(false);
+ }}
+ maxWidth="max-w-[420px]"
+ >
+ void onConfirmCancel()}
+ onClose={() => setCancelDialogOpen(false)}
+ />
+
+
+
{
+ if (busyPlanId === null) setChangeDialog(null);
+ }}
+ maxWidth="max-w-[420px]"
+ >
+ void onConfirmChangeTiming()}
+ onClose={() => setChangeDialog(null)}
+ />
+
);
}
-function PlanCard({
- name,
+function LivePlanCard({
+ plan,
price,
priceSub,
features,
- cta,
- ctaOutline = false,
- isLast = false,
+ isCurrent,
+ isPending,
+ isLast,
+ busy,
+ disabled,
+ action,
+ onSelect,
}: {
- name: string;
+ plan: DashboardBillingPlan;
price: string;
priceSub: string;
features: string[];
- cta: string;
- ctaOutline?: boolean;
- isLast?: boolean;
+ isCurrent: boolean;
+ isPending: boolean;
+ isLast: boolean;
+ busy: boolean;
+ disabled: boolean;
+ action: BillingPlanAction;
+ onSelect: () => void;
}) {
+ const isHighlighted = isCurrent || isPending;
return (
-
{name}
+ {isHighlighted ? (
+
+ ) : null}
+ {isCurrent ? (
+
+ Current plan
+
+ ) : null}
+ {isPending ? (
+
+ Payment pending
+
+ ) : null}
+
+ {plan.name}
+
{price}
@@ -313,16 +1205,23 @@ function PlanCard({
- {cta}
- {!ctaOutline && (
+ {busy
+ ? "Working…"
+ : billingPlanActionLabel(action, {
+ usagePlan: isUsagePlan(plan),
+ starterPlan: plan.isStarterDefault === true,
+ })}
+ {action !== "current" && !busy ? (
- )}
+ ) : null}
);
diff --git a/lib/console/billing-subscription-state.test.ts b/lib/console/billing-subscription-state.test.ts
new file mode 100644
index 0000000..b74a5b9
--- /dev/null
+++ b/lib/console/billing-subscription-state.test.ts
@@ -0,0 +1,376 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import {
+ dateInputToEffectiveAtIso,
+ defaultCancelTimingChoice,
+ canCancelBillingSubscription,
+ deriveBillingPlanAction,
+ deriveBillingSubscriptionUiState,
+ formatBillingPlanPrice,
+ formatIncludedUsdMicros,
+ formatPendingCancelDate,
+ isNothingToResumeError,
+ paidCatalogPlanIds,
+ resolveApplicablePendingCancel,
+ resolveTimingPayload,
+ includedUsageFeatureLabel,
+ starterIncludedUsageLabel,
+ toDateInputValue,
+ withCurrentPlanInDisplayList,
+} from "./billing-subscription-state";
+
+test("derives no subscription as a subscribe state", () => {
+ const state = deriveBillingSubscriptionUiState(null);
+
+ assert.deepEqual(state, { kind: "none", planId: null });
+ assert.equal(deriveBillingPlanAction(state, "pro"), "subscribe");
+});
+
+test("marks the active plan current and other plans switchable", () => {
+ const state = deriveBillingSubscriptionUiState({
+ planId: "pro",
+ status: "active",
+ });
+
+ assert.deepEqual(state, { kind: "active", planId: "pro" });
+ assert.equal(deriveBillingPlanAction(state, "pro"), "current");
+ assert.equal(deriveBillingPlanAction(state, "scale"), "change_plan");
+});
+
+test("Starter / non-catalog plans cannot be canceled — only paid catalog plans can", () => {
+ const starterActive = deriveBillingSubscriptionUiState({
+ planId: "starter",
+ status: "active",
+ });
+ assert.equal(
+ canCancelBillingSubscription(starterActive, ["pro", "scale"], true),
+ false,
+ );
+ assert.equal(
+ canCancelBillingSubscription(
+ deriveBillingSubscriptionUiState({ planId: "pro", status: "active" }),
+ ["pro", "scale"],
+ true,
+ ),
+ true,
+ );
+ assert.equal(
+ canCancelBillingSubscription(starterActive, ["pro"], false),
+ false,
+ );
+});
+
+test("withCurrentPlanInDisplayList injects Starter when missing from catalog", () => {
+ const paid = [
+ {
+ id: "pro",
+ name: "Pro",
+ type: "subscription",
+ status: "active",
+ priceAmount: "29",
+ priceCurrency: "USD",
+ billingCycle: "monthly",
+ chargeThresholdUsdMicros: null,
+ resolvedBehavior: null,
+ capabilityCount: 3,
+ isStarterDefault: false,
+ },
+ ];
+
+ const withStarter = withCurrentPlanInDisplayList(paid, {
+ planId: "starter",
+ planName: "Starter",
+ });
+ assert.equal(withStarter.length, 2);
+ assert.equal(withStarter[0]?.id, "starter");
+ assert.equal(withStarter[0]?.name, "Starter");
+ assert.equal(withStarter[0]?.type, "free");
+ assert.equal(withStarter[0]?.isStarterDefault, true);
+ assert.equal(withStarter[1]?.id, "pro");
+
+ // Already in catalog — no duplicate
+ assert.deepEqual(
+ withCurrentPlanInDisplayList(paid, { planId: "pro", planName: "Pro" }),
+ paid,
+ );
+
+ // No subscription — catalog only
+ assert.deepEqual(withCurrentPlanInDisplayList(paid, null), paid);
+
+ // Starter alone when no paid plans published
+ const starterOnly = withCurrentPlanInDisplayList([], {
+ planId: "starter",
+ planName: "Starter",
+ });
+ assert.equal(starterOnly.length, 1);
+ assert.equal(starterOnly[0]?.name, "Starter");
+});
+
+test("paidCatalogPlanIds excludes Starter defaults", () => {
+ assert.deepEqual(
+ paidCatalogPlanIds([
+ { id: "starter", isStarterDefault: true },
+ { id: "pro", isStarterDefault: false },
+ { id: "legacy" },
+ ]),
+ ["pro", "legacy"],
+ );
+});
+
+test("routes pending subscriptions to checkout retry", () => {
+ const state = deriveBillingSubscriptionUiState({
+ planId: "pro",
+ status: "pending",
+ });
+
+ assert.deepEqual(state, { kind: "pending", planId: "pro" });
+ assert.equal(deriveBillingPlanAction(state, "pro"), "retry_checkout");
+ assert.equal(deriveBillingPlanAction(state, "scale"), "retry_checkout");
+});
+
+test("cancel-at-period-end is canceling from pendingCancel or inactive status", () => {
+ const fromPending = deriveBillingSubscriptionUiState({
+ planId: "starter",
+ status: "canceled",
+ currentPeriodEnd: "2026-09-07T17:35:18.109Z",
+ pendingCancel: {
+ subscriptionId: "sub_starter",
+ planId: "starter",
+ planName: "Starter",
+ effectiveAt: "2026-09-07T17:35:18.109Z",
+ },
+ });
+ assert.deepEqual(fromPending, { kind: "canceling", planId: "starter" });
+ assert.equal(deriveBillingPlanAction(fromPending, "starter"), "current");
+ assert.equal(deriveBillingPlanAction(fromPending, "pro"), "change_plan");
+
+ const fromInactive = deriveBillingSubscriptionUiState({
+ planId: "starter",
+ status: "inactive",
+ currentPeriodEnd: "2026-09-07T17:35:18.109Z",
+ });
+ assert.deepEqual(fromInactive, { kind: "canceling", planId: "starter" });
+
+ // pendingCancel alone (no subscription.planId) still surfaces canceling
+ const pendingOnly = deriveBillingSubscriptionUiState({
+ planId: null,
+ status: null,
+ pendingCancel: {
+ subscriptionId: "sub_starter",
+ planId: "starter",
+ effectiveAt: "2026-09-07T17:35:18.109Z",
+ },
+ });
+ assert.deepEqual(pendingOnly, { kind: "canceling", planId: "starter" });
+});
+
+test("a pendingCancel for a superseded plan is ignored", () => {
+ const subscription = {
+ planId: "payg",
+ planName: "Pay as you go",
+ status: "active",
+ pendingCancel: {
+ subscriptionId: "sub_m2m",
+ planId: "m2m",
+ planName: "m2m user plan",
+ effectiveAt: "2026-09-07T17:35:18.109Z",
+ },
+ };
+
+ const state = deriveBillingSubscriptionUiState(subscription);
+ assert.deepEqual(state, { kind: "active", planId: "payg" });
+ assert.equal(deriveBillingPlanAction(state, "payg"), "current");
+ assert.equal(deriveBillingPlanAction(state, "m2m"), "change_plan");
+ assert.equal(resolveApplicablePendingCancel(subscription), null);
+});
+
+test("isNothingToResumeError reconciles only the upstream nothing_to_resume code", () => {
+ assert.equal(isNothingToResumeError("nothing_to_resume"), true);
+ // Sibling resume branches are real failures, whatever their status class.
+ assert.equal(isNothingToResumeError("resume_failed"), false);
+ assert.equal(isNothingToResumeError("confirm_required"), false);
+ assert.equal(isNothingToResumeError("openmeter_unavailable"), false);
+ // An unrelated 4xx (401/403/404) carries no code, or the SDK's sentinel.
+ assert.equal(isNothingToResumeError("pymthouse_http_error"), false);
+ assert.equal(isNothingToResumeError(undefined), false);
+});
+
+test("resume errors are classified by code, not status class", async () => {
+ const { ResumeSubscriptionError } = await import("./useBillingPlans");
+
+ const nothingToResume = new ResumeSubscriptionError(
+ "No scheduled cancellation to undo",
+ 404,
+ "nothing_to_resume",
+ );
+ assert.equal(isNothingToResumeError(nothingToResume.code), true);
+
+ // 502 resume_failed: a resume target existed but Konnect restore threw.
+ const resumeFailed = new ResumeSubscriptionError(
+ "Could not cancel the scheduled cancellation",
+ 502,
+ "resume_failed",
+ );
+ assert.equal(isNothingToResumeError(resumeFailed.code), false);
+
+ // Unrelated 4xx that the old status heuristic wrongly reported as success.
+ const unauthorized = new ResumeSubscriptionError("Unauthorized", 401, undefined);
+ assert.equal(isNothingToResumeError(unauthorized.code), false);
+ const unrelatedNotFound = new ResumeSubscriptionError(
+ "Not Found",
+ 404,
+ "pymthouse_http_error",
+ );
+ assert.equal(isNothingToResumeError(unrelatedNotFound.code), false);
+});
+
+test("user-not-found is recognized with and without an upstream code", async () => {
+ const { PmtHouseError } = await import("@pymthouse/builder-sdk");
+ const { isUserNotFoundError } = await import("./pymthouse-errors");
+
+ // The mint-token route answers `{"error":"not_found"}` with no `code`, so the
+ // SDK reports code as its `pymthouse_http_error` sentinel — auto-provisioning
+ // must still trigger.
+ assert.equal(
+ isUserNotFoundError(
+ new PmtHouseError("not_found", {
+ status: 404,
+ code: "pymthouse_http_error",
+ details: { error: "not_found" },
+ }),
+ ),
+ true,
+ );
+ // REST shape: prose on message, machine code on `code`.
+ assert.equal(
+ isUserNotFoundError(
+ new PmtHouseError("App user not found", {
+ status: 404,
+ code: "not_found",
+ details: { error: "App user not found", code: "not_found" },
+ }),
+ ),
+ true,
+ );
+ // An unrelated 404 must not provision a user.
+ assert.equal(
+ isUserNotFoundError(
+ new PmtHouseError("Subscription not found", {
+ status: 404,
+ code: "pymthouse_http_error",
+ details: { error: "Subscription not found" },
+ }),
+ ),
+ false,
+ );
+ assert.equal(
+ isUserNotFoundError(
+ new PmtHouseError("boom", { status: 500, code: "not_found" }),
+ ),
+ false,
+ );
+ assert.equal(isUserNotFoundError(new Error("network down")), false);
+});
+
+test("resolveCancelingEffectiveAt prefers pendingCancel then period end", async () => {
+ const {
+ resolveCancelingEffectiveAt,
+ resolveCancelingPlanName,
+ } = await import("./billing-subscription-state");
+ assert.equal(
+ resolveCancelingEffectiveAt({
+ planId: "starter",
+ status: "canceled",
+ currentPeriodEnd: "2026-09-01T00:00:00.000Z",
+ pendingCancel: {
+ subscriptionId: "s1",
+ effectiveAt: "2026-09-07T17:35:18.109Z",
+ },
+ }),
+ "2026-09-07T17:35:18.109Z",
+ );
+ assert.equal(
+ resolveCancelingPlanName({
+ planName: "__pymthouse_starter__",
+ pendingCancel: { planName: "Starter" },
+ }),
+ "Starter",
+ );
+});
+
+test("formatPendingCancelDate formats UTC calendar day", () => {
+ assert.equal(
+ formatPendingCancelDate("2026-08-20T12:00:00.000Z"),
+ "Aug 20, 2026",
+ );
+ assert.equal(formatPendingCancelDate(null), "the end of this period");
+ assert.equal(
+ formatPendingCancelDate("not-a-date"),
+ "the end of this period",
+ );
+});
+
+test("cancel timing helpers default to immediate and map date inputs", () => {
+ assert.equal(defaultCancelTimingChoice(), "immediate");
+ assert.equal(toDateInputValue("2026-08-08T00:00:00.000Z"), "2026-08-08");
+ assert.equal(
+ dateInputToEffectiveAtIso("2026-08-15"),
+ "2026-08-15T12:00:00.000Z",
+ );
+ assert.deepEqual(
+ resolveTimingPayload({
+ choice: "next_billing_cycle",
+ customDateYmd: "",
+ }),
+ { timing: "next_billing_cycle" },
+ );
+ assert.deepEqual(
+ resolveTimingPayload({ choice: "immediate", customDateYmd: "" }),
+ { timing: "immediate" },
+ );
+ assert.deepEqual(
+ resolveTimingPayload({
+ choice: "custom",
+ customDateYmd: "2026-08-15",
+ }),
+ { effectiveAt: "2026-08-15T12:00:00.000Z" },
+ );
+});
+
+test("formatIncludedUsdMicros converts OpenMeter discounts.usage micros", () => {
+ assert.equal(formatIncludedUsdMicros("10000000"), "$10");
+ assert.equal(formatIncludedUsdMicros("2500000"), "$2.50");
+ assert.equal(formatIncludedUsdMicros("0"), null);
+ assert.equal(formatIncludedUsdMicros(null), null);
+ assert.equal(formatIncludedUsdMicros("not-a-number"), null);
+});
+
+test("formatBillingPlanPrice prefers Starter included usage over $0 fee", () => {
+ assert.deepEqual(
+ formatBillingPlanPrice({
+ type: "free",
+ priceAmount: "0",
+ priceCurrency: "USD",
+ billingCycle: "monthly",
+ includedUsdMicros: "10000000",
+ isStarterDefault: true,
+ }),
+ { price: "$10", priceSub: " · included" },
+ );
+ assert.equal(
+ includedUsageFeatureLabel({ includedUsdMicros: "10000000" }),
+ "$10 included usage",
+ );
+ assert.equal(
+ includedUsageFeatureLabel({ includedUsdMicros: null }),
+ null,
+ );
+ assert.equal(
+ starterIncludedUsageLabel({ includedUsdMicros: "10000000" }),
+ "$10 included usage",
+ );
+ assert.equal(
+ starterIncludedUsageLabel({ includedUsdMicros: null }),
+ "Free included usage",
+ );
+});
diff --git a/lib/console/billing-subscription-state.ts b/lib/console/billing-subscription-state.ts
new file mode 100644
index 0000000..247acaf
--- /dev/null
+++ b/lib/console/billing-subscription-state.ts
@@ -0,0 +1,405 @@
+export type PendingCancelSnapshot = {
+ subscriptionId?: string;
+ planId?: string | null;
+ planName?: string | null;
+ effectiveAt?: string | null;
+};
+
+export type BillingSubscriptionSnapshot = {
+ planId: string | null;
+ status: string | null;
+ currentPeriodEnd?: string | null;
+ pendingCancel?: PendingCancelSnapshot | null;
+ timingOptions?: {
+ cancel: SubscriptionTimingOptions;
+ change: SubscriptionTimingOptions;
+ } | null;
+};
+
+export type SubscriptionTimingOptions = {
+ minEffectiveAt: string;
+ maxEffectiveAt: string | null;
+ presets: Array<"immediate" | "next_billing_cycle">;
+};
+
+export type BillingSubscriptionUiState =
+ | { kind: "none"; planId: null }
+ | { kind: "active"; planId: string }
+ | { kind: "pending"; planId: string }
+ | { kind: "canceling"; planId: string };
+
+export type BillingPlanAction =
+ | "subscribe"
+ | "change_plan"
+ | "retry_checkout"
+ | "current";
+
+export type SubscriptionTimingChoice =
+ | "immediate"
+ | "next_billing_cycle"
+ | "custom";
+
+const ACTIVE_STATUSES = new Set(["active", "trialing", "scheduled"]);
+const PENDING_STATUSES = new Set([
+ "pending",
+ "incomplete",
+ "incomplete_expired",
+ "checkout_pending",
+]);
+/** Includes Konnect `inactive` (cancel-at-period-end still occupying the slot). */
+const CANCELED_STATUSES = new Set(["canceled", "cancelled", "inactive"]);
+
+/** Sentinel when cancel-at-period-end is known but local plan id is missing. */
+const CANCELING_PLAN_FALLBACK = "__canceling__";
+
+/**
+ * pendingCancel that actually applies to the live subscription.
+ *
+ * Upstream reports any occupying canceled OpenMeter row, so a cancel scheduled
+ * on a plan the user has since switched away from keeps being returned. That
+ * stale row must not drive canceling UI or the resume CTA — resume would 4xx
+ * because there is no scheduled cancellation left to undo.
+ */
+export function resolveApplicablePendingCancel(
+ subscription:
+ | {
+ planId?: string | null;
+ pendingCancel?: PendingCancelSnapshot | null;
+ }
+ | null
+ | undefined
+): PendingCancelSnapshot | null {
+ const pending = subscription?.pendingCancel;
+ if (!pending) return null;
+ const activePlanId = subscription?.planId?.trim();
+ const pendingPlanId = pending.planId?.trim();
+ if (activePlanId && pendingPlanId && activePlanId !== pendingPlanId) {
+ return null;
+ }
+ return pending;
+}
+
+export function deriveBillingSubscriptionUiState(
+ subscription: BillingSubscriptionSnapshot | null | undefined
+): BillingSubscriptionUiState {
+ const pending = resolveApplicablePendingCancel(subscription);
+ const planId =
+ subscription?.planId?.trim() || pending?.planId?.trim() || null;
+ const status = subscription?.status?.trim().toLowerCase() || "";
+ const isCanceling = Boolean(pending) || CANCELED_STATUSES.has(status);
+
+ // Cancel-at-period-end still owns the OpenMeter customer until effectiveAt /
+ // currentPeriodEnd — surface that even when planId is only on pendingCancel.
+ if (isCanceling) {
+ return { kind: "canceling", planId: planId || CANCELING_PLAN_FALLBACK };
+ }
+
+ if (!planId) {
+ return { kind: "none", planId: null };
+ }
+
+ if (PENDING_STATUSES.has(status)) {
+ return { kind: "pending", planId };
+ }
+ if (ACTIVE_STATUSES.has(status)) {
+ return { kind: "active", planId };
+ }
+
+ return { kind: "none", planId: null };
+}
+
+export function deriveBillingPlanAction(
+ subscription: BillingSubscriptionUiState,
+ planId: string
+): BillingPlanAction {
+ if (subscription.kind === "none") {
+ return "subscribe";
+ }
+ if (subscription.kind === "pending") {
+ return "retry_checkout";
+ }
+ // canceling still treats current plan as current; other plans can switch/upgrade
+ return subscription.planId === planId ? "current" : "change_plan";
+}
+
+/**
+ * Cancel only applies to paid catalog plans — Starter is the floor and cannot
+ * be unsubscribed.
+ */
+export function canCancelBillingSubscription(
+ subscription: BillingSubscriptionUiState,
+ paidCatalogPlanIds: ReadonlyArray,
+ hasExternalUserId: boolean
+): boolean {
+ return (
+ hasExternalUserId &&
+ subscription.kind === "active" &&
+ paidCatalogPlanIds.includes(subscription.planId)
+ );
+}
+
+export type BillingPlanDisplaySeed = {
+ id: string;
+ name: string;
+ type: string;
+ status: string;
+ priceAmount: string;
+ priceCurrency: string;
+ billingCycle: string | null;
+ includedUsdMicros?: string | null;
+ chargeThresholdUsdMicros: string | null;
+ resolvedBehavior: string | null;
+ capabilityCount: number;
+ isStarterDefault?: boolean;
+};
+
+/** Format plan included-usage micros (discounts.usage) as a USD money string. */
+export function formatIncludedUsdMicros(
+ micros: string | null | undefined,
+ currency = "USD"
+): string | null {
+ const trimmed = micros?.trim();
+ if (!trimmed || !/^\d+$/.test(trimmed)) return null;
+ let usd: number;
+ try {
+ usd = Number(BigInt(trimmed)) / 1_000_000;
+ } catch {
+ return null;
+ }
+ if (!Number.isFinite(usd) || usd <= 0) return null;
+ return new Intl.NumberFormat("en-US", {
+ style: "currency",
+ currency: currency || "USD",
+ maximumFractionDigits: usd % 1 === 0 ? 0 : 2,
+ }).format(usd);
+}
+
+/**
+ * Starter is priced $0 but the headline value is the included-usage discount
+ * from OpenMeter (`allowance.includedUsdMicros`), not the subscription fee.
+ */
+export function formatBillingPlanPrice(plan: {
+ type: string;
+ priceAmount: string;
+ priceCurrency: string;
+ billingCycle: string | null;
+ includedUsdMicros?: string | null;
+ isStarterDefault?: boolean;
+}): { price: string; priceSub: string } {
+ const isStarter =
+ plan.isStarterDefault === true ||
+ plan.type.trim().toLowerCase() === "free";
+ const includedMoney = isStarter
+ ? formatIncludedUsdMicros(plan.includedUsdMicros, plan.priceCurrency)
+ : null;
+
+ if (isStarter) {
+ return {
+ price: includedMoney ?? formatPriceAmount(plan.priceAmount, plan.priceCurrency),
+ priceSub: " · included",
+ };
+ }
+
+ const money = formatPriceAmount(plan.priceAmount, plan.priceCurrency);
+ if (plan.type.trim().toLowerCase() === "usage") {
+ return { price: money, priceSub: " · pay as you go" };
+ }
+
+ const c = plan.billingCycle?.toLowerCase() ?? "";
+ if (c === "monthly" || c === "month") {
+ return { price: money, priceSub: " / month" };
+ }
+ if (c === "yearly" || c === "year" || c === "annual") {
+ return { price: money, priceSub: " / year" };
+ }
+ return {
+ price: money,
+ priceSub: plan.billingCycle ? ` · ${plan.billingCycle}` : "",
+ };
+}
+
+/**
+ * Feature-line label for plans that grant included usage via OpenMeter
+ * `allowance.includedUsdMicros` (Starter and paid plans alike).
+ */
+export function includedUsageFeatureLabel(plan: {
+ includedUsdMicros?: string | null;
+ priceCurrency?: string;
+}): string | null {
+ const money = formatIncludedUsdMicros(
+ plan.includedUsdMicros,
+ plan.priceCurrency || "USD"
+ );
+ return money ? `${money} included usage` : null;
+}
+
+/** @deprecated Prefer {@link includedUsageFeatureLabel}; kept for Starter fallback copy. */
+export function starterIncludedUsageLabel(plan: {
+ includedUsdMicros?: string | null;
+ priceCurrency?: string;
+}): string {
+ return includedUsageFeatureLabel(plan) ?? "Free included usage";
+}
+
+function formatPriceAmount(amount: string, currency: string): string {
+ const n = Number(amount);
+ if (!Number.isFinite(n)) return amount;
+ return new Intl.NumberFormat("en-US", {
+ style: "currency",
+ currency: currency || "USD",
+ maximumFractionDigits: n % 1 === 0 ? 0 : 2,
+ }).format(n);
+}
+
+/**
+ * Ensure the user's current plan appears in the grid even when it is missing
+ * from the catalog response (legacy / race). Prefer including Starter via the
+ * plans API (`isStarterDefault`) so upgrades and downgrades both show.
+ */
+export function withCurrentPlanInDisplayList(
+ catalogPlans: ReadonlyArray,
+ subscription: { planId: string | null; planName: string | null } | null
+): BillingPlanDisplaySeed[] {
+ const planId = subscription?.planId?.trim() || null;
+ if (!planId) return [...catalogPlans];
+ if (catalogPlans.some((p) => p.id === planId)) return [...catalogPlans];
+
+ return [
+ {
+ id: planId,
+ name: subscription?.planName?.trim() || "Starter",
+ type: "free",
+ status: "active",
+ priceAmount: "0",
+ priceCurrency: "USD",
+ billingCycle: null,
+ includedUsdMicros: null,
+ chargeThresholdUsdMicros: null,
+ resolvedBehavior: null,
+ capabilityCount: 0,
+ isStarterDefault: true,
+ },
+ ...catalogPlans,
+ ];
+}
+
+/** Paid (non-Starter) plan ids — used to gate Cancel subscription. */
+export function paidCatalogPlanIds(
+ plans: ReadonlyArray<{ id: string; isStarterDefault?: boolean }>
+): string[] {
+ return plans.filter((p) => !p.isStarterDefault).map((p) => p.id);
+}
+
+export function billingPlanActionLabel(
+ action: BillingPlanAction,
+ opts?: { usagePlan?: boolean; starterPlan?: boolean },
+): string {
+ switch (action) {
+ case "current":
+ return "Current plan";
+ case "change_plan":
+ if (opts?.starterPlan) return "Switch to Starter";
+ return opts?.usagePlan ? "Enable pay-per-use" : "Switch";
+ case "retry_checkout":
+ return "Complete payment";
+ case "subscribe":
+ if (opts?.starterPlan) return "Choose Starter";
+ return opts?.usagePlan ? "Enable pay-per-use" : "Subscribe";
+ }
+}
+
+export function isActiveSubscriptionConflict(message: string): boolean {
+ return /already has an active subscription/i.test(message);
+}
+
+export function isScheduledChangeConflict(code: string | undefined): boolean {
+ return code === "scheduled_change_exists";
+}
+
+/**
+ * End date for cancel-at-period-end, from builder/OpenMeter
+ * (`pendingCancel.effectiveAt` or subscription `currentPeriodEnd`).
+ */
+export function resolveCancelingEffectiveAt(
+ subscription: BillingSubscriptionSnapshot | null | undefined
+): string | null {
+ return (
+ resolveApplicablePendingCancel(subscription)?.effectiveAt?.trim() ||
+ subscription?.currentPeriodEnd?.trim() ||
+ null
+ );
+}
+
+/** Human plan label for the canceling banner / restore CTA. */
+export function resolveCancelingPlanName(
+ subscription: {
+ planId?: string | null;
+ planName?: string | null;
+ pendingCancel?: PendingCancelSnapshot | null;
+ } | null | undefined
+): string {
+ return (
+ resolveApplicablePendingCancel(subscription)?.planName?.trim() ||
+ subscription?.planName?.trim() ||
+ "your plan"
+ );
+}
+
+/**
+ * Upstream answers resume with `404 { code: "nothing_to_resume" }` when there
+ * is no scheduled cancellation left to undo. The local snapshot is simply
+ * stale, so reconcile instead of surfacing an error.
+ *
+ * Keyed on the code alone, never on the status class: the sibling resume
+ * branches (`confirm_required` 400, `resume_failed` 502, `openmeter_unavailable`
+ * 503) and any auth failure are real errors the user must see.
+ */
+export function isNothingToResumeError(code: string | undefined): boolean {
+ return code === "nothing_to_resume";
+}
+
+export function formatPendingCancelDate(
+ iso: string | null | undefined
+): string {
+ if (!iso) return "the end of this period";
+ const d = new Date(iso);
+ if (Number.isNaN(d.getTime())) return "the end of this period";
+ return d.toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ timeZone: "UTC",
+ });
+}
+
+/** YYYY-MM-DD for ` ` min/max (UTC calendar day). */
+export function toDateInputValue(iso: string | null | undefined): string {
+ if (!iso) return "";
+ const d = new Date(iso);
+ if (Number.isNaN(d.getTime())) return "";
+ return d.toISOString().slice(0, 10);
+}
+
+/** Noon UTC on the chosen calendar day — stable for Konnect ISO timing. */
+export function dateInputToEffectiveAtIso(dateYmd: string): string {
+ const [y, m, d] = dateYmd.split("-").map((p) => Number.parseInt(p, 10));
+ if (!y || !m || !d) {
+ throw new Error("Invalid date");
+ }
+ return new Date(Date.UTC(y, m - 1, d, 12, 0, 0, 0)).toISOString();
+}
+
+export function defaultCancelTimingChoice(): SubscriptionTimingChoice {
+ return "immediate";
+}
+
+export function resolveTimingPayload(input: {
+ choice: SubscriptionTimingChoice;
+ customDateYmd: string;
+}): { timing?: "immediate" | "next_billing_cycle"; effectiveAt?: string } {
+ if (input.choice === "immediate") return { timing: "immediate" };
+ if (input.choice === "next_billing_cycle") {
+ return { timing: "next_billing_cycle" };
+ }
+ return { effectiveAt: dateInputToEffectiveAtIso(input.customDateYmd) };
+}
diff --git a/lib/console/checkout-redirect.ts b/lib/console/checkout-redirect.ts
new file mode 100644
index 0000000..1fbc7b1
--- /dev/null
+++ b/lib/console/checkout-redirect.ts
@@ -0,0 +1,18 @@
+/** Only follow https (or localhost http, for dev) Checkout URLs. */
+export function redirectToCheckout(url: string): void {
+ let parsed: URL;
+ try {
+ parsed = new URL(url);
+ } catch {
+ throw new Error("Invalid checkout URL");
+ }
+ const isLocalhost =
+ parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1";
+ if (
+ parsed.protocol !== "https:" &&
+ !(parsed.protocol === "http:" && isLocalhost)
+ ) {
+ throw new Error("Unsafe checkout URL");
+ }
+ window.location.assign(parsed.toString());
+}
diff --git a/lib/console/pymthouse-bff.ts b/lib/console/pymthouse-bff.ts
index a916c32..8f769ef 100644
--- a/lib/console/pymthouse-bff.ts
+++ b/lib/console/pymthouse-bff.ts
@@ -25,6 +25,9 @@ export type {
AccountRequestsPayload,
AccountUsagePayload,
} from "@/lib/console/account-usage";
+import { isUserNotFoundError } from "@/lib/console/pymthouse-errors";
+
+export { isUserNotFoundError } from "@/lib/console/pymthouse-errors";
export function createPmtHouseClientForPublicApp(
publicClientId: string
@@ -39,22 +42,6 @@ export function createPmtHouseClientForPublicApp(
});
}
-/**
- * Pymthouse signals user-not-found with two envelopes: the REST shape
- * (`{ error: , code: "not_found" }`) and the OAuth shape used by the
- * mint-token route (`{ error: "not_found" }`, no `code`).
- */
-export function isUserNotFoundError(error: unknown): boolean {
- if (!(error instanceof PmtHouseError) || error.status !== 404) {
- return false;
- }
- if (error.code === "not_found") {
- return true;
- }
- const details = error.details as { error?: unknown } | null | undefined;
- return details?.error === "not_found";
-}
-
export async function ensureDashboardAppUser(
externalUserId: string,
email?: string
diff --git a/lib/console/pymthouse-billing-bff.ts b/lib/console/pymthouse-billing-bff.ts
new file mode 100644
index 0000000..2b74dfa
--- /dev/null
+++ b/lib/console/pymthouse-billing-bff.ts
@@ -0,0 +1,195 @@
+import "server-only";
+
+import {
+ PmtHouseError,
+ type BillingProduct,
+ type CreateBillingCheckoutResult,
+ type UserSubscriptionResponse,
+} from "@pymthouse/builder-sdk";
+import { createPmtHouseClientForPublicApp } from "@/lib/console/pymthouse-bff";
+import type {
+ DashboardBillingPlan,
+ DashboardScheduledChangeConflict,
+ DashboardSubscriptionChange,
+ DashboardUserSubscription,
+} from "@/lib/console/pymthouse-billing";
+import {
+ pymthouseAppsOrigin,
+ readM2mAuthHeader,
+ readPublicClientId,
+ readPymthouseResponse,
+} from "@/lib/console/pymthouse-http";
+
+export type {
+ DashboardBillingPlan,
+ DashboardScheduledChangeConflict,
+ DashboardSubscriptionChange,
+ DashboardUserSubscription,
+} from "@/lib/console/pymthouse-billing";
+
+function readOptionalString(value: unknown): string | null {
+ if (typeof value !== "string") return null;
+ const trimmed = value.trim();
+ return trimmed ? trimmed : null;
+}
+
+function mapProduct(product: BillingProduct): DashboardBillingPlan {
+ const dynamicProduct = product as BillingProduct & {
+ chargeThresholdUsdMicros?: unknown;
+ resolvedBehavior?: unknown;
+ };
+ const isStarterDefault = product.isStarterDefault === true;
+ const name = isStarterDefault
+ ? "Starter"
+ : product.name?.trim() || product.id;
+
+ return {
+ id: product.id,
+ name,
+ type: isStarterDefault ? "free" : product.type,
+ status: product.status,
+ priceAmount: product.priceAmount,
+ priceCurrency: product.priceCurrency,
+ billingCycle: product.allowance?.billingCycle ?? null,
+ includedUsdMicros: readOptionalString(product.allowance?.includedUsdMicros),
+ chargeThresholdUsdMicros: isStarterDefault
+ ? null
+ : readOptionalString(dynamicProduct.chargeThresholdUsdMicros),
+ resolvedBehavior: isStarterDefault
+ ? null
+ : readOptionalString(dynamicProduct.resolvedBehavior),
+ capabilityCount: product.capabilities?.length ?? 0,
+ isStarterDefault,
+ };
+}
+
+export async function listDashboardBillingPlans(): Promise<
+ DashboardBillingPlan[]
+> {
+ const client = createPmtHouseClientForPublicApp(readPublicClientId());
+ const { products } = await client.listBillingProducts();
+ return (products ?? [])
+ .filter((p) => p.status === "active" && !p.isNetworkDefault)
+ .map(mapProduct)
+ .sort((a, b) => Number(b.isStarterDefault) - Number(a.isStarterDefault));
+}
+
+export async function startDashboardBillingCheckout(input: {
+ planId: string;
+ externalUserId: string;
+ successUrl?: string;
+ cancelUrl?: string;
+}): Promise {
+ const client = createPmtHouseClientForPublicApp(readPublicClientId());
+ return client.createBillingCheckout({
+ planId: input.planId,
+ externalUserId: input.externalUserId,
+ ...(input.successUrl ? { successUrl: input.successUrl } : {}),
+ ...(input.cancelUrl ? { cancelUrl: input.cancelUrl } : {}),
+ });
+}
+
+export async function changeDashboardBillingSubscription(input: {
+ planId: string;
+ externalUserId: string;
+ successUrl?: string;
+ cancelUrl?: string;
+ timing?: string;
+ effectiveAt?: string;
+ confirmReplaceScheduled?: boolean;
+}): Promise {
+ const publicClientId = readPublicClientId();
+ const response = await fetch(
+ `${pymthouseAppsOrigin()}/api/v1/apps/${encodeURIComponent(publicClientId)}/users/${encodeURIComponent(input.externalUserId)}/subscription/change`,
+ {
+ method: "POST",
+ headers: {
+ Authorization: readM2mAuthHeader(),
+ Accept: "application/json",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ planId: input.planId,
+ ...(input.successUrl ? { successUrl: input.successUrl } : {}),
+ ...(input.cancelUrl ? { cancelUrl: input.cancelUrl } : {}),
+ ...(input.timing ? { timing: input.timing } : {}),
+ ...(input.effectiveAt ? { effectiveAt: input.effectiveAt } : {}),
+ ...(input.confirmReplaceScheduled
+ ? { confirmReplaceScheduled: true }
+ : {}),
+ }),
+ cache: "no-store",
+ }
+ );
+ if (response.status === 409) {
+ const text = await response.text();
+ let body: DashboardScheduledChangeConflict | null = null;
+ try {
+ body = text
+ ? (JSON.parse(text) as DashboardScheduledChangeConflict)
+ : null;
+ } catch {
+ body = null;
+ }
+ if (body?.code === "scheduled_change_exists") {
+ throw new PmtHouseError(body.error || "Scheduled plan change exists", {
+ status: 409,
+ code: "scheduled_change_exists",
+ details: body,
+ });
+ }
+ }
+ return readPymthouseResponse(response);
+}
+
+export async function getDashboardUserSubscription(
+ externalUserId: string
+): Promise {
+ const client = createPmtHouseClientForPublicApp(readPublicClientId());
+ const result: UserSubscriptionResponse =
+ await client.getUserSubscription(externalUserId);
+ const sub = result.subscription;
+ const pending = result.pendingCancel ?? null;
+ return {
+ planId: sub?.planId?.trim() || pending?.planId?.trim() || null,
+ planName: sub?.planName?.trim() || pending?.planName?.trim() || null,
+ status: sub?.status?.trim() || (pending ? "canceled" : null),
+ subscriptionId: sub?.id?.trim() || pending?.subscriptionId?.trim() || null,
+ currentPeriodEnd:
+ sub?.currentPeriodEnd?.trim() || pending?.effectiveAt?.trim() || null,
+ timingOptions: result.timingOptions ?? null,
+ pendingCancel: pending
+ ? {
+ subscriptionId: pending.subscriptionId,
+ planId: pending.planId,
+ planKey: pending.planKey,
+ planName: pending.planName,
+ effectiveAt: pending.effectiveAt,
+ }
+ : null,
+ };
+}
+
+export async function cancelDashboardUserSubscription(
+ externalUserId: string,
+ opts?: { timing?: string; effectiveAt?: string }
+): Promise {
+ const client = createPmtHouseClientForPublicApp(readPublicClientId());
+ await client.cancelUserSubscription(externalUserId, {
+ confirm: true,
+ ...(opts?.timing ? { timing: opts.timing } : {}),
+ ...(opts?.effectiveAt ? { effectiveAt: opts.effectiveAt } : {}),
+ });
+}
+
+export async function resumeDashboardUserSubscription(
+ externalUserId: string
+): Promise {
+ const client = createPmtHouseClientForPublicApp(readPublicClientId());
+ await client.resumeUserSubscription(externalUserId, { confirm: true });
+}
+
+export async function listDashboardUserSubscriptions(externalUserId: string) {
+ const client = createPmtHouseClientForPublicApp(readPublicClientId());
+ return client.listUserSubscriptions(externalUserId);
+}
diff --git a/lib/console/pymthouse-billing.ts b/lib/console/pymthouse-billing.ts
new file mode 100644
index 0000000..c241634
--- /dev/null
+++ b/lib/console/pymthouse-billing.ts
@@ -0,0 +1,62 @@
+export type DashboardBillingPlan = {
+ id: string;
+ name: string;
+ type: string;
+ status: string;
+ priceAmount: string;
+ priceCurrency: string;
+ billingCycle: string | null;
+ includedUsdMicros: string | null;
+ chargeThresholdUsdMicros: string | null;
+ resolvedBehavior: string | null;
+ capabilityCount: number;
+ isStarterDefault: boolean;
+};
+
+export type DashboardSubscriptionChange = {
+ subscriptionId: string;
+ planId: string;
+ effectiveAt: string | null;
+ timing: "immediate" | "next_billing_cycle" | string;
+ checkoutUrl?: string;
+};
+
+export type DashboardScheduledChangeConflict = {
+ code: "scheduled_change_exists";
+ error: string;
+ timingOptions: {
+ minEffectiveAt: string;
+ maxEffectiveAt: string | null;
+ presets: Array<"immediate" | "next_billing_cycle">;
+ } | null;
+ scheduledSubscriptionId: string | null;
+ scheduledPlanKey: string | null;
+ scheduledActiveFrom: string | null;
+};
+
+export type DashboardUserSubscription = {
+ planId: string | null;
+ planName: string | null;
+ status: string | null;
+ subscriptionId: string | null;
+ currentPeriodEnd: string | null;
+ timingOptions: {
+ cancel: {
+ minEffectiveAt: string;
+ maxEffectiveAt: string | null;
+ presets: Array<"immediate" | "next_billing_cycle">;
+ };
+ change: {
+ minEffectiveAt: string;
+ maxEffectiveAt: string | null;
+ presets: Array<"immediate" | "next_billing_cycle">;
+ };
+ } | null;
+ pendingCancel: {
+ subscriptionId: string;
+ planId: string | null;
+ planKey: string | null;
+ planName: string | null;
+ effectiveAt: string | null;
+ } | null;
+};
diff --git a/lib/console/pymthouse-errors.ts b/lib/console/pymthouse-errors.ts
new file mode 100644
index 0000000..efdc637
--- /dev/null
+++ b/lib/console/pymthouse-errors.ts
@@ -0,0 +1,25 @@
+import { PmtHouseError } from "@pymthouse/builder-sdk";
+
+/**
+ * Pymthouse signals user-not-found with two envelopes: the REST shape
+ * (`{ error: , code: "not_found" }`) and the OAuth shape used by the
+ * mint-token route (`{ error: "not_found" }`, no `code`).
+ */
+export function isUserNotFoundError(error: unknown): boolean {
+ if (!(error instanceof PmtHouseError) && !(error instanceof Error)) {
+ return false;
+ }
+ const candidate = error as {
+ status?: number;
+ code?: string;
+ message: string;
+ details?: { error?: unknown } | null;
+ };
+ if (candidate.status !== 404) {
+ return false;
+ }
+ if (candidate.code === "not_found" || candidate.message === "not_found") {
+ return true;
+ }
+ return candidate.details?.error === "not_found";
+}
diff --git a/lib/console/useBillingAccount.ts b/lib/console/useBillingAccount.ts
new file mode 100644
index 0000000..e6f6ac5
--- /dev/null
+++ b/lib/console/useBillingAccount.ts
@@ -0,0 +1,46 @@
+"use client";
+
+/** Placeholder until the wallet/payment-methods PR replaces this hook. */
+export function useBillingAccount(_enabled: boolean): {
+ state: {
+ status: "idle" | "loading" | "ready" | "error";
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ paymentMethods: any[];
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ invoices: any[];
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ subscriptions: any[];
+ paymentMethodsError: null;
+ invoicesError: null;
+ subscriptionsError: null;
+ message?: string;
+ };
+ reload: () => Promise;
+ startPaymentMethodCheckout: (
+ input?: unknown
+ ) => Promise<{ checkoutUrl: string }>;
+ openInvoice: (
+ input: unknown
+ ) => Promise<{ hostedInvoiceUrl: string; invoicePdf: string }>;
+ setDefaultPaymentMethod: (input: unknown) => Promise;
+ ensureDefaultPaymentMethod: (input?: unknown) => Promise;
+ removePaymentMethod: (input: unknown) => Promise;
+} {
+ return {
+ state: {
+ status: "ready",
+ paymentMethods: [],
+ invoices: [],
+ subscriptions: [],
+ paymentMethodsError: null,
+ invoicesError: null,
+ subscriptionsError: null,
+ },
+ reload: async () => {},
+ startPaymentMethodCheckout: async () => ({ checkoutUrl: "" }),
+ openInvoice: async () => ({ hostedInvoiceUrl: "", invoicePdf: "" }),
+ setDefaultPaymentMethod: async () => {},
+ ensureDefaultPaymentMethod: async () => {},
+ removePaymentMethod: async () => {},
+ };
+}
diff --git a/lib/console/useBillingPlans.ts b/lib/console/useBillingPlans.ts
new file mode 100644
index 0000000..733d9a9
--- /dev/null
+++ b/lib/console/useBillingPlans.ts
@@ -0,0 +1,207 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+import type {
+ DashboardBillingPlan,
+ DashboardScheduledChangeConflict,
+ DashboardUserSubscription,
+} from "@/lib/console/pymthouse-billing";
+import { readResponseJson } from "@/lib/console/read-response-json";
+
+type BillingPlansState =
+ | { status: "idle" }
+ | { status: "loading" }
+ | {
+ status: "ready";
+ plans: DashboardBillingPlan[];
+ subscription: DashboardUserSubscription | null;
+ }
+ | { status: "error"; message: string };
+
+export class ResumeSubscriptionError extends Error {
+ readonly status: number;
+ readonly code: string | undefined;
+
+ constructor(message: string, status: number, code: string | undefined) {
+ super(message);
+ this.name = "ResumeSubscriptionError";
+ this.status = status;
+ this.code = code;
+ }
+}
+
+export class ScheduledChangeConflictError extends Error {
+ readonly code = "scheduled_change_exists" as const;
+ readonly conflict: DashboardScheduledChangeConflict;
+
+ constructor(conflict: DashboardScheduledChangeConflict) {
+ super(conflict.error || "A plan change is already scheduled");
+ this.name = "ScheduledChangeConflictError";
+ this.conflict = conflict;
+ }
+}
+
+export function useBillingPlans(enabled: boolean) {
+ const [state, setState] = useState({ status: "idle" });
+
+ const load = useCallback(async () => {
+ if (!enabled) {
+ setState({ status: "error", message: "Sign in to load billing plans." });
+ return;
+ }
+ setState({ status: "loading" });
+ try {
+ const plansResponse = await fetch("/api/pymthouse/plans");
+ const plansBody = await readResponseJson<{
+ plans?: DashboardBillingPlan[];
+ error?: string;
+ }>(plansResponse);
+ if (!plansResponse.ok) {
+ throw new Error(
+ plansBody.error ?? `Plans fetch failed (${plansResponse.status})`
+ );
+ }
+
+ let subscription: DashboardUserSubscription | null = null;
+ const subResponse = await fetch("/api/pymthouse/subscription");
+ const subBody = await readResponseJson<{
+ subscription?: DashboardUserSubscription;
+ error?: string;
+ }>(subResponse);
+ if (subResponse.ok) {
+ subscription = subBody.subscription ?? null;
+ }
+
+ setState({
+ status: "ready",
+ plans: plansBody.plans ?? [],
+ subscription,
+ });
+ } catch (error) {
+ setState({
+ status: "error",
+ message:
+ error instanceof Error ? error.message : "Failed to load plans",
+ });
+ }
+ }, [enabled]);
+
+ useEffect(() => {
+ void load();
+ }, [load]);
+
+ const subscribe = useCallback(
+ async (input: {
+ planId: string;
+ successUrl?: string;
+ cancelUrl?: string;
+ }) => {
+ const response = await fetch("/api/pymthouse/subscribe", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(input),
+ });
+ const body = await readResponseJson<{
+ checkoutUrl?: string;
+ subscriptionId?: string;
+ error?: string;
+ }>(response);
+ if (!response.ok || !body.checkoutUrl) {
+ throw new Error(body.error ?? `Subscribe failed (${response.status})`);
+ }
+ return {
+ checkoutUrl: body.checkoutUrl,
+ subscriptionId: body.subscriptionId,
+ };
+ },
+ []
+ );
+
+ const changePlan = useCallback(
+ async (input: {
+ planId: string;
+ successUrl?: string;
+ cancelUrl?: string;
+ timing?: string;
+ effectiveAt?: string;
+ confirmReplaceScheduled?: boolean;
+ }) => {
+ const response = await fetch("/api/pymthouse/subscription/change", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(input),
+ });
+ const body = await readResponseJson<{
+ checkoutUrl?: string;
+ subscriptionId?: string;
+ error?: string;
+ code?: string;
+ timingOptions?: DashboardScheduledChangeConflict["timingOptions"];
+ scheduledSubscriptionId?: string | null;
+ scheduledPlanKey?: string | null;
+ scheduledActiveFrom?: string | null;
+ }>(response);
+ if (response.status === 409 && body.code === "scheduled_change_exists") {
+ throw new ScheduledChangeConflictError({
+ code: "scheduled_change_exists",
+ error: body.error ?? "A plan change is already scheduled",
+ timingOptions: body.timingOptions ?? null,
+ scheduledSubscriptionId: body.scheduledSubscriptionId ?? null,
+ scheduledPlanKey: body.scheduledPlanKey ?? null,
+ scheduledActiveFrom: body.scheduledActiveFrom ?? null,
+ });
+ }
+ if (!response.ok) {
+ throw new Error(
+ body.error ?? `Plan change failed (${response.status})`
+ );
+ }
+ return {
+ checkoutUrl: body.checkoutUrl,
+ subscriptionId: body.subscriptionId,
+ };
+ },
+ []
+ );
+
+ const cancelSubscription = useCallback(
+ async (opts?: { timing?: string; effectiveAt?: string }) => {
+ const response = await fetch("/api/pymthouse/subscription/cancel", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(opts ?? {}),
+ });
+ const body = await readResponseJson<{ error?: string }>(response);
+ if (!response.ok) {
+ throw new Error(body.error ?? `Cancel failed (${response.status})`);
+ }
+ },
+ []
+ );
+
+ const resumeSubscription = useCallback(async () => {
+ const response = await fetch("/api/pymthouse/subscription/resume", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ });
+ const body = await readResponseJson<{ error?: string; code?: string }>(
+ response
+ );
+ if (!response.ok) {
+ throw new ResumeSubscriptionError(
+ body.error ?? `Resume failed (${response.status})`,
+ response.status,
+ body.code
+ );
+ }
+ }, []);
+
+ return {
+ state,
+ reload: load,
+ subscribe,
+ changePlan,
+ cancelSubscription,
+ resumeSubscription,
+ };
+}
diff --git a/lib/console/useOwnerWallet.ts b/lib/console/useOwnerWallet.ts
new file mode 100644
index 0000000..9e4462d
--- /dev/null
+++ b/lib/console/useOwnerWallet.ts
@@ -0,0 +1,12 @@
+"use client";
+
+/** Placeholder until the wallet PR replaces this hook. */
+export function useWalletBillingState(_enabled: boolean): {
+ state:
+ | { status: "idle" }
+ | { status: "loading" }
+ | { status: "ready"; wallet: { billingState: unknown } }
+ | { status: "error"; message: string };
+} {
+ return { state: { status: "idle" } };
+}
diff --git a/lib/console/wallet-settlement-display.ts b/lib/console/wallet-settlement-display.ts
new file mode 100644
index 0000000..4a34d2f
--- /dev/null
+++ b/lib/console/wallet-settlement-display.ts
@@ -0,0 +1,22 @@
+export type IncludedUsageSummary = {
+ planName?: string;
+ planId?: string;
+ consumedUsdMicros: string;
+ totalUsdMicros: string;
+ remainingUsdMicros: string;
+ remainingUsd?: string;
+ totalUsd: string;
+ resetsAt?: string;
+};
+
+export function includedUsageSummary(
+ _billingState: unknown
+): IncludedUsageSummary | null {
+ return null;
+}
+
+export function includedUsageRemainingLabel(
+ _included: IncludedUsageSummary | null
+): string | null {
+ return null;
+}