diff --git a/web/src/app/api/stripe/portal/route.ts b/web/src/app/api/stripe/portal/route.ts new file mode 100644 index 0000000..7403ec5 --- /dev/null +++ b/web/src/app/api/stripe/portal/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { jsonError } from '@/lib/auth-helpers'; + +export const dynamic = 'force-dynamic'; + +const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000'; + +/** + * Stripe billing-portal proxy (security follow-up item 6) — Next → Express → + * Stripe. Forwards the browser's bearer token (Express's requireAuth verifies + * the same one) and returns the hosted portal URL. The portal is fully + * configured in Stripe (cancellations, plan switching, invoice history); this + * is the app-side link from account settings. + */ +export async function POST(req: NextRequest) { + const authHeader = req.headers.get('authorization'); + if (!authHeader) return jsonError(401, 'Log in to manage billing.'); + + try { + const upstream = await fetch(`${BACKEND_URL}/api/stripe/portal`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: authHeader }, + }); + const data = (await upstream.json().catch(() => ({}))) as { portal_url?: string; error?: string }; + if (!upstream.ok || !data.portal_url) { + return NextResponse.json( + { error: data.error || 'Billing portal is unavailable right now.' }, + { status: upstream.ok ? 502 : upstream.status }, + ); + } + return NextResponse.json({ portal_url: data.portal_url }, { status: 200 }); + } catch { + return jsonError(503, 'Billing portal is unavailable right now.'); + } +} diff --git a/web/src/app/profile/page.tsx b/web/src/app/profile/page.tsx index fbe3505..6c1d3e5 100644 --- a/web/src/app/profile/page.tsx +++ b/web/src/app/profile/page.tsx @@ -40,6 +40,26 @@ export default function ProfilePage() { .catch(() => setProfile(null)); }, [user]); + // Item 6 — open the Stripe billing portal (payment method, invoices, plan + // switching, cancellation). The portal itself is configured in Stripe; this + // just mints a session and redirects. + const handleManageBilling = async () => { + setWorking(true); + setError(''); + const token = currentAccessToken(); + const res = await fetch('/api/stripe/portal', { + method: 'POST', + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + setWorking(false); + const body = await res.json().catch(() => ({})); + if (res.ok && body.portal_url) { + window.location.href = body.portal_url; + } else { + setError(body.error || 'Billing portal is unavailable right now.'); + } + }; + const handleCancel = async () => { if (!confirm('Cancel your subscription at the end of the current period?')) return; setWorking(true); @@ -153,6 +173,19 @@ export default function ProfilePage() { )} + {/* Manage billing — Stripe customer portal (item 6) */} + {tier !== 'free' && ( + + Billing + + Update your payment method, switch plans, or download invoices in the secure Stripe portal. + + + {working ? 'Opening…' : 'Manage billing →'} + + + )} + {/* Subscription actions */} {tier !== 'free' && !profile.cancel_at_period_end && (
+ Update your payment method, switch plans, or download invoices in the secure Stripe portal. +