- 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>
349 lines
13 KiB
JavaScript
349 lines
13 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.getOrderRefunds = exports.createRefund = void 0;
|
|
const https_1 = require("firebase-functions/v2/https");
|
|
const app_1 = require("firebase-admin/app");
|
|
const firestore_1 = require("firebase-admin/firestore");
|
|
const stripe_1 = __importDefault(require("stripe"));
|
|
const uuid_1 = require("uuid");
|
|
// Initialize Firebase Admin if not already initialized
|
|
try {
|
|
(0, app_1.initializeApp)();
|
|
}
|
|
catch (error) {
|
|
// App already initialized
|
|
}
|
|
const db = (0, firestore_1.getFirestore)();
|
|
// Initialize Stripe
|
|
const stripe = new stripe_1.default(process.env.STRIPE_SECRET_KEY || "", {
|
|
apiVersion: "2024-06-20",
|
|
});
|
|
/**
|
|
* Helper function to check user permissions
|
|
*/
|
|
async function checkRefundPermissions(uid, orgId) {
|
|
try {
|
|
// Check if user is super admin
|
|
const userDoc = await db.collection("users").doc(uid).get();
|
|
if (!userDoc.exists) {
|
|
return false;
|
|
}
|
|
const userData = userDoc.data();
|
|
if (userData?.role === "super_admin") {
|
|
return true;
|
|
}
|
|
// Check if user is org admin
|
|
if (userData?.organization?.id === orgId && userData?.role === "admin") {
|
|
return true;
|
|
}
|
|
// TODO: Add territory manager check when territories are implemented
|
|
// if (userData?.role === "territory_manager" && userData?.territories?.includes(orgTerritory)) {
|
|
// return true;
|
|
// }
|
|
return false;
|
|
}
|
|
catch (error) {
|
|
console.error("Error checking refund permissions:", error);
|
|
return false;
|
|
}
|
|
}
|
|
/**
|
|
* 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);
|
|
}
|
|
}
|
|
/**
|
|
* Creates a refund for an order or specific ticket
|
|
*/
|
|
exports.createRefund = (0, https_1.onRequest)({ cors: true, enforceAppCheck: false, region: "us-central1" }, async (req, res) => {
|
|
const startTime = Date.now();
|
|
const action = "create_refund";
|
|
try {
|
|
console.log(`[${action}] Starting refund creation`, {
|
|
method: req.method,
|
|
body: req.body,
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
if (req.method !== "POST") {
|
|
res.status(405).json({ error: "Method not allowed" });
|
|
return;
|
|
}
|
|
const { orderId, ticketId, amountCents, reason } = req.body;
|
|
if (!orderId) {
|
|
res.status(400).json({ error: "orderId is required" });
|
|
return;
|
|
}
|
|
// Get user ID from Authorization header or Firebase Auth token
|
|
// For now, we'll use a mock uid - in production, extract from JWT
|
|
const uid = req.headers.authorization?.replace("Bearer ", "") || "mock-uid";
|
|
// Load order by orderId (sessionId)
|
|
const orderDoc = await db.collection("orders").doc(orderId).get();
|
|
if (!orderDoc.exists) {
|
|
console.error(`[${action}] Order not found: ${orderId}`);
|
|
res.status(404).json({ error: "Order not found" });
|
|
return;
|
|
}
|
|
const orderData = orderDoc.data();
|
|
if (!orderData) {
|
|
res.status(404).json({ error: "Order data not found" });
|
|
return;
|
|
}
|
|
const { orgId, eventId, paymentIntentId, stripeAccountId, totalCents, status } = orderData;
|
|
if (status !== "paid") {
|
|
res.status(400).json({ error: "Can only refund paid orders" });
|
|
return;
|
|
}
|
|
// Check permissions
|
|
const hasPermission = await checkRefundPermissions(uid, orgId);
|
|
if (!hasPermission) {
|
|
console.error(`[${action}] Permission denied for user ${uid} in org ${orgId}`);
|
|
res.status(403).json({ error: "Insufficient permissions" });
|
|
return;
|
|
}
|
|
let refundAmountCents = amountCents;
|
|
let ticketData = null;
|
|
// If ticketId is provided, validate and get ticket price
|
|
if (ticketId) {
|
|
const ticketDoc = await db.collection("tickets").doc(ticketId).get();
|
|
if (!ticketDoc.exists) {
|
|
res.status(404).json({ error: "Ticket not found" });
|
|
return;
|
|
}
|
|
ticketData = ticketDoc.data();
|
|
if (ticketData?.orderId !== orderId) {
|
|
res.status(400).json({ error: "Ticket does not belong to this order" });
|
|
return;
|
|
}
|
|
if (!["issued", "scanned"].includes(ticketData?.status)) {
|
|
res.status(400).json({
|
|
error: `Cannot refund ticket with status: ${ticketData?.status}`
|
|
});
|
|
return;
|
|
}
|
|
// If no amount specified, use ticket type price
|
|
if (!refundAmountCents) {
|
|
const ticketTypeDoc = await db.collection("ticket_types").doc(ticketData.ticketTypeId).get();
|
|
if (ticketTypeDoc.exists) {
|
|
refundAmountCents = ticketTypeDoc.data()?.priceCents || 0;
|
|
}
|
|
}
|
|
}
|
|
// Default to full order amount if no amount specified
|
|
if (!refundAmountCents) {
|
|
refundAmountCents = totalCents;
|
|
}
|
|
// Validate refund amount
|
|
if (refundAmountCents <= 0 || refundAmountCents > totalCents) {
|
|
res.status(400).json({
|
|
error: `Invalid refund amount: ${refundAmountCents}. Must be between 1 and ${totalCents}`
|
|
});
|
|
return;
|
|
}
|
|
// Create idempotency key for refund
|
|
const idempotencyKey = `${orderId}_${ticketId || "full"}_${refundAmountCents}`;
|
|
const refundId = (0, uuid_1.v4)();
|
|
// Create pending refund record for idempotency
|
|
const refundDoc = {
|
|
orgId,
|
|
eventId,
|
|
orderId,
|
|
ticketId,
|
|
amountCents: refundAmountCents,
|
|
reason,
|
|
requestedByUid: uid,
|
|
stripe: {
|
|
paymentIntentId,
|
|
accountId: stripeAccountId,
|
|
},
|
|
status: "pending",
|
|
createdAt: firestore_1.Timestamp.now(),
|
|
};
|
|
// Check for existing refund with same idempotency key
|
|
const existingRefundQuery = await db.collection("refunds")
|
|
.where("orderId", "==", orderId)
|
|
.where("amountCents", "==", refundAmountCents)
|
|
.get();
|
|
if (!existingRefundQuery.empty) {
|
|
const existingRefund = existingRefundQuery.docs[0].data();
|
|
if (existingRefund.ticketId === ticketId) {
|
|
console.log(`[${action}] Duplicate refund request detected`, { idempotencyKey });
|
|
res.status(200).json({
|
|
refundId: existingRefundQuery.docs[0].id,
|
|
status: existingRefund.status,
|
|
message: "Refund already exists"
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
// Create pending refund document
|
|
await db.collection("refunds").doc(refundId).set(refundDoc);
|
|
console.log(`[${action}] Created pending refund record`, { refundId, idempotencyKey });
|
|
try {
|
|
// Create Stripe refund
|
|
console.log(`[${action}] Creating Stripe refund`, {
|
|
paymentIntentId,
|
|
amount: refundAmountCents,
|
|
stripeAccountId,
|
|
});
|
|
const stripeRefund = await stripe.refunds.create({
|
|
payment_intent: paymentIntentId,
|
|
amount: refundAmountCents,
|
|
reason: reason ? "requested_by_customer" : undefined,
|
|
refund_application_fee: true,
|
|
reverse_transfer: true,
|
|
metadata: {
|
|
orderId,
|
|
ticketId: ticketId || "",
|
|
refundId,
|
|
orgId,
|
|
eventId,
|
|
},
|
|
}, {
|
|
stripeAccount: stripeAccountId,
|
|
idempotencyKey,
|
|
});
|
|
console.log(`[${action}] Stripe refund created successfully`, {
|
|
stripeRefundId: stripeRefund.id,
|
|
status: stripeRefund.status,
|
|
});
|
|
// Update refund record and related entities in transaction
|
|
await db.runTransaction(async (transaction) => {
|
|
// Update refund status
|
|
const refundRef = db.collection("refunds").doc(refundId);
|
|
transaction.update(refundRef, {
|
|
"stripe.refundId": stripeRefund.id,
|
|
status: "succeeded",
|
|
updatedAt: firestore_1.Timestamp.now(),
|
|
});
|
|
// Update ticket status if single ticket refund
|
|
if (ticketId) {
|
|
const ticketRef = db.collection("tickets").doc(ticketId);
|
|
transaction.update(ticketRef, {
|
|
status: "refunded",
|
|
updatedAt: firestore_1.Timestamp.now(),
|
|
});
|
|
}
|
|
// Create ledger entries
|
|
// Refund entry (negative)
|
|
await createLedgerEntry({
|
|
orgId,
|
|
eventId,
|
|
orderId,
|
|
type: "refund",
|
|
amountCents: -refundAmountCents,
|
|
currency: "USD",
|
|
stripe: {
|
|
refundId: stripeRefund.id,
|
|
accountId: stripeAccountId,
|
|
},
|
|
}, transaction);
|
|
// Platform fee refund (negative of original platform fee portion)
|
|
const platformFeeBps = parseInt(process.env.PLATFORM_FEE_BPS || "300");
|
|
const platformFeeRefund = Math.round((refundAmountCents * platformFeeBps) / 10000);
|
|
await createLedgerEntry({
|
|
orgId,
|
|
eventId,
|
|
orderId,
|
|
type: "platform_fee",
|
|
amountCents: -platformFeeRefund,
|
|
currency: "USD",
|
|
stripe: {
|
|
refundId: stripeRefund.id,
|
|
accountId: stripeAccountId,
|
|
},
|
|
}, transaction);
|
|
});
|
|
console.log(`[${action}] Refund completed successfully`, {
|
|
refundId,
|
|
stripeRefundId: stripeRefund.id,
|
|
amountCents: refundAmountCents,
|
|
processingTime: Date.now() - startTime,
|
|
});
|
|
res.status(200).json({
|
|
refundId,
|
|
stripeRefundId: stripeRefund.id,
|
|
amountCents: refundAmountCents,
|
|
status: "succeeded",
|
|
});
|
|
}
|
|
catch (stripeError) {
|
|
console.error(`[${action}] Stripe refund failed`, {
|
|
error: stripeError.message,
|
|
code: stripeError.code,
|
|
type: stripeError.type,
|
|
});
|
|
// Update refund status to failed
|
|
await db.collection("refunds").doc(refundId).update({
|
|
status: "failed",
|
|
failureReason: stripeError.message,
|
|
updatedAt: firestore_1.Timestamp.now(),
|
|
});
|
|
res.status(400).json({
|
|
error: "Refund failed",
|
|
details: stripeError.message,
|
|
refundId,
|
|
});
|
|
}
|
|
}
|
|
catch (error) {
|
|
console.error(`[${action}] Unexpected error`, {
|
|
error: error.message,
|
|
stack: error.stack,
|
|
processingTime: Date.now() - startTime,
|
|
});
|
|
res.status(500).json({
|
|
error: "Internal server error",
|
|
details: error.message,
|
|
});
|
|
}
|
|
});
|
|
/**
|
|
* Gets refunds for an order
|
|
*/
|
|
exports.getOrderRefunds = (0, https_1.onRequest)({ cors: true, enforceAppCheck: false, region: "us-central1" }, async (req, res) => {
|
|
try {
|
|
if (req.method !== "POST") {
|
|
res.status(405).json({ error: "Method not allowed" });
|
|
return;
|
|
}
|
|
const { orderId } = req.body;
|
|
if (!orderId) {
|
|
res.status(400).json({ error: "orderId is required" });
|
|
return;
|
|
}
|
|
const refundsSnapshot = await db.collection("refunds")
|
|
.where("orderId", "==", orderId)
|
|
.orderBy("createdAt", "desc")
|
|
.get();
|
|
const refunds = refundsSnapshot.docs.map(doc => ({
|
|
id: doc.id,
|
|
...doc.data(),
|
|
createdAt: doc.data().createdAt.toDate().toISOString(),
|
|
updatedAt: doc.data().updatedAt?.toDate().toISOString(),
|
|
}));
|
|
res.status(200).json({ refunds });
|
|
}
|
|
catch (error) {
|
|
console.error("Error getting order refunds:", error);
|
|
res.status(500).json({
|
|
error: "Internal server error",
|
|
details: error.message,
|
|
});
|
|
}
|
|
});
|
|
// # sourceMappingURL=refunds.js.map
|