Commit 57689a4ef5f9c789bf285dcf7a5e8b118f84b68f
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 = "open.bigmodel.cn"; |
| 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 | ... | ... |