Trials were anonymous — a 14-day trial you cannot talk to. trials gains an email column (idempotent ALTER), POST /api/the-nook/trial-email attaches the optional first-launch prompt's answer, and the address lands in a Brevo list when NOOK_TRIAL_LIST_ID is set. The lifecycle emails follow: a D+11 trial expiry reminder with a buy link, a purchase thank-you from the Stripe webhook, and scheduled testimonial (D+3) and review (D+14) asks via the daily /api/the-nook/lifecycle cron. Rows are marked before sending so retries never double-send; trial addresses that already bought get no buy reminder; the endpoint requires a Bearer secret (CRON_SECRET / NOOK_LIFECYCLE_SECRET) and fails closed when unconfigured — an open trigger would email real users. All sends are best-effort and logged, never thrown. Tests: trial-email (3) and lifecycle (6) suites; full run 539 pass. Issues: FRE-10, FRE-11
106 lines
3.6 KiB
TypeScript
106 lines
3.6 KiB
TypeScript
import type { APIEvent } from "@solidjs/start/server";
|
|
import { env } from "~/env/server";
|
|
import { NookConnectionFactory } from "~/server/db-connections";
|
|
import { nookSchemaBootstrap, issueLicense, emailLicenseKey } from "~/server/nook";
|
|
import { emailThankYou } from "~/server/nook-lifecycle";
|
|
import { json } from "../_lib";
|
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
|
|
/**
|
|
* POST /api/the-nook/webhooks/stripe
|
|
*
|
|
* Verifies the Stripe webhook signature manually (no `stripe` npm package),
|
|
* handles `checkout.session.completed`: issues a license row (idempotent via
|
|
* the UNIQUE `stripe_session_id`) and emails the key to the buyer.
|
|
*
|
|
* Email failure is a logged warning, never a purchase failure — the license
|
|
* row is written before emailing, and the success page can still fetch the
|
|
* key via by-session.
|
|
*/
|
|
|
|
interface StripeSessionCompleted {
|
|
id: string;
|
|
type: string;
|
|
data?: { object?: { id?: string; customer_details?: { email?: string } } };
|
|
}
|
|
|
|
function verifyStripeSignature(rawBody: string, signatureHeader: string): boolean {
|
|
const params = new Map<string, string>();
|
|
for (const pair of signatureHeader.split(",")) {
|
|
const eq = pair.indexOf("=");
|
|
if (eq > 0) params.set(pair.slice(0, eq), pair.slice(eq + 1));
|
|
}
|
|
const t = params.get("t");
|
|
const v1 = params.get("v1");
|
|
if (!t || !v1) return false;
|
|
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const ts = Number(t);
|
|
if (!Number.isFinite(ts) || Math.abs(now - ts) > 300) return false;
|
|
|
|
const expected = createHmac("sha256", env.NOOK_STRIPE_WEBHOOK_SECRET)
|
|
.update(`${t}.${rawBody}`)
|
|
.digest("hex");
|
|
const provided = Buffer.from(v1, "hex");
|
|
const expectedBuffer = Buffer.from(expected, "hex");
|
|
if (provided.length !== expectedBuffer.length) return false;
|
|
return timingSafeEqual(provided, expectedBuffer);
|
|
}
|
|
|
|
export async function POST(event: APIEvent) {
|
|
const rawBody = await event.request.text();
|
|
const signatureHeader = event.request.headers.get("Stripe-Signature");
|
|
if (!signatureHeader) {
|
|
return json({ error: "Missing signature" }, 400);
|
|
}
|
|
if (!verifyStripeSignature(rawBody, signatureHeader)) {
|
|
return json({ error: "Invalid signature" }, 400);
|
|
}
|
|
|
|
let payload: unknown;
|
|
try {
|
|
payload = JSON.parse(rawBody);
|
|
} catch {
|
|
return json({ error: "Invalid JSON" }, 400);
|
|
}
|
|
|
|
const body = payload as StripeSessionCompleted;
|
|
if (body.type !== "checkout.session.completed") {
|
|
// Unknown event types are acknowledged, not retried.
|
|
return json({ received: true });
|
|
}
|
|
|
|
const sessionId = body.data?.object?.id;
|
|
const email = body.data?.object?.customer_details?.email;
|
|
if (!sessionId || !email) {
|
|
console.error("checkout.session.completed missing id or email:", rawBody);
|
|
return json({ received: true });
|
|
}
|
|
|
|
await nookSchemaBootstrap;
|
|
const conn = NookConnectionFactory();
|
|
|
|
// Idempotency: a session that already produced a license is acknowledged
|
|
// without re-issuing or re-emailing.
|
|
const existing = await conn.execute({
|
|
sql: "SELECT id FROM licenses WHERE stripe_session_id = ?",
|
|
args: [sessionId]
|
|
});
|
|
if (existing.rows.length > 0) {
|
|
return json({ received: true });
|
|
}
|
|
|
|
try {
|
|
const { key } = await issueLicense(email, sessionId);
|
|
await emailLicenseKey(email, key);
|
|
// Purchase thank-you (the D+3 testimonial and D+14 review asks are
|
|
// scheduled from the daily lifecycle job, not here).
|
|
await emailThankYou(email);
|
|
} catch (error) {
|
|
// UNIQUE stripe_session_id conflict from a racing duplicate delivery.
|
|
console.error("Failed to issue The Nook license (webhook):", error);
|
|
}
|
|
|
|
return json({ received: true });
|
|
}
|