puffin-app/server/routes/webhooks.js
Matt dc4fc45c4f
All checks were successful
Build and Push Docker Images / docker (push) Successful in 2m28s
Add NocoDB integration for order management with comprehensive Stripe webhook logging
Features:
- Complete NocoDB schema with 42 fields supporting B2B and B2C customers
- Server-side NocoDB client (REST API integration)
- Stripe session data mapper with automatic field mapping
- Enhanced webhook handler with comprehensive logging
- Automatic order creation in NocoDB after payment
- Fulfillment data updates with Wren order IDs
- Support for business customers (VAT/EIN, business names)
- Complete billing address capture
- Non-blocking error handling (webhook succeeds even if NocoDB fails)

Files Added:
- server/utils/nocodbClient.js - NocoDB REST API client
- server/utils/nocodbMapper.js - Stripe to NocoDB data mapper
- docs/NOCODB_SCHEMA.md - Complete field reference (42 columns)
- docs/NOCODB_INTEGRATION_GUIDE.md - Testing and deployment guide
- docs/TESTING_STRIPE_WEBHOOK.md - Webhook testing instructions
- docs/STRIPE_INTEGRATION_SUMMARY.md - Project overview

Files Modified:
- server/routes/webhooks.js - Added NocoDB integration and enhanced logging
- src/types.ts - Updated OrderRecord interface with new fields
- src/api/nocodbClient.ts - Added createOrder() method
- .env.example - Added NocoDB configuration template

Schema includes:
- Payment tracking (Stripe session/intent/customer IDs, amounts, fees)
- Carbon offset details (tons, portfolio, Wren order ID)
- Customer information (name, email, phone, business name)
- Tax ID collection (VAT, EIN, etc.)
- Complete billing address
- Optional vessel/trip details for yacht calculations

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-03 16:35:15 +01:00

