diff --git a/.env.example b/.env.example index 920b0b8..1b4fb8e 100644 --- a/.env.example +++ b/.env.example @@ -6,20 +6,18 @@ THEGRAPH_API_KEY= # Aliases: DISCOVERY_URL, LIVEPEER_DISCOVERY_SERVICE_URL DISCOVERY_SERVICE_URL=https://discovery-service-production-8955.up.railway.app/v1/discovery/raw -# PymtHouse (usage + API keys + signer sessions) +# PymtHouse (usage + API keys + signer sessions + plans/subscribe) # Production: PYMTHOUSE_ISSUER_URL=https://pymthouse.com/api/v1/oidc -# Local pymthouse: http://localhost:3001/api/v1/oidc -# Public app client id (OAuth client_id) — used for Builder API path {clientId} +# Local pymthouse (`npm run dev` HTTPS): https://localhost:3001/api/v1/oidc +# (mkcert is trusted in the browser; Node fetch to localhost HTTPS usually works +# without PYMTHOUSE_ALLOW_INSECURE_HTTP when using the system/mkcert CA.) PYMTHOUSE_PUBLIC_CLIENT_ID= PYMTHOUSE_M2M_CLIENT_ID= PYMTHOUSE_M2M_CLIENT_SECRET= -# Remote signer DMZ base included as signer_url in API-key exchange responses -# (fallback when the issuer does not return signer_url). -# Production: https://pymthouse-production.up.railway.app -# Local compose: http://127.0.0.1:8080 -PYMTHOUSE_SIGNER_URL=https://pymthouse-production.up.railway.app -# Set to 1 for local dev when issuer uses http://127.0.0.1 +# Signer DMZ URL is returned by issuer exchange / GET …/apps/{id}/signer/routing +# — do not set PYMTHOUSE_SIGNER_URL on the dashboard. +# Set to 1 for local http issuer only (not needed for https://localhost with mkcert) PYMTHOUSE_ALLOW_INSECURE_HTTP= # Live-runner discovery (orchestrator /discovery endpoint). diff --git a/app/(app)/usage/page.tsx b/app/(app)/usage/page.tsx index 1c17602..d95011c 100644 --- a/app/(app)/usage/page.tsx +++ b/app/(app)/usage/page.tsx @@ -36,7 +36,7 @@ function UsageContent() { + + ); + } + + 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} +
+ + {error ? ( +

{error}

+ ) : null} + + { + if (busyPlanId === null) setChangeDialog(null); + }} + maxWidth="max-w-[420px]" + > + void onConfirmChangeTiming()} + onClose={() => setChangeDialog(null)} + /> + +
+ ); +} diff --git a/components/dashboard/SidebarUsageCard.tsx b/components/dashboard/SidebarUsageCard.tsx index 207e5dc..ea1178b 100644 --- a/components/dashboard/SidebarUsageCard.tsx +++ b/components/dashboard/SidebarUsageCard.tsx @@ -51,7 +51,7 @@ export default function SidebarUsageCard() { balance && BigInt(balance.lifetimeGrantedUsdMicros || "0") > BigInt(0); const resetsAt = formatPeriodResetLabel(data.period.end); - const planLabel = showUsdAllowance ? "Starter" : "Usage"; + const planLabel = showUsdAllowance ? "Included usage" : "Usage"; let primaryUsed: number; let primaryLimit: number | null; diff --git a/components/dashboard/TimingChoicePanel.tsx b/components/dashboard/TimingChoicePanel.tsx new file mode 100644 index 0000000..84d6b6a --- /dev/null +++ b/components/dashboard/TimingChoicePanel.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { + formatPendingCancelDate, + toDateInputValue, + type SubscriptionTimingChoice, + type SubscriptionTimingOptions, +} from "@/lib/dashboard/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/dashboard/UsageView.tsx b/components/dashboard/UsageView.tsx index 182a04d..6d104de 100644 --- a/components/dashboard/UsageView.tsx +++ b/components/dashboard/UsageView.tsx @@ -6,7 +6,6 @@ import StackedAreaChart, { MiniSpark } from "@/components/dashboard/StackedAreaC import Button from "@/components/design-system/Button"; import { useAuth } from "@/components/dashboard/AuthContext"; import { useAccountUsage } from "@/lib/dashboard/useAccountUsage"; -import { getUtcCalendarMonthIsoBounds } from "@pymthouse/builder-sdk"; import { buildUsageCapabilityRows, formatPeriodResetLabel, @@ -14,6 +13,8 @@ import { type UsageCapabilityRow, } from "@/lib/dashboard/usage-capability-display"; import DashboardPageSkeleton from "@/components/dashboard/DashboardPageSkeleton"; +import PlansPanel from "@/components/dashboard/PlansPanel"; +import WalletPanel from "@/components/dashboard/WalletPanel"; const PERIOD_DAYS = 30; @@ -83,7 +84,7 @@ function AllowanceStrip({

- {showUsdAllowance ? "Starter allowance" : "Usage this period"} + {showUsdAllowance ? "Included this period" : "Usage this period"}

{showUsdAllowance && !hasAccess && ( @@ -264,7 +265,7 @@ export default function UsageView() { const { data } = usageState; const grandReq = filteredRows.reduce((a, c) => a + c.requestCount, 0); const grandSpend = filteredRows.reduce((a, c) => a + c.spendUsd, 0); - const resetsAt = formatPeriodResetLabel(getUtcCalendarMonthIsoBounds().endDate); + const resetsAt = formatPeriodResetLabel(data.period.end); const grantedMicros = data.balance?.lifetimeGrantedUsdMicros ?? null; return ( @@ -288,6 +289,17 @@ export default function UsageView() { resetsAt={resetsAt} /> + + + +
@@ -516,7 +528,7 @@ function LimitsPanel({ const limits = balance ? [ { - label: "Included allowance", + label: "Included usage", used: microsToUsdDisplay(balance.consumedUsdMicros), max: `$${microsToUsdDisplay(balance.lifetimeGrantedUsdMicros)}`, pct: diff --git a/components/dashboard/WalletPanel.tsx b/components/dashboard/WalletPanel.tsx new file mode 100644 index 0000000..8c4b939 --- /dev/null +++ b/components/dashboard/WalletPanel.tsx @@ -0,0 +1,362 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Button from "@/components/design-system/Button"; +import { useOwnerWallet } from "@/lib/dashboard/useOwnerWallet"; +import { + availableRunway, + collectionSchedule, + formatWalletUsd, + overageLimitNote, + spendPostureBadge, + type SpendPostureTone, +} from "@/lib/dashboard/wallet-settlement-display"; + +type TopUpFlash = "succeeded" | "canceled" | "pm-saved"; + +function readTopUpFlash(): TopUpFlash | null { + if (typeof window === "undefined") return null; + const value = new URLSearchParams(window.location.search).get("topup"); + if (value === "succeeded" || value === "canceled" || value === "pm-saved") { + return value; + } + return null; +} + +function clearTopUpQueryParam(): void { + if (typeof window === "undefined") return; + const url = new URL(window.location.href); + if (!url.searchParams.has("topup")) return; + url.searchParams.delete("topup"); + window.history.replaceState({}, "", `${url.pathname}${url.search}${url.hash}`); +} + +function formatInvoiceDate(iso: string | undefined): string { + if (!iso) return "—"; + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return "—"; + return date.toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + }); +} + +const QUICK_AMOUNTS = ["10.00", "25.00", "100.00"] as const; + +const POSTURE_TONE_CLASS: Record = { + ok: "border-emerald-400/30 text-emerald-400", + info: "border-hairline text-fg-muted", + warn: "border-amber-400/30 text-amber-400", + danger: "border-rose-400/30 text-rose-400", +}; + +const AVAILABLE_TONE_CLASS: Record = { + ok: "text-fg", + info: "text-fg", + warn: "text-amber-400", + danger: "text-rose-400", +}; + +/** Only follow https (or localhost http, for dev) Checkout URLs. */ +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()); +} + +export default function WalletPanel({ + externalUserId, + periodBillableUsdMicros = null, +}: { + externalUserId: string | undefined; + /** Period end-user billable USD micros from the Usage page (metered usage, not credits). */ + periodBillableUsdMicros?: string | null; +}) { + const { state, reload, startTopUp, startPaymentMethodCheckout, ensureDefaultPaymentMethod } = + useOwnerWallet(Boolean(externalUserId), externalUserId); + const [showTopUp, setShowTopUp] = useState(false); + const [amountUsd, setAmountUsd] = useState("25.00"); + const [busy, setBusy] = useState<"topup" | "pm" | null>(null); + const [error, setError] = useState(null); + const [flash, setFlash] = useState(null); + + useEffect(() => { + const next = readTopUpFlash(); + if (!next) return; + setFlash(next); + clearTopUpQueryParam(); + if (next === "pm-saved") { + void (async () => { + try { + await ensureDefaultPaymentMethod(); + } catch { + // Webhook may already have promoted; list still refreshes below. + } + void reload(); + })(); + } else if (next === "succeeded") { + void reload(); + } + }, [ensureDefaultPaymentMethod, reload]); + + async function onTopUp() { + setError(null); + setBusy("topup"); + try { + const { checkoutUrl } = await startTopUp({ amountUsd: amountUsd.trim() }); + redirectToCheckout(checkoutUrl); + } catch (err) { + setError(err instanceof Error ? err.message : "Top-up failed"); + setBusy(null); + } + } + + async function onAddPaymentMethod() { + setError(null); + setBusy("pm"); + try { + const { checkoutUrl } = await startPaymentMethodCheckout(); + redirectToCheckout(checkoutUrl); + } catch (err) { + setError( + err instanceof Error ? err.message : "Payment method setup failed", + ); + setBusy(null); + } + } + + if (state.status === "loading" || state.status === "idle") { + return ( +
+
+
+
+ ); + } + + if (state.status === "error") { + return ( +
+

Could not load wallet.

+

{state.message}

+ +
+ ); + } + + const { wallet, paymentMethods, invoices } = state; + const usageUsd = formatWalletUsd(periodBillableUsdMicros); + const billingState = wallet.billingState; + const posture = spendPostureBadge(billingState.status); + const runway = availableRunway(billingState); + const limitNote = overageLimitNote(billingState); + const defaultPm = + paymentMethods.find((pm) => pm.isDefault) ?? paymentMethods[0] ?? null; + const hasPaymentMethod = + wallet.paymentMethod.hasDefault ?? paymentMethods.length > 0; + + return ( +
+
+
+
+ + {posture.label} + +

+ {billingState.explain.headline} +

+
+

+ {billingState.explain.detail} +

+ +
+
+

+ Available +

+

+ {runway.usd} +

+ {runway.detail ? ( +

{runway.detail}

+ ) : null} + {limitNote ? ( +

{limitNote}

+ ) : null} +
+
+

+ Usage this period +

+

+ ${usageUsd} +

+
+
+

+ {collectionSchedule(billingState)} +

+ {wallet.payPerUsePlans.map((plan) => ( +

+ {plan.planName}: {plan.resolvedBehavior} +

+ ))} +
+
+ {showTopUp ? ( +
+ $ + setAmountUsd(e.target.value)} + className="h-[30px] w-24 rounded-[4px] border border-hairline bg-dark-card px-2 font-mono text-[13px] tabular-nums text-fg outline-none focus-visible:ring-1 focus-visible:ring-green-bright/30" + aria-label="Top-up amount in USD" + /> + + +
+ ) : ( + + )} + {showTopUp ? ( +
+ {QUICK_AMOUNTS.map((preset) => ( + + ))} +
+ ) : null} +
+
+ + {flash === "succeeded" ? ( +

+ Funds added. Your balance updates once Stripe settles the payment. +

+ ) : null} + {flash === "pm-saved" ? ( +

+ Payment method saved. +

+ ) : null} + {flash === "canceled" ? ( +

+ Checkout canceled. +

+ ) : null} + +
+
+

+ Payment method for usage billing +

+

+ {hasPaymentMethod && defaultPm + ? `${defaultPm.brand ?? defaultPm.type}${defaultPm.last4 ? ` •••• ${defaultPm.last4}` : ""}` + : hasPaymentMethod + ? "Payment method on file." + : "No payment method on file — progressive invoices cannot charge once credits run out."} +

+
+ +
+ +
+

Billing history

+ {invoices.length === 0 ? ( +

+ No invoices or top-ups yet. +

+ ) : ( +
    + {invoices.slice(0, 8).map((invoice) => ( +
  • + + {invoice.number ?? invoice.id} + + + {formatInvoiceDate(invoice.issuedAt ?? invoice.periodEnd)} + + + {invoice.invoiceType === "auto_topup" + ? "top-up" + : invoice.status} + + + {invoice.totalAmount} {invoice.currency.toUpperCase()} + +
  • + ))} +
+ )} +
+ + {error ? ( +

+ {error} +

+ ) : null} +
+ ); +} diff --git a/components/dashboard/settings/BillingSection.tsx b/components/dashboard/settings/BillingSection.tsx index d46eb20..a7e67b0 100644 --- a/components/dashboard/settings/BillingSection.tsx +++ b/components/dashboard/settings/BillingSection.tsx @@ -1,292 +1,1179 @@ "use client"; -import { ArrowRight, Box, Check, Download, Plus } from "lucide-react"; +import { useEffect, useState } from "react"; +import { + ArrowRight, + Box, + Check, + CreditCard, + Download, + Plus, + Trash2, +} from "lucide-react"; +import { useAuth } from "@/components/dashboard/AuthContext"; +import Dialog from "@/components/design-system/Dialog"; +import TimingChoicePanel from "@/components/dashboard/TimingChoicePanel"; import { IconButton, SettingsCard, - SettingsField, SettingsHeader, - SettingsInput, - SettingsTextarea, ST_COLS_5, ST_HEAD_CLASS, } from "./SettingsPrimitives"; +import { + ResumeSubscriptionError, + ScheduledChangeConflictError, + useBillingPlans, +} from "@/lib/dashboard/useBillingPlans"; +import { useBillingAccount } from "@/lib/dashboard/useBillingAccount"; +import type { + DashboardBillingPlan, + DashboardScheduledChangeConflict, +} from "@/lib/dashboard/pymthouse-billing-bff"; +import { + billingPlanActionLabel, + canCancelBillingSubscription, + defaultCancelTimingChoice, + deriveBillingPlanAction, + deriveBillingSubscriptionUiState, + formatBillingPlanPrice, + formatPendingCancelDate, + isActiveSubscriptionConflict, + isNothingToResumeError, + paidCatalogPlanIds, + resolveApplicablePendingCancel, + resolveCancelingEffectiveAt, + resolveCancelingPlanName, + resolveTimingPayload, + includedUsageFeatureLabel, + toDateInputValue, + withCurrentPlanInDisplayList, + type BillingPlanAction, + type SubscriptionTimingChoice, +} from "@/lib/dashboard/billing-subscription-state"; + +function isUsagePlan( + plan: Pick +): 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 { user } = useAuth(); + const externalUserId = user?.id?.trim(); + const { + state: plansState, + reload: reloadPlans, + subscribe, + changePlan, + cancelSubscription, + resumeSubscription, + } = useBillingPlans(externalUserId); + const { + state: accountState, + reload: reloadAccount, + startPaymentMethodCheckout, + openInvoice, + setDefaultPaymentMethod, + ensureDefaultPaymentMethod, + removePaymentMethod, + } = useBillingAccount(externalUserId); + + 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 && !externalUserId) return; + + if (next) setFlash(next); + clearCheckoutQueryParam(); + if (next === "success") { + void (async () => { + if (externalUserId) { + try { + await ensureDefaultPaymentMethod({ externalUserId }); + } catch { + // Webhook may already have promoted; list/UI still refreshes. + } + } + if (resumePlanId && externalUserId) { + 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 + }, [ + externalUserId, + ensureDefaultPaymentMethod, + reloadPlans, + reloadAccount, + ]); + + async function ensurePaymentMethodForUsagePlan(planId: string) { + if (!externalUserId) 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({ + externalUserId, + }); + window.location.assign(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 (!externalUserId) return; + const result = await changePlan({ + planId, + externalUserId, + successUrl: billingChangePlanSuccessUrl(planId), + cancelUrl: billingChangePlanCancelUrl(), + ...timing, + }); + if (result.checkoutUrl) { + window.location.assign(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 (!externalUserId) { + 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, + externalUserId, + successUrl: billingChangePlanSuccessUrl( + action === "retry_checkout" + ? (subscriptionUiState.planId ?? planId) + : planId + ), + cancelUrl: billingChangePlanCancelUrl(), + }; + const result = await subscribe(input); + + if (result.checkoutUrl) { + window.location.assign(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 (!externalUserId) { + setError("Sign in to cancel."); + return; + } + setError(null); + setBillingNotice(null); + setLifecycleBusy(true); + try { + const payload = resolveTimingPayload({ + choice: cancelChoice, + customDateYmd: cancelCustomDate, + }); + await cancelSubscription(externalUserId, 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 (!externalUserId || !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 (!externalUserId) { + setError("Sign in to restore your plan."); + return; + } + setError(null); + setBillingNotice(null); + setFlash(null); + setLifecycleBusy(true); + try { + await resumeSubscription(externalUserId); + 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 (!externalUserId) { + setError("Sign in to add a payment method."); + return; + } + setError(null); + setPmBusy(true); + try { + const { checkoutUrl } = await startPaymentMethodCheckout({ + externalUserId, + }); + window.location.assign(checkoutUrl); + } catch (err) { + setError( + err instanceof Error ? err.message : "Payment method checkout failed" + ); + setPmBusy(false); + } + } + + async function onOpenInvoice(invoiceId: string, prefer: "hosted" | "pdf") { + if (!externalUserId) return; + setError(null); + setInvoiceBusyId(invoiceId); + try { + const links = await openInvoice({ externalUserId, 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 (!externalUserId) return; + setError(null); + setPaymentMethodActionId(paymentMethodId); + try { + await setDefaultPaymentMethod({ externalUserId, paymentMethodId }); + } catch (err) { + setError( + err instanceof Error + ? err.message + : "Could not set default payment method" + ); + } finally { + setPaymentMethodActionId(null); + } + } + + async function onRemovePaymentMethod(paymentMethodId: string) { + if (!externalUserId) return; + if (!window.confirm("Remove this payment method?")) return; + setError(null); + setPaymentMethodActionId(paymentMethodId); + try { + await removePaymentMethod({ externalUserId, 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}` + : 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(externalUserId) + ); + const canResume = + Boolean(externalUserId) && + Boolean(resolveApplicablePendingCancel(subscription)); + const showCancelingBanner = subscriptionUiState.kind === "canceling"; + return ( -
-