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;