puffin-app/server/routes/webhooks.js
Matt e9b79531e1
All checks were successful
Build and Push Docker Images / docker (push) Successful in 2m20s
Add comprehensive webhook payload logging for customer data extraction
- Log full Stripe webhook JSON payload for debugging
- Extract and log customer name, address, and metadata
- Makes it easy to see business names and custom fields in logs
- Helps identify available data for future enhancements

server/routes/webhooks.js:32-36, 101-109
2025-11-03 14:08:19 +01:00

334 lines
11 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import express from 'express';
import { stripe, webhookSecret } from '../config/stripe.js';
import { Order } from '../models/Order.js';
import { createWrenOffsetOrder, getWrenPortfolios } from '../utils/wrenClient.js';
import { sendReceiptEmail, sendAdminNotification } from '../utils/emailService.js';
import { selectComparisons } from '../utils/carbonComparisons.js';
import { formatPortfolioProjects } from '../utils/portfolioColors.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}`);
// Log full webhook payload for debugging and data extraction
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log('📦 Full Stripe Webhook Payload:');
console.log(JSON.stringify(event, null, 2));
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
// 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 Email: ${session.customer_details?.email}`);
console.log(` Customer Name: ${session.customer_details?.name || 'Not provided'}`);
console.log(` Amount: $${(order.total_amount / 100).toFixed(2)}`);
if (session.customer_details?.address) {
console.log(` Address: ${JSON.stringify(session.customer_details.address)}`);
}
if (session.metadata && Object.keys(session.metadata).length > 0) {
console.log(` Metadata: ${JSON.stringify(session.metadata)}`);
}
// 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}`);
// Send receipt email to customer
const customerEmail = session.customer_details?.email || order.customer_email;
if (customerEmail) {
try {
// Fetch portfolio data from Wren API
let portfolioProjects = [];
try {
const portfolios = await getWrenPortfolios();
const portfolio = portfolios.find(p => p.id === order.portfolio_id);
if (portfolio && portfolio.projects) {
// Format projects with colors and percentages
portfolioProjects = formatPortfolioProjects(portfolio.projects);
console.log(`✅ Portfolio data fetched: ${portfolioProjects.length} projects`);
}
} catch (portfolioError) {
console.warn('⚠️ Failed to fetch portfolio data (non-fatal):', portfolioError.message);
// Continue without portfolio data
}
// Calculate carbon impact comparisons
const carbonComparisons = selectComparisons(order.tons, 3);
console.log(`✅ Carbon comparisons calculated: ${carbonComparisons.length} items`);
await sendReceiptEmail(customerEmail, {
tons: order.tons,
portfolioId: order.portfolio_id,
baseAmount: (order.base_amount / 100).toFixed(2),
processingFee: (order.processing_fee / 100).toFixed(2),
totalAmount: (order.total_amount / 100).toFixed(2),
orderId: order.id,
stripeSessionId: session.id,
projects: portfolioProjects,
comparisons: carbonComparisons,
});
console.log(`📧 Receipt email sent to ${customerEmail}`);
// Send admin notification (non-blocking)
sendAdminNotification({
tons: order.tons,
portfolioId: order.portfolio_id,
totalAmount: (order.total_amount / 100).toFixed(2),
orderId: order.id,
}, customerEmail).catch(err => {
console.error('⚠️ Admin notification failed (non-fatal):', err.message);
});
} catch (emailError) {
console.error('❌ Failed to send receipt email:', emailError);
// Don't fail the order fulfillment if email fails
}
} else {
console.warn('⚠️ No customer email available, skipping receipt email');
}
} 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');
// Send alert to admin about failed fulfillment
const customerEmail = session.customer_details?.email || order.customer_email || 'unknown@example.com';
sendAdminNotification({
tons: order.tons,
portfolioId: order.portfolio_id,
totalAmount: (order.total_amount / 100).toFixed(2),
orderId: order.id,
}, customerEmail).catch(err => {
console.error('❌ Failed to send admin alert for failed fulfillment:', err.message);
});
}
}
export default router;