diff --git a/api/README.md b/api/README.md new file mode 100644 index 0000000..8cc76f9 --- /dev/null +++ b/api/README.md @@ -0,0 +1,48 @@ +# Checkout API + +Serverless Stripe Checkout integration for the Agentic Engineering course. + +## Deploy + +### Cloudflare Worker (recommended) + +```bash +npm install -g wrangler +cd api +wrangler deploy checkout.js --name checkout-api +wrangler secret put STRIPE_SECRET_KEY +``` + +### Vercel + +```bash +cd api +vercel deploy --prod +vercel secrets add STRIPE_SECRET_KEY sk_live_... +``` + +### Standalone + +```bash +export STRIPE_SECRET_KEY="sk_live_..." +node checkout.js +# Listens on port 8787 +``` + +## Price IDs + +Create these products in Stripe Dashboard, then update `site/checkout.md` with the real price IDs: + +| Product | Amount | Stripe Price ID | +|---------|--------|----------------| +| Self-Paced | $97 | `price_self_paced` → replace with real ID | +| Enterprise | $199 | `price_enterprise` → replace with real ID | +| Cohort | $247 | `price_cohort` → replace with real ID | + +## How It Works + +1. User clicks "Buy" → redirected to `/checkout?plan=self-paced` +2. Frontend calls `/api/checkout` with the price ID +3. API creates a Stripe Checkout Session +4. User is redirected to Stripe's hosted checkout page +5. After payment, user is redirected to `/checkout/success` diff --git a/api/checkout.js b/api/checkout.js new file mode 100644 index 0000000..e607942 --- /dev/null +++ b/api/checkout.js @@ -0,0 +1,141 @@ +/** + * Stripe Checkout Session API + * Deploy as a Cloudflare Worker, Vercel function, or standalone server. + * + * Environment variables needed: + * - STRIPE_SECRET_KEY: sk_live_... or sk_test_... + * - FRONTEND_URL: https://git.fdsa.agency (or your domain) + * + * Usage: + * POST /api/checkout + * Body: { "price_id": "price_abc123", "mode": "payment", "customer_email": "..." } + */ + +const stripeEndpoint = 'https://api.stripe.com/v1/checkout/sessions' + +async function handleRequest(request) { + // CORS for frontend + if (request.method === 'OPTIONS') { + return new Response(null, { + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', + }, + }) + } + + if (request.method !== 'POST') { + return new Response(JSON.stringify({ error: 'Method not allowed' }), { status: 405 }) + } + + const stripeKey = globalThis.STRIPE_SECRET_KEY || process?.env?.STRIPE_SECRET_KEY + const frontendUrl = globalThis.FRONTEND_URL || process?.env?.FRONTEND_URL || 'https://git.fdsa.agency' + + if (!stripeKey) { + return new Response(JSON.stringify({ error: 'Stripe not configured' }), { + status: 500, + headers: { 'Access-Control-Allow-Origin': '*' }, + }) + } + + try { + const { price_id, mode, customer_email } = await request.json() + + const body = new URLSearchParams({ + 'mode': mode || 'payment', + 'success_url': `${frontendUrl}/checkout/success?session_id={CHECKOUT_SESSION_ID}`, + 'cancel_url': `${frontendUrl}/checkout/cancel`, + 'allow_promotion_codes': 'true', + 'line_items[0][price]': price_id, + 'line_items[0][quantity]': '1', + }) + + if (customer_email) { + body.set('customer_email', customer_email) + } + + const stripeResp = await fetch(stripeEndpoint, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${stripeKey}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: body.toString(), + }) + + const data = await stripeJson(stripeResp) + + if (!stripeResp.ok) { + throw new Error(data.error?.message || 'Stripe error') + } + + return new Response(JSON.stringify({ url: data.url, session_id: data.id }), { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + }) + } catch (e) { + return new Response(JSON.stringify({ error: e.message }), { + status: 500, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + }) + } +} + +async function stripeJson(response) { + const text = await response.text() + try { + return JSON.parse(text) + } catch { + // Stripe sometimes returns unexpected formats — parse query string + const params = new URLSearchParams(text) + const obj = {} + for (const [k, v] of params) { + // Handle nested keys like 'error.message' + const parts = k.split('.') + if (parts.length > 1) { + let cursor = obj + for (let i = 0; i < parts.length - 1; i++) { + cursor[parts[i]] = cursor[parts[i]] || {} + cursor = cursor[parts[i]] + } + cursor[parts[parts.length - 1]] = v + } else { + obj[k] = v + } + } + return obj + } +} + +// Cloudflare Worker entry +export default { fetch: handleRequest } + +// Node.js entry +if (typeof module !== 'undefined' && require?.main === module) { + const http = require('http') + const server = http.createServer((req, res) => { + // Minimal Node.js adapter — assumes body is JSON + let body = '' + req.on('data', chunk => body += chunk) + req.on('end', async () => { + globalThis.process = process + globalThis.request = new Request(`http://localhost${req.url}`, { + method: req.method, + headers: req.headers, + body: body || undefined, + }) + const response = await handleRequest(globalThis.request) + res.writeHead(response.status, Object.fromEntries(response.headers)) + res.end(await response.text()) + }) + }) + const port = process.env.PORT || 8787 + server.listen(port, () => console.log(`Checkout API on :${port}`)) +} diff --git a/site/.vitepress/dist/404.html b/site/.vitepress/dist/404.html index 927ed84..4157099 100644 --- a/site/.vitepress/dist/404.html +++ b/site/.vitepress/dist/404.html @@ -6,12 +6,12 @@