import { useState, type ReactNode } from 'react'; import { useQuery } from '@tanstack/react-query'; import { FORMAT_LOCALES, formatCount, PLAN_IDS, PLANS, planOf, type PlanId } from '@wellformed/shared'; import { api } from '../api/client'; import { useI18n, type TranslateFn } from '../i18n'; import { useSession } from '../state/session'; import { useToasts } from '../state/toasts'; import { Modal } from './Modal'; /** * What this workspace is on, and what it is using. * * Usage is shown against the limit even when there is a long way to go, because * the moment someone wants this screen is the moment they hit one — and the * only useful version of that conversation starts with the real numbers. */ export function BillingDialog({ onClose }: { onClose(): void }): ReactNode { const session = useSession(); const toasts = useToasts(); const { t, locale } = useI18n(); const [busy, setBusy] = useState(false); const billing = useQuery({ queryKey: ['billing'], queryFn: () => api.billing() }); /* * Null means "not edited yet", so the field follows the server until someone * types — an initial-state copy would go stale the moment the query settles. */ const [draft, setDraft] = useState(null); const invoiceTo = draft ?? billing.data?.billingEmail ?? ''; const dirty = draft !== null && draft.trim() !== (billing.data?.billingEmail ?? ''); async function saveContact() { setBusy(true); try { await api.setBillingContact(invoiceTo.trim()); setDraft(null); await billing.refetch(); toasts.push(t('billing.invoicesAddressed'), 'success'); } catch (error) { toasts.fail(error); } finally { setBusy(false); } } async function upgrade(plan: PlanId) { if (!invoiceTo.trim()) { toasts.push(t('billing.needInvoiceAddress'), 'error'); return; } setBusy(true); try { const { url } = await api.checkout(plan, invoiceTo.trim()); // The provider is the merchant of record; the rest happens on their page. window.location.assign(url); } catch (error) { toasts.fail(error); setBusy(false); } } async function manage() { setBusy(true); try { const { url } = await api.billingPortal(); window.location.assign(url); } catch (error) { toasts.fail(error); setBusy(false); } } const summary = billing.data; const current = summary ? planOf(summary.effectivePlan) : null; /* * On an unmetered install the plan a workspace records is not a ceiling, so * the meters read against nothing and the price list does not belong on the * screen at all. What is left — how much is in here — is the half of this * dialog that was always true regardless of who was paying. */ const metered = summary?.metered ?? true; const workspaceName = session.workspace?.name ?? t('billing.thisWorkspace'); const limitText = (limit: number | null, noun: 'rows' | 'tables') => limit === null ? t(`billing.unlimited.${noun}`) : t(`billing.count.${noun}`, limit, locale); return (
{summary?.subscription.provider && ( )} } >
{billing.isLoading &&
{t('common.loading')}
} {billing.error &&
{t('billing.loadFailed')}
} {summary && current && ( <>
{!metered ? ( <> {summary.licensedTo ? ( <> {t('billing.licensedToBefore')} {summary.licensedTo} {t('billing.licensedToAfter')} ) : ( <>{t('billing.ownLicence')} )} ) : ( <> {t('billing.onPlanBefore')} {current.name} )} {metered && summary.subscription.status === 'trialing' && summary.subscription.trialEndsAt && ( <> {t( 'billing.trialClause', PLANS.team.name, daysLeft(summary.subscription.trialEndsAt, t), )} )} {metered && summary.subscription.status === 'past_due' && ( <>{t('billing.pastDueClause')} )} {metered && summary.subscription.cancelAtPeriodEnd && summary.subscription.periodEnd && ( <> {t( 'billing.endsClause', new Intl.DateTimeFormat(FORMAT_LOCALES[locale], { timeZone: 'UTC' }).format( new Date(summary.subscription.periodEnd.slice(0, 10)), ), )} )} .
{/* Belongs to the workspace, not to an admin — which is the whole point of asking rather than reading it off whoever is signed in. */} {summary.checkoutAvailable && (
{dirty && (
)}

{t('billing.contactHint')} {summary.subscription.provider && t('billing.contactProviderHint', summary.subscription.provider)}

)} {metered && (
{PLAN_IDS.map((id) => { const plan = PLANS[id]; const active = summary.effectivePlan === id; return (

{plan.name}

{plan.priceEur === 0 ? ( '€0' ) : ( <> €{plan.priceEur} {t('billing.perMonth')} )}
{limitText(plan.rowLimit, 'rows')} · {limitText(plan.tableLimit, 'tables')}
{active ? ( {t('billing.currentPlan')} ) : plan.priceEur > 0 ? ( ) : null}
); })}
)} {metered && !summary.checkoutAvailable && (

{t('billing.noPaymentsBefore')} npm run plan {t('billing.noPaymentsAfter')}

)} {metered ? (

{t('billing.overLimitBefore')} {t('billing.overLimitBold')} {t('billing.overLimitAfter')}

) : (

{t('billing.unmeteredHint')}

)} )}
); } function Meter({ label, used, limit, noun, }: { label: string; used: number; limit: number | null; noun: 'rows' | 'tables'; }): ReactNode { const { t, locale } = useI18n(); const share = limit === null ? 0 : Math.min(1, used / limit); return (
{label} {formatCount(used, locale)} / {limit === null ? '∞' : formatCount(limit, locale)}
{limit !== null && (
= 1 ? ' full' : share > 0.85 ? ' near' : '')} style={{ width: `${Math.round(share * 100)}%` }} />
)}
); } /** "6 days left", or "over" — never a raw timestamp on this screen. */ export function daysLeft(iso: string, t: TranslateFn): string { const ms = new Date(iso).getTime() - Date.now(); if (ms <= 0) return t('billing.trialOver'); const days = Math.ceil(ms / 86_400_000); return t('billing.daysLeft', days); }