ghostguild-org/server/utils/tickets.js
Jennie Robinson Faber 15329e3e84 refactor(events): gate member benefits on hasMemberAccess
Extracts hasMemberAccess(member) in tickets.js and uses it across event
registration, ticket purchase, and series purchase flows so guest, suspended,
and cancelled records no longer count as members while pending_payment still
does.
2026-04-18 17:06:17 +01:00

765 lines
22 KiB
JavaScript

// Ticket business logic utilities
/**
* Whether a Member document confers member-tier access to events.
* Status `active` and `pending_payment` are equivalent for access (payment is
* decoupled from membership). Status `guest`, `suspended`, `cancelled`, or no
* member at all do not confer access.
*/
export const hasMemberAccess = (member) =>
!!member && (member.status === "active" || member.status === "pending_payment");
/**
* 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) => {
// Members without access (guest/suspended/cancelled) get public pricing only.
const accessMember = hasMemberAccess(member) ? member : null;
if (!event.tickets?.enabled) {
// Legacy pricing model
if (event.pricing?.paymentRequired && !event.pricing?.isFree) {
return {
ticketType: accessMember ? "member" : "public",
price: accessMember ? 0 : event.pricing.publicPrice,
currency: event.pricing.currency || "CAD",
isEarlyBird: false,
isFree: accessMember ? true : event.pricing.publicPrice === 0,
};
}
return {
ticketType: "guest",
price: 0,
currency: "CAD",
isEarlyBird: false,
isFree: true,
};
}
const now = new Date();
// Member pricing
if (accessMember && 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 && accessMember.circle) {
const circleOverride = memberTicket.circleOverrides[accessMember.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 && !hasMemberAccess(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({ validateBeforeSave: false });
};
/**
* 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({ validateBeforeSave: false });
};
/**
* 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) => {
// Members without access (guest/suspended/cancelled) get public pricing only.
const accessMember = hasMemberAccess(member) ? member : null;
if (!series.tickets?.enabled) {
return {
ticketType: "guest",
price: 0,
currency: "CAD",
isEarlyBird: false,
isFree: true,
};
}
const now = new Date();
// Member pricing
if (accessMember && 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 && accessMember.circle) {
const circleOverride = memberTicket.circleOverrides[accessMember.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;
};