admin/services/bookings.ts

374 lines
13 KiB
TypeScript

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<ServiceResult<MyBooking[]>> => {
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<ServiceResult<MyBooking | null>> => {
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<ServiceResult<MyWaitlistEntry | null>> => {
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<ServiceResult<MyBooking>> => {
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<ServiceResult<MyWaitlistEntry>> => {
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<ServiceResult<MyWaitlistEntry[]>> => {
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<ServiceResult<MyWaitlistEntry>> => {
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<ServiceResult<{ waitlistEntryId: string; booking: MyBooking }>> => {
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<ServiceResult<MyBooking>> => {
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<ServiceResult<MyBooking>> => {
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 - selected cancellation fee. The platform
* commission is taken only from that fee and the remainder belongs to the
* host after the event completes.
* 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
cancellationFeePercent: number
hostCancellationFee: number
refundToGuest: number
}
export interface CancellationFeePolicy {
cancellationFeePercent: number
cancellationFeePercent12To24Hours: number
cancellationFeePercentMoreThan24Hours: number
}
const HOUR_MS = 60 * 60 * 1000
export const resolveCancellationFeePercent = (
policy: CancellationFeePolicy,
eventStartsAt: string | Date,
cancelledAt: Date = new Date()
): number => {
const remainingMs = new Date(eventStartsAt).getTime() - cancelledAt.getTime()
if (remainingMs <= 12 * HOUR_MS) return policy.cancellationFeePercent
if (remainingMs <= 24 * HOUR_MS) return policy.cancellationFeePercent12To24Hours
return policy.cancellationFeePercentMoreThan24Hours
}
export const calculateCancellationPreview = (
ticketPrice: number,
policy: CancellationFeePolicy,
commissionPercent: number,
eventStartsAt: string | Date,
cancelledAt: Date = new Date()
): CancellationPreview => {
const cancellationFeePercent = resolveCancellationFeePercent(policy, eventStartsAt, cancelledAt)
const grossCancellationFee = Math.floor(ticketPrice * (cancellationFeePercent / 100))
const platformCommission = Math.floor(grossCancellationFee * (commissionPercent / 100))
const hostCancellationFee = grossCancellationFee - platformCommission
const refundToGuest = ticketPrice - grossCancellationFee
return { ticketPrice, platformCommission, commissionPercent, cancellationFeePercent, hostCancellationFee, refundToGuest }
}
/** Gross base for guest-cancel preview — paid amount after discount, else list price. */
export const resolveCancellationTicketPrice = (booking: Pick<MyBooking, 'eventPrice' | 'payableAmount'>): number =>
booking.payableAmount ?? booking.eventPrice