← 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
A scripts/stripe-test-setup.jsM server.js
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 →