Commit 57689a4ef5f9c789bf285dcf7a5e8b118f84b68f

Authored by yibo
1 parent 7620d5b7

Add T24: Stripe topup, mock checkout for today, real path for tomorrow

createCheckoutSession() is the seam (dev-plan-4.md §1.2 item 4): with
no STRIPE_SECRET_KEY set — no real test-mode key exists yet, the user
said "先做一个 mock,我明天再给你真实测试的 key" — POST
/api/credits/checkout returns a same-origin /api/credits/mock-checkout
URL that completes the topup itself, standing in for "redirected to
Stripe, paid, redirected back" so the whole checkout -> credited loop
(and T29's UI on top of it) can be built and tested today. With a key
set, it creates a real Stripe Checkout Session via plain REST (fetch,
no stripe SDK — this project has no dependencies at all) — untested
against a live account until the key lands.

POST /api/credits/webhook hand-rolls Stripe's documented HMAC-SHA256
signature scheme and refuses anything unsigned or unverifiable,
including every request while STRIPE_WEBHOOK_SECRET is unset (not just
badly-signed ones). Both the mock and real paths route through one
completeTopup() -> creditLedger.topup() (T23) so Stripe's guaranteed
at-least-once webhook delivery can't double-credit — verified in
credits.test.js by redelivering the same event id twice.

Also added creditBalance to GET /api/auth/me's response (T29's need)
instead of a dedicated balance endpoint — it's the same "logged-in
user's own state" this route already serves, and live.html/dance.html
already poll it after login.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DUiYs6UjxDMkfvnjtsKFo
Showing 2 changed files with 432 additions and 1 deletions
credits.test.js 0 → 100644
  1 +// Integration test for T24 (Stripe topup, mock fallback), dev-plan-4.md.
  2 +// Spawns server.js on a throwaway port against an isolated tmpdir copy,
  3 +// same isolation approach as auth.test.js.
  4 +const test = require("node:test");
  5 +const assert = require("node:assert/strict");
  6 +const fs = require("node:fs");
  7 +const path = require("node:path");
  8 +const os = require("node:os");
  9 +const crypto = require("node:crypto");
  10 +const { spawn } = require("node:child_process");
  11 +
  12 +function waitForServer(url, timeoutMs = 10000) {
  13 + const deadline = Date.now() + timeoutMs;
  14 + return (async function poll() {
  15 + while (Date.now() < deadline) {
  16 + try {
  17 + await fetch(url);
  18 + return;
  19 + } catch {
  20 + await new Promise((r) => setTimeout(r, 100));
  21 + }
  22 + }
  23 + throw new Error("server did not come up in time");
  24 + })();
  25 +}
  26 +
  27 +function sessionCookie(res) {
  28 + const setCookie = res.headers.get("set-cookie") || "";
  29 + return setCookie.split(";")[0];
  30 +}
  31 +
  32 +function makeTmpCwd() {
  33 + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "credits-test-"));
  34 + for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js", "db.js", "pricing.js"]) {
  35 + fs.copyFileSync(path.join(__dirname, f), path.join(cwd, f));
  36 + }
  37 + return cwd;
  38 +}
  39 +
  40 +function spawnServer(port, extraEnv = {}) {
  41 + return spawn(process.execPath, ["server.js"], {
  42 + cwd: makeTmpCwd(),
  43 + env: { ...process.env, PORT: String(port), H3_CONSOLE_BASE: "", H3_CONSOLE_TOKEN: "", ADMIN_PASSWORD: "unused", ...extraEnv },
  44 + stdio: "pipe",
  45 + });
  46 +}
  47 +
  48 +async function registerVerifiedUser(base) {
  49 + const email = `credits-${Date.now()}-${Math.random().toString(36).slice(2)}@example.com`;
  50 + const registerRes = await fetch(`${base}/api/auth/register`, {
  51 + method: "POST",
  52 + headers: { "Content-Type": "application/json" },
  53 + body: JSON.stringify({ email, password: "correct-horse-battery" }),
  54 + });
  55 + const { devVerifyUrl } = await registerRes.json();
  56 + const cookie = sessionCookie(registerRes);
  57 + await fetch(`${base}${devVerifyUrl}`);
  58 + return cookie;
  59 +}
  60 +
  61 +const PORT = 27934 + Math.floor(Math.random() * 1000);
  62 +const BASE = `http://localhost:${PORT}`;
  63 +
  64 +let child;
  65 +test.before(async () => {
  66 + child = spawnServer(PORT);
  67 + await waitForServer(BASE);
  68 +});
  69 +test.after(() => child.kill());
  70 +
  71 +test("checkout requires login", async () => {
  72 + const res = await fetch(`${BASE}/api/credits/checkout`, {
  73 + method: "POST",
  74 + headers: { "Content-Type": "application/json" },
  75 + body: JSON.stringify({ tierKey: "topup_tier_1" }),
  76 + });
  77 + assert.equal(res.status, 401);
  78 +});
  79 +
  80 +test("checkout requires a verified email, not just a login", async () => {
  81 + const email = `unverified-${Date.now()}@example.com`;
  82 + const registerRes = await fetch(`${BASE}/api/auth/register`, {
  83 + method: "POST",
  84 + headers: { "Content-Type": "application/json" },
  85 + body: JSON.stringify({ email, password: "correct-horse-battery" }),
  86 + });
  87 + const cookie = sessionCookie(registerRes);
  88 + const res = await fetch(`${BASE}/api/credits/checkout`, {
  89 + method: "POST",
  90 + headers: { "Content-Type": "application/json", Cookie: cookie },
  91 + body: JSON.stringify({ tierKey: "topup_tier_1" }),
  92 + });
  93 + assert.equal(res.status, 403);
  94 +});
  95 +
  96 +test("checkout rejects an unknown tierKey", async () => {
  97 + const cookie = await registerVerifiedUser(BASE);
  98 + const res = await fetch(`${BASE}/api/credits/checkout`, {
  99 + method: "POST",
  100 + headers: { "Content-Type": "application/json", Cookie: cookie },
  101 + body: JSON.stringify({ tierKey: "not-a-real-tier" }),
  102 + });
  103 + assert.equal(res.status, 400);
  104 +});
  105 +
  106 +test("with no STRIPE_SECRET_KEY configured, checkout returns a same-origin mock checkout URL, and visiting it credits the account exactly once", async () => {
  107 + const cookie = await registerVerifiedUser(BASE);
  108 + const meBefore = await fetch(`${BASE}/api/auth/me`, { headers: { Cookie: cookie } }).then((r) => r.json());
  109 + assert.equal(meBefore.creditBalance, 0);
  110 +
  111 + const checkoutRes = await fetch(`${BASE}/api/credits/checkout`, {
  112 + method: "POST",
  113 + headers: { "Content-Type": "application/json", Cookie: cookie },
  114 + body: JSON.stringify({ tierKey: "topup_tier_1" }),
  115 + });
  116 + assert.equal(checkoutRes.status, 200);
  117 + const { url } = await checkoutRes.json();
  118 + assert.match(url, /^\/api\/credits\/mock-checkout\?token=/);
  119 +
  120 + const completeRes = await fetch(`${BASE}${url}`, { redirect: "manual" });
  121 + assert.equal(completeRes.status, 302);
  122 + assert.match(completeRes.headers.get("location"), /^\/dance\.html\?topup=success/);
  123 +
  124 + const meAfter = await fetch(`${BASE}/api/auth/me`, { headers: { Cookie: cookie } }).then((r) => r.json());
  125 + assert.ok(meAfter.creditBalance > 0, "mock checkout completion must actually credit the account");
  126 +
  127 + // single-use — visiting the same completion URL again must not double-credit
  128 + const secondRes = await fetch(`${BASE}${url}`, { redirect: "manual" });
  129 + assert.equal(secondRes.status, 400);
  130 + const meFinal = await fetch(`${BASE}/api/auth/me`, { headers: { Cookie: cookie } }).then((r) => r.json());
  131 + assert.equal(meFinal.creditBalance, meAfter.creditBalance);
  132 +});
  133 +
  134 +test("visiting an unknown/garbage mock-checkout token is a clear error, not a silent success", async () => {
  135 + const res = await fetch(`${BASE}/api/credits/mock-checkout?token=not-a-real-token`, { redirect: "manual" });
  136 + assert.equal(res.status, 400);
  137 +});
  138 +
  139 +// ---- webhook: needs its own server instance with STRIPE_WEBHOOK_SECRET
  140 +// set, since the default (unconfigured) instance above must refuse all
  141 +// webhook posts regardless of signature ----
  142 +
  143 +test("without STRIPE_WEBHOOK_SECRET configured, the webhook endpoint refuses everything, signed or not", async () => {
  144 + const res = await fetch(`${BASE}/api/credits/webhook`, {
  145 + method: "POST",
  146 + headers: { "Content-Type": "application/json", "Stripe-Signature": "t=1,v1=deadbeef" },
  147 + body: JSON.stringify({ type: "checkout.session.completed" }),
  148 + });
  149 + assert.equal(res.status, 400);
  150 +});
  151 +
  152 +function stripeSignatureHeader(payload, secret, timestamp = Math.floor(Date.now() / 1000)) {
  153 + const v1 = crypto.createHmac("sha256", secret).update(`${timestamp}.${payload}`).digest("hex");
  154 + return `t=${timestamp},v1=${v1}`;
  155 +}
  156 +
  157 +test("webhook: correctly-signed checkout.session.completed credits the account; an unsigned or mis-signed request is rejected; the same event id redelivered doesn't double-credit", async () => {
  158 + const secret = "whsec_test_only";
  159 + const port = 28934 + Math.floor(Math.random() * 1000);
  160 + const base = `http://localhost:${port}`;
  161 + const webhookChild = spawnServer(port, { STRIPE_WEBHOOK_SECRET: secret });
  162 + try {
  163 + await waitForServer(base);
  164 + const cookie = await registerVerifiedUser(base);
  165 + const { id: userId } = await fetch(`${base}/api/auth/me`, { headers: { Cookie: cookie } }).then((r) => r.json());
  166 +
  167 + const payload = JSON.stringify({
  168 + id: "evt_test_1",
  169 + type: "checkout.session.completed",
  170 + data: { object: { id: "cs_test_1", metadata: { userId, tierKey: "topup_tier_1" } } },
  171 + });
  172 +
  173 + // unsigned
  174 + const unsignedRes = await fetch(`${base}/api/credits/webhook`, { method: "POST", headers: { "Content-Type": "application/json" }, body: payload });
  175 + assert.equal(unsignedRes.status, 400);
  176 +
  177 + // mis-signed (right shape, wrong secret)
  178 + const wrongSig = stripeSignatureHeader(payload, "whsec_wrong");
  179 + const wrongRes = await fetch(`${base}/api/credits/webhook`, {
  180 + method: "POST",
  181 + headers: { "Content-Type": "application/json", "Stripe-Signature": wrongSig },
  182 + body: payload,
  183 + });
  184 + assert.equal(wrongRes.status, 400);
  185 +
  186 + // correctly signed
  187 + const goodSig = stripeSignatureHeader(payload, secret);
  188 + const goodRes = await fetch(`${base}/api/credits/webhook`, {
  189 + method: "POST",
  190 + headers: { "Content-Type": "application/json", "Stripe-Signature": goodSig },
  191 + body: payload,
  192 + });
  193 + assert.equal(goodRes.status, 200);
  194 + const meAfterFirst = await fetch(`${base}/api/auth/me`, { headers: { Cookie: cookie } }).then((r) => r.json());
  195 + assert.ok(meAfterFirst.creditBalance > 0);
  196 +
  197 + // Stripe's guaranteed at-least-once delivery: the exact same event
  198 + // (same id) can arrive again and must not double-credit.
  199 + const redeliveredRes = await fetch(`${base}/api/credits/webhook`, {
  200 + method: "POST",
  201 + headers: { "Content-Type": "application/json", "Stripe-Signature": goodSig },
  202 + body: payload,
  203 + });
  204 + assert.equal(redeliveredRes.status, 200);
  205 + const meAfterRedelivery = await fetch(`${base}/api/auth/me`, { headers: { Cookie: cookie } }).then((r) => r.json());
  206 + assert.equal(meAfterRedelivery.creditBalance, meAfterFirst.creditBalance);
  207 + } finally {
  208 + webhookChild.kill();
  209 + }
  210 +});
