"use strict"; const __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.stripeConnectWebhook = exports.createStripeCheckout = exports.stripeWebhook = exports.stripeConnectStatus = exports.stripeConnectStart = exports.stripeRefund = exports.stripe = void 0; const https_1 = require("firebase-functions/v2/https"); const firestore_1 = require("firebase-admin/firestore"); const stripe_1 = __importDefault(require("stripe")); const firestore_2 = require("firebase-admin/firestore"); // Initialize Stripe with secret key exports.stripe = new stripe_1.default(process.env.STRIPE_SECRET_KEY, { apiVersion: "2024-06-20", }); const db = (0, firestore_1.getFirestore)(); // Platform fee configuration const PLATFORM_FEE_BPS = parseInt(process.env.PLATFORM_FEE_BPS || "300"); // Default 3% const PLATFORM_FEE_FIXED = parseInt(process.env.PLATFORM_FEE_FIXED || "30"); // Default $0.30 function logWithContext(level, message, context) { const logData = { timestamp: new Date().toISOString(), level, message, ...context }; console.log(JSON.stringify(logData)); } // Helper function to validate request function validateApiRequest(req, allowedMethods) { if (!allowedMethods.includes(req.method)) { return false; } return true; } // Helper function to get app URL from environment function getAppUrl() { return process.env.APP_URL || "http://localhost:5173"; } /** * POST /api/stripe/connect/start * Starts the Stripe Connect onboarding flow for an organization */ /** * POST /api/stripe/refund * Process refunds for tickets with proper organization validation */ exports.stripeRefund = (0, https_1.onRequest)({ cors: { origin: [getAppUrl(), "http://localhost:5173", "https://localhost:5173"], methods: ["POST"], allowedHeaders: ["Content-Type", "Authorization"], }, }, async (req, res) => { try { if (!validateApiRequest(req, ["POST"])) { res.status(405).json({ error: "Method not allowed" }); return; } const { orgId, sessionId, paymentIntentId, amount, reason = "requested_by_customer" } = req.body; if (!orgId || (!sessionId && !paymentIntentId)) { res.status(400).json({ error: "Missing required fields: orgId and (sessionId or paymentIntentId)" }); return; } logWithContext('info', 'Processing refund request', { action: 'refund_start', orgId, sessionId, paymentIntentId, amount, reason }); // Get organization to verify connected account const orgRef = db.collection("orgs").doc(orgId); const orgDoc = await orgRef.get(); if (!orgDoc.exists) { res.status(404).json({ error: "Organization not found" }); return; } const orgData = orgDoc.data(); const accountId = orgData?.payment?.stripe?.accountId; if (!accountId) { res.status(400).json({ error: "Organization does not have a connected Stripe account" }); return; } // Find the order to validate ownership and get payment details let orderQuery = db.collection("orders").where("orgId", "==", orgId); if (sessionId) { orderQuery = orderQuery.where("stripeSessionId", "==", sessionId); } else { orderQuery = orderQuery.where("metadata.paymentIntentId", "==", paymentIntentId); } const orderDocs = await orderQuery.get(); if (orderDocs.empty) { res.status(404).json({ error: "Order not found for this organization" }); return; } const orderDoc = orderDocs.docs[0]; const orderData = orderDoc.data(); // Determine payment intent ID and refund amount const finalPaymentIntentId = paymentIntentId || orderData.metadata?.paymentIntentId; const refundAmount = amount || orderData.totalAmount; if (!finalPaymentIntentId) { res.status(400).json({ error: "Could not determine payment intent ID" }); return; } // Create refund with connected account context const refund = await exports.stripe.refunds.create({ payment_intent: finalPaymentIntentId, amount: refundAmount, reason, metadata: { orderId: orderData.id, orgId, eventId: orderData.eventId, refundedBy: "api" // Could be enhanced with user info } }, { stripeAccount: accountId }); // Update order status await orderDoc.ref.update({ status: refundAmount >= orderData.totalAmount ? "refunded" : "partially_refunded", refunds: firestore_2.FieldValue.arrayUnion({ refundId: refund.id, amount: refundAmount, reason, createdAt: new Date().toISOString() }) }); // Update ticket statuses if full refund if (refundAmount >= orderData.totalAmount && orderData.ticketIds) { const batch = db.batch(); orderData.ticketIds.forEach((ticketId) => { const ticketRef = db.collection("tickets").doc(ticketId); batch.update(ticketRef, { status: "refunded" }); }); await batch.commit(); } logWithContext('info', 'Refund processed successfully', { action: 'refund_success', refundId: refund.id, orgId, orderId: orderData.id, amount: refundAmount, accountId }); const response = { refundId: refund.id, amount: refundAmount, status: refund.status }; res.status(200).json(response); } catch (error) { logWithContext('error', 'Refund processing failed', { action: 'refund_error', error: error instanceof Error ? error.message : 'Unknown error', orgId: req.body.orgId }); res.status(500).json({ error: "Internal server error", details: error instanceof Error ? error.message : "Unknown error", }); } }); exports.stripeConnectStart = (0, https_1.onRequest)({ cors: { origin: [getAppUrl(), "http://localhost:5173", "https://localhost:5173"], methods: ["POST"], allowedHeaders: ["Content-Type", "Authorization"], }, }, async (req, res) => { try { // Validate request method if (!validateApiRequest(req, ["POST"])) { res.status(405).json({ error: "Method not allowed" }); return; } const { orgId, returnTo } = req.body; if (!orgId || typeof orgId !== "string") { res.status(400).json({ error: "orgId is required" }); return; } // Get organization document const orgRef = db.collection("orgs").doc(orgId); const orgDoc = await orgRef.get(); if (!orgDoc.exists) { res.status(404).json({ error: "Organization not found" }); return; } const orgData = orgDoc.data(); let accountId = orgData?.payment?.stripe?.accountId; // Create Stripe account if it doesn't exist if (!accountId) { const account = await exports.stripe.accounts.create({ type: "express", country: "US", // Default to US, can be made configurable email: orgData?.email || undefined, business_profile: { name: orgData?.name || `Organization ${orgId}`, }, }); accountId = account.id; // Save account ID to Firestore await orgRef.update({ "payment.provider": "stripe", "payment.stripe.accountId": accountId, "payment.connected": false, }); } // Create account link for onboarding const baseUrl = getAppUrl(); const returnUrl = returnTo ? `${baseUrl}${returnTo}?status=connected` : `${baseUrl}/org/${orgId}/payments?status=connected`; const refreshUrl = `${baseUrl}/org/${orgId}/payments?status=refresh`; const accountLink = await exports.stripe.accountLinks.create({ account: accountId, refresh_url: refreshUrl, return_url: returnUrl, type: "account_onboarding", }); const response = { url: accountLink.url, }; res.status(200).json(response); } catch (error) { console.error("Error starting Stripe Connect:", error); res.status(500).json({ error: "Internal server error", details: error instanceof Error ? error.message : "Unknown error", }); } }); /** * GET /api/stripe/connect/status?orgId=... * Gets the current Stripe Connect status for an organization */ exports.stripeConnectStatus = (0, https_1.onRequest)({ cors: { origin: [getAppUrl(), "http://localhost:5173", "https://localhost:5173"], methods: ["GET"], allowedHeaders: ["Content-Type", "Authorization"], }, }, async (req, res) => { try { // Validate request method if (!validateApiRequest(req, ["GET"])) { res.status(405).json({ error: "Method not allowed" }); return; } const {orgId} = req.query; if (!orgId || typeof orgId !== "string") { res.status(400).json({ error: "orgId is required" }); return; } // Get organization document const orgRef = db.collection("orgs").doc(orgId); const orgDoc = await orgRef.get(); if (!orgDoc.exists) { res.status(404).json({ error: "Organization not found" }); return; } const orgData = orgDoc.data(); const accountId = orgData?.payment?.stripe?.accountId; if (!accountId) { res.status(404).json({ error: "Stripe account not found for organization" }); return; } // Fetch current account status from Stripe const account = await exports.stripe.accounts.retrieve(accountId); // Update our Firestore document with latest status const paymentData = { provider: "stripe", connected: account.charges_enabled && account.details_submitted, stripe: { accountId: account.id, detailsSubmitted: account.details_submitted, chargesEnabled: account.charges_enabled, businessName: account.business_profile?.name || account.settings?.dashboard?.display_name || "", }, }; await orgRef.update({ payment: paymentData, }); const response = { payment: paymentData, }; res.status(200).json(response); } catch (error) { console.error("Error getting Stripe Connect status:", error); res.status(500).json({ error: "Internal server error", details: error instanceof Error ? error.message : "Unknown error", }); } }); /** * POST /api/stripe/webhook * Handles Stripe platform-level webhooks */ exports.stripeWebhook = (0, https_1.onRequest)({ cors: false, // Webhooks don't need CORS }, async (req, res) => { try { // Validate request method if (!validateApiRequest(req, ["POST"])) { res.status(405).json({ error: "Method not allowed" }); return; } const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET; if (!webhookSecret) { console.error("Missing STRIPE_WEBHOOK_SECRET environment variable"); res.status(500).json({ error: "Webhook secret not configured" }); return; } const sig = req.headers["stripe-signature"]; if (!sig) { res.status(400).json({ error: "Missing stripe-signature header" }); return; } let event; try { // Verify webhook signature event = exports.stripe.webhooks.constructEvent(req.rawBody, sig, webhookSecret); } catch (err) { console.error("Webhook signature verification failed:", err); res.status(400).json({ error: "Invalid signature" }); return; } // Handle the event switch (event.type) { case "account.updated": { const account = event.data.object; // Find the organization with this account ID const orgsQuery = await db.collection("orgs") .where("payment.stripe.accountId", "==", account.id) .get(); if (orgsQuery.empty) { console.warn(`No organization found for account ${account.id}`); break; } // Update each organization (should typically be just one) const batch = db.batch(); orgsQuery.docs.forEach((doc) => { const updateData = { connected: account.charges_enabled && account.details_submitted, stripe: { accountId: account.id, detailsSubmitted: account.details_submitted, chargesEnabled: account.charges_enabled, businessName: account.business_profile?.name || account.settings?.dashboard?.display_name || "", }, }; batch.update(doc.ref, { "payment.connected": updateData.connected, "payment.stripe": updateData.stripe, }); }); await batch.commit(); console.log(`Updated ${orgsQuery.docs.length} organizations for account ${account.id}`); break; } default: console.log(`Unhandled event type: ${event.type}`); } res.status(200).json({ received: true }); } catch (error) { console.error("Error handling webhook:", error); res.status(500).json({ error: "Internal server error", details: error instanceof Error ? error.message : "Unknown error", }); } }); /** * POST /api/stripe/checkout/create * Creates a Stripe Checkout session using the organization's connected account */ exports.createStripeCheckout = (0, https_1.onRequest)({ cors: { origin: [getAppUrl(), "http://localhost:5173", "https://localhost:5173"], methods: ["POST"], allowedHeaders: ["Content-Type", "Authorization"], }, }, async (req, res) => { try { // Validate request method if (!validateApiRequest(req, ["POST"])) { res.status(405).json({ error: "Method not allowed" }); return; } const { orgId, eventId, ticketTypeId, quantity, customerEmail, successUrl, cancelUrl, } = req.body; // Validate required fields if (!orgId || !eventId || !ticketTypeId || !quantity || quantity < 1) { res.status(400).json({ error: "Missing required fields: orgId, eventId, ticketTypeId, quantity" }); return; } // Get organization and verify connected account const orgRef = db.collection("orgs").doc(orgId); const orgDoc = await orgRef.get(); if (!orgDoc.exists) { res.status(404).json({ error: "Organization not found" }); return; } const orgData = orgDoc.data(); const accountId = orgData?.payment?.stripe?.accountId; const isConnected = orgData?.payment?.connected; if (!accountId || !isConnected) { res.status(400).json({ error: "Organization does not have a connected Stripe account" }); return; } // Get event details for pricing and validation const eventRef = db.collection("events").doc(eventId); const eventDoc = await eventRef.get(); if (!eventDoc.exists) { res.status(404).json({ error: "Event not found" }); return; } const eventData = eventDoc.data(); if (eventData?.orgId !== orgId) { res.status(403).json({ error: "Event does not belong to organization" }); return; } // Get ticket type details const ticketTypeRef = db.collection("ticketTypes").doc(ticketTypeId); const ticketTypeDoc = await ticketTypeRef.get(); if (!ticketTypeDoc.exists) { res.status(404).json({ error: "Ticket type not found" }); return; } const ticketTypeData = ticketTypeDoc.data(); if (ticketTypeData?.eventId !== eventId) { res.status(403).json({ error: "Ticket type does not belong to event" }); return; } // Calculate pricing (price is stored in cents) const unitPrice = ticketTypeData.price; // Already in cents const totalAmount = unitPrice * quantity; // Calculate platform fee using configurable rates const platformFee = Math.round(totalAmount * (PLATFORM_FEE_BPS / 10000)) + PLATFORM_FEE_FIXED; logWithContext('info', 'Creating checkout session', { action: 'checkout_create_start', sessionId: 'pending', accountId, orgId, eventId, ticketTypeId, quantity, unitPrice, totalAmount, platformFee }); const baseUrl = getAppUrl(); const defaultSuccessUrl = `${baseUrl}/checkout/success?session_id={CHECKOUT_SESSION_ID}`; const defaultCancelUrl = `${baseUrl}/checkout/cancel`; // Create Stripe Checkout Session with connected account const session = await exports.stripe.checkout.sessions.create({ mode: "payment", payment_method_types: ["card"], line_items: [ { price_data: { currency: "usd", product_data: { name: `${eventData.title} - ${ticketTypeData.name}`, description: `${quantity} x ${ticketTypeData.name} ticket${quantity > 1 ? "s" : ""} for ${eventData.title}`, metadata: { eventId, ticketTypeId, }, }, unit_amount: unitPrice, }, quantity, }, ], success_url: successUrl || defaultSuccessUrl, cancel_url: cancelUrl || defaultCancelUrl, customer_email: customerEmail, payment_intent_data: { application_fee_amount: platformFee, metadata: { orgId, eventId, ticketTypeId, quantity: quantity.toString(), unitPrice: unitPrice.toString(), platformFee: platformFee.toString(), }, }, metadata: { orgId, eventId, ticketTypeId, quantity: quantity.toString(), type: "ticket_purchase", }, }, { stripeAccount: accountId, // Use the connected account }); logWithContext('info', 'Checkout session created successfully', { action: 'checkout_create_success', sessionId: session.id, accountId, orgId, eventId, ticketTypeId, quantity }); const response = { url: session.url, sessionId: session.id, }; res.status(200).json(response); } catch (error) { logWithContext('error', 'Failed to create checkout session', { action: 'checkout_create_error', error: error instanceof Error ? error.message : 'Unknown error', orgId: req.body.orgId, eventId: req.body.eventId, ticketTypeId: req.body.ticketTypeId }); res.status(500).json({ error: "Internal server error", details: error instanceof Error ? error.message : "Unknown error", }); } }); /** * POST /api/stripe/webhook/connect * Handles Stripe Connect webhooks from connected accounts * This endpoint receives events from connected accounts, not the platform */ exports.stripeConnectWebhook = (0, https_1.onRequest)({ cors: false, // Webhooks don't need CORS }, async (req, res) => { try { // Validate request method if (!validateApiRequest(req, ["POST"])) { res.status(405).json({ error: "Method not allowed" }); return; } const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET; if (!webhookSecret) { console.error("Missing STRIPE_WEBHOOK_SECRET environment variable"); res.status(500).json({ error: "Webhook secret not configured" }); return; } const sig = req.headers["stripe-signature"]; if (!sig) { res.status(400).json({ error: "Missing stripe-signature header" }); return; } // Get the connected account ID - check both header and event.account let stripeAccount = req.headers["stripe-account"]; // Parse event first to potentially get account from event data let tempEvent; try { tempEvent = exports.stripe.webhooks.constructEvent(req.rawBody, sig, webhookSecret); // Use event.account if available, fallback to header stripeAccount = tempEvent.account || stripeAccount; } catch (err) { console.error("Initial webhook signature verification failed:", err); res.status(400).json({ error: "Invalid signature" }); return; } if (!stripeAccount) { res.status(400).json({ error: "Missing stripe-account identifier" }); return; } // Use the pre-verified event const event = tempEvent; logWithContext('info', 'Received connect webhook', { action: 'webhook_received', eventType: event.type, accountId: stripeAccount, eventId: event.id }); // Handle the event switch (event.type) { case "checkout.session.completed": { const session = event.data.object; if (session.metadata?.type === "ticket_purchase") { await handleTicketPurchaseCompleted(session, stripeAccount); } break; } case "payment_intent.succeeded": { const paymentIntent = event.data.object; logWithContext('info', 'Payment intent succeeded', { action: 'payment_succeeded', paymentIntentId: paymentIntent.id, accountId: stripeAccount, amount: paymentIntent.amount }); break; } default: logWithContext('info', 'Unhandled webhook event type', { action: 'webhook_unhandled', eventType: event.type, accountId: stripeAccount }); } res.status(200).json({ received: true }); } catch (error) { logWithContext('error', 'Connect webhook processing failed', { action: 'webhook_error', error: error instanceof Error ? error.message : 'Unknown error' }); // Return 200 to Stripe to prevent retries for application errors res.status(200).json({ received: true, error: error instanceof Error ? error.message : "Unknown error", }); } }); /** * Handle completed ticket purchase with idempotency and transactional inventory */ async function handleTicketPurchaseCompleted(session, stripeAccount) { const { orgId, eventId, ticketTypeId, quantity, } = session.metadata; const sessionId = session.id; const quantityNum = parseInt(quantity); logWithContext('info', 'Starting ticket purchase processing', { action: 'ticket_purchase_start', sessionId, accountId: stripeAccount, orgId, eventId, ticketTypeId, quantity: quantityNum }); // Step 1: Idempotency check using processedSessions collection const processedSessionRef = db.collection('processedSessions').doc(sessionId); try { await db.runTransaction(async (transaction) => { // Check if session already processed const processedDoc = await transaction.get(processedSessionRef); if (processedDoc.exists) { logWithContext('warn', 'Session already processed - skipping', { action: 'idempotency_skip', sessionId, accountId: stripeAccount, orgId, eventId, ticketTypeId }); return; // Exit early - session already processed } // Mark session as processing (prevents concurrent processing) transaction.set(processedSessionRef, { sessionId, orgId, eventId, ticketTypeId, quantity: quantityNum, stripeAccount, processedAt: new Date().toISOString(), status: 'processing' }); // Step 2: Transactional inventory check and update const ticketTypeRef = db.collection('ticketTypes').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; // Check for overselling if (currentInventory < quantityNum) { logWithContext('error', 'Insufficient inventory - sold out', { action: 'inventory_sold_out', sessionId, accountId: stripeAccount, orgId, eventId, ticketTypeId, requestedQuantity: quantityNum, availableInventory: currentInventory }); throw new Error('SOLD_OUT'); } // Update inventory atomically transaction.update(ticketTypeRef, { inventory: currentInventory - quantityNum, sold: currentSold + quantityNum, lastSaleDate: new Date().toISOString() }); // Step 3: Generate and save tickets const customerEmail = session.customer_details?.email || session.customer_email; if (!customerEmail) { throw new Error('No customer email found in session'); } const tickets = []; const ticketIds = []; for (let i = 0; i < quantityNum; i++) { // Use crypto-strong ticket ID generation const ticketId = `ticket_${Date.now()}_${Math.random().toString(36).substr(2, 12)}_${i}`; ticketIds.push(ticketId); const ticket = { id: ticketId, eventId, ticketTypeId, orgId, customerEmail, customerName: session.customer_details?.name || '', purchaseDate: new Date().toISOString(), status: 'active', qrCode: ticketId, // Use ticket ID as QR code stripeSessionId: sessionId, stripeAccount, metadata: { paymentIntentId: session.payment_intent, amountPaid: session.amount_total, currency: session.currency } }; tickets.push(ticket); // Add ticket to transaction const ticketRef = db.collection('tickets').doc(ticketId); transaction.set(ticketRef, ticket); } // Step 4: Create order record const orderId = `order_${Date.now()}_${Math.random().toString(36).substr(2, 12)}`; const orderRef = db.collection('orders').doc(orderId); transaction.set(orderRef, { id: orderId, orgId, eventId, ticketTypeId, customerEmail, customerName: session.customer_details?.name || '', quantity: quantityNum, totalAmount: session.amount_total, currency: session.currency, status: 'completed', createdAt: new Date().toISOString(), stripeSessionId: sessionId, stripeAccount, ticketIds }); // Step 5: Mark session as completed transaction.update(processedSessionRef, { status: 'completed', orderId, ticketIds, completedAt: new Date().toISOString() }); logWithContext('info', 'Ticket purchase completed successfully', { action: 'ticket_purchase_success', sessionId, accountId: stripeAccount, orgId, eventId, ticketTypeId, quantity: quantityNum, orderId, ticketCount: tickets.length }); // TODO: Send confirmation email with tickets // This would typically use a service like Resend or SendGrid console.log(`Would send confirmation email to ${customerEmail} with ${tickets.length} tickets`); }); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; logWithContext('error', 'Ticket purchase processing failed', { action: 'ticket_purchase_error', sessionId, accountId: stripeAccount, orgId, eventId, ticketTypeId, error: errorMessage }); // For sold out scenario, mark session as failed but don't throw if (errorMessage === 'SOLD_OUT') { try { await processedSessionRef.set({ sessionId, orgId, eventId, ticketTypeId, quantity: quantityNum, stripeAccount, processedAt: new Date().toISOString(), status: 'failed', error: 'SOLD_OUT', failedAt: new Date().toISOString() }); } catch (markError) { logWithContext('error', 'Failed to mark session as failed', { action: 'mark_session_failed_error', sessionId, error: markError instanceof Error ? markError.message : 'Unknown error' }); } return; // Don't throw - webhook should return 200 } throw error; // Re-throw for other errors } } // # sourceMappingURL=stripeConnect.js.map