// Ticket business logic utilities /** * Calculate the applicable ticket price for a user * @param {Object} event - Event document * @param {Object} member - Member document (null if not a member) * @returns {Object} { ticketType, price, currency, isEarlyBird } */ export const calculateTicketPrice = (event, member = null) => { if (!event.tickets?.enabled) { // Legacy pricing model if (event.pricing?.paymentRequired && !event.pricing?.isFree) { return { ticketType: member ? "member" : "public", price: member ? 0 : event.pricing.publicPrice, currency: event.pricing.currency || "CAD", isEarlyBird: false, isFree: member ? true : event.pricing.publicPrice === 0, }; } return { ticketType: "guest", price: 0, currency: "CAD", isEarlyBird: false, isFree: true, }; } const now = new Date(); // Member pricing if (member && event.tickets.member?.available) { const memberTicket = event.tickets.member; let price = memberTicket.price || 0; let isFree = memberTicket.isFree; // Check for circle-specific overrides if (memberTicket.circleOverrides && member.circle) { const circleOverride = memberTicket.circleOverrides[member.circle]; if (circleOverride) { if (circleOverride.isFree !== undefined) { isFree = circleOverride.isFree; } if (circleOverride.price !== undefined) { price = circleOverride.price; } } } return { ticketType: "member", price: isFree ? 0 : price, currency: event.tickets.currency || "CAD", isEarlyBird: false, isFree, name: memberTicket.name || "Member Ticket", description: memberTicket.description, }; } // Public pricing if (event.tickets.public?.available) { const publicTicket = event.tickets.public; let price = publicTicket.price || 0; let isEarlyBird = false; // Check for early bird pricing if ( publicTicket.earlyBirdPrice !== undefined && publicTicket.earlyBirdDeadline && now < new Date(publicTicket.earlyBirdDeadline) ) { price = publicTicket.earlyBirdPrice; isEarlyBird = true; } return { ticketType: "public", price, currency: event.tickets.currency || "CAD", isEarlyBird, isFree: price === 0, name: publicTicket.name || "Public Ticket", description: publicTicket.description, }; } // No tickets available (members only event, user not a member) return null; }; /** * Check if tickets are available for an event * @param {Object} event - Event document * @param {String} ticketType - 'member' or 'public' * @returns {Object} { available, remaining, waitlistAvailable } */ export const checkTicketAvailability = (event, ticketType = "public") => { if (!event.tickets?.enabled) { // Legacy registration system if (!event.maxAttendees) { return { available: true, remaining: null, waitlistAvailable: false }; } const registered = event.registrations?.length || 0; const remaining = event.maxAttendees - registered; return { available: remaining > 0, remaining: Math.max(0, remaining), waitlistAvailable: event.tickets?.waitlist?.enabled || false, }; } const registered = event.registrations?.length || 0; // Check overall capacity first if (event.tickets.capacity?.total) { const totalRemaining = event.tickets.capacity.total - registered; if (totalRemaining <= 0) { return { available: false, remaining: 0, waitlistAvailable: event.tickets.waitlist?.enabled || false, }; } } // Check ticket-type specific availability if (ticketType === "public" && event.tickets.public?.available) { if (event.tickets.public.quantity) { const sold = event.tickets.public.sold || 0; const reserved = event.tickets.public.reserved || 0; const remaining = event.tickets.public.quantity - sold - reserved; return { available: remaining > 0, remaining: Math.max(0, remaining), waitlistAvailable: event.tickets.waitlist?.enabled || false, }; } // Unlimited public tickets return { available: true, remaining: null, waitlistAvailable: false }; } if (ticketType === "member" && event.tickets.member?.available) { // Members typically have unlimited access unless capacity is reached if (event.tickets.capacity?.total) { const totalRemaining = event.tickets.capacity.total - registered; return { available: totalRemaining > 0, remaining: Math.max(0, totalRemaining), waitlistAvailable: event.tickets.waitlist?.enabled || false, }; } // Unlimited member tickets return { available: true, remaining: null, waitlistAvailable: false }; } return { available: false, remaining: 0, waitlistAvailable: false }; }; /** * Validate if a user can purchase a ticket * @param {Object} event - Event document * @param {Object} user - User data { email, name, member } * @returns {Object} { valid, reason, ticketInfo } */ export const validateTicketPurchase = (event, user) => { // Check if event is cancelled if (event.isCancelled) { return { valid: false, reason: "Event has been cancelled" }; } // Check if event has passed if (new Date(event.startDate) < new Date()) { return { valid: false, reason: "Event has already started" }; } // Check if registration deadline has passed if ( event.registrationDeadline && new Date(event.registrationDeadline) < new Date() ) { return { valid: false, reason: "Registration deadline has passed" }; } // Check if user is already registered const alreadyRegistered = event.registrations?.some( (reg) => reg.email.toLowerCase() === user.email.toLowerCase() && !reg.cancelledAt, ); if (alreadyRegistered) { return { valid: false, reason: "You are already registered for this event", }; } // Check member-only restrictions if (event.membersOnly && !user.member) { return { valid: false, reason: "This event is for members only. Please join to register.", }; } // Calculate ticket price and check availability const ticketInfo = calculateTicketPrice(event, user.member); if (!ticketInfo) { return { valid: false, reason: "No tickets available for your membership status", }; } const availability = checkTicketAvailability(event, ticketInfo.ticketType); if (!availability.available) { return { valid: false, reason: "Event is sold out", waitlistAvailable: availability.waitlistAvailable, }; } return { valid: true, ticketInfo, availability, }; }; /** * Reserve a ticket temporarily during checkout (prevents race conditions) * @param {Object} event - Event document * @param {String} ticketType - 'member' or 'public' * @param {Number} ttl - Time to live in seconds (default 10 minutes) * @returns {Object} { success, reservationId, expiresAt } */ export const reserveTicket = async (event, ticketType, ttl = 600) => { const availability = checkTicketAvailability(event, ticketType); if (!availability.available) { return { success: false, reason: "No tickets available", }; } // Increment reserved count if (ticketType === "public" && event.tickets.public) { event.tickets.public.reserved = (event.tickets.public.reserved || 0) + 1; } if (event.tickets.capacity) { event.tickets.capacity.reserved = (event.tickets.capacity.reserved || 0) + 1; } await event.save(); const expiresAt = new Date(Date.now() + ttl * 1000); return { success: true, reservationId: `${event._id}-${ticketType}-${Date.now()}`, expiresAt, }; }; /** * Release a reserved ticket (if payment fails or user abandons checkout) * @param {Object} event - Event document * @param {String} ticketType - 'member' or 'public' */ export const releaseTicket = async (event, ticketType) => { if (ticketType === "public" && event.tickets.public) { event.tickets.public.reserved = Math.max( 0, (event.tickets.public.reserved || 0) - 1, ); } if (event.tickets.capacity) { event.tickets.capacity.reserved = Math.max( 0, (event.tickets.capacity.reserved || 0) - 1, ); } await event.save(); }; /** * Complete ticket purchase (after successful payment) * @param {Object} event - Event document * @param {String} ticketType - 'member' or 'public' */ export const completeTicketPurchase = async (event, ticketType) => { // Decrement reserved count await releaseTicket(event, ticketType); // Increment sold count for public tickets if (ticketType === "public" && event.tickets.public) { event.tickets.public.sold = (event.tickets.public.sold || 0) + 1; } await event.save(); }; /** * Add user to waitlist * @param {Object} event - Event document * @param {Object} userData - { name, email, membershipLevel } * @returns {Object} { success, position } */ export const addToWaitlist = async (event, userData) => { if (!event.tickets?.waitlist?.enabled) { return { success: false, reason: "Waitlist is not enabled for this event" }; } // Check if already on waitlist const alreadyOnWaitlist = event.tickets.waitlist.entries?.some( (entry) => entry.email.toLowerCase() === userData.email.toLowerCase(), ); if (alreadyOnWaitlist) { return { success: false, reason: "You are already on the waitlist" }; } // Check waitlist capacity const maxSize = event.tickets.waitlist.maxSize; const currentSize = event.tickets.waitlist.entries?.length || 0; if (maxSize && currentSize >= maxSize) { return { success: false, reason: "Waitlist is full" }; } // Add to waitlist if (!event.tickets.waitlist.entries) { event.tickets.waitlist.entries = []; } event.tickets.waitlist.entries.push({ name: userData.name, email: userData.email.toLowerCase(), membershipLevel: userData.membershipLevel || "non-member", addedAt: new Date(), notified: false, }); await event.save(); return { success: true, position: event.tickets.waitlist.entries.length, }; }; /** * Format price for display * @param {Number} price - Price in cents or dollars * @param {String} currency - Currency code (default CAD) * @returns {String} Formatted price string */ export const formatPrice = (price, currency = "CAD") => { if (price === 0) return "Free"; return new Intl.NumberFormat("en-CA", { style: "currency", currency, }).format(price); }; // ============================================================================ // SERIES TICKET FUNCTIONS // ============================================================================ /** * Calculate the applicable series pass price for a user * @param {Object} series - Series document * @param {Object} member - Member document (null if not a member) * @returns {Object} { ticketType, price, currency, isEarlyBird } */ export const calculateSeriesTicketPrice = (series, member = null) => { if (!series.tickets?.enabled) { return { ticketType: "guest", price: 0, currency: "CAD", isEarlyBird: false, isFree: true, }; } const now = new Date(); // Member pricing if (member && series.tickets.member?.available) { const memberTicket = series.tickets.member; let price = memberTicket.price || 0; let isFree = memberTicket.isFree; // Check for circle-specific overrides if (memberTicket.circleOverrides && member.circle) { const circleOverride = memberTicket.circleOverrides[member.circle]; if (circleOverride) { if (circleOverride.isFree !== undefined) { isFree = circleOverride.isFree; } if (circleOverride.price !== undefined) { price = circleOverride.price; } } } return { ticketType: "member", price: isFree ? 0 : price, currency: series.tickets.currency || "CAD", isEarlyBird: false, isFree, name: memberTicket.name || "Member Series Pass", description: memberTicket.description, }; } // Public pricing if (series.tickets.public?.available) { const publicTicket = series.tickets.public; let price = publicTicket.price || 0; let isEarlyBird = false; // Check for early bird pricing if ( publicTicket.earlyBirdPrice !== undefined && publicTicket.earlyBirdDeadline && now < new Date(publicTicket.earlyBirdDeadline) ) { price = publicTicket.earlyBirdPrice; isEarlyBird = true; } return { ticketType: "public", price, currency: series.tickets.currency || "CAD", isEarlyBird, isFree: price === 0, name: publicTicket.name || "Series Pass", description: publicTicket.description, }; } // No tickets available return null; }; /** * Check if series passes are available * @param {Object} series - Series document * @param {String} ticketType - 'member' or 'public' * @returns {Object} { available, remaining, waitlistAvailable } */ export const checkSeriesTicketAvailability = ( series, ticketType = "public", ) => { if (!series.tickets?.enabled) { return { available: false, remaining: 0, waitlistAvailable: false }; } const registered = series.registrations?.filter((r) => !r.cancelledAt).length || 0; // Check overall capacity first if (series.tickets.capacity?.total) { const totalRemaining = series.tickets.capacity.total - registered; if (totalRemaining <= 0) { return { available: false, remaining: 0, waitlistAvailable: series.tickets.waitlist?.enabled || false, }; } } // Check ticket-type specific availability if (ticketType === "public" && series.tickets.public?.available) { if (series.tickets.public.quantity) { const sold = series.tickets.public.sold || 0; const reserved = series.tickets.public.reserved || 0; const remaining = series.tickets.public.quantity - sold - reserved; return { available: remaining > 0, remaining: Math.max(0, remaining), waitlistAvailable: series.tickets.waitlist?.enabled || false, }; } // Unlimited public tickets return { available: true, remaining: null, waitlistAvailable: false }; } if (ticketType === "member" && series.tickets.member?.available) { // Members typically have unlimited access unless capacity is reached if (series.tickets.capacity?.total) { const totalRemaining = series.tickets.capacity.total - registered; return { available: totalRemaining > 0, remaining: Math.max(0, totalRemaining), waitlistAvailable: series.tickets.waitlist?.enabled || false, }; } // Unlimited member tickets return { available: true, remaining: null, waitlistAvailable: false }; } return { available: false, remaining: 0, waitlistAvailable: false }; }; /** * Validate if a user can purchase a series pass * @param {Object} series - Series document * @param {Object} user - User data { email, name, member } * @returns {Object} { valid, reason, ticketInfo } */ export const validateSeriesTicketPurchase = (series, user) => { // Check if series is active if (!series.isActive) { return { valid: false, reason: "This series is not currently available" }; } // Check if user is already registered for the series const alreadyRegistered = series.registrations?.some( (reg) => reg.email.toLowerCase() === user.email.toLowerCase() && !reg.cancelledAt, ); if (alreadyRegistered) { return { valid: false, reason: "You already have a pass for this series" }; } // Calculate ticket price and check availability const ticketInfo = calculateSeriesTicketPrice(series, user.member); if (!ticketInfo) { return { valid: false, reason: "No series passes available for your membership status", }; } const availability = checkSeriesTicketAvailability( series, ticketInfo.ticketType, ); if (!availability.available) { return { valid: false, reason: "Series passes are sold out", waitlistAvailable: availability.waitlistAvailable, }; } return { valid: true, ticketInfo, availability, }; }; /** * Reserve a series pass temporarily during checkout * @param {Object} series - Series document * @param {String} ticketType - 'member' or 'public' * @param {Number} ttl - Time to live in seconds (default 10 minutes) * @returns {Object} { success, reservationId, expiresAt } */ export const reserveSeriesTicket = async (series, ticketType, ttl = 600) => { const availability = checkSeriesTicketAvailability(series, ticketType); if (!availability.available) { return { success: false, reason: "No series passes available", }; } // Increment reserved count if (ticketType === "public" && series.tickets.public) { series.tickets.public.reserved = (series.tickets.public.reserved || 0) + 1; } if (series.tickets.capacity) { series.tickets.capacity.reserved = (series.tickets.capacity.reserved || 0) + 1; } await series.save(); const expiresAt = new Date(Date.now() + ttl * 1000); return { success: true, reservationId: `${series._id}-${ticketType}-${Date.now()}`, expiresAt, }; }; /** * Release a reserved series pass * @param {Object} series - Series document * @param {String} ticketType - 'member' or 'public' */ export const releaseSeriesTicket = async (series, ticketType) => { if (ticketType === "public" && series.tickets.public) { series.tickets.public.reserved = Math.max( 0, (series.tickets.public.reserved || 0) - 1, ); } if (series.tickets.capacity) { series.tickets.capacity.reserved = Math.max( 0, (series.tickets.capacity.reserved || 0) - 1, ); } await series.save(); }; /** * Complete series pass purchase (after successful payment) * @param {Object} series - Series document * @param {String} ticketType - 'member' or 'public' */ export const completeSeriesTicketPurchase = async (series, ticketType) => { // Decrement reserved count await releaseSeriesTicket(series, ticketType); // Increment sold count for public tickets if (ticketType === "public" && series.tickets.public) { series.tickets.public.sold = (series.tickets.public.sold || 0) + 1; } await series.save(); }; /** * Check if a user has a valid series pass for a series * @param {Object} series - Series document * @param {String} userEmail - User email to check * @returns {Object} { hasPass, registration } */ export const checkUserSeriesPass = (series, userEmail) => { const registration = series.registrations?.find( (reg) => reg.email.toLowerCase() === userEmail.toLowerCase() && !reg.cancelledAt && reg.paymentStatus !== "failed", ); return { hasPass: !!registration, registration: registration || null, }; }; /** * Register user for all events in a series using their series pass * @param {Object} series - Series document * @param {Array} events - Array of event documents in the series * @param {Object} seriesRegistration - The series pass registration * @returns {Array} Array of event registration results */ export const registerForAllSeriesEvents = async ( series, events, seriesRegistration, ) => { const results = []; for (const event of events) { try { // Check if already registered for this event const alreadyRegistered = event.registrations?.some( (reg) => reg.email.toLowerCase() === seriesRegistration.email.toLowerCase() && !reg.cancelledAt, ); if (alreadyRegistered) { results.push({ eventId: event._id, success: false, reason: "Already registered", }); continue; } // Add registration to event event.registrations.push({ memberId: seriesRegistration.memberId, name: seriesRegistration.name, email: seriesRegistration.email, membershipLevel: seriesRegistration.membershipLevel, isMember: seriesRegistration.isMember, ticketType: "series_pass", ticketPrice: 0, // Already paid at series level isSeriesTicketHolder: true, seriesTicketId: series._id, paymentStatus: "not_required", // Paid at series level registeredAt: new Date(), }); await event.save(); // Update series registration with event reference const eventReg = event.registrations[event.registrations.length - 1]; seriesRegistration.eventRegistrations.push({ eventId: event._id, registrationId: eventReg._id, }); results.push({ eventId: event._id, success: true, registrationId: eventReg._id, }); } catch (error) { results.push({ eventId: event._id, success: false, reason: error.message, }); } } // Save series with updated event registrations await series.save(); return results; };