... ...
server.js
... ... @@ -29,6 +29,13 @@ const GLM_HOST = &quot;open.bigmodel.cn&quot;;
29 29 const GLM_PATH = "/api/coding/paas/v4/chat/completions";
30 30 const GLM_MODEL = "glm-5.3-flash";
31 31  
  32 +// T24 credit topup. No test-mode key yet (2026-09-03: user said "先做一个
  33 +// mock,我明天再给你真实测试的 key") — see createCheckoutSession()'s doc
  34 +// comment for how the two paths split.
  35 +const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY || "";
  36 +const STRIPE_WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET || "";
  37 +const APP_BASE_URL = process.env.APP_BASE_URL || `http://localhost:${process.env.PORT || 8934}`;
  38 +
32 39 const MIME = {
33 40 ".html": "text/html",
34 41 ".js": "text/javascript",
... ... @@ -117,6 +124,18 @@ function readJsonBody(req) {
117 124 });
118 125 }
119 126  
  127 +// Stripe's webhook signature is computed over the exact raw request bytes
  128 +// (T24) — JSON.parse-then-restringify would not reproduce the same bytes,
  129 +// so this can't reuse readJsonBody for that one route.
  130 +function readRawTextBody(req) {
  131 + return new Promise((resolve, reject) => {
  132 + let data = "";
  133 + req.on("data", (chunk) => (data += chunk));
  134 + req.on("end", () => resolve(data));
  135 + req.on("error", reject);
  136 + });
  137 +}
  138 +
