puffin-app/server/routes/webhooks.js
Matt 837411699f
All checks were successful
Build and Push Docker Images / docker (push) Successful in 45s
Fix Wren API request body to match official API specification
Changed request parameters to match Wren API docs:
- 'portfolio' → 'portfolioId' (correct parameter name)
- 'dry_run' → 'dryRun' (camelCase as per API)
- Removed 'currency' and 'amount_charged' (API calculates amount)
- Removed 'source' object (not in API spec)
- Added 'note' with customer email for record keeping

API will now calculate and return the amount charged based on tons and portfolio.
Reference: https://www.wren.co/api/offset-orders documentation
2025-10-31 12:58:48 +01:00

257 lines
7.6 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 } 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;