diff --git a/.env.example b/.env.example index 1909953..2889016 100644 --- a/.env.example +++ b/.env.example @@ -3,3 +3,10 @@ VITE_GROQ_API_KEY=Your-API-Key # Weather Widget (OpenWeatherMap) VITE_OPENWEATHER_API_KEY=Your-API-Key + +# --- Payments Module (Stripe Hosted Checkout) --- +# Frontend (safe to expose). Until set, the Payments page shows a "configure" notice. +VITE_STRIPE_PUBLISHABLE_KEY=pk_test_REPLACE_WITH_YOUR_PUBLISHABLE_KEY +# Backend only (Vercel env / .env for `vercel dev`) — NEVER expose these to the client. +STRIPE_SECRET_KEY=sk_test_REPLACE_WITH_YOUR_SECRET_KEY +STRIPE_WEBHOOK_SECRET=whsec_REPLACE_WITH_YOUR_WEBHOOK_SECRET diff --git a/api/payments/create-checkout-session.js b/api/payments/create-checkout-session.js new file mode 100644 index 0000000..d94b3e7 --- /dev/null +++ b/api/payments/create-checkout-session.js @@ -0,0 +1,85 @@ +/** + * Stripe — Create Checkout Session + * -------------------------------- + * Creates a Stripe-hosted Checkout Session and returns its URL so the frontend + * can redirect the customer to Stripe's secure payment page. We never see or + * store card details — that is the whole point of using hosted Checkout. + * + * Flow: Pay Now → POST here → Stripe Session → redirect to session.url → + * Stripe hosted page → success_url / cancel_url back to the app. + * + * Env (set in Vercel project settings, and .env locally for `vercel dev`): + * STRIPE_SECRET_KEY sk_live_… / sk_test_… (server only — never exposed) + * + * If STRIPE_SECRET_KEY is missing we return 503 with a clear message instead of + * crashing, so the module degrades gracefully until credentials are provided. + */ + +// Server-side source of truth for invoice amounts. Prices are resolved HERE, +// never trusted from the client, so a tampered request can't change the amount. +// In production this would be a DB/CRM lookup; mirrors src/modules/payments/data/invoices.js. +const INVOICES = { + 'INV-2041': { title: 'Roof Repair — Leak Detection & Shingle Replacement', amount: 184500, currency: 'usd' }, + 'INV-2039': { title: 'Full Roof Inspection & Storm Damage Report', amount: 32500, currency: 'usd' }, + 'INV-2012': { title: 'Gutter Cleaning & Maintenance Plan (Q1)', amount: 14900, currency: 'usd' }, +}; + +export default async function handler(req, res) { + if (req.method !== 'POST') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + const secretKey = (process.env.STRIPE_SECRET_KEY || '').trim(); + if (!secretKey) { + return res.status(503).json({ + error: 'Stripe is not configured. Set STRIPE_SECRET_KEY to enable payments.', + code: 'stripe_not_configured', + }); + } + + try { + const { invoiceId, customerEmail } = req.body || {}; + const invoice = INVOICES[invoiceId]; + if (!invoice) { + return res.status(404).json({ error: `Unknown invoice: ${invoiceId}` }); + } + + // Lazy import keeps the module out of the cold-start path when unconfigured. + const Stripe = (await import('stripe')).default; + const stripe = new Stripe(secretKey, { apiVersion: '2024-06-20' }); + + // Where Stripe sends the customer back. Derive the app origin from the + // request so this works across local/preview/production without config. + const origin = + req.headers.origin || + (req.headers.host ? `https://${req.headers.host}` : ''); + + const session = await stripe.checkout.sessions.create({ + mode: 'payment', + payment_method_types: ['card'], + line_items: [ + { + quantity: 1, + price_data: { + currency: invoice.currency, + unit_amount: invoice.amount, // integer, in cents + product_data: { + name: invoice.title, + metadata: { invoiceId }, + }, + }, + }, + ], + ...(customerEmail ? { customer_email: customerEmail } : {}), + // Echoed back on the session + every webhook event for reconciliation. + metadata: { invoiceId }, + success_url: `${origin}/payments/success?session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${origin}/payments/cancel?invoice=${encodeURIComponent(invoiceId)}`, + }); + + return res.status(200).json({ id: session.id, url: session.url }); + } catch (err) { + console.error('[create-checkout-session] error:', err.message); + return res.status(500).json({ error: err.message || 'Failed to create checkout session' }); + } +} diff --git a/api/payments/session-status.js b/api/payments/session-status.js new file mode 100644 index 0000000..b8f7504 --- /dev/null +++ b/api/payments/session-status.js @@ -0,0 +1,55 @@ +/** + * Stripe — Retrieve Checkout Session status + * ----------------------------------------- + * The success return page calls this with the `session_id` Stripe appended to + * the success_url. We retrieve the session server-side (using the secret key) + * and return a trimmed, safe status object the UI can render and record. + * + * Returning here does NOT replace the webhook — the webhook is the authoritative + * record of truth (it fires even if the customer closes the tab). This endpoint + * just gives the returning customer immediate, verified confirmation. + * + * Env: STRIPE_SECRET_KEY + */ + +export default async function handler(req, res) { + if (req.method !== 'GET') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + const secretKey = (process.env.STRIPE_SECRET_KEY || '').trim(); + if (!secretKey) { + return res.status(503).json({ + error: 'Stripe is not configured.', + code: 'stripe_not_configured', + }); + } + + const sessionId = req.query?.session_id; + if (!sessionId) { + return res.status(400).json({ error: 'session_id is required' }); + } + + try { + const Stripe = (await import('stripe')).default; + const stripe = new Stripe(secretKey, { apiVersion: '2024-06-20' }); + + const session = await stripe.checkout.sessions.retrieve(sessionId); + + return res.status(200).json({ + id: session.id, + // status: open | complete | expired + status: session.status, + // payment_status: paid | unpaid | no_payment_required + paymentStatus: session.payment_status, + amountTotal: session.amount_total, + currency: session.currency, + customerEmail: session.customer_details?.email || null, + invoiceId: session.metadata?.invoiceId || null, + created: session.created ? session.created * 1000 : null, + }); + } catch (err) { + console.error('[session-status] error:', err.message); + return res.status(500).json({ error: err.message || 'Failed to retrieve session' }); + } +} diff --git a/api/payments/transactions.js b/api/payments/transactions.js new file mode 100644 index 0000000..8e55368 --- /dev/null +++ b/api/payments/transactions.js @@ -0,0 +1,27 @@ +/** + * Stripe — Transaction history + * ---------------------------- + * Returns the payment records written by the webhook (the server-side source of + * truth). Reads from Vercel KV when connected; returns an empty list otherwise + * so the frontend degrades gracefully and falls back to its local record. + * + * GET /api/payments/transactions -> { transactions: [...] } + */ + +export default async function handler(req, res) { + if (req.method !== 'GET') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + try { + const { kv } = await import('@vercel/kv'); + const raw = await kv.lrange('payments:transactions', 0, 199); + const transactions = (raw || []).map((item) => + typeof item === 'string' ? JSON.parse(item) : item + ); + return res.status(200).json({ transactions }); + } catch { + // KV not configured — the frontend will use its local record instead. + return res.status(200).json({ transactions: [] }); + } +} diff --git a/api/payments/webhook.js b/api/payments/webhook.js new file mode 100644 index 0000000..28eaf64 --- /dev/null +++ b/api/payments/webhook.js @@ -0,0 +1,125 @@ +/** + * Stripe — Webhook receiver + * ------------------------- + * The authoritative record of truth for payments. Stripe POSTs events here; + * we verify the signature, then react to payment lifecycle events. This fires + * server-to-server even if the customer closes their browser, so the + * transaction record must be written here (not only on the success page). + * + * Configure in the Stripe Dashboard → Developers → Webhooks: + * Endpoint URL: https://.vercel.app/api/payments/webhook + * Events: checkout.session.completed, + * checkout.session.async_payment_succeeded, + * checkout.session.async_payment_failed, + * checkout.session.expired + * + * Env: + * STRIPE_SECRET_KEY sk_… (to construct the Stripe client) + * STRIPE_WEBHOOK_SECRET whsec_… (to verify the signature) + * + * Local testing: `stripe listen --forward-to localhost:5173/api/payments/webhook` + * + * Persistence uses Vercel KV when connected (same optional pattern as + * api/hail-webhook.js); without KV it logs the event and ack's 200. + */ + +// Stripe requires the *raw* request body to verify the signature, so the +// default JSON body parser must be disabled for this route. +export const config = { api: { bodyParser: false } }; + +async function readRawBody(req) { + const chunks = []; + for await (const chunk of req) { + chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk); + } + return Buffer.concat(chunks); +} + +/** Persist a payment record (best-effort; no-op without Vercel KV). */ +async function recordPayment(record) { + try { + const { kv } = await import('@vercel/kv'); + // Keep a capped, newest-first list of transactions. + await kv.lpush('payments:transactions', JSON.stringify(record)); + await kv.ltrim('payments:transactions', 0, 199); + if (record.invoiceId && record.status === 'paid') { + await kv.set(`payments:invoice:${record.invoiceId}`, 'paid'); + } + console.log('[payments/webhook] recorded in KV:', record.id); + } catch { + console.log('[payments/webhook] KV unavailable — event received but not persisted:', record); + } +} + +export default async function handler(req, res) { + if (req.method !== 'POST') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + const secretKey = (process.env.STRIPE_SECRET_KEY || '').trim(); + const webhookSecret = (process.env.STRIPE_WEBHOOK_SECRET || '').trim(); + if (!secretKey || !webhookSecret) { + return res.status(503).json({ error: 'Stripe webhook is not configured.' }); + } + + let event; + try { + const Stripe = (await import('stripe')).default; + const stripe = new Stripe(secretKey, { apiVersion: '2024-06-20' }); + + const rawBody = await readRawBody(req); + const signature = req.headers['stripe-signature']; + event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret); + } catch (err) { + // Signature verification failed — reject (could be a forged request). + console.error('[payments/webhook] signature verification failed:', err.message); + return res.status(400).send(`Webhook Error: ${err.message}`); + } + + try { + switch (event.type) { + case 'checkout.session.completed': + case 'checkout.session.async_payment_succeeded': { + const s = event.data.object; + await recordPayment({ + id: s.id, + invoiceId: s.metadata?.invoiceId || null, + amount: s.amount_total, + currency: s.currency, + customerEmail: s.customer_details?.email || null, + status: s.payment_status === 'paid' ? 'paid' : s.payment_status, + eventType: event.type, + createdAt: new Date().toISOString(), + }); + break; + } + case 'checkout.session.async_payment_failed': { + const s = event.data.object; + await recordPayment({ + id: s.id, + invoiceId: s.metadata?.invoiceId || null, + amount: s.amount_total, + currency: s.currency, + status: 'failed', + eventType: event.type, + createdAt: new Date().toISOString(), + }); + break; + } + case 'checkout.session.expired': { + const s = event.data.object; + console.log('[payments/webhook] checkout session expired/cancelled:', s.id); + break; + } + default: + // Unhandled event types are acknowledged so Stripe stops retrying. + console.log('[payments/webhook] unhandled event:', event.type); + } + } catch (err) { + console.error('[payments/webhook] handler error:', err.message); + // Still 200 below if we got a valid event — returning 500 makes Stripe + // retry, which is only desirable for transient persistence failures. + } + + return res.status(200).json({ received: true }); +} diff --git a/eslint.config.js b/eslint.config.js index 4fa125d..5d1987d 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -26,4 +26,11 @@ export default defineConfig([ 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }], }, }, + { + // Serverless functions and build config run on Node, not the browser. + files: ['api/**/*.js', '**/*.config.js'], + languageOptions: { + globals: { ...globals.node }, + }, + }, ]) diff --git a/package.json b/package.json index 1fc5808..a014f1b 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@dnd-kit/utilities": "^3.2.2", "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.5.0", + "@stripe/stripe-js": "^9.7.0", "@vercel/analytics": "^1.6.1", "autoprefixer": "^10.4.23", "class-variance-authority": "^0.7.1", @@ -36,6 +37,7 @@ "react-router-dom": "^7.13.0", "recharts": "^3.7.0", "sonner": "^2.0.7", + "stripe": "^22.2.0", "tailwind-merge": "^3.4.0", "tailwindcss": "^3.4.1", "three": "^0.182.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f40e551..f6a00ba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@react-three/fiber': specifier: ^9.5.0 version: 9.5.0(@types/react@19.2.10)(immer@11.1.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.182.0) + '@stripe/stripe-js': + specifier: ^9.7.0 + version: 9.7.0 '@vercel/analytics': specifier: ^1.6.1 version: 1.6.1(react@19.2.4) @@ -83,6 +86,9 @@ importers: sonner: specifier: ^2.0.7 version: 2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + stripe: + specifier: ^22.2.0 + version: 22.2.0 tailwind-merge: specifier: ^3.4.0 version: 3.4.0 @@ -686,6 +692,10 @@ packages: '@standard-schema/utils@0.3.0': resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@stripe/stripe-js@9.7.0': + resolution: {integrity: sha512-r1ElolvWXM4aYnZZVHvKW3EDL8JcwEuIgTuWxlB5lvC+YsvjkQ0gX35x9d8dTDubX395fViLVqkaolVs1PmIQQ==} + engines: {node: '>=12.16'} + '@tweenjs/tween.js@23.1.3': resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==} @@ -803,6 +813,7 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@use-gesture/core@10.3.1': resolution: {integrity: sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==} @@ -2055,6 +2066,15 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + stripe@22.2.0: + resolution: {integrity: sha512-WFGpMOom9QZqso1kcnSwJsCdC1QHDlMoCOxBZRf3JraMzhkfw7dgSdD2a1CFZrqC+mzAfqeEtYILrZhWKIDruA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + style-to-js@1.1.21: resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} @@ -2801,6 +2821,8 @@ snapshots: '@standard-schema/utils@0.3.0': {} + '@stripe/stripe-js@9.7.0': {} + '@tweenjs/tween.js@23.1.3': {} '@types/babel__core@7.20.5': @@ -4286,6 +4308,8 @@ snapshots: strip-json-comments@3.1.1: {} + stripe@22.2.0: {} + style-to-js@1.1.21: dependencies: style-to-object: 1.0.14 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..84b3853 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +allowBuilds: + core-js: set this to true or false + esbuild: set this to true or false diff --git a/src/App.jsx b/src/App.jsx index 4a48c8b..f13f236 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -9,6 +9,7 @@ import Maps from './pages/Maps'; import AdminSchedule from './pages/AdminSchedule'; import CustomerProfile from './pages/CustomerProfile'; import LeaderboardPage from './pages/LeaderboardPage'; +import { PaymentProvider, PaymentsPage, PaymentSuccess, PaymentCancelled, DemoCheckout } from './modules/payments'; import ErrorBoundary from './components/ErrorBoundary'; import SmoothScroll from './components/SmoothScroll'; import { Toaster } from 'sonner'; @@ -90,6 +91,15 @@ function App() { } /> + {/* Payments Module (independent) — Stripe hosted checkout */} + + + + + + } /> + {/* Self-service profile for internal roles */} @@ -432,6 +442,22 @@ function App() { } /> + {/* Stripe hosted-checkout return pages (public, full-screen). + Public on purpose: the round-trip to Stripe drops the in-memory + auth session, so these must render without it. */} + + + + } /> + + + + } /> + {/* Simulated hosted checkout — only used in demo mode (no Stripe keys). */} + } /> + {/* Catch all */} } /> diff --git a/src/components/Layout.jsx b/src/components/Layout.jsx index 6b74130..bd90f48 100644 --- a/src/components/Layout.jsx +++ b/src/components/Layout.jsx @@ -6,7 +6,7 @@ import { LayoutDashboard, Map, Calendar, LogOut, User, Home, MessageSquare, ChevronLeft, ChevronRight, Sun, Moon, Trophy, Users, Briefcase, FileText, Menu, X, Calculator, PlusCircle, ClipboardList, Zap, LayoutGrid, Settings, CloudLightning, - HardHat, ShieldCheck + HardHat, ShieldCheck, CreditCard } from 'lucide-react'; import PageTransition from './PageTransition'; @@ -210,6 +210,7 @@ const Layout = () => { default: // Customer or Fallback return [ { to: "/customer/profile", icon: LayoutDashboard, label: "Dashboard" }, + { to: "/portal/payments", icon: CreditCard, label: "Payments" }, ...commonItems, ]; } diff --git a/src/modules/payments/README.md b/src/modules/payments/README.md new file mode 100644 index 0000000..be82cca --- /dev/null +++ b/src/modules/payments/README.md @@ -0,0 +1,88 @@ +# Payments Module — Stripe Hosted Checkout + +A **completely independent** payments module using Stripe's official **hosted +Checkout** flow. No custom card forms, no card data ever touches this app — +the customer enters payment details only on Stripe's secure page. + +> This is **real Stripe integration architecture**, not a simulation. It ships +> with credential **placeholders**; the moment real keys are added it goes live. +> Until then, the UI shows a clear "configure Stripe" notice. + +## Flow +``` +Customer → Pay Now + → POST /api/payments/create-checkout-session (amount resolved server-side) + → redirect to Stripe-hosted checkout page (session.url) + → customer pays on stripe.com + → Stripe redirects back: + success_url → /payments/success?session_id=… (verifies + records) + cancel_url → /payments/cancel + → Stripe also POSTs /api/payments/webhook (authoritative record) +``` + +Statuses handled: **success**, **processing/pending** (async methods), +**failed**, and **cancelled**. + +## Structure +``` +api/payments/ # Vercel serverless functions (backend) +├── create-checkout-session.js # creates the Checkout Session, returns session.url +├── session-status.js # verifies a session for the return page +├── webhook.js # signature-verified Stripe event receiver +└── transactions.js # serves webhook-written history (Vercel KV) + +src/modules/payments/ # frontend (self-contained) +├── config/stripeConfig.js # publishable key + isConfigured flag +├── services/checkoutService.js # startCheckout() + getSessionStatus() +├── context/PaymentProvider.jsx # history (server KV ∪ local), invoices +├── data/invoices.js # sample invoices (display only) +├── components/ # InvoiceList, TransactionHistory, ConfigNotice +├── pages/ # PaymentsPage, PaymentSuccess, PaymentCancelled +└── index.js +``` + +## Routes (registered in `src/App.jsx`) +| Path | Access | Purpose | +|---|---|---| +| `/portal/payments` | CUSTOMER | Invoice list + history | +| `/payments/success` | public | Stripe success return (verifies session) | +| `/payments/cancel` | public | Stripe cancel return | + +The return pages are **public** on purpose: the full-page round-trip to Stripe +loses the app's in-memory auth session, so the return pages must render without +it. (With persisted/JWT auth in production this is seamless.) + +## Configuration +Add to your environment (see repo `.env.example`): +``` +VITE_STRIPE_PUBLISHABLE_KEY=pk_test_… # frontend (safe to expose) +STRIPE_SECRET_KEY=sk_test_… # backend only — never expose +STRIPE_WEBHOOK_SECRET=whsec_… # backend only — webhook signature +``` +In Vercel: add `STRIPE_SECRET_KEY` + `STRIPE_WEBHOOK_SECRET` as Project env vars, +and `VITE_STRIPE_PUBLISHABLE_KEY` as a build-time var. + +### Stripe Dashboard → Webhooks +- Endpoint: `https:///api/payments/webhook` +- Events: `checkout.session.completed`, `checkout.session.async_payment_succeeded`, + `checkout.session.async_payment_failed`, `checkout.session.expired` + +### Persistence +The webhook writes records to **Vercel KV** when connected (same optional pattern +as `api/hail-webhook.js`); without KV it logs and the frontend falls back to its +local record of the customer's own completed payments. + +## Local testing +``` +npm i # installs stripe + @stripe/stripe-js +# put test keys in .env, then: +vercel dev # runs the /api functions for real +# webhook: +stripe listen --forward-to localhost:3000/api/payments/webhook +``` +`npm run dev` also bridges `create-checkout-session` and `session-status` via the +existing Vite dev-API plugin (needs test keys in `.env`). + +## Dependencies added +- `@stripe/stripe-js` (frontend redirect fallback) +- `stripe` (backend Node SDK) diff --git a/src/modules/payments/components/InvoiceList.jsx b/src/modules/payments/components/InvoiceList.jsx new file mode 100644 index 0000000..e818c8b --- /dev/null +++ b/src/modules/payments/components/InvoiceList.jsx @@ -0,0 +1,138 @@ +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { FileText, Clock, Lock, Loader2, CheckCircle2 } from 'lucide-react'; +import { toast } from 'sonner'; +import { SpotlightCard } from '../../../components/SpotlightCard'; +import { formatAmount } from '../data/invoices'; +import { stripeConfig } from '../config/stripeConfig'; +import { startCheckout } from '../services/checkoutService'; + +/** + * Lists outstanding invoices with a "Pay Now" button that redirects the + * customer to Stripe's hosted Checkout page. No card details are collected here. + */ +const InvoiceList = ({ invoices, paidIds = [], customerEmail }) => { + const navigate = useNavigate(); + const [redirectingId, setRedirectingId] = useState(null); + + const isPaid = (inv) => inv.status === 'paid' || paidIds.includes(inv.id); + const due = invoices.filter((i) => !isPaid(i)); + const paid = invoices.filter(isPaid); + + const handlePay = async (invoice) => { + if (redirectingId) return; + setRedirectingId(invoice.id); + + // No Stripe keys → use the simulated hosted-checkout page so the demo + // works without credentials. With keys, redirect to real Stripe. + if (!stripeConfig.isConfigured) { + navigate(`/payments/demo-checkout?invoice=${encodeURIComponent(invoice.id)}`); + return; + } + + try { + // On success this navigates away to Stripe; control won't return. + await startCheckout({ invoiceId: invoice.id, customerEmail }); + } catch (err) { + toast.error('Unable to start checkout', { description: err.message }); + setRedirectingId(null); + } + }; + + return ( +
+
+

