[object Object]

← 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

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&rsquo;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&rsquo;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 →