- Add comprehensive analytics components with export functionality - Implement territory management with manager performance tracking - Add seatmap components for venue layout management - Create customer management features with modal interface - Add advanced hooks for dashboard flags and territory data - Implement seat selection and venue management utilities - Add type definitions for ticketing and seatmap systems 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
827 lines
32 KiB
JavaScript
827 lines
32 KiB
JavaScript
"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
|