diff --git a/apps/minting-service/src/lib/email.spec.ts b/apps/minting-service/src/lib/email.spec.ts index ec3bac6a7..81663e55a 100644 --- a/apps/minting-service/src/lib/email.spec.ts +++ b/apps/minting-service/src/lib/email.spec.ts @@ -8,6 +8,7 @@ describe('renderLicenseEmail', () => { seats: 3, token: 'PAYLOAD.SIG', expiresAt: new Date('2027-04-20T00:00:00Z'), + stripeCustomerId: 'cus_test', }); expect(out.text).toContain('-----BEGIN THREADPLANE LICENSE-----'); @@ -21,6 +22,7 @@ describe('renderLicenseEmail', () => { seats: 3, token: 't.s', expiresAt: new Date('2027-04-20T00:00:00Z'), + stripeCustomerId: 'cus_test', }); expect(out.subject).toBe('Your ThreadPlane license — developer_seat (3 seats)'); }); @@ -31,6 +33,7 @@ describe('renderLicenseEmail', () => { seats: 1, token: 't.s', expiresAt: new Date('2027-04-20T00:00:00Z'), + stripeCustomerId: 'cus_test', }); expect(out.subject).toBe('Your ThreadPlane license — team (1 seat)'); }); @@ -41,6 +44,7 @@ describe('renderLicenseEmail', () => { seats: 1, token: 't.s', expiresAt: new Date('2027-04-20T00:00:00Z'), + stripeCustomerId: 'cus_test', }); expect(out.text).toContain('Expires: 2027-04-20T00:00:00.000Z'); }); @@ -51,6 +55,7 @@ describe('renderLicenseEmail', () => { seats: 1, token: 'PAYLOAD.SIG', expiresAt: new Date('2027-04-20T00:00:00Z'), + stripeCustomerId: 'cus_test', }); expect(out.html).toContain(' +Manage subscription: ${portal} Docs: https://threadplane.ai/docs/licensing Questions: reply to this email. @@ -52,7 +68,7 @@ Questions: reply to this email. `; const html = `

Thanks for your ThreadPlane license purchase.

-

Your license is valid for 12 months from today. Paste the token below into your @ngaf/chat configuration:

+

Paste the token below into your @ngaf/chat configuration. Your subscription renews automatically on ${escapeHtml(expiresIso.slice(0, 10))}; manage or cancel any time.

-----BEGIN THREADPLANE LICENSE-----
 ${escapeHtml(vars.token)}
 -----END THREADPLANE LICENSE-----
@@ -66,8 +82,7 @@ ${escapeHtml(vars.token)} // .env THREADPLANE_LICENSE=<paste token above> -

Docs: threadplane.ai/docs/licensing
-Questions: reply to this email.

+

Manage subscription · Docs · Questions: reply to this email.

-- The ThreadPlane team

`; diff --git a/apps/minting-service/src/lib/handlers.ts b/apps/minting-service/src/lib/handlers.ts index 57097c3bb..698dc146b 100644 --- a/apps/minting-service/src/lib/handlers.ts +++ b/apps/minting-service/src/lib/handlers.ts @@ -167,7 +167,7 @@ async function mintAndEmail( resendApiKey: deps.resendApiKey, from: deps.emailFrom, to: email, - vars: { tier, seats, token, expiresAt }, + vars: { tier, seats, token, expiresAt, stripeCustomerId: customerId }, }); } diff --git a/apps/website/src/app/api/portal/session/route.ts b/apps/website/src/app/api/portal/session/route.ts new file mode 100644 index 000000000..d1666d35a --- /dev/null +++ b/apps/website/src/app/api/portal/session/route.ts @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: MIT +import { NextRequest, NextResponse } from 'next/server'; +import { getStripe } from '../../../../lib/stripe'; + +/** + * Mint a Stripe Customer Portal session URL for a buyer who just completed + * Checkout. We accept the Checkout session id (passed back via the + * success_url query param) and resolve the customer id from it. + * + * No durable-auth dependency: the buyer holds the Checkout session id in + * their /thanks URL for the lifetime of that browser context. Beyond that, + * the portal is reachable via the "Manage subscription" link that ships + * in their license email — that link contains the customer id directly. + * + * For a hard ongoing-access story (forgotten URL, lost email), we'll add a + * "look up my subscription by email" magic-link flow in a follow-up. + */ +function getOrigin(req: NextRequest): string { + const forwardedHost = req.headers.get('x-forwarded-host'); + const host = forwardedHost ?? req.headers.get('host') ?? 'localhost:3000'; + const proto = req.headers.get('x-forwarded-proto') ?? (host.startsWith('localhost') ? 'http' : 'https'); + return `${proto}://${host}`; +} + +interface RequestBody { + /** Checkout Session id (cs_test_… / cs_live_…). Preferred input. */ + session_id?: string; + /** Stripe customer id (cus_…). Used when we already know it (e.g. email link). */ + customer_id?: string; +} + +async function resolveCustomerId( + body: RequestBody, + stripe: ReturnType, +): Promise { + if (body.customer_id && /^cus_[A-Za-z0-9]+$/.test(body.customer_id)) { + return body.customer_id; + } + if (body.session_id && /^cs_(test|live)_[A-Za-z0-9]+$/.test(body.session_id)) { + const session = await stripe.checkout.sessions.retrieve(body.session_id); + const customer = session.customer; + if (typeof customer === 'string') return customer; + if (customer && 'id' in customer) return customer.id; + } + return null; +} + +async function readBody(req: NextRequest): Promise { + const contentType = req.headers.get('content-type') ?? ''; + if (contentType.includes('application/json')) { + try { + return (await req.json()) as RequestBody; + } catch { + return {}; + } + } + const form = await req.formData(); + const session_id = form.get('session_id'); + const customer_id = form.get('customer_id'); + return { + session_id: typeof session_id === 'string' ? session_id : undefined, + customer_id: typeof customer_id === 'string' ? customer_id : undefined, + }; +} + +export async function POST(req: NextRequest) { + const stripe = getStripe(); + const body = await readBody(req); + const customerId = await resolveCustomerId(body, stripe); + if (!customerId) { + return NextResponse.json( + { error: 'Pass session_id (Checkout) or customer_id (cus_…)' }, + { status: 400 }, + ); + } + + const origin = getOrigin(req); + const portal = await stripe.billingPortal.sessions.create({ + customer: customerId, + return_url: `${origin}/thanks`, + }); + + if (!portal.url) { + return NextResponse.json({ error: 'Stripe did not return a portal URL' }, { status: 502 }); + } + + return NextResponse.redirect(portal.url, { status: 303 }); +} + +/** + * GET handler so the buyer can click a plain link from their license email + * or the /thanks page and land on the portal. Same params as POST, via + * query string. + */ +export async function GET(req: NextRequest) { + const url = new URL(req.url); + const body: RequestBody = { + session_id: url.searchParams.get('session_id') ?? undefined, + customer_id: url.searchParams.get('customer_id') ?? undefined, + }; + const stripe = getStripe(); + const customerId = await resolveCustomerId(body, stripe); + if (!customerId) { + return NextResponse.json( + { error: 'Pass session_id or customer_id as a query param' }, + { status: 400 }, + ); + } + + const origin = getOrigin(req); + const portal = await stripe.billingPortal.sessions.create({ + customer: customerId, + return_url: `${origin}/thanks`, + }); + + if (!portal.url) { + return NextResponse.json({ error: 'Stripe did not return a portal URL' }, { status: 502 }); + } + + return NextResponse.redirect(portal.url, { status: 303 }); +} diff --git a/apps/website/src/app/thanks/page.tsx b/apps/website/src/app/thanks/page.tsx index cf2f5cb9b..48a18bb88 100644 --- a/apps/website/src/app/thanks/page.tsx +++ b/apps/website/src/app/thanks/page.tsx @@ -13,7 +13,16 @@ export const metadata = createPageMetadata({ type: 'website', }); -export default function ThanksPage() { +interface PageProps { + searchParams: Promise<{ session_id?: string }>; +} + +export default async function ThanksPage({ searchParams }: PageProps) { + const { session_id: sessionId } = await searchParams; + const portalHref = + sessionId && /^cs_(test|live)_[A-Za-z0-9]+$/.test(sessionId) + ? `/api/portal/session?session_id=${encodeURIComponent(sessionId)}` + : null; return (
@@ -60,6 +69,11 @@ export default function ThanksPage() { + {portalHref && ( + + )}