120 139 // Minimal admin auth (dev-plan.md T6): one shared password from .env, an
121 140 // HttpOnly session cookie, no accounts/roles/DB — the account system in
122 141 // MVCP M.1/M.10 supersedes this in period 3. Sessions are in-memory only,
... ... @@ -465,6 +484,97 @@ function glmChat(messages) {
465 484 });
466 485 }
467 486  
  487 +// ---- T24: Stripe topup, with a mock fallback until a real test-mode key
  488 +// exists (2026-09-03: user said "先做一个 mock,我明天再给你真实测试的
  489 +// key") — createCheckoutSession() is the single seam both paths sit
  490 +// behind, matching T20/T25/T26's "one pluggable function" precedent. ----
  491 +const pendingMockCheckouts = new Map(); // token -> {userId, tierKey, expiresAt}
  492 +const MOCK_CHECKOUT_TTL_MS = 30 * 60 * 1000;
  493 +
  494 +// Both the mock path (GET /api/credits/mock-checkout) and the real path
  495 +// (POST /api/credits/webhook) end up here — one place that actually moves
  496 +// money, so there's exactly one code path responsible for the
  497 +// ledger/idempotency bookkeeping (topup()'s paymentRef dedup, db.js).
  498 +function completeTopup({ userId, tierKey, paymentRef }) {
  499 + const tier = pricing.get(tierKey);
  500 + if (!tier || tier.kind !== "topup_tier" || !tier.isActive) {
  501 + throw new Error(`unknown or inactive topup tier: ${tierKey}`);
  502 + }
  503 + return creditLedger.topup({ userId, amount: tier.credits, reason: "topup", paymentRef });
  504 +}
  505 +
  506 +// Real Stripe path: plain REST via global fetch, no stripe npm SDK — this
  507 +// project has no package.json/dependencies, and Stripe's Checkout Sessions
  508 +// API is documented as plain form-encoded REST (same "use fetch, not a new
  509 +// dependency" call as forwardToH3Assets in uploads.js). Untested against a
  510 +// real Stripe account as of this commit — no test-mode key exists yet, see
  511 +// dev-plan-4.md §1.2 item 4.
  512 +async function createRealStripeCheckoutSession({ userId, tierKey, tier }) {
  513 + const params = new URLSearchParams({
  514 + mode: "payment",
  515 + "line_items[0][quantity]": "1",
  516 + "line_items[0][price_data][currency]": "usd",
  517 + "line_items[0][price_data][unit_amount]": String(tier.priceCents),
  518 + "line_items[0][price_data][product_data][name]": `${tier.credits} credits`,
  519 + success_url: `${APP_BASE_URL}/dance.html?topup=success`,
  520 + cancel_url: `${APP_BASE_URL}/dance.html?topup=cancelled`,
  521 + "metadata[userId]": userId,
  522 + "metadata[tierKey]": tierKey,
  523 + });
  524 + const res = await fetch("https://api.stripe.com/v1/checkout/sessions", {
  525 + method: "POST",
  526 + headers: { Authorization: `Bearer ${STRIPE_SECRET_KEY}`, "Content-Type": "application/x-www-form-urlencoded" },
  527 + body: params,
  528 + });
  529 + const session = await res.json();
  530 + if (!res.ok) throw new Error(session.error?.message || "Stripe checkout session creation failed");
  531 + return session.url;
  532 +}
  533 +
  534 +// Mock path: no real payment happens, but the whole checkout -> redirect ->
  535 +// credited loop is real and testable today — T29's UI and topup()'s own
  536 +// dedup logic don't need to wait on a real Stripe key to be built and
  537 +// exercised end to end.
  538 +function createMockCheckoutUrl({ userId, tierKey }) {
  539 + const token = crypto.randomUUID();
  540 + pendingMockCheckouts.set(token, { userId, tierKey, expiresAt: Date.now() + MOCK_CHECKOUT_TTL_MS });
  541 + return `/api/credits/mock-checkout?token=${token}`;
  542 +}
  543 +
  544 +async function createCheckoutSession({ userId, tierKey }) {
  545 + const tier = pricing.get(tierKey);
  546 + if (!tier || tier.kind !== "topup_tier" || !tier.isActive) {
  547 + throw new Error(`unknown or inactive topup tier: ${tierKey}`);
  548 + }
  549 + if (!STRIPE_SECRET_KEY) return { url: createMockCheckoutUrl({ userId, tierKey }) };
  550 + return { url: await createRealStripeCheckoutSession({ userId, tierKey, tier }) };
  551 +}
  552 +
  553 +// Stripe's documented signature scheme (Stripe-Signature header:
  554 +// "t=<timestamp>,v1=<hex hmac>"): HMAC-SHA256 over "<timestamp>.<raw body>"
  555 +// with the webhook signing secret, compared in constant time. Hand-rolled
  556 +// rather than the stripe SDK for the same "no new dependency" reason as
  557 +// the rest of this integration.
  558 +function verifyStripeWebhookSignature(rawBody, signatureHeader, secret) {
  559 + if (!signatureHeader) return false;
  560 + const parts = Object.fromEntries(
  561 + signatureHeader
  562 + .split(",")
  563 + .map((kv) => kv.split("="))
  564 + .filter((kv) => kv.length === 2)
  565 + );
  566 + if (!parts.t || !parts.v1) return false;
  567 + const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
  568 + let expectedBuf, actualBuf;
  569 + try {
  570 + expectedBuf = Buffer.from(expected, "hex");
  571 + actualBuf = Buffer.from(parts.v1, "hex");
  572 + } catch {
  573 + return false;
  574 + }
  575 + return expectedBuf.length === actualBuf.length && crypto.timingSafeEqual(expectedBuf, actualBuf);
  576 +}
  577 +
