feat: integrate Stripe payment module with API + UI components
This commit is contained in:
@@ -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' });
|
||||
}
|
||||
}
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
@@ -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: [] });
|
||||
}
|
||||
}
|
||||
@@ -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://<your-app>.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 });
|
||||
}
|
||||
Reference in New Issue
Block a user