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}

+ +
+ ); + } + + 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 + + ) : ( + + )} +
  • + ); + })} +
+ {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.choice === "custom" ? ( + props.onCustomDate(e.target.value)} + /> + ) : null} +
+ + +
+
+ ); +} 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 ( -
-