import { describe, it, expect } from 'vitest' import { calculateTicketPrice, checkTicketAvailability, validateTicketPurchase, formatPrice, calculateSeriesTicketPrice, checkSeriesTicketAvailability, validateSeriesTicketPurchase, checkUserSeriesPass } from '../../../server/utils/tickets.js' // --------------------------------------------------------------------------- // Helpers — build minimal event / series / member stubs // --------------------------------------------------------------------------- const futureDate = () => new Date(Date.now() + 86400000).toISOString() const pastDate = () => new Date(Date.now() - 86400000).toISOString() const baseMember = (overrides = {}) => ({ email: 'member@example.com', circle: 'community', ...overrides }) const legacyFreeEvent = (overrides = {}) => ({ startDate: futureDate(), registrations: [], ...overrides }) const legacyPaidEvent = (overrides = {}) => ({ startDate: futureDate(), registrations: [], pricing: { paymentRequired: true, isFree: false, publicPrice: 25, currency: 'CAD' }, ...overrides }) const ticketedEvent = (overrides = {}) => ({ startDate: futureDate(), registrations: [], tickets: { enabled: true, currency: 'CAD', capacity: { total: 100 }, member: { available: true, price: 10, isFree: false, name: 'Member Ticket', description: 'For members' }, public: { available: true, price: 30, quantity: 50, sold: 0, reserved: 0, name: 'General Admission', description: 'Public ticket' }, waitlist: { enabled: false }, ...overrides.tickets }, ...overrides }) const ticketedSeries = (overrides = {}) => ({ isActive: true, registrations: [], tickets: { enabled: true, currency: 'CAD', capacity: { total: 50 }, member: { available: true, price: 20, isFree: false, name: 'Member Series Pass', description: 'For members' }, public: { available: true, price: 60, quantity: 30, sold: 0, reserved: 0, name: 'Series Pass', description: 'Public series pass' }, waitlist: { enabled: false }, ...overrides.tickets }, ...overrides }) // =========================================================================== // calculateTicketPrice // =========================================================================== describe('calculateTicketPrice', () => { describe('legacy pricing (tickets not enabled)', () => { it('returns free guest ticket for events without payment requirement', () => { const event = legacyFreeEvent() const result = calculateTicketPrice(event) expect(result).toEqual({ ticketType: 'guest', price: 0, currency: 'CAD', isEarlyBird: false, isFree: true }) }) it('returns free ticket for member on paid event', () => { const event = legacyPaidEvent() const member = baseMember() const result = calculateTicketPrice(event, member) expect(result.ticketType).toBe('member') expect(result.price).toBe(0) expect(result.isFree).toBe(true) }) it('returns public price for non-member on paid event', () => { const event = legacyPaidEvent() const result = calculateTicketPrice(event, null) expect(result.ticketType).toBe('public') expect(result.price).toBe(25) expect(result.isFree).toBe(false) }) it('returns guest/free when pricing.isFree is true even if paymentRequired', () => { const event = legacyFreeEvent({ pricing: { paymentRequired: true, isFree: true, publicPrice: 10 } }) const result = calculateTicketPrice(event) expect(result.ticketType).toBe('guest') expect(result.price).toBe(0) expect(result.isFree).toBe(true) }) }) describe('member ticket pricing', () => { it('returns base member price when isFree is false', () => { const event = ticketedEvent() const member = baseMember() const result = calculateTicketPrice(event, member) expect(result.ticketType).toBe('member') expect(result.price).toBe(10) expect(result.isFree).toBe(false) expect(result.name).toBe('Member Ticket') expect(result.description).toBe('For members') }) it('returns price 0 when member ticket isFree is true', () => { const event = ticketedEvent({ tickets: { enabled: true, currency: 'CAD', member: { available: true, price: 10, isFree: true }, public: { available: true, price: 30 } } }) const member = baseMember() const result = calculateTicketPrice(event, member) expect(result.price).toBe(0) expect(result.isFree).toBe(true) }) it('applies circle override price', () => { const event = ticketedEvent({ tickets: { enabled: true, currency: 'CAD', member: { available: true, price: 10, isFree: false, circleOverrides: { founder: { price: 5 } } }, public: { available: true, price: 30 } } }) const member = baseMember({ circle: 'founder' }) const result = calculateTicketPrice(event, member) expect(result.price).toBe(5) expect(result.isFree).toBe(false) }) it('applies circle override isFree', () => { const event = ticketedEvent({ tickets: { enabled: true, currency: 'CAD', member: { available: true, price: 10, isFree: false, circleOverrides: { practitioner: { isFree: true } } }, public: { available: true, price: 30 } } }) const member = baseMember({ circle: 'practitioner' }) const result = calculateTicketPrice(event, member) expect(result.price).toBe(0) expect(result.isFree).toBe(true) }) it('ignores circle override for a different circle', () => { const event = ticketedEvent({ tickets: { enabled: true, currency: 'CAD', member: { available: true, price: 10, isFree: false, circleOverrides: { practitioner: { isFree: true } } }, public: { available: true, price: 30 } } }) const member = baseMember({ circle: 'community' }) const result = calculateTicketPrice(event, member) expect(result.price).toBe(10) expect(result.isFree).toBe(false) }) }) describe('public ticket pricing', () => { it('returns base public price when no member provided', () => { const event = ticketedEvent() const result = calculateTicketPrice(event, null) expect(result.ticketType).toBe('public') expect(result.price).toBe(30) expect(result.isEarlyBird).toBe(false) expect(result.isFree).toBe(false) expect(result.name).toBe('General Admission') }) it('returns early bird price before deadline', () => { const event = ticketedEvent({ tickets: { enabled: true, currency: 'CAD', member: { available: false }, public: { available: true, price: 30, earlyBirdPrice: 20, earlyBirdDeadline: futureDate() } } }) const result = calculateTicketPrice(event, null) expect(result.price).toBe(20) expect(result.isEarlyBird).toBe(true) expect(result.isFree).toBe(false) }) it('returns regular price after early bird deadline', () => { const event = ticketedEvent({ tickets: { enabled: true, currency: 'CAD', member: { available: false }, public: { available: true, price: 30, earlyBirdPrice: 20, earlyBirdDeadline: pastDate() } } }) const result = calculateTicketPrice(event, null) expect(result.price).toBe(30) expect(result.isEarlyBird).toBe(false) }) it('returns isFree true when public price is 0', () => { const event = ticketedEvent({ tickets: { enabled: true, currency: 'CAD', member: { available: false }, public: { available: true, price: 0 } } }) const result = calculateTicketPrice(event, null) expect(result.price).toBe(0) expect(result.isFree).toBe(true) }) }) describe('no tickets available', () => { it('returns null when member tickets unavailable and no public tickets', () => { const event = ticketedEvent({ tickets: { enabled: true, member: { available: false }, public: { available: false } } }) const result = calculateTicketPrice(event, null) expect(result).toBeNull() }) it('returns null for non-member when only member tickets available', () => { const event = ticketedEvent({ tickets: { enabled: true, currency: 'CAD', member: { available: true, price: 10, isFree: false }, public: { available: false } } }) const result = calculateTicketPrice(event, null) expect(result).toBeNull() }) }) }) // =========================================================================== // checkTicketAvailability // =========================================================================== describe('checkTicketAvailability', () => { describe('legacy (tickets not enabled)', () => { it('returns available with null remaining when no maxAttendees', () => { const event = legacyFreeEvent() const result = checkTicketAvailability(event) expect(result).toEqual({ available: true, remaining: null, waitlistAvailable: false }) }) it('returns available with remaining count when under capacity', () => { const event = legacyFreeEvent({ maxAttendees: 10, registrations: [{ email: 'a@b.com' }, { email: 'c@d.com' }] }) const result = checkTicketAvailability(event) expect(result.available).toBe(true) expect(result.remaining).toBe(8) }) it('returns unavailable at capacity', () => { const event = legacyFreeEvent({ maxAttendees: 2, registrations: [{ email: 'a@b.com' }, { email: 'c@d.com' }] }) const result = checkTicketAvailability(event) expect(result.available).toBe(false) expect(result.remaining).toBe(0) }) }) describe('ticketed events — overall capacity', () => { it('returns unavailable when total capacity exceeded', () => { const event = ticketedEvent() event.tickets.capacity.total = 2 event.registrations = [{ email: 'a@b.com' }, { email: 'c@d.com' }] const result = checkTicketAvailability(event, 'public') expect(result.available).toBe(false) expect(result.remaining).toBe(0) }) it('respects waitlist flag when capacity exceeded', () => { const event = ticketedEvent() event.tickets.capacity.total = 1 event.tickets.waitlist = { enabled: true } event.registrations = [{ email: 'a@b.com' }] const result = checkTicketAvailability(event, 'public') expect(result.available).toBe(false) expect(result.waitlistAvailable).toBe(true) }) }) describe('ticketed events — public tickets', () => { it('returns correct remaining for quantity-limited public tickets', () => { const event = ticketedEvent() event.tickets.public.quantity = 10 event.tickets.public.sold = 3 event.tickets.public.reserved = 2 const result = checkTicketAvailability(event, 'public') expect(result.available).toBe(true) expect(result.remaining).toBe(5) }) it('returns unavailable when public tickets sold out', () => { const event = ticketedEvent() event.tickets.public.quantity = 5 event.tickets.public.sold = 5 event.tickets.public.reserved = 0 const result = checkTicketAvailability(event, 'public') expect(result.available).toBe(false) expect(result.remaining).toBe(0) }) it('returns unlimited when no quantity set on public tickets', () => { const event = ticketedEvent() delete event.tickets.public.quantity const result = checkTicketAvailability(event, 'public') expect(result.available).toBe(true) expect(result.remaining).toBeNull() }) }) describe('ticketed events — member tickets', () => { it('returns available with capacity remaining', () => { const event = ticketedEvent() event.tickets.capacity.total = 10 event.registrations = [{ email: 'a@b.com' }] const result = checkTicketAvailability(event, 'member') expect(result.available).toBe(true) expect(result.remaining).toBe(9) }) it('returns unlimited when no total capacity set', () => { const event = ticketedEvent() delete event.tickets.capacity const result = checkTicketAvailability(event, 'member') expect(result.available).toBe(true) expect(result.remaining).toBeNull() }) }) describe('edge cases', () => { it('returns unavailable for unknown ticket type', () => { const event = ticketedEvent() const result = checkTicketAvailability(event, 'vip') expect(result.available).toBe(false) expect(result.remaining).toBe(0) }) }) }) // =========================================================================== // validateTicketPurchase // =========================================================================== describe('validateTicketPurchase', () => { const validUser = { email: 'user@example.com', name: 'Test User', member: null } const memberUser = { email: 'member@example.com', name: 'Member', member: baseMember() } it('rejects cancelled event', () => { const event = ticketedEvent({ isCancelled: true }) const result = validateTicketPurchase(event, validUser) expect(result.valid).toBe(false) expect(result.reason).toBe('Event has been cancelled') }) it('rejects past event', () => { const event = ticketedEvent({ startDate: pastDate() }) const result = validateTicketPurchase(event, validUser) expect(result.valid).toBe(false) expect(result.reason).toBe('Event has already started') }) it('rejects when registration deadline has passed', () => { const event = ticketedEvent({ registrationDeadline: pastDate() }) const result = validateTicketPurchase(event, validUser) expect(result.valid).toBe(false) expect(result.reason).toBe('Registration deadline has passed') }) it('rejects already registered user', () => { const event = ticketedEvent({ registrations: [{ email: 'user@example.com', cancelledAt: null }] }) const result = validateTicketPurchase(event, validUser) expect(result.valid).toBe(false) expect(result.reason).toBe('You are already registered for this event') }) it('allows user whose previous registration was cancelled', () => { const event = ticketedEvent({ registrations: [{ email: 'user@example.com', cancelledAt: new Date() }] }) const result = validateTicketPurchase(event, validUser) expect(result.valid).toBe(true) }) it('rejects non-member for members-only event', () => { const event = ticketedEvent({ membersOnly: true }) const result = validateTicketPurchase(event, validUser) expect(result.valid).toBe(false) expect(result.reason).toContain('members only') }) it('allows member for members-only event', () => { const event = ticketedEvent({ membersOnly: true }) const result = validateTicketPurchase(event, memberUser) expect(result.valid).toBe(true) expect(result.ticketInfo).toBeDefined() expect(result.availability).toBeDefined() }) it('rejects when no tickets available for user status', () => { const event = ticketedEvent({ tickets: { enabled: true, member: { available: false }, public: { available: false } } }) const result = validateTicketPurchase(event, validUser) expect(result.valid).toBe(false) expect(result.reason).toContain('No tickets available') }) it('rejects when sold out with waitlist info', () => { const event = ticketedEvent() event.tickets.public.quantity = 1 event.tickets.public.sold = 1 event.tickets.waitlist = { enabled: true } const result = validateTicketPurchase(event, validUser) expect(result.valid).toBe(false) expect(result.reason).toBe('Event is sold out') expect(result.waitlistAvailable).toBe(true) }) it('returns valid with ticket info and availability for good purchase', () => { const event = ticketedEvent() const result = validateTicketPurchase(event, validUser) expect(result.valid).toBe(true) expect(result.ticketInfo.ticketType).toBe('public') expect(result.ticketInfo.price).toBe(30) expect(result.availability.available).toBe(true) }) it('is case-insensitive on email match for duplicate check', () => { const event = ticketedEvent({ registrations: [{ email: 'USER@EXAMPLE.COM', cancelledAt: null }] }) const result = validateTicketPurchase(event, validUser) expect(result.valid).toBe(false) expect(result.reason).toBe('You are already registered for this event') }) }) // =========================================================================== // formatPrice // =========================================================================== describe('formatPrice', () => { it('returns "Free" for zero price', () => { expect(formatPrice(0)).toBe('Free') }) it('formats CAD price by default', () => { const result = formatPrice(25) expect(result).toContain('25.00') }) it('formats decimal prices', () => { const result = formatPrice(9.99) expect(result).toContain('9.99') }) it('respects USD currency', () => { const result = formatPrice(25, 'USD') expect(result).toContain('25.00') // US$ or $ prefix depending on locale — just confirm it formats expect(result).toMatch(/\$/) }) }) // =========================================================================== // calculateSeriesTicketPrice // =========================================================================== describe('calculateSeriesTicketPrice', () => { it('returns free guest ticket when tickets not enabled', () => { const series = { tickets: { enabled: false } } const result = calculateSeriesTicketPrice(series) expect(result).toEqual({ ticketType: 'guest', price: 0, currency: 'CAD', isEarlyBird: false, isFree: true }) }) it('returns member price for authenticated member', () => { const series = ticketedSeries() const member = baseMember() const result = calculateSeriesTicketPrice(series, member) expect(result.ticketType).toBe('member') expect(result.price).toBe(20) expect(result.isFree).toBe(false) expect(result.name).toBe('Member Series Pass') }) it('applies circle override to series member price', () => { const series = ticketedSeries({ tickets: { enabled: true, currency: 'CAD', member: { available: true, price: 20, isFree: false, circleOverrides: { founder: { price: 10 } } }, public: { available: true, price: 60 } } }) const member = baseMember({ circle: 'founder' }) const result = calculateSeriesTicketPrice(series, member) expect(result.price).toBe(10) }) it('returns public price for non-member', () => { const series = ticketedSeries() const result = calculateSeriesTicketPrice(series, null) expect(result.ticketType).toBe('public') expect(result.price).toBe(60) }) it('applies early bird pricing before deadline', () => { const series = ticketedSeries({ tickets: { enabled: true, currency: 'CAD', member: { available: false }, public: { available: true, price: 60, earlyBirdPrice: 40, earlyBirdDeadline: futureDate() } } }) const result = calculateSeriesTicketPrice(series, null) expect(result.price).toBe(40) expect(result.isEarlyBird).toBe(true) }) it('returns null when no tickets available for user', () => { const series = ticketedSeries({ tickets: { enabled: true, member: { available: false }, public: { available: false } } }) const result = calculateSeriesTicketPrice(series, null) expect(result).toBeNull() }) }) // =========================================================================== // checkSeriesTicketAvailability // =========================================================================== describe('checkSeriesTicketAvailability', () => { it('returns unavailable when tickets not enabled', () => { const series = { tickets: { enabled: false } } const result = checkSeriesTicketAvailability(series) expect(result.available).toBe(false) }) it('returns available with remaining for public tickets', () => { const series = ticketedSeries() series.tickets.public.quantity = 30 series.tickets.public.sold = 10 series.tickets.public.reserved = 5 const result = checkSeriesTicketAvailability(series, 'public') expect(result.available).toBe(true) expect(result.remaining).toBe(15) }) it('returns unavailable when public tickets sold out', () => { const series = ticketedSeries() series.tickets.public.quantity = 5 series.tickets.public.sold = 5 const result = checkSeriesTicketAvailability(series, 'public') expect(result.available).toBe(false) expect(result.remaining).toBe(0) }) it('returns unavailable when total capacity reached', () => { const series = ticketedSeries() series.tickets.capacity.total = 2 series.registrations = [ { email: 'a@b.com' }, { email: 'c@d.com' } ] const result = checkSeriesTicketAvailability(series, 'member') expect(result.available).toBe(false) expect(result.remaining).toBe(0) }) it('excludes cancelled registrations from count', () => { const series = ticketedSeries() series.tickets.capacity.total = 2 series.registrations = [ { email: 'a@b.com', cancelledAt: null }, { email: 'c@d.com', cancelledAt: new Date() } ] const result = checkSeriesTicketAvailability(series, 'member') expect(result.available).toBe(true) expect(result.remaining).toBe(1) }) it('returns unlimited for member tickets with no capacity', () => { const series = ticketedSeries() delete series.tickets.capacity const result = checkSeriesTicketAvailability(series, 'member') expect(result.available).toBe(true) expect(result.remaining).toBeNull() }) }) // =========================================================================== // validateSeriesTicketPurchase // =========================================================================== describe('validateSeriesTicketPurchase', () => { const validUser = { email: 'user@example.com', name: 'Test User', member: null } const memberUser = { email: 'member@example.com', name: 'Member', member: baseMember() } it('rejects inactive series', () => { const series = ticketedSeries({ isActive: false }) const result = validateSeriesTicketPurchase(series, validUser) expect(result.valid).toBe(false) expect(result.reason).toBe('This series is not currently available') }) it('rejects already registered user', () => { const series = ticketedSeries({ registrations: [{ email: 'user@example.com', cancelledAt: null }] }) const result = validateSeriesTicketPurchase(series, validUser) expect(result.valid).toBe(false) expect(result.reason).toBe('You already have a pass for this series') }) it('allows user whose previous registration was cancelled', () => { const series = ticketedSeries({ registrations: [{ email: 'user@example.com', cancelledAt: new Date() }] }) const result = validateSeriesTicketPurchase(series, validUser) expect(result.valid).toBe(true) }) it('rejects when no tickets available for user status', () => { const series = ticketedSeries({ tickets: { enabled: true, member: { available: false }, public: { available: false } } }) const result = validateSeriesTicketPurchase(series, validUser) expect(result.valid).toBe(false) expect(result.reason).toContain('No series passes available') }) it('rejects when sold out', () => { const series = ticketedSeries() series.tickets.public.quantity = 1 series.tickets.public.sold = 1 series.tickets.waitlist = { enabled: true } const result = validateSeriesTicketPurchase(series, validUser) expect(result.valid).toBe(false) expect(result.reason).toBe('Series passes are sold out') expect(result.waitlistAvailable).toBe(true) }) it('returns valid with ticket info for good purchase', () => { const series = ticketedSeries() const result = validateSeriesTicketPurchase(series, validUser) expect(result.valid).toBe(true) expect(result.ticketInfo.ticketType).toBe('public') expect(result.ticketInfo.price).toBe(60) expect(result.availability.available).toBe(true) }) it('returns member ticket info for authenticated member', () => { const series = ticketedSeries() const result = validateSeriesTicketPurchase(series, memberUser) expect(result.valid).toBe(true) expect(result.ticketInfo.ticketType).toBe('member') expect(result.ticketInfo.price).toBe(20) }) }) // =========================================================================== // checkUserSeriesPass // =========================================================================== describe('checkUserSeriesPass', () => { it('returns hasPass true when user has active registration', () => { const series = { registrations: [ { email: 'user@example.com', cancelledAt: null, paymentStatus: 'completed' } ] } const result = checkUserSeriesPass(series, 'user@example.com') expect(result.hasPass).toBe(true) expect(result.registration).toBeDefined() expect(result.registration.email).toBe('user@example.com') }) it('returns hasPass false when no registration exists', () => { const series = { registrations: [] } const result = checkUserSeriesPass(series, 'user@example.com') expect(result.hasPass).toBe(false) expect(result.registration).toBeNull() }) it('returns hasPass false when registration is cancelled', () => { const series = { registrations: [ { email: 'user@example.com', cancelledAt: new Date(), paymentStatus: 'completed' } ] } const result = checkUserSeriesPass(series, 'user@example.com') expect(result.hasPass).toBe(false) }) it('returns hasPass false when payment failed', () => { const series = { registrations: [ { email: 'user@example.com', cancelledAt: null, paymentStatus: 'failed' } ] } const result = checkUserSeriesPass(series, 'user@example.com') expect(result.hasPass).toBe(false) }) it('is case-insensitive on email', () => { const series = { registrations: [ { email: 'USER@EXAMPLE.COM', cancelledAt: null, paymentStatus: 'completed' } ] } const result = checkUserSeriesPass(series, 'user@example.com') expect(result.hasPass).toBe(true) }) it('handles missing registrations array', () => { const series = {} const result = checkUserSeriesPass(series, 'user@example.com') expect(result.hasPass).toBe(false) expect(result.registration).toBeNull() }) })