[object Object]

← back to CelebritySignatures

payments(TEST): gate signature downloads behind Stripe checkout — /api/signature-checkout + paid-session verification on /api/signature-file (idempotent by sid, inert without test key, live key never read)

08810333dcb52e44f360ff51d7104eff2da4315a · 2026-08-04 10:14:46 -0700 · Steve Abrams

Files touched

Diff

commit 08810333dcb52e44f360ff51d7104eff2da4315a
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Aug 4 10:14:46 2026 -0700

    payments(TEST): gate signature downloads behind Stripe checkout — /api/signature-checkout + paid-session verification on /api/signature-file (idempotent by sid, inert without test key, live key never read)
---
 server.js | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
 1 file changed, 55 insertions(+), 8 deletions(-)

diff --git a/server.js b/server.js
index c6a9511..6e59650 100644
--- a/server.js
+++ b/server.js
@@ -380,19 +380,66 @@ ${paid ? `<div class="ok">✓</div><h1>Order confirmed</h1>
       return sendJSON(res, 200, ups.filter(x => x.status === 'approved')
         .map(x => ({ id: x.id, celebrity_name: x.celebrity_name, downloads: x.downloads, priceUsd: x.priceUsd, at: x.at })));
     }
+    // ===== PAID SIGNATURE DOWNLOAD (Stripe TEST mode) =====
+    // Create a Stripe TEST checkout for a single signature file. On payment,
+    // Stripe redirects to /api/signature-file/:id?sid=… which verifies the paid
+    // session before releasing the file. Inert (503) until a test key is set;
+    // the LIVE key is deliberately never read here — going live is a separate gate.
+    if (path === '/api/signature-checkout' && M === 'POST') {
+      if (!STRIPE_TEST_KEY) return sendJSON(res, 503, { ok: false, error: 'downloads not purchasable yet (awaiting Stripe test key)' });
+      const b = await readBody(req);
+      const ups = await load('celebrity-uploads.json', []);
+      const x = ups.find(e => e.id === String(b.id || ''));
+      if (!x || x.status !== 'approved') return sendJSON(res, 404, { ok: false, error: 'not available' });
+      const params = new URLSearchParams();
+      params.set('mode', 'payment');
+      params.set('success_url', `https://celebsignatures.com/api/signature-file/${x.id}?sid={CHECKOUT_SESSION_ID}`);
+      params.set('cancel_url', 'https://celebsignatures.com/');
+      params.set('line_items[0][quantity]', '1');
+      params.set('line_items[0][price_data][currency]', 'usd');
+      params.set('line_items[0][price_data][unit_amount]', String(Math.round(x.priceUsd * 100)));
+      params.set('line_items[0][price_data][product_data][name]', `${x.celebrity_name} — signature file`);
+      params.set('metadata[upload_id]', x.id);
+      try {
+        const sres = await fetch('https://api.stripe.com/v1/checkout/sessions', {
+          method: 'POST',
+          headers: { Authorization: `Bearer ${STRIPE_TEST_KEY}`, 'Content-Type': 'application/x-www-form-urlencoded' },
+          body: params.toString(),
+        });
+        const sj = await sres.json();
+        if (!sres.ok) return sendJSON(res, 502, { ok: false, error: sj.error?.message || 'stripe error' });
+        return sendJSON(res, 200, { ok: true, url: sj.url });
+      } catch (e) { return sendJSON(res, 502, { ok: false, error: 'payment gateway unreachable' }); }
+    }
     const fm = path.match(/^\/api\/signature-file\/(up_[a-f0-9]+)$/);
     if (fm && M === 'GET') {
       const ups = await load('celebrity-uploads.json', []);
       const x = ups.find(e => e.id === fm[1]);
       if (!x || x.status !== 'approved') return sendJSON(res, 404, { ok: false, error: 'not available' });
-      const dl = await currentUser(req); // downloader identity when signed in
-      const commission = +(x.priceUsd * x.commissionPct / 100).toFixed(2);
-      x.downloads++;
-      await store('celebrity-uploads.json', ups);
-      await appendFile(join(DATA, 'download-ledger.jsonl'), JSON.stringify({
-        ts: new Date().toISOString(), uploadId: x.id, ownerEmail: x.ownerEmail,
-        downloader: dl ? dl.email : null, priceUsd: x.priceUsd, commissionUsd: commission,
-      }) + '\n');
+      // PAYMENT GATE (TEST mode): require a PAID Stripe checkout session for this file.
+      const sid = url.searchParams.get('sid') || '';
+      if (!STRIPE_TEST_KEY) return sendJSON(res, 503, { ok: false, error: 'downloads require payment — not configured yet' });
+      if (!/^cs_test_[A-Za-z0-9]+$/.test(sid)) return sendJSON(res, 402, { ok: false, error: 'payment required — purchase this download first', purchase: '/api/signature-checkout' });
+      let paid = false;
+      try {
+        const s = await (await fetch(`https://api.stripe.com/v1/checkout/sessions/${sid}`, { headers: { Authorization: `Bearer ${STRIPE_TEST_KEY}` } })).json();
+        paid = s.payment_status === 'paid' && String(s.metadata?.upload_id) === x.id;
+      } catch {}
+      if (!paid) return sendJSON(res, 402, { ok: false, error: 'payment not verified' });
+      // Idempotent credit: append to the ledger + increment downloads ONCE per session id
+      // (re-hitting the success URL re-downloads the file but never double-credits).
+      let firstUse = true;
+      try { firstUse = !(await readFile(join(DATA, 'download-ledger.jsonl'), 'utf8')).includes(`"sid":"${sid}"`); } catch {}
+      if (firstUse) {
+        const dl = await currentUser(req); // downloader identity when signed in
+        const commission = +(x.priceUsd * x.commissionPct / 100).toFixed(2);
+        x.downloads++;
+        await store('celebrity-uploads.json', ups);
+        await appendFile(join(DATA, 'download-ledger.jsonl'), JSON.stringify({
+          ts: new Date().toISOString(), uploadId: x.id, ownerEmail: x.ownerEmail,
+          downloader: dl ? dl.email : null, priceUsd: x.priceUsd, commissionUsd: commission, sid,
+        }) + '\n');
+      }
       const buf = await readFile(join(UPLOADS_DIR, x.file));
       res.writeHead(200, {
         'Content-Type': x.mime,

← faf97c9 GA4 game_start: guard category param — only log when #catOpt  ·  back to CelebritySignatures  ·  payments(TEST): Cody gate fixes — atomic USED_SIDS guard (cl f2fc2ab →