126 lines
5.1 KiB
JavaScript
126 lines
5.1 KiB
JavaScript
/**
|
|
* 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 });
|
|
}
|