468 578 // Distilled from docs/h3skill.md (MiniMax's own H3 prompt-writing methodology) down to the one
469 579 // path that applies here: T2VA (no reference images — every chat prompt is plain text) on a clip
470 580 // fixed at ~10s (H3_GEN_PARAMS.frames / FPS). At that length it's one continuous shot, never a
... ... @@ -1211,7 +1321,118 @@ http
1211 1321 return;
1212 1322 }
1213 1323 res.writeHead(200, { "Content-Type": "application/json" });
1214   - res.end(JSON.stringify({ id: user.id, email: user.email, emailVerified: user.emailVerified, nickname: user.nickname }));
  1324 + // creditBalance riding along here (T29) rather than a dedicated
  1325 + // /api/credits/balance endpoint — it's the same "logged-in user's own
  1326 + // state" this route already exists to serve, and refreshAuthState()
  1327 + // in live.html/dance.html (T22) already polls this after every login.
  1328 + res.end(JSON.stringify({ id: user.id, email: user.email, emailVerified: user.emailVerified, nickname: user.nickname, creditBalance: user.creditBalance }));
  1329 + return;
  1330 + }
  1331 +
  1332 + // T24: credit topup. Login + emailVerified matches M.10's "任何扣积分/
  1333 + // 写用户资产的接口" rule — a topup adds money rather than spending it,
  1334 + // but still needs an accountable identity before a real charge runs.
  1335 + if (pathname === "/api/credits/checkout" && req.method === "POST") {
  1336 + const user = requireUserSession(req);
  1337 + if (!user) {
  1338 + res.writeHead(401, { "Content-Type": "application/json" });
  1339 + res.end(JSON.stringify({ error: "login required" }));
  1340 + return;
  1341 + }
  1342 + if (!user.emailVerified) {
  1343 + res.writeHead(403, { "Content-Type": "application/json" });
  1344 + res.end(JSON.stringify({ error: "email verification required before purchasing credits" }));
  1345 + return;
  1346 + }
  1347 + readJsonBody(req)
  1348 + .then(async (body) => {
  1349 + try {
  1350 + const session = await createCheckoutSession({ userId: user.id, tierKey: (body.tierKey || "").trim() });
  1351 + res.writeHead(200, { "Content-Type": "application/json" });
  1352 + res.end(JSON.stringify(session));
  1353 + } catch (err) {
  1354 + res.writeHead(400, { "Content-Type": "application/json" });
  1355 + res.end(JSON.stringify({ error: err.message }));
  1356 + }
  1357 + })
  1358 + .catch(() => {
  1359 + res.writeHead(400, { "Content-Type": "application/json" });
  1360 + res.end(JSON.stringify({ error: "invalid JSON body" }));
  1361 + });
  1362 + return;
  1363 + }
  1364 +
  1365 + // Dev-only completion of a mock checkout created above — simulates the
  1366 + // browser landing back from Stripe's hosted page after a successful
  1367 + // payment. Only ever reachable via a token this same process handed
  1368 + // out (createMockCheckoutUrl), so there's nothing to authenticate here
  1369 + // beyond "does this token exist and is it unused."
  1370 + if (pathname === "/api/credits/mock-checkout" && req.method === "GET") {
  1371 + const token = new URL(req.url, "http://x").searchParams.get("token") || "";
  1372 + const pending = pendingMockCheckouts.get(token);
  1373 + pendingMockCheckouts.delete(token); // single-use, same spirit as T20's email-verify tokens
  1374 + if (!pending || pending.expiresAt < Date.now()) {
  1375 + res.writeHead(400, { "Content-Type": "text/plain" });
  1376 + res.end("invalid or expired mock checkout token");
  1377 + return;
  1378 + }
  1379 + try {
  1380 + completeTopup({ userId: pending.userId, tierKey: pending.tierKey, paymentRef: `mock_${token}` });
  1381 + res.writeHead(302, { Location: "/dance.html?topup=success" });
  1382 + res.end();
  1383 + } catch (err) {
  1384 + res.writeHead(400, { "Content-Type": "text/plain" });
  1385 + res.end(err.message);
  1386 + }
  1387 + return;
  1388 + }
  1389 +
  1390 + // Real Stripe webhook — only meaningful once a real
  1391 + // STRIPE_WEBHOOK_SECRET is configured (tomorrow, per the user).
  1392 + // Refuses anything it can't verify rather than trusting an
  1393 + // unsigned/unverifiable request, even before a secret exists
  1394 + // (dev-plan-4.md T24's explicit acceptance criterion).
  1395 + if (pathname === "/api/credits/webhook" && req.method === "POST") {
  1396 + readRawTextBody(req)
  1397 + .then((rawBody) => {
  1398 + if (!STRIPE_WEBHOOK_SECRET || !verifyStripeWebhookSignature(rawBody, req.headers["stripe-signature"], STRIPE_WEBHOOK_SECRET)) {
  1399 + res.writeHead(400, { "Content-Type": "application/json" });
  1400 + res.end(JSON.stringify({ error: "signature verification failed" }));
  1401 + return;
  1402 + }
  1403 + let event;
  1404 + try {
  1405 + event = JSON.parse(rawBody);
  1406 + } catch {
  1407 + res.writeHead(400, { "Content-Type": "application/json" });
  1408 + res.end(JSON.stringify({ error: "invalid JSON" }));
  1409 + return;
  1410 + }
  1411 + if (event.type === "checkout.session.completed") {
  1412 + const session = event.data.object;
  1413 + try {
  1414 + // event.id, not session.id: Stripe's at-least-once
  1415 + // redelivery resends the same event id for the same
  1416 + // occurrence, which is exactly what topup()'s paymentRef
  1417 + // dedup (db.js) keys on.
  1418 + completeTopup({ userId: session.metadata.userId, tierKey: session.metadata.tierKey, paymentRef: event.id });
  1419 + } catch (err) {
  1420 + // Already-applied duplicates are handled inside topup()
  1421 + // itself (returns the existing row, doesn't throw) — a throw
  1422 + // here means something genuinely went wrong (unknown tier,
  1423 + // missing user), not a benign redelivery.
  1424 + res.writeHead(400, { "Content-Type": "application/json" });
  1425 + res.end(JSON.stringify({ error: err.message }));
  1426 + return;
  1427 + }
  1428 + }
  1429 + res.writeHead(200, { "Content-Type": "application/json" });
  1430 + res.end(JSON.stringify({ received: true }));
  1431 + })
  1432 + .catch(() => {
  1433 + res.writeHead(400, { "Content-Type": "application/json" });
  1434 + res.end(JSON.stringify({ error: "failed to read request body" }));
  1435 + });
1215 1436 return;
1216 1437 }
1217 1438  
... ...