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