From 6bbd5f03818fc8e15aec0ed9fadedacca3582b33 Mon Sep 17 00:00:00 2001 From: ci Date: Sun, 12 Jul 2026 20:48:18 -0500 Subject: [PATCH] chore: remove Stripe payment setup, webhooks, and citations to align on USDC-on-Base payments --- STRIPE-SETUP.md | 96 ----------- __tests__/stripe-webhook.test.js | 51 ------ api/stripe-kava-sweep.js | 123 -------------- api/stripe-webhook.js | 268 ------------------------------- payments.js | 14 +- 5 files changed, 4 insertions(+), 548 deletions(-) delete mode 100644 STRIPE-SETUP.md delete mode 100644 __tests__/stripe-webhook.test.js delete mode 100644 api/stripe-kava-sweep.js delete mode 100644 api/stripe-webhook.js diff --git a/STRIPE-SETUP.md b/STRIPE-SETUP.md deleted file mode 100644 index 684a990..0000000 --- a/STRIPE-SETUP.md +++ /dev/null @@ -1,96 +0,0 @@ -# Stripe Payment Links — 2-minute setup - -The site is wired for Stripe. All three pages (`index.html`, `thanks.html`, `checklist.html`) read a single config file — `/payments.js` — so going live is a one-file edit. - -No Stripe secret keys live in this repo. Payment Links are public URLs; Stripe handles checkout, receipts, fulfillment trigger, refunds, and tax on their side. - ---- - -## Step 1 — Create the product in Stripe - -1. Log in → https://dashboard.stripe.com -2. **Products → + Add product** -3. Name: `Ruby Starter Pack` -4. Price: `$17.00 USD` — one-time -5. Save - -## Step 2 — Create the Payment Link - -1. On the product page → **Create payment link** -2. Options to flip on: - - [x] Collect customer email (needed for delivery) - - [x] Collect billing address (helps with card auth + dispute defense) - - [x] Limit to 1 purchase per customer (optional) - - [x] After payment → **Show confirmation page** → custom message with the download link OR redirect to `https://royalruby.co/thanks.html?paid=1` -3. **Create link** -4. Copy the URL — it looks like `https://buy.stripe.com/aEUxxxxxxxxxxx` - -## Step 3 — Paste it into `/payments.js` - -Open `payments.js`, find the `ruby-starter-pack` block, paste the URL into `url`: - -```js -'ruby-starter-pack': { - name: 'Ruby Starter Pack', - price: 17, - url: 'https://buy.stripe.com/aEUxxxxxxxxxxx', // ← here - ctaLive: 'Get the Starter Pack — $17', - ctaWaitlist: 'Join the Starter Pack waitlist', -}, -``` - -Save. Commit. Deploy. - -```bash -cd ~/Desktop/Royal-Ruby-Live -git add payments.js -git commit -m "feat(stripe): wire starter pack payment link" -npm run deploy:vercel -``` - -On the next page load, every button with `data-buy="ruby-starter-pack"` auto-rewrites from the `mailto:` waitlist to the Stripe URL, and the label flips from *"Join the Starter Pack waitlist"* to *"Get the Starter Pack — $17"*. - -## Step 4 — Test it - -1. Visit the deployed site in a private window -2. Click the CTA → you should land on `buy.stripe.com` -3. In Stripe dashboard, flip to **Test mode** → use card `4242 4242 4242 4242`, any future expiry, any CVC -4. Complete the test payment → confirm the confirmation/redirect works -5. Flip Stripe back to **Live mode**, create the live Payment Link, swap it into `payments.js`, redeploy - ---- - -## Fulfillment — how the buyer actually gets the product - -Pick one. Simplest first: - -### Option A — Stripe confirmation page link (zero code) -On the Payment Link's "After payment" settings, paste a direct link to the product PDF or a Gumroad/Google Drive download URL. Stripe shows it on the confirmation page, sends it in the receipt email. - -### Option B — Redirect to a gated page on royalruby.co -Set "After payment" → redirect to `https://royalruby.co/thanks.html?paid=1`. Then add a small JS block that reveals a download button if `?paid=1` is in the URL. (Weak gate — determined users can guess the URL — but fine for $17 products.) - -### Option C — Stripe webhook → send email (real, later) -Requires a backend. Out of scope for the static site. Revisit when you migrate to a real app. - ---- - -## Adding the next products - -`payments.js` already has slots for: -- `ruby-dispute-vault` — $47 -- `ruby-credit-stacker` — $97 - -Same process: create in Stripe, paste the URL, commit, deploy. Add `data-buy="ruby-dispute-vault"` to any CTA you want rewritten. - ---- - -## Sanity checklist before flipping live - -- [ ] Business details filled in Stripe (legal name, bank account, EIN if applicable) -- [ ] Statement descriptor reads clearly (`ROYAL RUBY`) -- [ ] Email receipts enabled -- [ ] Refund policy linked from `/terms.html` matches what Stripe shows -- [ ] Test purchase with 4242 card in test mode → ✅ -- [ ] Live Payment Link created → pasted → committed → deployed -- [ ] One real $1 test purchase on the live link → refund yourself diff --git a/__tests__/stripe-webhook.test.js b/__tests__/stripe-webhook.test.js deleted file mode 100644 index a60159f..0000000 --- a/__tests__/stripe-webhook.test.js +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, it, expect } from 'vitest'; - -/** - * Smoke test for the Stripe webhook edge function. - * - * The webhook module exports a default handler and an edge runtime config. - * This test verifies: - * 1. The module loads without throwing - * 2. The exported handler is a function - * 3. Non-POST requests are rejected with 405 - */ - -describe('stripe-webhook', () => { - it('exports a handler function and edge config', async () => { - const mod = await import('../api/stripe-webhook.js'); - - expect(mod.default).toBeDefined(); - expect(typeof mod.default).toBe('function'); - expect(mod.config).toEqual({ runtime: 'edge' }); - }); - - it('rejects non-POST requests with 405', async () => { - const mod = await import('../api/stripe-webhook.js'); - const handler = mod.default; - - const req = new Request('https://localhost/api/stripe-webhook', { - method: 'GET', - }); - - const res = await handler(req); - expect(res.status).toBe(405); - }); - - it('rejects POST when STRIPE_WEBHOOK_SECRET is not set', async () => { - const mod = await import('../api/stripe-webhook.js'); - const handler = mod.default; - - // Ensure the env var is unset for this test - delete process.env.STRIPE_WEBHOOK_SECRET; - - const req = new Request('https://localhost/api/stripe-webhook', { - method: 'POST', - body: '{}', - }); - - const res = await handler(req); - expect(res.status).toBe(500); - const text = await res.text(); - expect(text).toContain('webhook secret not configured'); - }); -}); diff --git a/api/stripe-kava-sweep.js b/api/stripe-kava-sweep.js deleted file mode 100644 index f8dc0bf..0000000 --- a/api/stripe-kava-sweep.js +++ /dev/null @@ -1,123 +0,0 @@ -/* - * Royal Ruby — Stripe → Kava treasury sweep - * ----------------------------------------- - * Listens to Stripe `payout.paid` webhooks. For every confirmed payout, - * logs the amount and (optionally) triggers an on-chain USDC transfer of - * 25% of the net to the Kava treasury wallet on Base. - * - * This is intentionally opt-in: the on-chain step requires a hot wallet - * private key in env, which is a security exposure. Until you're ready - * for autonomous on-chain moves, this endpoint logs-only and you sweep - * manually per `treasury-log.md`. - * - * Env: - * STRIPE_WEBHOOK_SECRET whsec_... (same as main webhook) - * KAVA_TREASURY_ADDRESS 0x... on Base - * TREASURY_SPLIT_BPS 2500 (25%) — default if not set - * AUTO_SWEEP "true" to actually move ETH on chain (requires SWEEP_PK) - * SWEEP_PK 0x... — hot wallet private key (use a burner) - * BASE_RPC_URL https://mainnet.base.org (default) - */ - -export const config = { runtime: 'edge' }; - -const SPLIT_BPS = Number(process.env.TREASURY_SPLIT_BPS || 2500); - -async function verifyStripeSignature(rawBody, header, secret) { - if (!header) throw new Error('missing signature'); - const parts = Object.fromEntries( - header.split(',').map((p) => { - const i = p.indexOf('='); - return [p.slice(0, i), p.slice(i + 1)]; - }) - ); - const ts = parts.t; - const sig = parts.v1; - if (!ts || !sig) throw new Error('bad header'); - - const key = await crypto.subtle.importKey( - 'raw', - new TextEncoder().encode(secret), - { name: 'HMAC', hash: 'SHA-256' }, - false, - ['sign'] - ); - const mac = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(`${ts}.${rawBody}`)); - const expected = Array.from(new Uint8Array(mac)) - .map((b) => b.toString(16).padStart(2, '0')) - .join(''); - - if (expected.length !== sig.length) throw new Error('mismatch'); - let m = 0; - for (let i = 0; i < expected.length; i++) m |= expected.charCodeAt(i) ^ sig.charCodeAt(i); - if (m !== 0) throw new Error('mismatch'); - - if (Math.floor(Date.now() / 1000) - Number(ts) > 300) throw new Error('stale'); - return true; -} - -export default async function handler(req) { - if (req.method !== 'POST') { - return new Response('method not allowed', { status: 405 }); - } - const secret = process.env.STRIPE_WEBHOOK_SECRET; - if (!secret) { - return new Response('webhook secret missing', { status: 500 }); - } - - const raw = await req.text(); - try { - await verifyStripeSignature(raw, req.headers.get('stripe-signature'), secret); - } catch (e) { - return new Response('signature fail: ' + e.message, { status: 400 }); - } - - let event; - try { event = JSON.parse(raw); } - catch { return new Response('bad json', { status: 400 }); } - - // Only react to successful payouts — not individual charges - if (event.type !== 'payout.paid') { - return new Response(JSON.stringify({ ignored: event.type }), { - status: 200, - headers: { 'content-type': 'application/json' }, - }); - } - - const payout = event.data.object; - const netCents = payout.amount || 0; - const currency = payout.currency || 'usd'; - const sweepCents = Math.floor((netCents * SPLIT_BPS) / 10_000); - const logEntry = { - ts: new Date().toISOString(), - payoutId: payout.id, - netCents, - currency, - sweepCents, - splitBps: SPLIT_BPS, - mode: process.env.AUTO_SWEEP === 'true' ? 'auto' : 'log-only', - }; - - console.log('[kava-sweep]', JSON.stringify(logEntry)); - - // Auto-sweep path — gated behind env flag - if (process.env.AUTO_SWEEP === 'true') { - const pk = process.env.SWEEP_PK; - const to = process.env.KAVA_TREASURY_ADDRESS; - if (!pk || !to) { - console.error('[kava-sweep] auto mode without SWEEP_PK + KAVA_TREASURY_ADDRESS'); - return new Response('auto-sweep misconfigured', { status: 500 }); - } - try { - // On-chain transfer deferred to worker service - console.log('[kava-sweep] transfer-pending', { sweepCents, queued: true }); - } catch (e) { - console.error('[kava-sweep] transfer failed:', e.message); - } - } - - return new Response(JSON.stringify({ received: true, logged: logEntry }), { - status: 200, - headers: { 'content-type': 'application/json' }, - }); -} diff --git a/api/stripe-webhook.js b/api/stripe-webhook.js deleted file mode 100644 index 154a2a5..0000000 --- a/api/stripe-webhook.js +++ /dev/null @@ -1,268 +0,0 @@ -/* - * Royal Ruby — Stripe webhook (Vercel Edge Function) - * -------------------------------------------------- - * Receives checkout.session.completed events from Stripe and: - * 1. Verifies the webhook signature using the raw body - * 2. Looks up the product (by slug in metadata OR by price id) - * 3. Sends the buyer the download link via Resend email - * 4. Logs the fulfillment event - * - * Why Edge runtime? Free on Vercel, low cold-start, globally deployed, - * no backend server to maintain. - * - * Env vars required (set in Vercel dashboard → Project → Environment): - * STRIPE_WEBHOOK_SECRET whsec_... — from Stripe Dashboard → Webhooks - * RESEND_API_KEY re_... — from resend.com (free tier: 100/day) - * RESEND_FROM "Royal Ruby " - * RESEND_REPLY_TO jadedfocus@gmail.com - * RR_STARTER_PACK_URL https://... (hidden Gumroad or signed S3 link) - * RR_DISPUTE_VAULT_URL https://... - * RR_CREDIT_STACKER_URL https://... - * - * Wire up in Stripe: - * Dashboard → Developers → Webhooks → + Add endpoint - * URL: https://royalruby.co/api/stripe-webhook - * Event: checkout.session.completed - * Copy the signing secret → Vercel env var STRIPE_WEBHOOK_SECRET → redeploy - */ - -export const config = { runtime: 'edge' }; - -const PRODUCTS = { - 'ruby-starter-pack': { - name: 'Ruby Starter Pack', - price: 17, - downloadEnvVar: 'RR_STARTER_PACK_URL', - subject: 'Your Ruby Starter Pack is ready', - body: (link) => ` -Thank you for trusting Royal Ruby. - -Your Ruby Starter Pack is ready: - -→ ${link} - -Inside you'll find: - • The 5-page 90-day credit reset checklist - • A 90-day budget builder (spreadsheet) - • The debt destroyer ranked by interest cost - • 3 dispute letter templates (the three most common situations) - -Start with page 1. Don't skip it. The people who win are the ones who do the first step on the first day. - -If anything is broken or the link doesn't load, reply to this email directly — it comes straight to me. - -— Dr. Herman Marigny III -Royal Ruby -`, - }, - 'ruby-dispute-vault': { - name: 'Ruby Dispute Vault', - price: 47, - downloadEnvVar: 'RR_DISPUTE_VAULT_URL', - subject: 'The Ruby Dispute Vault is yours', - body: (link) => ` -Welcome to the Vault. - -Your full Ruby Dispute Vault is here: - -→ ${link} - -What's inside: - • 12 battle-tested dispute letter templates - • The FCRA § 611 method-of-verification script - • Certified-mail walkthrough + tracking sheet - • 30-day follow-up cadence - • Goodwill deletion + pay-for-delete negotiation scripts - -Start with the "Dispute 01 — Inaccurate Collection" template. It's the most common win. - -These are educational templates, not legal advice. Read the intro first. - -— Dr. Herman Marigny III -Royal Ruby -`, - }, - 'ruby-credit-stacker': { - name: 'Ruby Credit Stacker', - price: 97, - downloadEnvVar: 'RR_CREDIT_STACKER_URL', - subject: 'The Ruby Credit Stacker is unlocked', - body: (link) => ` -The Stacker is yours. - -→ ${link} - -You now have the full 90-page Credit Stacker playbook — the advanced moves for people who've already cleaned up their basics and want to build real credit capacity. - -Chapter 1 is the secured-card ladder. If you're under 650, start there. - -If you want to jump to the highest-leverage section first, go to Chapter 4 — Business Credit Separation. Most people skip it and lose ten years of compounding. - -Questions? Reply directly. - -— Dr. Herman Marigny III -Royal Ruby -`, - }, -}; - -// ---------- signature verification ---------- - -async function verifyStripeSignature(rawBody, header, secret) { - if (!header) throw new Error('missing Stripe-Signature header'); - const parts = Object.fromEntries( - header.split(',').map((p) => { - const i = p.indexOf('='); - return [p.slice(0, i), p.slice(i + 1)]; - }) - ); - const timestamp = parts.t; - const sig = parts.v1; - if (!timestamp || !sig) throw new Error('bad signature header'); - - const payload = `${timestamp}.${rawBody}`; - const key = await crypto.subtle.importKey( - 'raw', - new TextEncoder().encode(secret), - { name: 'HMAC', hash: 'SHA-256' }, - false, - ['sign'] - ); - const mac = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(payload)); - const expected = Array.from(new Uint8Array(mac)) - .map((b) => b.toString(16).padStart(2, '0')) - .join(''); - - // Constant-time compare - if (expected.length !== sig.length) throw new Error('signature mismatch'); - let mismatch = 0; - for (let i = 0; i < expected.length; i++) { - mismatch |= expected.charCodeAt(i) ^ sig.charCodeAt(i); - } - if (mismatch !== 0) throw new Error('signature mismatch'); - - // Reject replays older than 5 minutes - const age = Math.floor(Date.now() / 1000) - Number(timestamp); - if (age > 300) throw new Error('timestamp too old'); - - return true; -} - -// ---------- email send (Resend) ---------- - -async function sendEmail({ to, subject, body }) { - const key = process.env.RESEND_API_KEY; - if (!key) { - console.warn('[webhook] RESEND_API_KEY not set — logging-only mode'); - return { skipped: true }; - } - const res = await fetch('https://api.resend.com/emails', { - method: 'POST', - headers: { - Authorization: 'Bearer ' + key, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - from: process.env.RESEND_FROM || 'Royal Ruby ', - to: [to], - reply_to: process.env.RESEND_REPLY_TO || undefined, - subject, - text: body.trim(), - }), - }); - if (!res.ok) { - const err = await res.text(); - throw new Error('resend ' + res.status + ': ' + err); - } - return await res.json(); -} - -// ---------- product resolution ---------- - -function resolveProduct(session) { - const slug = session?.metadata?.slug; - if (slug && PRODUCTS[slug]) return { slug, ...PRODUCTS[slug] }; - - // Fallback: walk line items if the webhook includes them - const items = session?.line_items?.data || []; - for (const li of items) { - const desc = (li.description || '').toLowerCase(); - if (desc.includes('starter pack')) return { slug: 'ruby-starter-pack', ...PRODUCTS['ruby-starter-pack'] }; - if (desc.includes('dispute vault')) return { slug: 'ruby-dispute-vault', ...PRODUCTS['ruby-dispute-vault'] }; - if (desc.includes('credit stacker')) return { slug: 'ruby-credit-stacker', ...PRODUCTS['ruby-credit-stacker'] }; - } - - return null; -} - -// ---------- entrypoint ---------- - -export default async function handler(req) { - if (req.method !== 'POST') { - return new Response('method not allowed', { status: 405 }); - } - - const secret = process.env.STRIPE_WEBHOOK_SECRET; - if (!secret) { - return new Response('webhook secret not configured', { status: 500 }); - } - - const raw = await req.text(); - const sigHeader = req.headers.get('stripe-signature'); - - try { - await verifyStripeSignature(raw, sigHeader, secret); - } catch (e) { - console.error('[webhook] signature fail:', e.message); - return new Response('signature verification failed', { status: 400 }); - } - - let event; - try { - event = JSON.parse(raw); - } catch { - return new Response('bad json', { status: 400 }); - } - - if (event.type !== 'checkout.session.completed') { - return new Response(JSON.stringify({ received: true, ignored: event.type }), { - status: 200, - headers: { 'content-type': 'application/json' }, - }); - } - - const session = event.data?.object || {}; - const email = session.customer_details?.email || session.customer_email; - if (!email) { - console.warn('[webhook] no email on session', session.id); - return new Response('no email', { status: 200 }); - } - - const product = resolveProduct(session); - if (!product) { - console.warn('[webhook] unknown product for session', session.id); - return new Response('unknown product', { status: 200 }); - } - - const downloadUrl = process.env[product.downloadEnvVar]; - if (!downloadUrl) { - console.error('[webhook] download url missing:', product.downloadEnvVar); - return new Response('download url missing', { status: 500 }); - } - - try { - await sendEmail({ - to: email, - subject: product.subject, - body: product.body(downloadUrl), - }); - console.log('[webhook] fulfilled', { session: session.id, email, slug: product.slug }); - return new Response(JSON.stringify({ received: true, fulfilled: product.slug }), { - status: 200, - headers: { 'content-type': 'application/json' }, - }); - } catch (e) { - console.error('[webhook] email send failed:', e.message); - return new Response('email send failed', { status: 500 }); - } -} diff --git a/payments.js b/payments.js index 72c04bb..9868301 100644 --- a/payments.js +++ b/payments.js @@ -1,16 +1,10 @@ /* - * Royal Ruby — Stripe Payment Link config + * Royal Ruby — Product Link config * ---------------------------------------- - * Single source of truth for product pricing + Stripe URLs. - * Static site, no backend. Uses Stripe Payment Links (no keys in source). - * - * To go live: - * 1. Stripe Dashboard → Products → + Add product → set name/price → Save - * 2. On the product page → Create payment link → copy the https://buy.stripe.com/... URL - * 3. Paste it into the `url` field below, save, redeploy. + * Single source of truth for product pricing + waitlist URLs. + * Static site, no backend. Gated on USDC-on-chain and waitlist flow. * * Until a URL is filled in, CTAs fall back to the existing mailto: waitlist. - * See STRIPE-SETUP.md for the full walkthrough. */ (function () { 'use strict'; @@ -46,7 +40,7 @@ const product = PRODUCTS[slug]; if (!product) return; - const hasLiveLink = typeof product.url === 'string' && product.url.length > 0 && (product.url.startsWith('https://buy.stripe.com/') || product.url.includes('lemonsqueezy.com')); + const hasLiveLink = typeof product.url === 'string' && product.url.length > 0 && product.url.startsWith('http'); if (hasLiveLink) { el.setAttribute('href', product.url);