import express from 'express'; import { stripe, webhookSecret } from '../config/stripe.js'; import { Order } from '../models/Order.js'; import { createWrenOffsetOrder } from '../utils/wrenClient.js'; const router = express.Router(); /** * POST /api/webhooks/stripe * Handle Stripe webhook events * * IMPORTANT: This endpoint requires raw body, not JSON parsed */ router.post('/stripe', express.raw({ type: 'application/json' }), async (req, res) => { const sig = req.headers['stripe-signature']; let event; try { // Verify webhook signature event = stripe.webhooks.constructEvent(req.body, sig, webhookSecret); } catch (err) { console.error('❌ Webhook signature verification failed:', err.message); return res.status(400).send(`Webhook Error: ${err.message}`); } console.log(`đŸ“Ŧ Received webhook: ${event.type}`); // Handle different event types switch (event.type) { case 'checkout.session.completed': await handleCheckoutSessionCompleted(event.data.object); break; case 'checkout.session.async_payment_succeeded': await handleAsyncPaymentSucceeded(event.data.object); break; case 'checkout.session.async_payment_failed': await handleAsyncPaymentFailed(event.data.object); break; case 'checkout.session.expired': await handleCheckoutSessionExpired(event.data.object); break; default: console.log(`â„šī¸ Unhandled event type: ${event.type}`); } // Return 200 to acknowledge receipt res.json({ received: true }); }); /** * Handle checkout.session.completed event * This fires when payment succeeds (or session completes for delayed payment methods) */ async function handleCheckoutSessionCompleted(session) { console.log(`✅ Checkout session completed: ${session.id}`); try { // Find order in database const order = Order.findBySessionId(session.id); if (!order) { console.error(`❌ Order not found for session: ${session.id}`); return; } // 🔒 IDEMPOTENCY CHECK: Prevent duplicate processing if (order.status === 'fulfilled' || order.wren_order_id) { console.log(`âš ī¸ Order ${order.id} already fulfilled (status: ${order.status}), skipping duplicate webhook`); return; } if (order.status === 'paid') { console.log(`âš ī¸ Order ${order.id} already marked as paid, skipping payment update`); // Still attempt fulfillment if not fulfilled yet await fulfillOrder(order, session); return; } // Atomically update payment intent and status to prevent race conditions if (session.payment_intent) { Order.updatePaymentAndStatus(order.id, session.payment_intent, 'paid'); } else { Order.updateStatus(order.id, 'paid'); } console.log(`đŸ’ŗ Payment confirmed for order: ${order.id}`); console.log(` Customer: ${session.customer_details?.email}`); console.log(` Amount: $${(order.total_amount / 100).toFixed(2)}`); // Fulfill order via Wren API await fulfillOrder(order, session); } catch (error) { console.error('❌ Error handling checkout session completed:', error); } } /** * Handle async payment succeeded (e.g., ACH, bank transfer) */ async function handleAsyncPaymentSucceeded(session) { console.log(`✅ Async payment succeeded: ${session.id}`); try { const order = Order.findBySessionId(session.id); if (!order) { console.error(`❌ Order not found for session: ${session.id}`); return; } // 🔒 IDEMPOTENCY CHECK: Prevent duplicate processing if (order.status === 'fulfilled' || order.wren_order_id) { console.log(`âš ī¸ Order ${order.id} already fulfilled, skipping duplicate webhook`); return; } if (order.status === 'paid') { console.log(`âš ī¸ Order ${order.id} already marked as paid, attempting fulfillment only`); await fulfillOrder(order, session); return; } // Update status to paid Order.updateStatus(order.id, 'paid'); // Fulfill order await fulfillOrder(order, session); } catch (error) { console.error('❌ Error handling async payment succeeded:', error); } } /** * Handle async payment failed */ async function handleAsyncPaymentFailed(session) { console.log(`❌ Async payment failed: ${session.id}`); try { const order = Order.findBySessionId(session.id); if (!order) { console.error(`❌ Order not found for session: ${session.id}`); return; } // 🔒 IDEMPOTENCY CHECK: Only update if not already failed if (order.status === 'failed') { console.log(`âš ī¸ Order ${order.id} already marked as failed, skipping duplicate webhook`); return; } // Update status to failed Order.updateStatus(order.id, 'failed'); console.log(`💔 Order ${order.id} marked as failed`); // TODO: Send notification to customer about failed payment } catch (error) { console.error('❌ Error handling async payment failed:', error); } } /** * Handle checkout session expired * This fires when a session expires (24 hours) without payment */ async function handleCheckoutSessionExpired(session) { console.log(`⏰ Checkout session expired: ${session.id}`); try { const order = Order.findBySessionId(session.id); if (!order) { console.error(`❌ Order not found for session: ${session.id}`); return; } // 🔒 IDEMPOTENCY CHECK: Only update if still pending if (order.status !== 'pending') { console.log(`âš ī¸ Order ${order.id} already processed (status: ${order.status}), skipping expiration`); return; } // Update status to expired Order.updateStatus(order.id, 'expired'); console.log(`🕐 Order ${order.id} marked as expired (abandoned cart)`); console.log(` Session was created but never completed`); // TODO: Optional - Send abandoned cart reminder email // TODO: Optional - Track abandonment metrics } catch (error) { console.error('❌ Error handling checkout session expired:', error); } } /** * Fulfill order by creating carbon offset via Wren API * @param {Object} order - Database order object * @param {Object} session - Stripe session object */ async function fulfillOrder(order, session) { try { // 🔒 IDEMPOTENCY CHECK: Don't fulfill if already fulfilled if (order.wren_order_id) { console.log(`âš ī¸ Order ${order.id} already has Wren order ID: ${order.wren_order_id}, skipping fulfillment`); return; } console.log(`🌱 Fulfilling order ${order.id} via Wren API...`); // Determine if we should use dry run mode based on environment const isDryRun = process.env.WREN_DRY_RUN === 'true'; if (isDryRun) { console.log('âš ī¸ DRY RUN MODE: No real offset will be created'); } // Create Wren offset order const wrenOrder = await createWrenOffsetOrder({ tons: order.tons, portfolioId: order.portfolio_id, customerEmail: session.customer_details?.email || order.customer_email, dryRun: isDryRun, }); // Update order with Wren order ID Order.updateWrenOrder(order.id, wrenOrder.id, 'fulfilled'); console.log(`✅ Order ${order.id} fulfilled successfully`); console.log(` Wren Order ID: ${wrenOrder.id}`); console.log(` Tons offset: ${order.tons}`); // TODO: Send confirmation email to customer } catch (error) { console.error(`❌ Order fulfillment failed for order ${order.id}:`, error); // Mark order as paid but unfulfilled (manual intervention needed) Order.updateStatus(order.id, 'paid'); // TODO: Send alert to admin about failed fulfillment } } export default router;