puffin-app/server/routes/webhooks.js
Matt bc9e2d3782
All checks were successful
Build and Push Docker Images / docker (push) Successful in 1m22s
Implement comprehensive Stripe security fixes and production deployment
CRITICAL SECURITY FIXES:
- Add webhook secret validation to prevent signature bypass
- Implement idempotency protection across all webhook handlers
- Add atomic database updates to prevent race conditions
- Improve CORS security with origin validation and logging
- Remove .env from git tracking to protect secrets

STRIPE INTEGRATION:
- Add support for checkout.session.expired webhook event
- Add Stripe publishable key to environment configuration
- Fix webhook handlers with proper idempotency checks
- Update Order model with atomic updatePaymentAndStatus method
- Add comprehensive logging for webhook processing

DEPLOYMENT ARCHITECTURE:
- Split into two Docker images (frontend-latest, backend-latest)
- Update CI/CD to build separate frontend and backend images
- Configure backend on port 3801 (internal 3001)
- Add production-ready docker-compose.yml
- Remove redundant docker-compose.portainer.yml
- Update nginx configuration for both frontend and backend

DOCUMENTATION:
- Add PRODUCTION-SETUP.md with complete deployment guide
- Add docs/stripe-security-fixes.md with security audit details
- Add docs/stripe-checkout-sessions.md with integration docs
- Add docs/stripe-webhooks.md with webhook configuration
- Update .env.example with all required variables including Stripe publishable key

CONFIGURATION:
- Consolidate to single .env.example template
- Update .gitignore to protect all .env variants
- Add server/Dockerfile for backend container
- Update DEPLOYMENT.md with new architecture

🔒 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 12:18:57 +01:00

259 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,
currency: order.currency,
amountCents: order.total_amount,
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;