All checks were successful
Build and Push Docker Images / docker (push) Successful in 2m20s
- 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
334 lines
11 KiB
JavaScript
334 lines
11 KiB
JavaScript
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;
|