← back to Animals
yolo: Wire Stripe Checkout for upgrade orders
856153be67ab78b6ac3b370954f676ad7ed3d2f2 · 2026-05-09 09:48:41 -0700 · animal-yolo
Task: yolo.upgrade-stripe-real
Prompt: In ~/Projects/animals: the upgrade_orders table currently goes to 'pending_payment' manually. Wire actual Stripe Checkout: when an order is placed, create a Stripe checkout session with the right line
Files touched
M .env.exampleM agents/animal-yolo/state.jsonA src/lib/stripe.jsM src/server/community.jsM src/server/community_render.jsM src/server/index.js
Diff
commit 856153be67ab78b6ac3b370954f676ad7ed3d2f2
Author: animal-yolo <steve@designerwallcoverings.com>
Date: Sat May 9 09:48:41 2026 -0700
yolo: Wire Stripe Checkout for upgrade orders
Task: yolo.upgrade-stripe-real
Prompt: In ~/Projects/animals: the upgrade_orders table currently goes to 'pending_payment' manually. Wire actual Stripe Checkout: when an order is placed, create a Stripe checkout session with the right line
---
.env.example | 6 +++
agents/animal-yolo/state.json | 4 +-
src/lib/stripe.js | 105 +++++++++++++++++++++++++++++++++++++++++
src/server/community.js | 63 +++++++++++++++++++++++--
src/server/community_render.js | 2 +-
src/server/index.js | 51 ++++++++++++++++++++
6 files changed, 225 insertions(+), 6 deletions(-)
diff --git a/.env.example b/.env.example
index 33094df..6706740 100644
--- a/.env.example
+++ b/.env.example
@@ -17,8 +17,14 @@ ADMIN_EMAIL=steve@designerwallcoverings.com
ADMIN_PASSWORD=change-me
# Stripe (upgrade orders)
+# Test mode keys: sk_test_… / whsec_…
+# Webhook URL to register in Stripe dashboard:
+# ${PUBLIC_BASE_URL}/webhooks/stripe
+# Subscribe to: checkout.session.completed, checkout.session.async_payment_succeeded,
+# checkout.session.expired
STRIPE_SECRET_KEY=
STRIPE_PUBLIC_KEY=
+STRIPE_WEBHOOK_SECRET=
STRIPE_PRICE_ID_BASIC=
STRIPE_PRICE_ID_PRO=
diff --git a/agents/animal-yolo/state.json b/agents/animal-yolo/state.json
index 392b5f1..c971f82 100644
--- a/agents/animal-yolo/state.json
+++ b/agents/animal-yolo/state.json
@@ -1,5 +1,5 @@
{
- "cursor": 9,
- "runs": 9,
+ "cursor": 10,
+ "runs": 10,
"started_at": "2026-05-08T07:01:22.386Z"
}
\ No newline at end of file
diff --git a/src/lib/stripe.js b/src/lib/stripe.js
new file mode 100644
index 0000000..6402e43
--- /dev/null
+++ b/src/lib/stripe.js
@@ -0,0 +1,105 @@
+// Stripe wrapper for upgrade-order checkout + webhook verification.
+//
+// Lazy init so the server still boots if STRIPE_SECRET_KEY is unset; we only
+// fail loudly when something actually tries to charge.
+//
+// Pricing (kept in sync with community_render.js + community.js):
+// starter — $499 one-time setup + $49/mo hosting (subscription)
+// pro — $999 one-time setup + $49/mo hosting (subscription)
+//
+// Stripe Checkout in `mode: subscription` accepts one-time line_items alongside
+// the recurring price; the one-time items are added to the first invoice.
+
+import Stripe from 'stripe';
+
+let _stripe = null;
+
+export function getStripe() {
+ if (_stripe) return _stripe;
+ const key = process.env.STRIPE_SECRET_KEY;
+ if (!key) {
+ const err = new Error('STRIPE_SECRET_KEY not configured');
+ err.code = 'STRIPE_NOT_CONFIGURED';
+ throw err;
+ }
+ if (key.startsWith('sk_live_') && process.env.NODE_ENV !== 'production') {
+ console.warn('[stripe] live key detected outside production — refusing');
+ const err = new Error('refusing to use live Stripe key outside NODE_ENV=production');
+ err.code = 'STRIPE_LIVE_IN_DEV';
+ throw err;
+ }
+ _stripe = new Stripe(key, { apiVersion: '2024-12-18.acacia' });
+ return _stripe;
+}
+
+export function isStripeConfigured() {
+ return Boolean(process.env.STRIPE_SECRET_KEY);
+}
+
+const HOSTING_CENTS = 4900;
+
+const PLANS = {
+ starter_499: { setup_cents: 49900, label: 'Site rebuild — Starter' },
+ pro_999: { setup_cents: 99900, label: 'Site rebuild — Pro' },
+};
+
+export function planSpec(plan) {
+ return PLANS[plan] || null;
+}
+
+export async function createUpgradeCheckoutSession({
+ orderId, plan, customerEmail, businessName, baseUrl,
+}) {
+ const spec = PLANS[plan];
+ if (!spec) {
+ const err = new Error(`unknown plan: ${plan}`);
+ err.code = 'STRIPE_UNKNOWN_PLAN';
+ throw err;
+ }
+ const stripe = getStripe();
+ const productLabel = businessName ? `${spec.label} — ${businessName}` : spec.label;
+ return await stripe.checkout.sessions.create({
+ mode: 'subscription',
+ customer_email: customerEmail || undefined,
+ line_items: [
+ {
+ price_data: {
+ currency: 'usd',
+ product_data: { name: productLabel },
+ unit_amount: spec.setup_cents,
+ },
+ quantity: 1,
+ },
+ {
+ price_data: {
+ currency: 'usd',
+ product_data: { name: 'Site hosting' },
+ unit_amount: HOSTING_CENTS,
+ recurring: { interval: 'month' },
+ },
+ quantity: 1,
+ },
+ ],
+ success_url: `${baseUrl}/upgrade/success?session_id={CHECKOUT_SESSION_ID}`,
+ cancel_url: `${baseUrl}/upgrade/cancel?order_id=${orderId}`,
+ metadata: { upgrade_order_id: String(orderId), plan },
+ subscription_data: {
+ metadata: { upgrade_order_id: String(orderId), plan },
+ },
+ // Lock the session so users can't sit on a stale checkout for days.
+ expires_at: Math.floor(Date.now() / 1000) + 60 * 60 * 23,
+ });
+}
+
+// Verifies the X-Stripe-Signature header against the raw request body using
+// STRIPE_WEBHOOK_SECRET. Throws if invalid. Returns the parsed event.
+export function verifyWebhookEvent(rawBody, signatureHeader) {
+ const stripe = getStripe();
+ const secret = process.env.STRIPE_WEBHOOK_SECRET;
+ if (!secret) {
+ const err = new Error('STRIPE_WEBHOOK_SECRET not configured');
+ err.code = 'STRIPE_WEBHOOK_NOT_CONFIGURED';
+ throw err;
+ }
+ return stripe.webhooks.constructEvent(rawBody, signatureHeader, secret);
+}
diff --git a/src/server/community.js b/src/server/community.js
index 5de97f4..31dc49b 100644
--- a/src/server/community.js
+++ b/src/server/community.js
@@ -9,6 +9,7 @@ import { unsubscribe as newsletterUnsubscribe } from '../lib/weekly_newsletter.j
import { dispatchLostPetAlert, alertsOff as lostPetAlertsOff } from '../lib/lost_pet_alert.js';
import { geocodeZip } from '../lib/geo.js';
import { renderSignup, renderLogin, renderMarketplace, renderListing, renderListingNew, renderCircle, renderMyCircles, renderMarketingFirms, renderUpgradePreview } from './community_render.js';
+import { createUpgradeCheckoutSession, planSpec, isStripeConfigured } from '../lib/stripe.js';
export const community = express.Router();
@@ -284,14 +285,70 @@ community.post('/upgrade/preview/:bizId/order', async (req, res) => {
if (!biz) return res.status(404).json({ error: 'not found' });
const { full_name, email, phone, plan } = req.body || {};
if (!full_name || !email) return res.status(400).json({ error: 'name + email required' });
+ const planKey = plan === 'pro' ? 'pro_999' : 'starter_499';
+ const spec = planSpec(planKey);
const r = await one(
`INSERT INTO upgrade_orders (business_id, full_name, email, phone, business_name, website, plan, amount_cents, status, ip, user_agent)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'pending_payment', $9, $10) RETURNING id`,
[biz.id, full_name, email, phone || null, biz.name, biz.website,
- plan === 'pro' ? 'pro_999' : 'starter_499',
- plan === 'pro' ? 99900 : 49900,
+ planKey, spec.setup_cents,
req.ip, req.headers['user-agent'] || null]);
- res.json({ ok: true, order_id: r.id });
+
+ // Create a Stripe Checkout session and stash its id + URL on the row.
+ // If Stripe isn't configured yet, fall back to the legacy "we'll email you a
+ // payment link" flow so the order still lands and admin gets notified.
+ if (!isStripeConfigured()) {
+ return res.json({ ok: true, order_id: r.id, checkout_url: null,
+ message: 'Order recorded. Payment link will be emailed within 24h.' });
+ }
+ try {
+ const baseUrl = process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`;
+ const session = await createUpgradeCheckoutSession({
+ orderId: r.id, plan: planKey, customerEmail: email,
+ businessName: biz.name, baseUrl,
+ });
+ await pool.query(
+ `UPDATE upgrade_orders SET stripe_session_id = $1, payment_link = $2 WHERE id = $3`,
+ [session.id, session.url, r.id]);
+ res.json({ ok: true, order_id: r.id, checkout_url: session.url });
+ } catch (err) {
+ console.error('[upgrade] stripe session create failed:', err.message);
+ res.json({ ok: true, order_id: r.id, checkout_url: null,
+ message: 'Order recorded. Payment link will be emailed within 24h.' });
+ }
+});
+
+// Stripe redirects here on successful checkout. We do NOT mark the order paid
+// from this route — the webhook is the source of truth — but we surface a
+// friendly confirmation so the user knows the payment landed.
+community.get('/upgrade/success', async (req, res) => {
+ const sid = String(req.query.session_id || '');
+ const order = sid ? await one(
+ `SELECT id, status, business_name, plan FROM upgrade_orders WHERE stripe_session_id = $1`, [sid]) : null;
+ res.set('Cache-Control', 'no-store');
+ res.send(`<!doctype html><meta charset=utf-8><title>Payment received</title>
+<style>body{font-family:system-ui,sans-serif;max-width:36em;margin:4em auto;padding:0 1em;line-height:1.5;color:#222}
+h1{color:#0f4d3a}.box{background:#f4f8f5;border:1px solid #cfe0d6;border-radius:8px;padding:1.2em 1.4em;margin:1.4em 0}</style>
+<h1>Payment received — thank you!</h1>
+<p>${order ? `Order #${order.id} for <strong>${order.business_name || 'your business'}</strong> is confirmed.` : 'Your order is confirmed.'}</p>
+<div class=box>
+ <p><strong>What happens next:</strong></p>
+ <ol>
+ <li>You'll get a Stripe receipt by email within a minute.</li>
+ <li>Our team starts your site rebuild within 24 hours.</li>
+ <li>We deliver the new site within 7 days.</li>
+ </ol>
+</div>
+<p><a href="/">← Back to AnimalsDirectory</a></p>`);
+});
+
+community.get('/upgrade/cancel', async (req, res) => {
+ res.set('Cache-Control', 'no-store');
+ res.send(`<!doctype html><meta charset=utf-8><title>Checkout cancelled</title>
+<style>body{font-family:system-ui,sans-serif;max-width:36em;margin:4em auto;padding:0 1em;line-height:1.5;color:#222}</style>
+<h1>Checkout cancelled</h1>
+<p>No charge was made. If something went wrong or you have questions, reply to the email we sent — we'll work it out.</p>
+<p><a href="/">← Back to AnimalsDirectory</a></p>`);
});
// ── circles ───────────────────────────────────────────────────────────────
diff --git a/src/server/community_render.js b/src/server/community_render.js
index 42ea620..b577066 100644
--- a/src/server/community_render.js
+++ b/src/server/community_render.js
@@ -292,7 +292,7 @@ export function renderUpgradePreview({ biz, audit, mockups }) {
<div class="tier featured"><strong>$999</strong><span>Pro</span><p>Everything in Starter + custom photos, blog, monthly content, and Google Business Profile setup.</p></div>
</section>
<h2>Yes, I want this</h2>
- <form class="auth-form" onsubmit="event.preventDefault();const fd=new FormData(this);const obj=Object.fromEntries(fd);fetch('/upgrade/preview/${biz.id}/order',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(obj)}).then(r=>r.json()).then(j=>{if(j.ok){this.outerHTML='<p class=ok>Got it! Order #'+j.order_id+'. We’ll email a payment link within 24h.</p>'}else{this.querySelector('.err').textContent=j.error||'failed'}});">
+ <form class="auth-form" onsubmit="event.preventDefault();const fd=new FormData(this);const obj=Object.fromEntries(fd);const btn=this.querySelector('button');btn.disabled=true;btn.textContent='Redirecting to payment…';fetch('/upgrade/preview/${biz.id}/order',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(obj)}).then(r=>r.json()).then(j=>{if(j.ok&&j.checkout_url){window.location=j.checkout_url}else if(j.ok){this.outerHTML='<p class=ok>Got it! Order #'+j.order_id+'. '+(j.message||'We’ll email a payment link within 24h.')+'</p>'}else{btn.disabled=false;btn.textContent='Request my upgrade';this.querySelector('.err').textContent=j.error||'failed'}}).catch(()=>{btn.disabled=false;btn.textContent='Request my upgrade';this.querySelector('.err').textContent='network error'});">
<div class="row2">
<label>Your name <input name="full_name" required></label>
<label>Email <input name="email" type="email" required></label>
diff --git a/src/server/index.js b/src/server/index.js
index 95a5900..3214d3b 100644
--- a/src/server/index.js
+++ b/src/server/index.js
@@ -34,6 +34,7 @@ import { attachDogPark } from './dogpark.js';
import { avatarsRouter } from './avatars_router.js';
import { attachUser, requireUser } from '../lib/auth.js';
import { issueAndSendClaimToken, consumeClaimToken, maskEmail } from '../lib/business_claim.js';
+import { verifyWebhookEvent } from '../lib/stripe.js';
// SECURITY: middleware to gate admin routes registered directly on `app`
// (the adminPitch router has its own internal gate, but `/admin`, `/admin/flags`,
@@ -97,6 +98,52 @@ app.use(helmet({ contentSecurityPolicy: false }));
// for HTML-heavy responses. Skip the SSE/streaming case via the default
// `compression.filter`. Threshold 1KB so we don't pay encode tax on tiny replies.
app.use(compression({ threshold: 1024 }));
+
+// Stripe webhook MUST be mounted before express.json() — signature verification
+// requires the raw request body byte-for-byte. Also bypasses BASIC_AUTH (Stripe
+// servers can't send Basic Auth headers); the signature itself is the auth.
+app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
+ const sig = req.get('stripe-signature');
+ if (!sig) return res.status(400).send('missing signature');
+ let event;
+ try {
+ event = verifyWebhookEvent(req.body, sig);
+ } catch (err) {
+ console.error('[stripe webhook] signature verification failed:', err.message);
+ return res.status(400).send(`signature_failed: ${err.message}`);
+ }
+ try {
+ if (event.type === 'checkout.session.completed') {
+ const s = event.data.object;
+ const orderId = s.metadata?.upgrade_order_id ? Number(s.metadata.upgrade_order_id) : null;
+ // payment_status === 'paid' for one-time, 'no_payment_required' if zero,
+ // and for subscription mode it's 'paid' once the first invoice clears.
+ const paid = s.payment_status === 'paid' || s.payment_status === 'no_payment_required';
+ const r = await pool.query(
+ `UPDATE upgrade_orders
+ SET status = 'paid', paid_at = NOW()
+ WHERE (id = $1 OR stripe_session_id = $2)
+ AND status = 'pending_payment'`,
+ [orderId, s.id]);
+ console.log(`[stripe webhook] checkout.session.completed sid=${s.id} order=${orderId} paid=${paid} updated=${r.rowCount}`);
+ } else if (event.type === 'checkout.session.async_payment_succeeded') {
+ const s = event.data.object;
+ await pool.query(
+ `UPDATE upgrade_orders SET status = 'paid', paid_at = NOW()
+ WHERE stripe_session_id = $1 AND status = 'pending_payment'`, [s.id]);
+ } else if (event.type === 'checkout.session.expired') {
+ const s = event.data.object;
+ await pool.query(
+ `UPDATE upgrade_orders SET status = 'cancelled'
+ WHERE stripe_session_id = $1 AND status = 'pending_payment'`, [s.id]);
+ }
+ res.json({ received: true });
+ } catch (err) {
+ console.error('[stripe webhook] handler error:', err.message);
+ res.status(500).json({ error: 'handler_failed' });
+ }
+});
+
app.use(express.json({ limit: '256kb' }));
app.use(express.urlencoded({ extended: true }));
@@ -109,6 +156,10 @@ if (!BASIC_AUTH) {
const AUTH_HEADER = 'Basic ' + Buffer.from(BASIC_AUTH).toString('base64');
app.use((req, res, next) => {
if (req.path === '/health' || req.path === '/healthz') return next();
+ // Stripe webhook authenticates via signature — Stripe's servers can't send
+ // a Basic Auth header. Webhook handler runs above this middleware anyway,
+ // but keep this exemption defensive in case route order changes.
+ if (req.path === '/webhooks/stripe') return next();
if (req.get('authorization') === AUTH_HEADER) return next();
res.set('WWW-Authenticate', 'Basic realm="mac2-pm2"');
res.status(401).send('auth required');
← f2d7975 yolo: Lost-pet listing → SMS alert to neighbors in ZIP
·
back to Animals
·
yolo: Generate sitemap.xml + robots.txt 51d1f13 →