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:
499
reactrebuild0825/functions/lib/webhooks.js
Normal file
499
reactrebuild0825/functions/lib/webhooks.js
Normal 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
|
||||
Reference in New Issue
Block a user