+ Outstanding Invoices +

+ + {due.length > 0 ? ( +
+ {due.map((inv) => { + const busy = redirectingId === inv.id; + return ( + +
+
+
+ +
+
+
+

{inv.title}

+ + {inv.id} + +
+

+ {inv.description} +

+

+ Due {new Date(inv.dueDate).toLocaleDateString()} · {inv.property} +

+
+
+ +
+ + {formatAmount(inv.amount, inv.currency, stripeConfig.locale)} + + +
+
+
+ ); + })} +
+ ) : ( +
+ You're all caught up — no outstanding invoices. 🎉 +
+ )} +
+ + {paid.length > 0 && ( +
+

+ Paid +

+
+ {paid.map((inv) => ( +
+
+ +
+

{inv.title}

+

{inv.id}

+
+
+ + {formatAmount(inv.amount, inv.currency, stripeConfig.locale)} + +
+ ))} +
+
+ )} + +

+ Payments are processed securely by Stripe. We never see or store your card details. +

+
+ ); +}; + +export default InvoiceList; diff --git a/src/modules/payments/components/StripeConfigNotice.jsx b/src/modules/payments/components/StripeConfigNotice.jsx new file mode 100644 index 0000000..826622d --- /dev/null +++ b/src/modules/payments/components/StripeConfigNotice.jsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { FlaskConical } from 'lucide-react'; + +/** + * Shown when no real Stripe publishable key is configured. Payments run through + * the simulated checkout (no real charge); adding keys switches to live Stripe. + */ +const StripeConfigNotice = () => ( +
+
+
+ +
+
+

+ Demo mode — simulated Stripe Checkout +

+

+ No Stripe keys are configured, so "Pay Now" runs a simulated checkout + (no real charge). Add the keys below to switch to live Stripe-hosted + Checkout — no code changes needed: +

+
    +
  • VITE_STRIPE_PUBLISHABLE_KEY (frontend)
  • +
  • STRIPE_SECRET_KEY (backend)
  • +
  • STRIPE_WEBHOOK_SECRET (backend / webhook)
  • +
+
+
+
+); + +export default StripeConfigNotice; diff --git a/src/modules/payments/components/TransactionHistory.jsx b/src/modules/payments/components/TransactionHistory.jsx new file mode 100644 index 0000000..5c12178 --- /dev/null +++ b/src/modules/payments/components/TransactionHistory.jsx @@ -0,0 +1,73 @@ +import React from 'react'; +import { Receipt, CreditCard, XCircle } from 'lucide-react'; +import { SpotlightCard } from '../../../components/SpotlightCard'; +import { stripeConfig } from '../config/stripeConfig'; +import { formatAmount } from '../data/invoices'; + +const STATUS_STYLES = { + paid: { + label: 'Paid', + chip: 'bg-emerald-100 dark:bg-emerald-500/20 text-emerald-700 dark:text-emerald-300', + icon: CreditCard, + iconWrap: 'bg-emerald-50 dark:bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-100 dark:border-emerald-500/20', + }, + failed: { + label: 'Failed', + chip: 'bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-300', + icon: XCircle, + iconWrap: 'bg-red-50 dark:bg-red-500/10 text-red-600 dark:text-red-400 border-red-100 dark:border-red-500/20', + }, +}; + +/** + * Lists payment records (merged server + local history). Read-only. + */ +const TransactionHistory = ({ transactions }) => { + if (!transactions.length) { + return ( +
+ + No transactions yet. Completed payments will appear here. +
+ ); + } + + return ( +
+ {transactions.map((t) => { + const style = STATUS_STYLES[t.status] || STATUS_STYLES.paid; + const Icon = style.icon; + return ( + +
+
+
+ +
+
+

{t.invoiceId || 'Payment'}

+

+ {t.customerEmail ? `${t.customerEmail} · ` : ''} + {t.createdAt ? new Date(t.createdAt).toLocaleString() : ''} +

+

{t.id}

+
+
+ +
+ + {formatAmount(t.amount, t.currency || stripeConfig.currency, stripeConfig.locale)} + + + {style.label} + +
+
+
+ ); + })} +
+ ); +}; + +export default TransactionHistory; diff --git a/src/modules/payments/config/stripeConfig.js b/src/modules/payments/config/stripeConfig.js new file mode 100644 index 0000000..093d69b --- /dev/null +++ b/src/modules/payments/config/stripeConfig.js @@ -0,0 +1,39 @@ +/** + * Stripe configuration (frontend) for the Payments module. + * + * Only the PUBLISHABLE key lives on the frontend — it is safe to expose. The + * Secret Key and Webhook Secret are backend-only (see /api/payments/*). + * + * Set in `.env` (see .env.example): + * VITE_STRIPE_PUBLISHABLE_KEY=pk_test_… or pk_live_… + * + * Until a real key is provided, `isConfigured` is false and the UI shows a + * clear "configure Stripe" notice rather than attempting a broken redirect. + */ + +const PLACEHOLDER = 'pk_test_REPLACE_WITH_YOUR_PUBLISHABLE_KEY'; + +const publishableKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY || PLACEHOLDER; + +export const stripeConfig = { + publishableKey, + + // Base path for the payment API endpoints (Vercel serverless functions). + apiBase: '/api/payments', + + // True only when a real publishable key is present. + isConfigured: + typeof publishableKey === 'string' && + publishableKey.startsWith('pk_') && + publishableKey !== PLACEHOLDER, + + currency: 'usd', + locale: 'en-US', + + merchant: { + name: 'LynkedUp Pro Roofing', + supportEmail: 'billing@lynkeduppro.com', + }, +}; + +export default stripeConfig; diff --git a/src/modules/payments/context/PaymentProvider.jsx b/src/modules/payments/context/PaymentProvider.jsx new file mode 100644 index 0000000..c6b4fa2 --- /dev/null +++ b/src/modules/payments/context/PaymentProvider.jsx @@ -0,0 +1,89 @@ +/** + * PaymentProvider — module-local state, fully independent from the app's global + * mockStore so the Payments module can be added/removed in isolation. + * + * Transaction history is sourced from two places and merged by id: + * 1. The server (api/payments/transactions → Vercel KV, written by the + * webhook) — the production source of truth. + * 2. A local record (localStorage) written when a customer returns to the + * success page — gives instant feedback and works without KV in the demo. + */ + +import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'; +import { INVOICES } from '../data/invoices'; +import { stripeConfig } from '../config/stripeConfig'; + +const STORAGE_KEY = 'lynkeduppro.payments.transactions'; + +const loadLocal = () => { + try { + const raw = localStorage.getItem(STORAGE_KEY); + return raw ? JSON.parse(raw) : []; + } catch { + return []; + } +}; + +const persistLocal = (txns) => { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(txns)); + } catch { + /* localStorage unavailable (private mode) — keep in-memory only. */ + } +}; + +const mergeById = (a, b) => { + const map = new Map(); + [...a, ...b].forEach((t) => { + if (t && t.id) map.set(t.id, { ...map.get(t.id), ...t }); + }); + return Array.from(map.values()).sort( + (x, y) => new Date(y.createdAt || 0) - new Date(x.createdAt || 0) + ); +}; + +const PaymentContext = createContext(null); + +export const PaymentProvider = ({ children }) => { + const [invoices] = useState(INVOICES); + const [transactions, setTransactions] = useState(loadLocal); + + // Pull the server-side (webhook-written) history and merge it in. + useEffect(() => { + let cancelled = false; + (async () => { + try { + const res = await fetch(`${stripeConfig.apiBase}/transactions`); + if (!res.ok) return; + const { transactions: server = [] } = await res.json(); + if (cancelled || !server.length) return; + setTransactions((prev) => mergeById(prev, server)); + } catch { + /* offline / endpoint unavailable — local history is fine. */ + } + })(); + return () => { + cancelled = true; + }; + }, []); + + /** Record a transaction confirmed on the success return page. */ + const recordTransaction = useCallback((txn) => { + if (!txn?.id) return; + setTransactions((prev) => { + const next = mergeById(prev, [txn]); + persistLocal(next); + return next; + }); + }, []); + + const value = { invoices, transactions, recordTransaction }; + + return {children}; +}; + +export const usePayments = () => { + const ctx = useContext(PaymentContext); + if (!ctx) throw new Error('usePayments must be used within a .'); + return ctx; +}; diff --git a/src/modules/payments/data/invoices.js b/src/modules/payments/data/invoices.js new file mode 100644 index 0000000..dcb42c9 --- /dev/null +++ b/src/modules/payments/data/invoices.js @@ -0,0 +1,45 @@ +/** + * Sample invoices for the customer to pay (display only). Amounts are stored in + * cents to match Stripe's API. The SERVER independently re-resolves the amount + * from its own copy (see api/payments/create-checkout-session.js) so the client + * can never alter what is charged — these values are for rendering the list. + * + * In production this list would come from the CRM/billing backend. + */ + +export const INVOICES = [ + { + id: 'INV-2041', + title: 'Roof Repair — Leak Detection & Shingle Replacement', + description: 'Emergency leak repair on north-facing slope, 12 shingles replaced.', + property: '2612 Dunwick Dr, Plano, TX 75023', + dueDate: '2026-06-15', + amount: 184500, + currency: 'usd', + }, + { + id: 'INV-2039', + title: 'Full Roof Inspection & Storm Damage Report', + description: 'Comprehensive drone inspection with insurance-ready documentation.', + property: '2612 Dunwick Dr, Plano, TX 75023', + dueDate: '2026-05-25', + amount: 32500, + currency: 'usd', + }, + { + id: 'INV-2012', + title: 'Gutter Cleaning & Maintenance Plan (Q1)', + description: 'Quarterly gutter clearing and downspout flush.', + property: '2612 Dunwick Dr, Plano, TX 75023', + dueDate: '2026-03-18', + amount: 14900, + currency: 'usd', + }, +]; + +/** Format an integer cent amount into a localized currency string. */ +export const formatAmount = (cents, currency = 'usd', locale = 'en-US') => + new Intl.NumberFormat(locale, { + style: 'currency', + currency: (currency || 'usd').toUpperCase(), + }).format((cents || 0) / 100); diff --git a/src/modules/payments/index.js b/src/modules/payments/index.js new file mode 100644 index 0000000..a5ca7ec --- /dev/null +++ b/src/modules/payments/index.js @@ -0,0 +1,13 @@ +/** + * Payments Module — public surface. + * + * Self-contained Stripe Checkout integration. Import from here so the rest of + * the app never reaches into the module internals. + */ + +export { default as PaymentsPage } from './pages/PaymentsPage'; +export { default as PaymentSuccess } from './pages/PaymentSuccess'; +export { default as PaymentCancelled } from './pages/PaymentCancelled'; +export { default as DemoCheckout } from './pages/DemoCheckout'; +export { PaymentProvider, usePayments } from './context/PaymentProvider'; +export { stripeConfig } from './config/stripeConfig'; diff --git a/src/modules/payments/pages/DemoCheckout.jsx b/src/modules/payments/pages/DemoCheckout.jsx new file mode 100644 index 0000000..510009c --- /dev/null +++ b/src/modules/payments/pages/DemoCheckout.jsx @@ -0,0 +1,138 @@ +import React, { useState } from 'react'; +import { useSearchParams, useNavigate } from 'react-router-dom'; +import { Lock, Loader2, ArrowLeft, FlaskConical, CheckCircle2 } from 'lucide-react'; +import { INVOICES, formatAmount } from '../data/invoices'; +import { stripeConfig } from '../config/stripeConfig'; + +/** + * Demo-only stand-in for Stripe's hosted Checkout page. Used ONLY when no + * Stripe keys are configured, so the flow (Pay Now → checkout → success → + * history) works for a demo without credentials. It collects no real card data + * and makes no charge. When real keys are added, this page is never reached — + * `startCheckout` redirects to the genuine Stripe-hosted page instead. + * + * Mirrors Stripe Checkout's two-column hosted layout for visual familiarity. + */ +const DemoCheckout = () => { + const [params] = useSearchParams(); + const navigate = useNavigate(); + const invoiceId = params.get('invoice'); + const invoice = INVOICES.find((i) => i.id === invoiceId); + + const [paying, setPaying] = useState(false); + + if (!invoice) { + return ( + +

Invoice not found.

+ navigate('/portal/payments')} /> +
+ ); + } + + const amountLabel = formatAmount(invoice.amount, invoice.currency, stripeConfig.locale); + + const handlePay = () => { + if (paying) return; + setPaying(true); + // Simulate Stripe's processing latency, then return to the success page. + setTimeout(() => { + navigate( + `/payments/success?demo=1&invoice=${encodeURIComponent(invoice.id)}` + + `&amount=${invoice.amount}¤cy=${invoice.currency}` + ); + }, 1600); + }; + + return ( + + {/* Demo banner */} +
+ + Demo mode — simulated Stripe Checkout. No real card or charge. Add Stripe keys to go live. +
+ +
+ {/* Left: order summary (Stripe-style) */} +
+

{stripeConfig.merchant.name}

+

{amountLabel}

+ +
+
+

{invoice.title}

+

{invoice.id}

+
+ {amountLabel} +
+
+ Total due + {amountLabel} +
+
+ + {/* Right: payment (read-only demo — no real card entry) */} +
+

+ Pay with card +

+ +
+ +
+ + +
+
+

+ Test card pre-filled for the demo — just press Pay. +

+ + + +

+ Powered by Stripe (simulated) +

+ + {!paying && navigate('/payments/cancel?invoice=' + invoice.id)} />} +
+
+
+ ); +}; + +const Shell = ({ children }) => ( +
+
+ {children} +
+
+); + +const Row = ({ label, value }) => ( +
+ {label} + {value} +
+); + +const BackButton = ({ onClick }) => ( + +); + +export default DemoCheckout; diff --git a/src/modules/payments/pages/PaymentCancelled.jsx b/src/modules/payments/pages/PaymentCancelled.jsx new file mode 100644 index 0000000..8dca9a2 --- /dev/null +++ b/src/modules/payments/pages/PaymentCancelled.jsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { useSearchParams, useNavigate } from 'react-router-dom'; +import { Ban, ArrowLeft } from 'lucide-react'; +import { SpotlightCard } from '../../../components/SpotlightCard'; + +/** + * Stripe redirects here (cancel_url) when the customer backs out of the hosted + * Checkout page without paying. Public route — no charge was made. + */ +const PaymentCancelled = () => { + const [params] = useSearchParams(); + const navigate = useNavigate(); + const invoice = params.get('invoice'); + + return ( +
+
+ +
+ +
+

Payment Cancelled

+

+ No charge was made{invoice ? ` for ${invoice}` : ''}. You can complete the + payment whenever you're ready. +

+ +
+
+
+ ); +}; + +export default PaymentCancelled; diff --git a/src/modules/payments/pages/PaymentSuccess.jsx b/src/modules/payments/pages/PaymentSuccess.jsx new file mode 100644 index 0000000..25733bc --- /dev/null +++ b/src/modules/payments/pages/PaymentSuccess.jsx @@ -0,0 +1,201 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { useSearchParams, useNavigate } from 'react-router-dom'; +import { CheckCircle2, XCircle, Loader2, Clock, ArrowLeft, Receipt } from 'lucide-react'; +import { SpotlightCard } from '../../../components/SpotlightCard'; +import { stripeConfig } from '../config/stripeConfig'; +import { formatAmount } from '../data/invoices'; +import { getSessionStatus } from '../services/checkoutService'; +import { usePayments } from '../context/PaymentProvider'; + +/** + * Stripe redirects here (success_url) after the customer leaves the hosted + * Checkout page. We verify the session server-side, then show + record the + * result. This route is PUBLIC so it renders even though the in-memory auth + * session was lost during the full-page round-trip to Stripe. + * + * Handled states: verifying → paid | pending (async) | failed | error. + */ +const PaymentSuccess = () => { + const [params] = useSearchParams(); + const navigate = useNavigate(); + const { recordTransaction } = usePayments(); + const sessionId = params.get('session_id'); + const isDemo = params.get('demo') === '1'; + + // Initialize from the URL so the effect never needs a synchronous setState + // (which would trigger cascading renders). + const [state, setState] = useState(() => + isDemo ? 'paid' : sessionId ? 'verifying' : 'error' + ); // verifying | paid | pending | failed | error + const [details, setDetails] = useState(() => + isDemo + ? { + id: `cs_demo_${Date.now()}`, + invoiceId: params.get('invoice'), + amountTotal: Number(params.get('amount')) || 0, + currency: params.get('currency') || stripeConfig.currency, + customerEmail: null, + } + : null + ); + const [errorMsg, setErrorMsg] = useState(() => + sessionId || isDemo ? '' : 'Missing session reference.' + ); + const recordedRef = useRef(false); + + useEffect(() => { + // Demo mode: no server to verify against — record the simulated payment. + if (isDemo) { + if (!recordedRef.current && details) { + recordedRef.current = true; + recordTransaction({ + id: details.id, + invoiceId: details.invoiceId, + amount: details.amountTotal, + currency: details.currency, + customerEmail: null, + status: 'paid', + createdAt: new Date().toISOString(), + demo: true, + }); + } + return; + } + + if (!sessionId) return; + + let cancelled = false; + (async () => { + try { + const data = await getSessionStatus(sessionId); + if (cancelled) return; + setDetails(data); + + const paid = data.paymentStatus === 'paid' || data.status === 'complete'; + if (paid) { + setState('paid'); + if (!recordedRef.current) { + recordedRef.current = true; + recordTransaction({ + id: data.id, + invoiceId: data.invoiceId, + amount: data.amountTotal, + currency: data.currency, + customerEmail: data.customerEmail, + status: 'paid', + createdAt: new Date().toISOString(), + }); + } + } else if (data.status === 'open') { + // Payment still processing (e.g. async method). + setState('pending'); + } else { + setState('failed'); + } + } catch (err) { + if (cancelled) return; + setState('error'); + setErrorMsg(err.message); + } + })(); + + return () => { + cancelled = true; + }; + }, [sessionId, isDemo, details, recordTransaction]); + + return ( +
+
+ + {state === 'verifying' && ( + } + title="Confirming your payment…" + subtitle="Verifying with Stripe, one moment." + /> + )} + + {state === 'pending' && ( + } + title="Payment processing" + subtitle="Your payment is being processed. We'll update your history once it settles." + /> + )} + + {state === 'paid' && details && ( + <> + } + title="Payment Successful" + subtitle={`${formatAmount(details.amountTotal, details.currency, stripeConfig.locale)} paid to ${stripeConfig.merchant.name}.`} + /> + + + )} + + {state === 'failed' && ( + } + title="Payment not completed" + subtitle="This payment didn't go through. You can try again from your invoices." + /> + )} + + {state === 'error' && ( + } + title="Couldn't verify payment" + subtitle={errorMsg || 'Please check your transaction history in a moment.'} + /> + )} + + + +
+
+ ); +}; + +const Body = ({ icon, title, subtitle }) => ( + <> +
+ {icon} +
+

{title}

+

{subtitle}

+ +); + +const Receipt2 = ({ details }) => { + const rows = [ + { label: 'Invoice', value: details.invoiceId || '—' }, + { label: 'Email', value: details.customerEmail || '—' }, + { label: 'Session', value: details.id, mono: true }, + ]; + return ( +
+
+ Receipt +
+ {rows.map((r) => ( +
+ + {r.label} + + + {r.value} + +
+ ))} +
+ ); +}; + +export default PaymentSuccess; diff --git a/src/modules/payments/pages/PaymentsPage.jsx b/src/modules/payments/pages/PaymentsPage.jsx new file mode 100644 index 0000000..412d1d4 --- /dev/null +++ b/src/modules/payments/pages/PaymentsPage.jsx @@ -0,0 +1,81 @@ +import React, { useState } from 'react'; +import { CreditCard } from 'lucide-react'; +import { useAuth } from '../../../context/AuthContext'; +import { usePayments } from '../context/PaymentProvider'; +import { stripeConfig } from '../config/stripeConfig'; +import InvoiceList from '../components/InvoiceList'; +import TransactionHistory from '../components/TransactionHistory'; +import StripeConfigNotice from '../components/StripeConfigNotice'; + +const TabButton = ({ id, label, activeTab, onSelect }) => ( + +); + +/** + * Payments module entry point (customer portal). Lists invoices to pay via + * Stripe-hosted Checkout and shows transaction history. Shares no state with + * the rest of the app beyond reading the logged-in user's email. + */ +const PaymentsPage = () => { + const { user } = useAuth(); + const { invoices, transactions } = usePayments(); + const [activeTab, setActiveTab] = useState('pay'); + + // Invoices marked paid by the (server) transaction history. + const paidIds = transactions + .filter((t) => t.status === 'paid' && t.invoiceId) + .map((t) => t.invoiceId); + + return ( +
+
+ {/* Header */} +
+
+ +
+
+

+ Payments +

+

+ Pay invoices securely via Stripe and review past transactions. +

+
+
+ + {!stripeConfig.isConfigured && } + + {/* Tabs */} +
+ + +
+ + {/* Content */} +
+ {activeTab === 'pay' ? ( + + ) : ( + + )} +
+
+
+ ); +}; + +export default PaymentsPage; diff --git a/src/modules/payments/services/checkoutService.js b/src/modules/payments/services/checkoutService.js new file mode 100644 index 0000000..a17a723 --- /dev/null +++ b/src/modules/payments/services/checkoutService.js @@ -0,0 +1,74 @@ +/** + * Checkout service — the only place the frontend talks to Stripe / the payment + * API. The UI calls these functions; it never handles card data. + * + * `startCheckout` follows Stripe's recommended hosted-checkout flow: + * 1. Ask our backend to create a Checkout Session (amount resolved server-side). + * 2. Redirect the browser to the Stripe-hosted `session.url`. + * 3. Stripe handles payment, then redirects back to success_url / cancel_url. + * + * A Stripe.js `redirectToCheckout({ sessionId })` fallback (which uses the + * publishable key) is kept for older API behavior, but `session.url` is the + * current default and avoids loading Stripe.js for the redirect. + */ + +import { loadStripe } from '@stripe/stripe-js'; +import { stripeConfig } from '../config/stripeConfig'; + +let stripePromise; +const getStripe = () => { + if (!stripePromise) stripePromise = loadStripe(stripeConfig.publishableKey); + return stripePromise; +}; + +/** + * Create a session for an invoice and redirect to Stripe's hosted page. + * Throws (with a friendly message) if the backend isn't configured/reachable. + */ +export async function startCheckout({ invoiceId, customerEmail } = {}) { + if (!stripeConfig.isConfigured) { + const err = new Error( + 'Stripe is not configured yet. Add your publishable key to enable payments.' + ); + err.code = 'stripe_not_configured'; + throw err; + } + + const res = await fetch(`${stripeConfig.apiBase}/create-checkout-session`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ invoiceId, customerEmail }), + }); + + const data = await res.json().catch(() => ({})); + if (!res.ok) { + throw new Error(data.error || 'Could not start checkout. Please try again.'); + } + + // Preferred: redirect straight to the Stripe-hosted Checkout URL. + if (data.url) { + window.location.assign(data.url); + return; + } + + // Fallback: Stripe.js redirect using the session id + publishable key. + const stripe = await getStripe(); + if (!stripe) throw new Error('Unable to initialize Stripe.'); + const { error } = await stripe.redirectToCheckout({ sessionId: data.id }); + if (error) throw new Error(error.message); +} + +/** + * Fetch the verified status of a completed Checkout Session (used by the + * success return page). Returns the trimmed status object from the backend. + */ +export async function getSessionStatus(sessionId) { + const res = await fetch( + `${stripeConfig.apiBase}/session-status?session_id=${encodeURIComponent(sessionId)}` + ); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + throw new Error(data.error || 'Could not verify payment status.'); + } + return data; +} diff --git a/vite.config.js b/vite.config.js index 3b60404..8f55add 100644 --- a/vite.config.js +++ b/vite.config.js @@ -22,6 +22,11 @@ const DEV_API_HANDLERS = { 'hail-proxy': () => import('./api/hail-proxy.js'), 'storm-events': () => import('./api/storm-events.js'), 'storm-polygons': () => import('./api/storm-polygons.js'), + // Payments module (needs real Stripe test keys in .env to function locally). + // The webhook is intentionally excluded — it needs the raw body + Stripe CLI. + 'payments/create-checkout-session': () => import('./api/payments/create-checkout-session.js'), + 'payments/session-status': () => import('./api/payments/session-status.js'), + 'payments/transactions': () => import('./api/payments/transactions.js'), } // Server-side env vars the handlers read (mirrors what you'd set in Vercel). @@ -30,6 +35,8 @@ const SERVER_ENV_KEYS = [ 'HAIL_RECON_ACCESS_KEY', 'HAIL_RECON_ACCESS_SECRET', 'HAIL_RECON_WEBHOOK_SECRET', + 'STRIPE_SECRET_KEY', + 'STRIPE_WEBHOOK_SECRET', ] function devApi(env) { @@ -62,6 +69,19 @@ function devApi(env) { res.end(JSON.stringify(obj)) return res } + res.send = (body) => { res.end(typeof body === 'string' ? body : JSON.stringify(body)); return res } + + // Parse a JSON body for POST/PUT/PATCH (Vercel does this automatically). + if (['POST', 'PUT', 'PATCH'].includes(req.method)) { + req.body = await new Promise((resolve) => { + let data = '' + req.on('data', (c) => { data += c }) + req.on('end', () => { + try { resolve(data ? JSON.parse(data) : {}) } catch { resolve({}) } + }) + req.on('error', () => resolve({})) + }) + } try { const mod = await load()