buildmymcpserver/apps/web/app/(dashboard)/settings/billing/page.tsx
Marco Sadjadi cf423de3d5
All checks were successful
Deploy to Production / deploy (push) Successful in 1m22s
@
feat(billing): in-app embedded Stripe checkout + webhook hardening

Checkout previously used hosted ui_mode → window.location to checkout.stripe.com,
which pops out of the installed PWA into the system browser. Switch to embedded:

- API: ui_mode embedded_page (stripe-node v22 / API 2025-10 renamed the enum),
  return_url instead of success/cancel_url, returns client_secret.
- web: @stripe/react-stripe-js EmbeddedCheckout mounted in an in-app modal;
  NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY baked at build (Dockerfile arg + compose arg).
- .env.production.example: full Stripe section (was missing) + admin-email
  placeholder (INF-001).

Also bundled (same files): BILL-002 invoice.paid resets quota only on
subscription_cycle; BILL-003 webhook dedup rolled back on handler failure;
BILL-001 change-plan writes plan locally; BILL-004 webhook cross-checks
sub.customer before trusting metadata.orgId; INF-003 API routed off the raw
docker.sock through a locked-down tecnativa/docker-socket-proxy (CONTAINERS+POST).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
2026-05-29 20:56:40 +02:00

597 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import { Button } from '@/components/ui/button';
import { apiFetch } from '@/lib/api';
import { EmbeddedCheckout, EmbeddedCheckoutProvider } from '@stripe/react-stripe-js';
import { loadStripe } from '@stripe/stripe-js';
import { Loader2, X } from 'lucide-react';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { Suspense, useCallback, useEffect, useState } from 'react';
// Load Stripe.js once at module scope (Stripe's recommendation). Null when the
// publishable key isn't baked into the build — the modal then shows a clear
// "not configured" message instead of throwing.
const STRIPE_PK = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY;
const stripePromise = STRIPE_PK ? loadStripe(STRIPE_PK) : null;
type Plan = 'hobby' | 'pro' | 'team' | 'enterprise';
type Tier = 'pro_monthly' | 'pro_yearly' | 'team_monthly' | 'team_yearly';
interface SubscriptionInfo {
id: string;
status: string;
currentPeriodEnd: number;
cancelAtPeriodEnd: boolean;
priceId: string | null;
amount: number | null;
currency: string | null;
interval: string | null;
}
interface Invoice {
id: string;
number: string | null;
status: string | null;
amountPaid: number;
currency: string;
created: number;
pdfUrl: string | null;
hostedUrl: string | null;
}
interface BillingStatus {
plan: Plan;
hasCustomer: boolean;
hasSubscription: boolean;
suspended: boolean;
suspendedReason: string | null;
subscription?: SubscriptionInfo;
invoices?: Invoice[];
_stripeError?: boolean;
}
const PLAN_LABEL: Record<Plan, string> = {
hobby: 'Hobby (free)',
pro: 'Pro',
team: 'Team',
enterprise: 'Enterprise',
};
function formatMoney(amount: number | null, currency: string | null): string {
if (amount === null || currency === null) return '—';
return new Intl.NumberFormat(undefined, {
style: 'currency',
currency: currency.toUpperCase(),
}).format(amount / 100);
}
function BillingInner() {
const router = useRouter();
const searchParams = useSearchParams();
const justSubscribed = searchParams.get('success') === 'true';
const cancelledCheckout = searchParams.get('cancelled') === 'true';
const autoUpgradeTier = searchParams.get('tier') as Tier | null;
const [status, setStatus] = useState<BillingStatus | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
// When set, the in-app embedded Stripe checkout modal is open.
const [clientSecret, setClientSecret] = useState<string | null>(null);
const loadStatus = useCallback(() => {
apiFetch<BillingStatus>('/v1/billing/status')
.then(setStatus)
.catch((e) => {
const err = e as { status?: number };
if (err.status === 401) router.push('/login?returnTo=/settings/billing');
else setError((e as Error).message);
});
}, [router]);
useEffect(() => {
loadStatus();
}, [loadStatus]);
useEffect(() => {
if (!justSubscribed) return;
let tries = 0;
const id = setInterval(() => {
tries += 1;
loadStatus();
if (tries >= 6) clearInterval(id);
}, 1500);
return () => clearInterval(id);
}, [justSubscribed, loadStatus]);
const startCheckout = useCallback(async (tier: Tier) => {
setBusy(tier);
setError(null);
try {
const res = await apiFetch<{ clientSecret: string }>('/v1/billing/checkout-session', {
method: 'POST',
body: JSON.stringify({ tier }),
});
// Open the embedded checkout in-app instead of redirecting to Stripe.
setClientSecret(res.clientSecret);
} catch (e) {
const detail = (e as { detail?: { detail?: string; error?: string } }).detail;
setError(detail?.detail ?? detail?.error ?? (e as Error).message);
} finally {
setBusy(null);
}
}, []);
useEffect(() => {
if (!autoUpgradeTier || !status) return;
if (status.hasSubscription) return;
void startCheckout(autoUpgradeTier);
}, [autoUpgradeTier, status, startCheckout]);
async function changePlan(tier: Tier) {
if (!confirm(`Switch to ${tier.replace('_', ' ')}? Prorated charges apply immediately.`)) return;
setBusy(`change-${tier}`);
setError(null);
try {
await apiFetch('/v1/billing/change-plan', {
method: 'POST',
body: JSON.stringify({ tier }),
});
// Webhook will update plan asynchronously; poll briefly.
let tries = 0;
const id = setInterval(() => {
tries += 1;
loadStatus();
if (tries >= 6) clearInterval(id);
}, 1500);
} catch (e) {
const detail = (e as { detail?: { detail?: string; error?: string } }).detail;
setError(detail?.detail ?? detail?.error ?? (e as Error).message);
} finally {
setBusy(null);
}
}
async function cancelSubscription() {
if (!confirm('Cancel subscription at the end of the current billing period?')) return;
setBusy('cancel');
setError(null);
try {
await apiFetch('/v1/billing/cancel', { method: 'POST', body: '{}' });
loadStatus();
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(null);
}
}
async function reactivateSubscription() {
setBusy('reactivate');
setError(null);
try {
await apiFetch('/v1/billing/reactivate', { method: 'POST', body: '{}' });
loadStatus();
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(null);
}
}
if (!status && !error) {
return (
<div className="mx-auto max-w-3xl px-6 py-12 text-center">
<Loader2 className="mx-auto animate-spin text-[--color-fg-muted]" size={20} />
</div>
);
}
const sub = status?.subscription;
const hasSub = Boolean(status?.hasSubscription && sub);
const planValue = status?.plan ?? 'hobby';
const currentPlanIsPro = planValue === 'pro';
const currentPlanIsTeam = planValue === 'team';
return (
<div className="mx-auto max-w-3xl px-6 py-10">
{clientSecret && (
<CheckoutModal
clientSecret={clientSecret}
onClose={() => {
setClientSecret(null);
setBusy(null);
}}
/>
)}
<div>
<h1 className="text-[22px] font-semibold tracking-tight">Billing</h1>
<p className="mt-1 text-[13px] text-[--color-fg-muted]">
Plan, renewal, invoices and cancellation everything in-app.
</p>
</div>
{cancelledCheckout && (
<Alert tone="muted">Checkout cancelled. No charge made.</Alert>
)}
{justSubscribed && (
<Alert tone="success">
Subscription active. Plan updates within a few seconds refreshing
</Alert>
)}
{status?.suspended && (
<Alert tone="warn">
<strong>Subscription paused</strong> {status.suspendedReason ?? 'payment issue'}. New
servers + previews are blocked until payment succeeds; existing servers keep running.
</Alert>
)}
{status?._stripeError && (
<Alert tone="warn">
Live billing data temporarily unavailable showing local state only. Try again in a
minute.
</Alert>
)}
{error && <Alert tone="error">{error}</Alert>}
{status && (
<div className="panel mt-6 p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="text-[11px] uppercase tracking-wider text-[--color-fg-subtle]">
Current plan
</div>
<div className="mt-1 flex items-baseline gap-2">
<span className="text-[22px] font-semibold tracking-tight">
{PLAN_LABEL[status.plan]}
</span>
{sub?.amount !== null && sub?.amount !== undefined && (
<span className="text-[13px] text-[--color-fg-muted]">
{formatMoney(sub.amount, sub.currency)} / {sub.interval}
</span>
)}
</div>
</div>
{sub && (
<div className="text-right text-[12px]">
<div className="text-[--color-fg-subtle]">
{sub.cancelAtPeriodEnd ? 'Cancels' : 'Renews'}
</div>
<div className="mt-0.5 mono text-[--color-fg]">
{new Date(sub.currentPeriodEnd * 1000).toLocaleDateString()}
</div>
</div>
)}
</div>
{hasSub && sub && (
<div className="mt-5 flex flex-wrap gap-2 border-t border-[--color-border] pt-4">
{sub.cancelAtPeriodEnd ? (
<>
<p className="w-full text-[12.5px] text-[--color-fg-muted]">
Scheduled to cancel on{' '}
<span className="mono text-[--color-fg]">
{new Date(sub.currentPeriodEnd * 1000).toLocaleDateString()}
</span>
. You keep paid features until then.
</p>
<Button
variant="primary"
size="md"
onClick={reactivateSubscription}
disabled={busy === 'reactivate'}
>
{busy === 'reactivate' ? 'Reactivating…' : 'Keep subscription'}
</Button>
</>
) : (
<Button
variant="ghost"
size="md"
onClick={cancelSubscription}
disabled={busy === 'cancel'}
>
{busy === 'cancel' ? 'Cancelling…' : 'Cancel subscription'}
</Button>
)}
</div>
)}
</div>
)}
{status && !status.hasSubscription && (
<>
<h2 className="mt-10 text-[15px] font-semibold tracking-tight">Choose a plan</h2>
<div className="mt-3 grid gap-3 md:grid-cols-2">
<TierCard
name="Pro"
monthly={49}
yearly={490}
features={['5 MCP servers', '1M tool calls / mo', 'Custom domain', 'Claude Haiku 4.5']}
busy={busy}
onSubscribe={startCheckout}
monthlyTier="pro_monthly"
yearlyTier="pro_yearly"
highlight
/>
<TierCard
name="Team"
monthly={199}
yearly={1990}
features={[
'25 MCP servers',
'10M tool calls / mo',
'RBAC + audit log',
'Claude Sonnet 4.6',
]}
busy={busy}
onSubscribe={startCheckout}
monthlyTier="team_monthly"
yearlyTier="team_yearly"
/>
</div>
<p className="mt-4 text-[12px] text-[--color-fg-subtle]">
Annual saves 2 months. VAT calculated automatically based on your billing address.
Cancel anytime from this page service continues until end of period.
</p>
</>
)}
{status && status.hasSubscription && (
<>
<h2 className="mt-10 text-[15px] font-semibold tracking-tight">Switch plan</h2>
<p className="mt-1 text-[12.5px] text-[--color-fg-muted]">
Prorated immediately. The new amount is added to your next invoice.
</p>
<div className="mt-3 grid gap-2 md:grid-cols-2">
{!currentPlanIsPro && (
<PlanSwitch
label="Pro — €49 / month"
onClick={() => changePlan('pro_monthly')}
busy={busy === 'change-pro_monthly'}
/>
)}
<PlanSwitch
label={currentPlanIsPro ? 'Pro — €490 / year (2 months free)' : 'Pro — yearly'}
onClick={() => changePlan('pro_yearly')}
busy={busy === 'change-pro_yearly'}
/>
{!currentPlanIsTeam && (
<PlanSwitch
label="Team — €199 / month"
onClick={() => changePlan('team_monthly')}
busy={busy === 'change-team_monthly'}
/>
)}
<PlanSwitch
label={currentPlanIsTeam ? 'Team — €1990 / year (2 months free)' : 'Team — yearly'}
onClick={() => changePlan('team_yearly')}
busy={busy === 'change-team_yearly'}
/>
</div>
</>
)}
{status?.invoices && status.invoices.length > 0 && (
<>
<h2 className="mt-10 text-[15px] font-semibold tracking-tight">Invoices</h2>
<div className="panel mt-3 divide-y divide-[--color-border]">
{status.invoices.map((inv) => (
<div key={inv.id} className="flex items-center justify-between px-4 py-3 text-[12.5px]">
<div>
<div className="mono text-[--color-fg]">{inv.number ?? inv.id}</div>
<div className="text-[11px] text-[--color-fg-subtle]">
{new Date(inv.created * 1000).toLocaleDateString()} · {inv.status ?? 'unknown'}
</div>
</div>
<div className="flex items-center gap-3">
<span className="mono text-[--color-fg]">
{formatMoney(inv.amountPaid, inv.currency)}
</span>
{inv.pdfUrl && (
<a
href={inv.pdfUrl}
target="_blank"
rel="noreferrer"
className="text-[11.5px] text-[--color-accent] hover:underline"
>
PDF
</a>
)}
{inv.hostedUrl && (
<a
href={inv.hostedUrl}
target="_blank"
rel="noreferrer"
className="text-[11.5px] text-[--color-fg-muted] hover:text-[--color-fg]"
>
View
</a>
)}
</div>
</div>
))}
</div>
</>
)}
<p className="mt-10 text-[12px] text-[--color-fg-subtle]">
Payment-method updates and other rare actions:{' '}
<button
type="button"
onClick={async () => {
try {
const r = await apiFetch<{ url: string }>('/v1/billing/portal', {
method: 'POST',
body: '{}',
});
window.location.href = r.url;
} catch {
setError('Portal could not open');
}
}}
className="text-[--color-accent] hover:underline"
>
open Stripe billing portal
</button>{' '}
·{' '}
<Link href="/pricing" className="hover:text-[--color-fg]">
compare plans
</Link>
</p>
</div>
);
}
function CheckoutModal({
clientSecret,
onClose,
}: {
clientSecret: string;
onClose: () => void;
}) {
return (
<div
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/60 p-4 backdrop-blur-sm sm:p-8"
role="dialog"
aria-modal="true"
>
<div className="relative my-auto w-full max-w-xl rounded-lg border border-[--color-border] bg-[--color-bg] p-1 shadow-xl">
<button
type="button"
onClick={onClose}
aria-label="Close checkout"
className="absolute right-2 top-2 z-10 rounded-md p-1.5 text-[--color-fg-muted] hover:bg-[--color-bg-subtle] hover:text-[--color-fg]"
>
<X size={18} />
</button>
{stripePromise ? (
<EmbeddedCheckoutProvider stripe={stripePromise} options={{ clientSecret }}>
<EmbeddedCheckout />
</EmbeddedCheckoutProvider>
) : (
<div className="p-6">
<Alert tone="error">
Payments arent configured (missing Stripe publishable key). Please contact support.
</Alert>
</div>
)}
</div>
</div>
);
}
function Alert({
tone,
children,
}: {
tone: 'muted' | 'success' | 'warn' | 'error';
children: React.ReactNode;
}) {
const cls =
tone === 'success'
? 'border-emerald-500/30 bg-emerald-500/10'
: tone === 'warn'
? 'border-amber-500/40 bg-amber-500/10'
: tone === 'error'
? 'border-[--color-danger]/40 bg-[--color-danger]/10'
: 'border-[--color-border] bg-[--color-bg-subtle]';
return (
<div className={`mt-4 rounded-md border px-3.5 py-2.5 text-[12.5px] ${cls}`}>
{children}
</div>
);
}
function TierCard({
name,
monthly,
yearly,
features,
busy,
onSubscribe,
monthlyTier,
yearlyTier,
highlight,
}: {
name: string;
monthly: number;
yearly: number;
features: string[];
busy: string | null;
onSubscribe: (tier: Tier) => void;
monthlyTier: Tier;
yearlyTier: Tier;
highlight?: boolean;
}) {
return (
<div
className={`panel flex h-full flex-col p-4 ${highlight ? 'border-[--color-accent]/40' : ''}`}
>
<div className="text-[11px] uppercase tracking-wider text-[--color-fg-subtle]">{name}</div>
<div className="mt-1 flex items-baseline gap-1">
<span className="text-[24px] font-semibold tracking-tight">{monthly}</span>
<span className="text-[12px] text-[--color-fg-subtle]">/ month</span>
</div>
<ul className="mt-3 space-y-1 text-[12.5px] text-[--color-fg-muted]">
{features.map((f) => (
<li key={f}> {f}</li>
))}
</ul>
<div className="mt-4 flex flex-col gap-2">
<Button
variant={highlight ? 'primary' : 'secondary'}
size="md"
onClick={() => onSubscribe(monthlyTier)}
disabled={Boolean(busy)}
>
{busy === monthlyTier ? 'Loading…' : `Subscribe — €${monthly}/mo`}
</Button>
<Button
variant="ghost"
size="md"
onClick={() => onSubscribe(yearlyTier)}
disabled={Boolean(busy)}
>
{busy === yearlyTier ? 'Loading…' : `Or €${yearly}/year — 2 months free`}
</Button>
</div>
</div>
);
}
function PlanSwitch({
label,
onClick,
busy,
}: {
label: string;
onClick: () => void;
busy: boolean;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={busy}
className="panel flex items-center justify-between p-3 text-left text-[12.5px] transition-colors hover:bg-[--color-bg-subtle] disabled:opacity-60"
>
<span className="text-[--color-fg]">{label}</span>
<span className="text-[--color-fg-muted]">{busy ? '…' : '→'}</span>
</button>
);
}
export default function BillingPage() {
return (
<Suspense
fallback={
<div className="mx-auto max-w-3xl px-6 py-12 text-center">
<Loader2 className="mx-auto animate-spin text-[--color-fg-muted]" size={20} />
</div>
}
>
<BillingInner />
</Suspense>
);
}