528 lines
19 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';
import { nocodbClient } from '../utils/nocodbClient.js';
import { mapStripeSessionToNocoDBOrder, mapWrenFulfillmentData } from '../utils/nocodbMapper.js';
const router = express.Router();
/**
* Log all available Stripe session data for schema verification
* This helps us verify what fields are actually available from Stripe
*/
function logStripeSessionData(session) {
console.log('\n╔════════════════════════════════════════════════════════════════╗');
console.log('║ STRIPE CHECKOUT SESSION - COMPREHENSIVE DATA ║');
console.log('╚════════════════════════════════════════════════════════════════╝\n');
// Core Session Info
console.log('📋 SESSION INFORMATION:');
console.log(' Session ID:', session.id || 'N/A');
console.log(' Payment Intent:', session.payment_intent || 'N/A');
console.log(' Payment Status:', session.payment_status || 'N/A');
console.log(' Status:', session.status || 'N/A');
console.log(' Mode:', session.mode || 'N/A');
console.log(' Created:', session.created ? new Date(session.created * 1000).toISOString() : 'N/A');
console.log(' Expires At:', session.expires_at ? new Date(session.expires_at * 1000).toISOString() : 'N/A');
// Amount Details
console.log('\n💰 PAYMENT AMOUNT:');
console.log(' Amount Total:', session.amount_total ? `${session.amount_total} cents ($${(session.amount_total / 100).toFixed(2)})` : 'N/A');
console.log(' Amount Subtotal:', session.amount_subtotal ? `${session.amount_subtotal} cents ($${(session.amount_subtotal / 100).toFixed(2)})` : 'N/A');
console.log(' Currency:', session.currency ? session.currency.toUpperCase() : 'N/A');
// Customer Details
console.log('\n👤 CUSTOMER DETAILS:');
if (session.customer_details) {
console.log(' Name (Display):', session.customer_details.name || 'N/A');
console.log(' Business Name:', session.customer_details.business_name || 'N/A');
console.log(' Individual Name:', session.customer_details.individual_name || 'N/A');
console.log(' Email:', session.customer_details.email || 'N/A');
console.log(' Phone:', session.customer_details.phone || 'N/A');
console.log(' Tax Exempt:', session.customer_details.tax_exempt || 'N/A');
// Billing Address
console.log('\n📬 BILLING ADDRESS:');
if (session.customer_details.address) {
const addr = session.customer_details.address;
console.log(' Line 1:', addr.line1 || 'N/A');
console.log(' Line 2:', addr.line2 || 'N/A');
console.log(' City:', addr.city || 'N/A');
console.log(' State:', addr.state || 'N/A');
console.log(' Postal Code:', addr.postal_code || 'N/A');
console.log(' Country:', addr.country || 'N/A');
} else {
console.log(' No address provided');
}
} else {
console.log(' No customer details provided');
}
// Customer Object Reference
console.log('\n🔗 CUSTOMER OBJECT:');
console.log(' Customer ID:', session.customer || 'N/A');
// Payment Method Details
console.log('\n💳 PAYMENT METHOD:');
if (session.payment_method_types && session.payment_method_types.length > 0) {
console.log(' Types:', session.payment_method_types.join(', '));
} else {
console.log(' Types: N/A');
}
// Metadata (our custom data)
console.log('\n🏷 METADATA (Our Custom Fields):');
if (session.metadata && Object.keys(session.metadata).length > 0) {
Object.entries(session.metadata).forEach(([key, value]) => {
console.log(` ${key}:`, value);
});
} else {
console.log(' No metadata');
}
// Line Items (what they purchased)
console.log('\n🛒 LINE ITEMS:');
if (session.line_items) {
console.log(' Available in session object');
} else {
console.log(' Not expanded (need to fetch separately)');
}
// Additional Fields
console.log('\n🔧 ADDITIONAL FIELDS:');
console.log(' Client Reference ID:', session.client_reference_id || 'N/A');
console.log(' Locale:', session.locale || 'N/A');
console.log(' Success URL:', session.success_url || 'N/A');
console.log(' Cancel URL:', session.cancel_url || 'N/A');
// Shipping (if collected)
if (session.shipping) {
console.log('\n📦 SHIPPING (if collected):');
console.log(' Name:', session.shipping.name || 'N/A');
if (session.shipping.address) {
const addr = session.shipping.address;
console.log(' Address Line 1:', addr.line1 || 'N/A');
console.log(' Address Line 2:', addr.line2 || 'N/A');
console.log(' City:', addr.city || 'N/A');
console.log(' State:', addr.state || 'N/A');
console.log(' Postal Code:', addr.postal_code || 'N/A');
console.log(' Country:', addr.country || 'N/A');
}
}
// Tax IDs (if collected)
if (session.customer_details?.tax_ids && session.customer_details.tax_ids.length > 0) {
console.log('\n🆔 TAX IDS (if collected):');
session.customer_details.tax_ids.forEach((taxId, index) => {
console.log(` Tax ID ${index + 1}:`, taxId.type, '-', taxId.value);
});
}
console.log('\n╔════════════════════════════════════════════════════════════════╗');
console.log('║ END OF STRIPE DATA LOG ║');
console.log('╚════════════════════════════════════════════════════════════════╝\n');
}
/**
* 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 comprehensive Stripe data for schema verification
if (event.type === 'checkout.session.completed') {
logStripeSessionData(event.data.object);
}
// 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(` Amount: $${(order.total_amount / 100).toFixed(2)}`);
// (Detailed session data already logged above via logStripeSessionData())
// Save order to NocoDB
await saveOrderToNocoDB(order, session);
// 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}`);
// Update NocoDB with fulfillment data
await updateNocoDBFulfillment(order.id, wrenOrder);
// 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);
});
}
}
/**
* Save order to NocoDB
* @param {Object} order - Local database order object
* @param {Object} session - Stripe session object
*/
async function saveOrderToNocoDB(order, session) {
// Skip if NocoDB is not configured
if (!nocodbClient.isConfigured()) {
console.log(' NocoDB not configured, skipping database save');
return;
}
try {
console.log(`💾 Saving order ${order.id} to NocoDB...`);
// Map Stripe session data to NocoDB format
const nocodbOrderData = mapStripeSessionToNocoDBOrder(session, order);
// Create record in NocoDB
const response = await nocodbClient.createOrder(nocodbOrderData);
console.log(`✅ Order saved to NocoDB successfully`);
console.log(` NocoDB Record ID: ${response.Id}`);
console.log(` Order ID: ${nocodbOrderData.orderId}`);
console.log(` Customer: ${nocodbOrderData.customerName} (${nocodbOrderData.customerEmail})`);
if (nocodbOrderData.businessName) {
console.log(` Business: ${nocodbOrderData.businessName}`);
}
if (nocodbOrderData.taxIdType && nocodbOrderData.taxIdValue) {
console.log(` Tax ID: ${nocodbOrderData.taxIdType} - ${nocodbOrderData.taxIdValue}`);
}
} catch (error) {
console.error('❌ Failed to save order to NocoDB:', error.message);
console.error(' This is non-fatal - order is still saved locally');
// Don't throw - we don't want to fail the webhook if NocoDB is down
}
}
/**
* Update NocoDB with fulfillment data
* @param {string} orderId - Order ID
* @param {Object} wrenOrder - Wren order response
*/
async function updateNocoDBFulfillment(orderId, wrenOrder) {
// Skip if NocoDB is not configured
if (!nocodbClient.isConfigured()) {
return;
}
try {
console.log(`💾 Updating NocoDB with fulfillment data for order ${orderId}...`);
// Map Wren fulfillment data
const fulfillmentData = mapWrenFulfillmentData(orderId, wrenOrder);
// Update NocoDB record
await nocodbClient.updateOrderFulfillment(orderId, fulfillmentData);
console.log(`✅ NocoDB updated with fulfillment data`);
console.log(` Wren Order ID: ${fulfillmentData.wrenOrderId}`);
console.log(` Status: ${fulfillmentData.status}`);
} catch (error) {
console.error('❌ Failed to update NocoDB with fulfillment:', error.message);
// Non-fatal - don't throw
}
}
export default router;