Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/minting-service/src/lib/email.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-----');
Expand All @@ -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)');
});
Expand All @@ -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)');
});
Expand All @@ -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');
});
Expand All @@ -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('<pre');
expect(out.html).toContain('PAYLOAD.SIG');
Expand Down
25 changes: 20 additions & 5 deletions apps/minting-service/src/lib/email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@ export interface LicenseEmailVars {
seats: number;
token: string;
expiresAt: Date;
/** Stripe customer id; used to mint a portal link inline in the email. */
stripeCustomerId: string;
}

/**
* Public URL where the buyer can reach the Stripe Customer Portal for
* their subscription. Reads from PORTAL_BASE_URL when set (e.g. on
* preview deploys), otherwise points at the production threadplane.ai
* host.
*/
function portalUrl(stripeCustomerId: string): string {
const base = process.env['PORTAL_BASE_URL'] ?? 'https://threadplane.ai';
return `${base}/api/portal/session?customer_id=${encodeURIComponent(stripeCustomerId)}`;
}

export interface RenderedEmail {
Expand All @@ -23,10 +36,12 @@ export function renderLicenseEmail(vars: LicenseEmailVars): RenderedEmail {
const subject = `Your ThreadPlane license — ${vars.tier} (${vars.seats} ${seatWord})`;
const expiresIso = vars.expiresAt.toISOString();

const portal = portalUrl(vars.stripeCustomerId);
const text = `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 ${expiresIso.slice(0, 10)}; manage or cancel it via
the link at the bottom of this email.

-----BEGIN THREADPLANE LICENSE-----
${vars.token}
Expand All @@ -45,14 +60,15 @@ Installation:
// .env
THREADPLANE_LICENSE=<paste token above>

Manage subscription: ${portal}
Docs: https://threadplane.ai/docs/licensing
Questions: reply to this email.

-- The ThreadPlane team
`;

const html = `<p>Thanks for your ThreadPlane license purchase.</p>
<p>Your license is valid for 12 months from today. Paste the token below into your <code>@ngaf/chat</code> configuration:</p>
<p>Paste the token below into your <code>@ngaf/chat</code> configuration. Your subscription renews automatically on ${escapeHtml(expiresIso.slice(0, 10))}; <a href="${escapeHtml(portal)}">manage or cancel</a> any time.</p>
<pre style="white-space:pre-wrap;word-break:break-all;font-family:monospace;font-size:12px;background:#f4f4f4;padding:12px;border-radius:4px">-----BEGIN THREADPLANE LICENSE-----
${escapeHtml(vars.token)}
-----END THREADPLANE LICENSE-----</pre>
Expand All @@ -66,8 +82,7 @@ ${escapeHtml(vars.token)}

// .env
THREADPLANE_LICENSE=&lt;paste token above&gt;</pre>
<p>Docs: <a href="https://threadplane.ai/docs/licensing">threadplane.ai/docs/licensing</a><br>
Questions: reply to this email.</p>
<p><a href="${escapeHtml(portal)}">Manage subscription</a> &middot; <a href="https://threadplane.ai/docs/licensing">Docs</a> &middot; Questions: reply to this email.</p>
<p>-- The ThreadPlane team</p>
`;

Expand Down
2 changes: 1 addition & 1 deletion apps/minting-service/src/lib/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
});
}

Expand Down
121 changes: 121 additions & 0 deletions apps/website/src/app/api/portal/session/route.ts
Original file line number Diff line number Diff line change
@@ -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<typeof getStripe>,
): Promise<string | null> {
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<RequestBody> {
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 });
}
16 changes: 15 additions & 1 deletion apps/website/src/app/thanks/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Section surface="canvas" ariaLabelledBy="thanks-heading">
<Container>
Expand Down Expand Up @@ -60,6 +69,11 @@ export default function ThanksPage() {
<Button variant="primary" size="md" href="/docs/licensing">
Installation & licensing
</Button>
{portalHref && (
<Button variant="secondary" size="md" href={portalHref}>
Manage subscription
</Button>
)}
<Button variant="ghost" size="md" href="/contact">
Contact support
</Button>
Expand Down
Loading