feat: add advanced analytics and territory management system

- 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>
This commit is contained in:
2025-08-26 09:25:10 -06:00
parent d5c3953888
commit aa81eb5adb
438 changed files with 90509 additions and 2787 deletions

View File

@@ -0,0 +1,499 @@
"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