From 889e8621b48500fca5477b79fffca2017195c215 Mon Sep 17 00:00:00 2001 From: Kev Date: Sat, 18 Jul 2026 13:55:39 -0400 Subject: [PATCH] =?UTF-8?q?Item=206=20=E2=80=94=20activate=20the=20Stripe?= =?UTF-8?q?=20billing=20portal=20link=20from=20account=20settings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The billing portal is fully configured in Stripe (cancellations, plan switching, invoice history) and the Express endpoint (POST /api/stripe/portal) existed, but nothing in the UI linked to it. Added the Next proxy (app/api/stripe/portal) and a "Manage billing →" button in the profile billing section (paid tiers) that mints a portal session and redirects. Kev still activates the hosted portal in the Stripe dashboard; this is the app-side link. Dunning verification (item 6): cancel-on-exhaustion is correctly wired — Smart Retries exhausting cancels the subscription → customer.subscription.deleted → webhook sets a 48h grace → middleware/gracePeriod.checkGracePeriod downgrades tier to free in both users + user_profiles after the grace expires. See the report for one nuance (the 48h grace on the FIRST payment_failed is shorter than Stripe's 2-week retry window — self-correcting via subscription.updated, but worth a product decision). Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/app/api/stripe/portal/route.ts | 35 ++++++++++++++++++++++++++ web/src/app/profile/page.tsx | 33 ++++++++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 web/src/app/api/stripe/portal/route.ts 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. +

+ +
+ )} + {/* Subscription actions */} {tier !== 'free' && !profile.cancel_at_period_end && (