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> @
This commit is contained in:
@@ -2,11 +2,19 @@
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
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';
|
||||
|
||||
@@ -68,6 +76,8 @@ function BillingInner() {
|
||||
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')
|
||||
@@ -98,15 +108,17 @@ function BillingInner() {
|
||||
setBusy(tier);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await apiFetch<{ url: string }>('/v1/billing/checkout-session', {
|
||||
const res = await apiFetch<{ clientSecret: string }>('/v1/billing/checkout-session', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ tier }),
|
||||
});
|
||||
window.location.href = res.url;
|
||||
// Open the embedded checkout in-app instead of redirecting to Stripe.
|
||||
setClientSecret(res.clientSecret);
|
||||
} catch (e) {
|
||||
setBusy(null);
|
||||
const detail = (e as { detail?: { detail?: string; error?: string } }).detail;
|
||||
setError(detail?.detail ?? detail?.error ?? (e as Error).message);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -183,6 +195,15 @@ function BillingInner() {
|
||||
|
||||
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]">
|
||||
@@ -421,6 +442,44 @@ function BillingInner() {
|
||||
);
|
||||
}
|
||||
|
||||
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 aren’t configured (missing Stripe publishable key). Please contact support.
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Alert({
|
||||
tone,
|
||||
children,
|
||||
@@ -485,7 +544,7 @@ function TierCard({
|
||||
onClick={() => onSubscribe(monthlyTier)}
|
||||
disabled={Boolean(busy)}
|
||||
>
|
||||
{busy === monthlyTier ? 'Redirecting…' : `Subscribe — €${monthly}/mo`}
|
||||
{busy === monthlyTier ? 'Loading…' : `Subscribe — €${monthly}/mo`}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -493,7 +552,7 @@ function TierCard({
|
||||
onClick={() => onSubscribe(yearlyTier)}
|
||||
disabled={Boolean(busy)}
|
||||
>
|
||||
{busy === yearlyTier ? 'Redirecting…' : `Or €${yearly}/year — 2 months free`}
|
||||
{busy === yearlyTier ? 'Loading…' : `Or €${yearly}/year — 2 months free`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user