"use strict"; const __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.stripeWebhookConnect = void 0; const https_1 = require("firebase-functions/v2/https"); const firebase_functions_1 = require("firebase-functions"); const firestore_1 = require("firebase-admin/firestore"); const stripe_1 = __importDefault(require("stripe")); const uuid_1 = require("uuid"); const email_1 = require("./email"); const disputes_1 = require("./disputes"); const stripe = new stripe_1.default(process.env.STRIPE_SECRET_KEY, { apiVersion: "2024-11-20.acacia", }); const db = (0, firestore_1.getFirestore)(); const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET_CONNECT; const APP_URL = process.env.APP_URL || "https://staging.blackcanyontickets.com"; const isDev = process.env.NODE_ENV !== "production"; /** * Helper function to create ledger entry */ async function createLedgerEntry(entry, transaction) { const ledgerEntry = { ...entry, createdAt: firestore_1.Timestamp.now(), }; const entryId = (0, uuid_1.v4)(); const docRef = db.collection("ledger").doc(entryId); if (transaction) { transaction.set(docRef, ledgerEntry); } else { await docRef.set(ledgerEntry); } } /** * Handles Stripe webhooks from connected accounts * POST /api/stripe/webhook/connect */ exports.stripeWebhookConnect = (0, https_1.onRequest)({ cors: false, enforceAppCheck: false, region: "us-central1", }, async (req, res) => { if (req.method !== "POST") { res.status(405).json({ error: "Method not allowed" }); return; } const sig = req.headers["stripe-signature"]; let event; try { // Verify webhook signature event = stripe.webhooks.constructEvent(req.rawBody || req.body, sig, webhookSecret); } catch (error) { firebase_functions_1.logger.error("Webhook signature verification failed", { error: error instanceof Error ? error.message : String(error), }); res.status(400).json({ error: "Invalid signature" }); return; } firebase_functions_1.logger.info("Received webhook event", { type: event.type, id: event.id, account: event.account, }); try { // Handle different event types if (event.type === "checkout.session.completed") { await handleCheckoutCompleted(event); } else if (event.type === "charge.dispute.created") { await (0, disputes_1.handleDisputeCreated)(event.data.object, event.account); } else if (event.type === "charge.dispute.closed") { await (0, disputes_1.handleDisputeClosed)(event.data.object, event.account); } else if (event.type === "refund.created") { await handleRefundCreated(event); } res.status(200).json({ received: true }); } catch (error) { firebase_functions_1.logger.error("Error processing webhook", { eventType: event.type, eventId: event.id, account: event.account, error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, }); // Always return 200 to prevent Stripe retries on our internal errors res.status(200).json({ received: true, error: "Internal processing error" }); } }); /** * Handles checkout.session.completed events with idempotency and inventory safety */ async function handleCheckoutCompleted(event) { const session = event.data.object; const sessionId = session.id; const paymentIntentId = session.payment_intent; const stripeAccountId = event.account; firebase_functions_1.logger.info("Processing checkout completion", { sessionId, paymentIntentId, stripeAccountId, metadata: session.metadata, }); // Extract metadata const { orgId, eventId, ticketTypeId, qty: qtyStr, purchaserEmail } = session.metadata || {}; if (!orgId || !eventId || !ticketTypeId || !qtyStr) { firebase_functions_1.logger.error("Missing required metadata in session", { sessionId, metadata: session.metadata, }); return; } const qty = parseInt(qtyStr); if (isNaN(qty) || qty <= 0) { firebase_functions_1.logger.error("Invalid quantity in session metadata", { sessionId, qtyStr, }); return; } // IDEMPOTENCY CHECK: Try to create processed session document const processedSessionRef = db.collection("processedSessions").doc(sessionId); try { await db.runTransaction(async (transaction) => { const processedDoc = await transaction.get(processedSessionRef); if (processedDoc.exists) { firebase_functions_1.logger.info("Session already processed, skipping", { sessionId }); return; } // Mark as processed first to ensure idempotency transaction.set(processedSessionRef, { sessionId, processedAt: new Date(), orgId, eventId, ticketTypeId, qty, paymentIntentId, stripeAccountId, }); // INVENTORY TRANSACTION: Safely decrement inventory const ticketTypeRef = db.collection("ticket_types").doc(ticketTypeId); const ticketTypeDoc = await transaction.get(ticketTypeRef); if (!ticketTypeDoc.exists) { throw new Error(`Ticket type ${ticketTypeId} not found`); } const ticketTypeData = ticketTypeDoc.data(); const currentInventory = ticketTypeData.inventory || 0; const currentSold = ticketTypeData.sold || 0; const available = currentInventory - currentSold; firebase_functions_1.logger.info("Inventory check", { sessionId, ticketTypeId, currentInventory, currentSold, available, requestedQty: qty, }); if (available < qty) { // Mark order as failed due to sold out const orderRef = db.collection("orders").doc(sessionId); transaction.update(orderRef, { status: "failed_sold_out", failureReason: `Not enough tickets available. Requested: ${qty}, Available: ${available}`, updatedAt: new Date(), }); firebase_functions_1.logger.error("Insufficient inventory for completed checkout", { sessionId, available, requested: qty, }); return; } // Update inventory atomically transaction.update(ticketTypeRef, { sold: currentSold + qty, updatedAt: new Date(), }); // Create tickets const tickets = []; const ticketEmailData = []; for (let i = 0; i < qty; i++) { const ticketId = (0, uuid_1.v4)(); const qr = (0, uuid_1.v4)(); const ticketData = { orgId, eventId, ticketTypeId, orderId: sessionId, purchaserEmail: purchaserEmail || session.customer_email || "", qr, status: "issued", createdAt: new Date(), scannedAt: null, }; tickets.push(ticketData); ticketEmailData.push({ ticketId, qr, eventName: "", ticketTypeName: "", startAt: "", }); const ticketRef = db.collection("tickets").doc(ticketId); transaction.set(ticketRef, ticketData); } // Update order status const orderRef = db.collection("orders").doc(sessionId); transaction.update(orderRef, { status: "paid", paymentIntentId, updatedAt: new Date(), }); firebase_functions_1.logger.info("Transaction completed successfully", { sessionId, ticketsCreated: tickets.length, inventoryUpdated: true, }); }); // Create ledger entries after successful transaction (outside transaction) await createLedgerEntriesForSale(sessionId, stripeAccountId, paymentIntentId, orgId, eventId); // Send confirmation email (outside transaction) await sendConfirmationEmail(sessionId, orgId, eventId, ticketTypeId, qty); } catch (error) { firebase_functions_1.logger.error("Transaction failed", { sessionId, error: error instanceof Error ? error.message : String(error), }); // Don't re-throw to prevent webhook retries } } /** * Creates ledger entries for a completed sale, including fee capture */ async function createLedgerEntriesForSale(sessionId, stripeAccountId, paymentIntentId, orgId, eventId) { try { firebase_functions_1.logger.info("Creating ledger entries for sale", { sessionId, paymentIntentId, stripeAccountId, }); // Get the payment intent to access the charge const paymentIntent = await stripe.paymentIntents.retrieve(paymentIntentId, { stripeAccount: stripeAccountId, }); if (!paymentIntent.latest_charge) { firebase_functions_1.logger.error("No charge found for payment intent", { paymentIntentId }); return; } // Get the charge to access balance transaction const charge = await stripe.charges.retrieve(paymentIntent.latest_charge, { stripeAccount: stripeAccountId, }); if (!charge.balance_transaction) { firebase_functions_1.logger.error("No balance transaction found for charge", { chargeId: charge.id }); return; } // Get balance transaction details for fee information const balanceTransaction = await stripe.balanceTransactions.retrieve(charge.balance_transaction, { stripeAccount: stripeAccountId }); const totalAmount = paymentIntent.amount; const stripeFee = balanceTransaction.fee; const applicationFeeAmount = paymentIntent.application_fee_amount || 0; firebase_functions_1.logger.info("Fee details captured", { sessionId, totalAmount, stripeFee, applicationFeeAmount, balanceTransactionId: balanceTransaction.id, }); // Create sale ledger entry (positive) await createLedgerEntry({ orgId, eventId, orderId: sessionId, type: "sale", amountCents: totalAmount, currency: "USD", stripe: { balanceTxnId: balanceTransaction.id, chargeId: charge.id, accountId: stripeAccountId, }, meta: { paymentIntentId, }, }); // Create platform fee entry (positive for platform) if (applicationFeeAmount > 0) { await createLedgerEntry({ orgId, eventId, orderId: sessionId, type: "platform_fee", amountCents: applicationFeeAmount, currency: "USD", stripe: { balanceTxnId: balanceTransaction.id, chargeId: charge.id, accountId: stripeAccountId, }, }); } // Create Stripe fee entry (negative for organizer) if (stripeFee > 0) { await createLedgerEntry({ orgId, eventId, orderId: sessionId, type: "fee", amountCents: -stripeFee, currency: "USD", stripe: { balanceTxnId: balanceTransaction.id, chargeId: charge.id, accountId: stripeAccountId, }, }); } firebase_functions_1.logger.info("Ledger entries created successfully", { sessionId, totalAmount, stripeFee, applicationFeeAmount, }); } catch (error) { firebase_functions_1.logger.error("Failed to create ledger entries for sale", { sessionId, error: error instanceof Error ? error.message : String(error), }); } } /** * Handles refund.created webhook events */ async function handleRefundCreated(event) { const refund = event.data.object; const stripeAccountId = event.account; firebase_functions_1.logger.info("Processing refund created webhook", { refundId: refund.id, amount: refund.amount, chargeId: refund.charge, stripeAccountId, }); try { // Get charge details to find payment intent const charge = await stripe.charges.retrieve(refund.charge, { stripeAccount: stripeAccountId, }); const paymentIntentId = charge.payment_intent; // Find the order by payment intent const ordersSnapshot = await db.collection("orders") .where("paymentIntentId", "==", paymentIntentId) .limit(1) .get(); if (ordersSnapshot.empty) { firebase_functions_1.logger.error("Order not found for refund webhook", { refundId: refund.id, paymentIntentId, }); return; } const orderDoc = ordersSnapshot.docs[0]; const orderData = orderDoc.data(); const { orgId, eventId } = orderData; // Get refund balance transaction for fee details let refundFee = 0; if (refund.balance_transaction) { const refundBalanceTransaction = await stripe.balanceTransactions.retrieve(refund.balance_transaction, { stripeAccount: stripeAccountId }); refundFee = refundBalanceTransaction.fee; } // Create refund ledger entry (negative) await createLedgerEntry({ orgId, eventId, orderId: orderDoc.id, type: "refund", amountCents: -refund.amount, currency: "USD", stripe: { balanceTxnId: refund.balance_transaction, chargeId: charge.id, refundId: refund.id, accountId: stripeAccountId, }, }); // Create refund fee entry if applicable (negative) if (refundFee > 0) { await createLedgerEntry({ orgId, eventId, orderId: orderDoc.id, type: "fee", amountCents: -refundFee, currency: "USD", stripe: { balanceTxnId: refund.balance_transaction, refundId: refund.id, accountId: stripeAccountId, }, meta: { reason: "refund_fee", }, }); } firebase_functions_1.logger.info("Refund ledger entries created", { refundId: refund.id, orderId: orderDoc.id, refundAmount: refund.amount, refundFee, }); } catch (error) { firebase_functions_1.logger.error("Failed to process refund webhook", { refundId: refund.id, error: error instanceof Error ? error.message : String(error), }); } } /** * Sends confirmation email with ticket details */ async function sendConfirmationEmail(sessionId, orgId, eventId, ticketTypeId, qty) { try { // Get email details const [orderDoc, eventDoc, ticketTypeDoc, orgDoc] = await Promise.all([ db.collection("orders").doc(sessionId).get(), db.collection("events").doc(eventId).get(), db.collection("ticket_types").doc(ticketTypeId).get(), db.collection("orgs").doc(orgId).get(), ]); if (!orderDoc.exists || !eventDoc.exists || !ticketTypeDoc.exists) { firebase_functions_1.logger.error("Missing documents for email", { sessionId, orderExists: orderDoc.exists, eventExists: eventDoc.exists, ticketTypeExists: ticketTypeDoc.exists, }); return; } const orderData = orderDoc.data(); const eventData = eventDoc.data(); const ticketTypeData = ticketTypeDoc.data(); const orgData = orgDoc.exists ? orgDoc.data() : null; const {purchaserEmail} = orderData; if (!purchaserEmail) { firebase_functions_1.logger.warn("No purchaser email for order", { sessionId }); return; } // Get tickets for this order const ticketsSnapshot = await db .collection("tickets") .where("orderId", "==", sessionId) .get(); const ticketEmailData = ticketsSnapshot.docs.map((doc) => { const data = doc.data(); return { ticketId: doc.id, qr: data.qr, eventName: eventData.name, ticketTypeName: ticketTypeData.name, startAt: eventData.startAt?.toDate?.()?.toISOString() || eventData.startAt, }; }); const emailOptions = { to: purchaserEmail, eventName: eventData.name, tickets: ticketEmailData, organizationName: orgData?.name || "Black Canyon Tickets", }; if (isDev) { await (0, email_1.logTicketEmail)(emailOptions); } else { await (0, email_1.sendTicketEmail)(emailOptions); } firebase_functions_1.logger.info("Confirmation email sent", { sessionId, to: purchaserEmail, ticketCount: ticketEmailData.length, }); } catch (error) { firebase_functions_1.logger.error("Failed to send confirmation email", { sessionId, error: error instanceof Error ? error.message : String(error), }); } } // # sourceMappingURL=webhooks.js.map