[object Object]

← back to Agent Ad Network

Stripe TEST-mode billing: checkout + signature-verified webhook + balance credit + setup script; https clickUrl

bbba5deb456a5dad42cae437202a1cb236173b67 · 2026-08-02 01:12:06 -0700 · Steve Abrams

Files touched

Diff

commit bbba5deb456a5dad42cae437202a1cb236173b67
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun Aug 2 01:12:06 2026 -0700

    Stripe TEST-mode billing: checkout + signature-verified webhook + balance credit + setup script; https clickUrl
---
 scripts/stripe-test-setup.js | 37 +++++++++++++++++++++
 server.js                    | 78 +++++++++++++++++++++++++++++++++++++++++++-
 2 files changed, 114 insertions(+), 1 deletion(-)

diff --git a/scripts/stripe-test-setup.js b/scripts/stripe-test-setup.js
new file mode 100644
index 0000000..c650062
--- /dev/null
+++ b/scripts/stripe-test-setup.js
@@ -0,0 +1,37 @@
+#!/usr/bin/env node
+// One-shot Stripe TEST-mode setup for agent-ad-network. Run AFTER pasting
+// STRIPE_TEST_KEY=sk_test_… into the environment (or .env sourced by pm2).
+//   node scripts/stripe-test-setup.js
+// Creates: (1) the top-up product + a $25 reference price, (2) the webhook
+// endpoint at $PUBLIC_URL/api/billing/webhook, then prints the whsec_ signing
+// secret to add as STRIPE_WEBHOOK_SECRET. Refuses anything but sk_test_ keys.
+'use strict';
+const KEY = process.env.STRIPE_TEST_KEY || '';
+const PUBLIC_URL = process.env.PUBLIC_URL || 'https://ads.agentabrams.com';
+if (!KEY.startsWith('sk_test_')) { console.error('STRIPE_TEST_KEY must be a sk_test_ key — refusing.'); process.exit(1); }
+
+const api = (method, path, params) => new Promise((resolve, reject) => {
+  const body = params ? new URLSearchParams(params).toString() : '';
+  const r = require('https').request({
+    hostname: 'api.stripe.com', path, method,
+    headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(body) }
+  }, res => { let d = ''; res.on('data', c => d += c); res.on('end', () => {
+    try { const j = JSON.parse(d); j.error ? reject(new Error(j.error.message)) : resolve(j); } catch (e) { reject(e); }
+  }); });
+  r.on('error', reject); r.end(body);
+});
+
+(async () => {
+  const product = await api('POST', '/v1/products', { name: 'Agent Ad Network — budget top-up (TEST)' });
+  console.log('product:', product.id);
+  const price = await api('POST', '/v1/prices', {
+    product: product.id, currency: 'usd', unit_amount: '2500', nickname: 'reference $25 top-up'
+  });
+  console.log('price:', price.id);
+  const hook = await api('POST', '/v1/webhook_endpoints', {
+    url: `${PUBLIC_URL}/api/billing/webhook`, 'enabled_events[0]': 'checkout.session.completed'
+  });
+  console.log('webhook endpoint:', hook.id);
+  console.log('\nAdd to env and restart pm2 with --update-env:');
+  console.log(`  STRIPE_WEBHOOK_SECRET=${hook.secret}`);
+})().catch(e => { console.error('setup failed:', e.message); process.exit(1); });
diff --git a/server.js b/server.js
index a2beb69..4d9bf59 100644
--- a/server.js
+++ b/server.js
@@ -47,6 +47,37 @@ function adminAuthed(req, res) {
 }
 const advertiserByKey = (req) => advertisers.find(a => a.apiKey === (req.headers['x-api-key'] || ''));
 
