import type { BookingResponseDto, WaitlistResponseDto, WaitlistStatus } from '@/api/generated/models' import { errorResult, handleServiceError, type ServiceCallOptions, type ServiceResult, shouldBubbleErrorToParent, successResult, } from '@/services/errorHandler' import { unwrapApiData } from '@/services/apiResponse' import { getBookings } from '@/api/generated/bookings/bookings' import { getWaitlist } from '@/api/generated/waitlist/waitlist' import { unwrapApiPayload } from '@/helpers/listResponse' import { ANALYTICS_EVENTS, trackAnalyticsEventOnce } from '@/lib/analytics' import { getEventAttribution } from '@/lib/trafficAttribution' import { texts } from '@/texts' // Shape mirrors the admin `bookingsColumns` fields used for the same // underlying entity in app/(dashboard)/users/[id]/page.tsx (bookingCode, // eventTitle, status, checkedInAt, createdAt) — plus `id`/`eventId`, which // this self-service list needs for keys and the booking-detail modal. // The `event*` fields come from bookingSelect's event relation // (backend/src/modules/bookings/bookings.select.ts) — added alongside this // service so the list + detail views don't need a second round-trip to // /events/:id for title/price/fee. export type MyBooking = BookingResponseDto export type MyWaitlistEntry = WaitlistResponseDto // Waitlist entries still considered "in progress" for this event — mirrors // the DB constraint in waitlist.service.ts (assertNoActiveWaitlist). const ACTIVE_WAITLIST_STATUSES: WaitlistStatus[] = ['waiting', 'notified', 'accepted'] const bookingsApi = getBookings() const waitlistApi = getWaitlist() export const GET_MY_BOOKINGS = async (options?: ServiceCallOptions): Promise> => { try { const res = await bookingsApi.bookingsControllerListMine({ page: 1, pageSize: 100 }) const payload = unwrapApiPayload(res.data) const items = Array.isArray(payload) ? payload : Array.isArray(payload.items) ? payload.items : [] return successResult(items as MyBooking[]) } catch (error) { const normalizedError = handleServiceError(error, options) if (shouldBubbleErrorToParent(options)) { throw normalizedError } return errorResult(normalizedError) } } /** Guest's own active (pending_payment/confirmed) booking for one event, if any — event-booking.md entry checks. */ export const GET_MY_ACTIVE_BOOKING_FOR_EVENT = async ( eventId: string, options?: ServiceCallOptions ): Promise> => { try { // At most one active booking per (user, event); list endpoint still // requires pagination, so pageSize=1 is enough for this lookup. const res = await bookingsApi.bookingsControllerListMine({ filters: { eventId }, pageSize: 1, sort: '-createdAt', }) const payload = unwrapApiPayload(res.data) const items = (Array.isArray(payload) ? payload : Array.isArray(payload.items) ? payload.items : []) as MyBooking[] const latest = items[0] const active = latest && (latest.status === 'pending_payment' || latest.status === 'confirmed') ? latest : null return successResult(active) } catch (error) { const normalizedError = handleServiceError(error, options) if (shouldBubbleErrorToParent(options)) { throw normalizedError } return errorResult(normalizedError) } } /** Guest's own active waitlist entry for one event, if any — event-booking.md Path B. */ export const GET_MY_WAITLIST_ENTRY_FOR_EVENT = async ( eventId: string, options?: ServiceCallOptions ): Promise> => { try { // At most one active waitlist row per (user, event); pageSize=1 is enough. const res = await waitlistApi.waitlistControllerListMine({ filters: { eventId }, pageSize: 1, sort: '-createdAt', }) const payload = unwrapApiPayload(res.data) const items = (Array.isArray(payload) ? payload : Array.isArray(payload.items) ? payload.items : []) as MyWaitlistEntry[] const latest = items[0] const active = latest && ACTIVE_WAITLIST_STATUSES.includes(latest.status) ? latest : null return successResult(active) } catch (error) { const normalizedError = handleServiceError(error, options) if (shouldBubbleErrorToParent(options)) { throw normalizedError } return errorResult(normalizedError) } } /** POST /events/:eventId/bookings — direct reserve path (event-booking.md, Path A). */ export const CREATE_BOOKING = async (eventId: string, options?: ServiceCallOptions): Promise> => { try { const attributionSource = getEventAttribution(eventId) const res = await bookingsApi.bookingsControllerCreate(eventId, { ...(attributionSource ? { attributionSource } : {}), }) const data = res.data.success ? unwrapApiData(res.data) : undefined if (data?.id) { trackAnalyticsEventOnce(ANALYTICS_EVENTS.BOOKING_CREATED, data.id, { booking_id: data.id, event_id: eventId, booking_status: data.status, ...(attributionSource ? { traffic_source: attributionSource } : {}), }) } if (!data) { throw new Error(texts.bookings.emptyServerResponse) } return successResult(data) } catch (error) { const normalizedError = handleServiceError(error, options) if (shouldBubbleErrorToParent(options)) { throw normalizedError } return errorResult(normalizedError) } } /** POST /events/:eventId/waitlist — join waitlist path (event-booking.md, Path B). */ export const JOIN_WAITLIST = async (eventId: string, options?: ServiceCallOptions): Promise> => { try { const attributionSource = getEventAttribution(eventId) const res = await waitlistApi.waitlistControllerJoin(eventId, { ...(attributionSource ? { attributionSource } : {}), }) const data = res.data.success ? unwrapApiData(res.data) : undefined if (!data) { throw new Error(texts.bookings.emptyServerResponse) } return successResult(data) } catch (error) { const normalizedError = handleServiceError(error, options) if (shouldBubbleErrorToParent(options)) { throw normalizedError } return errorResult(normalizedError) } } /** * GET /events/:eventId/waitlist — host-only view of the waiting queue for * their own event, primarily used to hand-pick who gets an opened seat when * `settings.waitlistAutoOffer` is `false`. */ export const GET_EVENT_WAITLIST = async (eventId: string, options?: ServiceCallOptions): Promise> => { try { const res = await waitlistApi.waitlistControllerListForEvent(eventId, { pageSize: 100 }) const payload = unwrapApiPayload(res.data) const items = (Array.isArray(payload) ? payload : Array.isArray(payload.items) ? payload.items : []) as MyWaitlistEntry[] return successResult(items) } catch (error) { const normalizedError = handleServiceError(error, options) if (shouldBubbleErrorToParent(options)) { throw normalizedError } return errorResult(normalizedError) } } /** POST /events/:eventId/waitlist/:entryId/offer — host manually offers a seat to a chosen entry. */ export const OFFER_WAITLIST_SEAT = async ( eventId: string, entryId: string, options?: ServiceCallOptions ): Promise> => { try { const res = await waitlistApi.waitlistControllerOfferToEntry(eventId, entryId) const data = res.data.success ? unwrapApiData(res.data) : undefined if (!data) { throw new Error(texts.bookings.emptyServerResponse) } return successResult(data) } catch (error) { const normalizedError = handleServiceError(error, options) if (shouldBubbleErrorToParent(options)) { throw normalizedError } return errorResult(normalizedError) } } /** PATCH /waitlist/:id/accept — guest accepts a notified offer and receives the pending booking for checkout. */ export const ACCEPT_WAITLIST = async ( entryId: string, options?: ServiceCallOptions ): Promise> => { try { const res = await waitlistApi.waitlistControllerAccept(entryId) const data = res.data.success ? unwrapApiData(res.data) : undefined if (!data?.booking) { throw new Error(texts.bookings.emptyServerResponse) } return successResult({ waitlistEntryId: data.waitlistEntryId, booking: data.booking as MyBooking }) } catch (error) { const normalizedError = handleServiceError(error, options) if (shouldBubbleErrorToParent(options)) { throw normalizedError } return errorResult(normalizedError) } } /** GET /bookings/:id — single booking detail (owner only). */ export const GET_BOOKING = async (id: string, options?: ServiceCallOptions): Promise> => { try { const res = await bookingsApi.bookingsControllerFindOne(id) const data = res.data.success ? unwrapApiData(res.data) : undefined if (!data) { throw new Error(texts.bookings.emptyServerResponse) } return successResult(data) } catch (error) { const normalizedError = handleServiceError(error, options) if (shouldBubbleErrorToParent(options)) { throw normalizedError } return errorResult(normalizedError) } } /** PATCH /bookings/:id/cancel — guest-initiated cancel with mandatory reason (booking-cancellation-refund.md). */ export const CANCEL_BOOKING = async ( id: string, cancellationReason: string, options?: ServiceCallOptions ): Promise> => { try { const res = await bookingsApi.bookingsControllerCancel(id, { cancellationReason }) const data = res.data.success ? unwrapApiData(res.data) : undefined if (!data) { throw new Error(texts.bookings.emptyServerResponse) } trackAnalyticsEventOnce(ANALYTICS_EVENTS.BOOKING_CANCELLED, data.id, { booking_id: data.id, event_id: data.eventId, booking_status: data.status, }) return successResult(data) } catch (error) { const normalizedError = handleServiceError(error, options) if (shouldBubbleErrorToParent(options)) { throw normalizedError } return errorResult(normalizedError) } } /** * Client-side guest-cancel refund preview — booking-cancellation-refund.md * formula: refund = paidGross × (1 - platform commission % - cancellationFeePercent). * Mirrors refund.service.ts#refundGuestCancellation exactly (same * floor() rounding on each component) so this preview never shows a * different number than what's actually credited. * * `ticketPrice` must be the **amount actually paid** (`payments.total_amount` * / `MyBooking.payableAmount`), not the list `eventPrice`. When a discount * code was applied, those diverge — using list price overstates commission, * host fee, and the toast refund. Callers: `payableAmount ?? eventPrice`. * * `commissionPercent` must be the booking's resolved rate * (`MyBooking.eventEffectiveCommissionPercent` — event override if set, else * the platform default; see bookings.service.ts#mapBooking on the backend) * and NOT a locally hardcoded constant: the platform default has changed * before (COMMISSION_PERCENT, backend/src/common/constants/business.constants.ts) * and events can carry their own override, so any constant duplicated here * silently drifts from what actually gets charged. Only meaningful for a * `confirmed` (paid) booking; `pending_payment` has nothing to refund * (never actually paid). */ export interface CancellationPreview { ticketPrice: number platformCommission: number /** The rate actually applied, echoed back so callers can label the commission line without re-touching the booking. */ commissionPercent: number hostCancellationFee: number refundToGuest: number } export const calculateCancellationPreview = ( ticketPrice: number, cancellationFeePercent: number, commissionPercent: number ): CancellationPreview => { const platformCommission = Math.floor(ticketPrice * (commissionPercent / 100)) const hostCancellationFee = Math.floor(ticketPrice * (cancellationFeePercent / 100)) const refundToGuest = ticketPrice - platformCommission - hostCancellationFee return { ticketPrice, platformCommission, commissionPercent, hostCancellationFee, refundToGuest } } /** Gross base for guest-cancel preview — paid amount after discount, else list price. */ export const resolveCancellationTicketPrice = (booking: Pick): number => booking.payableAmount ?? booking.eventPrice