import { clerkClient } from "@clerk/nextjs/server"; import { verifyCheckoutSession } from "@/lib/verify-checkout-session"; type StripeSession = { metadata?: Record; customer_email?: string | null; customer_details?: { email?: string | null }; }; /** See verify-checkout-session.ts — same charset gate for path interpolation. */ const STRIPE_SESSION_ID_RE = /^[A-Za-z0-9_-]{1,256}$/; function safeStripeSessionId(sessionId: string): string | null { if (typeof sessionId !== "string" || !STRIPE_SESSION_ID_RE.test(sessionId)) { return null; } return encodeURIComponent(sessionId); } async function fetchStripeSession(sessionId: string): Promise { const stripeKey = process.env.STRIPE_SECRET_KEY; if (!stripeKey) return null; const safeSessionId = safeStripeSessionId(sessionId); if (!safeSessionId) return null; const resp = await fetch(`https://api.stripe.com/v1/checkout/sessions/${safeSessionId}`, { headers: { Authorization: `Bearer ${stripeKey}` }, }); if (!resp.ok) return null; return (await resp.json()) as StripeSession; } async function setSessionMetadata( sessionId: string, fields: Record, ): Promise { const stripeKey = process.env.STRIPE_SECRET_KEY; if (!stripeKey) return false; const safeSessionId = safeStripeSessionId(sessionId); if (!safeSessionId) return false; const formBody = new URLSearchParams(); for (const [key, value] of Object.entries(fields)) { formBody.set(`metadata[${key}]`, value); } const resp = await fetch(`https://api.stripe.com/v1/checkout/sessions/${safeSessionId}`, { method: "POST", headers: { Authorization: `Bearer ${stripeKey}`, "Content-Type": "application/x-www-form-urlencoded", }, body: formBody.toString(), }); return resp.ok; } function setStripeClaimedBy(sessionId: string, userId: string): Promise { return setSessionMetadata(sessionId, { claimedByUserId: userId }); } /** * The address that paid, which is the address the eSIM email goes to. * * Deliberately taken from the Stripe session rather than from Clerk. The * person who paid is the person expecting the QR code; the signed-in account * may have several addresses on it, and picking one of those would be a guess. */ export async function orderPurchaserEmail(orderId: string): Promise { if (!orderId.startsWith("cs_")) return null; const session = await fetchStripeSession(orderId); return session ? sessionPurchaserEmail(session) : null; } /** * When the delivery email for this order was sent, if it was. * * The storefront has no database — Stripe session metadata is the only place * it can record a fact about an order, and it is already used that way for * `claimedByUserId`. Without this marker every reload of the activation page * would send the customer another copy of the same email. */ export async function qrEmailSentAt(orderId: string): Promise { if (!process.env.STRIPE_SECRET_KEY || !orderId.startsWith("cs_")) return null; const session = await fetchStripeSession(orderId); return session?.metadata?.qrEmailSentAt ?? null; } /** Best-effort. A send that happened and was not recorded is a duplicate, not a loss. */ export async function markQrEmailSent(orderId: string, at: string): Promise { if (!process.env.STRIPE_SECRET_KEY || !orderId.startsWith("cs_")) return; await setSessionMetadata(orderId, { qrEmailSentAt: at }).catch(() => false); } function claimedOrderIds(metadata: Record | undefined): string[] { const value = metadata?.claimedOrderIds; return Array.isArray(value) ? value.filter((id): id is string => typeof id === "string") : []; } function sessionPurchaserEmail(session: StripeSession): string | null { return session.customer_details?.email ?? session.customer_email ?? null; } /** * Does this Clerk user own the address that paid for the session? * * `requireVerified` is the difference between two very different questions. * Claiming an unclaimed order only needs the addresses on the account, because * nobody loses anything. Taking an order off another account needs a verified * address, because an unverified one is a string the claimant typed. */ async function userMatchesSessionPurchaser( session: StripeSession, userId: string, opts: { requireVerified?: boolean } = {}, ): Promise { const purchaserEmail = sessionPurchaserEmail(session); if (!purchaserEmail) return !opts.requireVerified; const client = await clerkClient(); const user = await client.users.getUser(userId); const normalizedPurchaserEmail = purchaserEmail.toLowerCase(); return user.emailAddresses.some( (address) => address.emailAddress.toLowerCase() === normalizedPurchaserEmail && (!opts.requireVerified || address.verification?.status === "verified"), ); } /** Drop an order id from a user's claimed list. Used when the order moves away. */ async function removeClaimedOrderFromUser(userId: string, orderId: string): Promise { const client = await clerkClient(); const user = await client.users.getUser(userId); const existing = claimedOrderIds(user.privateMetadata as Record | undefined); if (!existing.includes(orderId)) return; await client.users.updateUserMetadata(userId, { privateMetadata: { claimedOrderIds: existing.filter((id) => id !== orderId) }, }); } async function addClaimedOrderToUser(userId: string, orderId: string): Promise { const client = await clerkClient(); const user = await client.users.getUser(userId); const existing = claimedOrderIds(user.privateMetadata as Record | undefined); if (existing.includes(orderId)) return; await client.users.updateUserMetadata(userId, { privateMetadata: { claimedOrderIds: [...existing, orderId] }, }); } export async function claimCheckoutOrder( userId: string, sessionId: string, templateId: string, ): Promise<{ ok: true; orderId: string } | { ok: false; status: number; error: string }> { const verification = await verifyCheckoutSession(sessionId, templateId); if (!verification.ok) { return { ok: false, status: 400, error: "Invalid or unpaid checkout session" }; } const orderId = verification.orderId; if (sessionId !== "mock" && process.env.STRIPE_SECRET_KEY) { const session = await fetchStripeSession(sessionId); if (!session) { return { ok: false, status: 400, error: "Invalid or unpaid checkout session" }; } if (!(await userMatchesSessionPurchaser(session, userId))) { return { ok: false, status: 403, error: "This order is linked to another account" }; } const claimedBy = session.metadata?.claimedByUserId; if (claimedBy && claimedBy !== userId) { /* * The order is already on another account. That is the normal shape of a * guest purchase that has since become a real account: pay signed out, * the order lands on whichever account first claimed it, and then the * buyer signs in — or signs up — as themselves. * * Refusing here made the person who paid for the eSIM the one who could * not see it. So the order moves to the account that owns the paying * address, rather than the claim being rewritten to point at an identity * nobody verified. The address is the fact Stripe recorded at payment * time; a Clerk id is just who got here first. * * The bar for a move is a *verified* address on the signed-in account. * Without that, anyone who learned a session id could take someone * else's order by typing their email into a new account. */ const ownsPayingAddress = await userMatchesSessionPurchaser(session, userId, { requireVerified: true, }); if (!ownsPayingAddress) { return { ok: false, status: 403, error: "This order is linked to another account" }; } const moved = await setStripeClaimedBy(sessionId, userId); if (!moved) { return { ok: false, status: 502, error: "Failed to claim order" }; } const afterMove = await fetchStripeSession(sessionId); if (afterMove?.metadata?.claimedByUserId !== userId) { return { ok: false, status: 403, error: "This order is linked to another account" }; } // Best-effort tidy-up. The order is already on the right account; a // stale id left on the old one shows a dashboard row that no longer // resolves, which is untidy, not broken. await removeClaimedOrderFromUser(claimedBy, orderId).catch(() => {}); await addClaimedOrderToUser(userId, orderId); return { ok: true, orderId }; } if (!claimedBy) { const updated = await setStripeClaimedBy(sessionId, userId); if (!updated) { return { ok: false, status: 502, error: "Failed to claim order" }; } const afterClaim = await fetchStripeSession(sessionId); if (afterClaim?.metadata?.claimedByUserId !== userId) { return { ok: false, status: 403, error: "This order is linked to another account" }; } } } await addClaimedOrderToUser(userId, orderId); return { ok: true, orderId }; } export async function userOwnsOrder(userId: string, orderId: string): Promise { if (process.env.STRIPE_SECRET_KEY && orderId.startsWith("cs_")) { const session = await fetchStripeSession(orderId); return session?.metadata?.claimedByUserId === userId; } const client = await clerkClient(); const user = await client.users.getUser(userId); return claimedOrderIds(user.privateMetadata as Record | undefined).includes( orderId, ); } /** * Every order this user has claimed, newest last. * * Clerk's private metadata is the storefront's only record of who bought what: * there is no local database, and Stripe cannot be queried by user. The * dashboard reads this and then asks fulfilment about each id. */ export async function listClaimedOrderIds(userId: string): Promise { const client = await clerkClient(); const user = await client.users.getUser(userId); return claimedOrderIds(user.privateMetadata as Record | undefined); }