+// ---- Stripe billing — TEST MODE ONLY (sk_test_ enforced; a live key disables billing) ----
+const STRIPE_KEY = process.env.STRIPE_TEST_KEY || '';
+const STRIPE_WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET || '';
+const PUBLIC_URL = process.env.PUBLIC_URL || 'https://ads.agentabrams.com';
+const stripeReady = STRIPE_KEY.startsWith('sk_test_');
+if (STRIPE_KEY && !stripeReady) console.error('REFUSING non-test Stripe key — billing stays disabled (sk_test_ only)');
+
+const stripeApi = (method, apiPath, params) => new Promise((resolve, reject) => {
+  const body = params ? new URLSearchParams(params).toString() : '';
+  const r = require('https').request({
+    hostname: 'api.stripe.com', path: apiPath, method,
+    headers: { Authorization: `Bearer ${STRIPE_KEY}`, 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(body) }
+  }, res2 => { let d = ''; res2.on('data', c => d += c); res2.on('end', () => {
+    try { const j = JSON.parse(d); j.error ? reject(new Error(j.error.message)) : resolve(j); } catch (e) { reject(e); }
+  }); });
+  r.on('error', reject); r.end(body);
+});
+
+const readRaw = (req) => new Promise((resolve, reject) => {
+  let b = ''; req.on('data', c => { b += c; if (b.length > 1e6) req.destroy(); });
+  req.on('end', () => resolve(b)); req.on('error', reject);
+});
+
+function verifyStripeSig(raw, header) {
+  if (!STRIPE_WEBHOOK_SECRET || !header) return false;
+  const parts = Object.fromEntries(header.split(',').map(kv => kv.split('=')));
+  if (!parts.t || !parts.v1) return false;
+  const expected = crypto.createHmac('sha256', STRIPE_WEBHOOK_SECRET).update(`${parts.t}.${raw}`).digest('hex');
+  try { return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1)); } catch { return false; }
+}
+
 function adStats(adId, events) {
   const evs = (events || readEvents()).filter(e => e.adId === adId);
   const impressions = evs.filter(e => e.type === 'impression').length;
@@ -83,7 +114,8 @@ const server = http.createServer(async (req, res) => {
       if (ad.spent_usd >= ad.budget_usd) ad.status = 'exhausted';
       saveAds();
       logEvent({ type: 'impression', adId: ad.id, advertiserId: ad.advertiserId, context });
-      const clickUrl = `http://${req.headers.host}/c/${ad.id}`;
+      const proto = req.headers['x-forwarded-proto'] || 'http';
+      const clickUrl = `${proto}://${req.headers.host}/c/${ad.id}`;
       if ((u.searchParams.get('format') || 'text') === 'json') {
         return json(res, 200, { ad: { id: ad.id, headline: ad.headline, body: ad.body, clickUrl, sponsor: ad.sponsor } });
       }
@@ -138,6 +170,50 @@ const server = http.createServer(async (req, res) => {
       if (b.budget_usd) ad.budget_usd = Math.max(Number(b.budget_usd), ad.spent_usd);
       saveAds(); return json(res, 200, ad);
     }
+    // ---------- billing (Stripe TEST mode only) ----------
+    if (req.method === 'GET' && p === '/api/billing/status') {
+      return json(res, 200, { mode: 'test', configured: stripeReady, webhook_configured: !!STRIPE_WEBHOOK_SECRET });
+    }
+    if (req.method === 'POST' && p === '/api/billing/checkout') {
+      const adv = advertiserByKey(req);
+      if (!adv) return json(res, 401, { error: 'valid x-api-key required' });
+      if (!stripeReady) return json(res, 503, { error: 'billing not configured (TEST key pending)' });
+      const b = await readBody(req);
+      const amount = Math.min(Math.max(Number(b.amount_usd) || 0, 5), 500);
+      if (!Number(b.amount_usd)) return json(res, 400, { error: 'amount_usd required ($5–$500 test)' });
+      const session = await stripeApi('POST', '/v1/checkout/sessions', {
+        mode: 'payment',
+        'line_items[0][price_data][currency]': 'usd',
+        'line_items[0][price_data][product_data][name]': 'Agent Ad Network — budget top-up (TEST)',
+        'line_items[0][price_data][unit_amount]': String(Math.round(amount * 100)),
+        'line_items[0][quantity]': '1',
+        'metadata[advertiserId]': adv.id,
+        success_url: `${PUBLIC_URL}/billing/success`,
+        cancel_url: `${PUBLIC_URL}/billing/cancelled`
+      });
+      logEvent({ type: 'checkout_created', advertiserId: adv.id, usd: amount, session: session.id });
+      return json(res, 200, { url: session.url, session: session.id, amount_usd: amount, mode: 'test' });
+    }
+    if (req.method === 'POST' && p === '/api/billing/webhook') {
+      const raw = await readRaw(req);
+      if (!verifyStripeSig(raw, req.headers['stripe-signature'])) return json(res, 400, { error: 'bad signature' });
+      const ev = JSON.parse(raw);
+      if (ev.type === 'checkout.session.completed' && ev.data.object.payment_status === 'paid') {
+        const s = ev.data.object;
+        const adv = advertisers.find(a => a.id === (s.metadata || {}).advertiserId);
+        if (adv) {
+          adv.balance_usd = +((adv.balance_usd || 0) + s.amount_total / 100).toFixed(2);
+          saveAdvertisers();
+          logEvent({ type: 'topup', advertiserId: adv.id, usd: s.amount_total / 100, session: s.id, mode: 'test' });
+        }
+      }
+      return json(res, 200, { received: true });
+    }
+    if (req.method === 'GET' && (p === '/billing/success' || p === '/billing/cancelled')) {
+      res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
+      return res.end(p.endsWith('success') ? 'Payment received (TEST mode) — your balance will update momentarily.\n' : 'Checkout cancelled.\n');
+    }
+
     if (req.method === 'GET' && p === '/api/stats') {
       const events = readEvents();
       return json(res, 200, {

← d3d3416 accept dbrown second admin credential (standing rule)  ·  back to Agent Ad Network  ·  README: live at ads.agentabrams.com + Stripe TEST rails stat e3ccc11 →