@voyantjs/bookings 0.119.1 → 0.119.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/action-ledger-drift.d.ts.map +1 -1
  2. package/dist/action-ledger-drift.js +7 -2
  3. package/dist/pricing-assignment/age.d.ts +33 -0
  4. package/dist/pricing-assignment/age.d.ts.map +1 -0
  5. package/dist/pricing-assignment/age.js +109 -0
  6. package/dist/pricing-assignment/draft.d.ts +69 -0
  7. package/dist/pricing-assignment/draft.d.ts.map +1 -0
  8. package/dist/pricing-assignment/draft.js +284 -0
  9. package/dist/pricing-assignment/types.d.ts +113 -0
  10. package/dist/pricing-assignment/types.d.ts.map +1 -0
  11. package/dist/pricing-assignment/types.js +30 -0
  12. package/dist/pricing-assignment/unit-helpers.d.ts +11 -0
  13. package/dist/pricing-assignment/unit-helpers.d.ts.map +1 -0
  14. package/dist/pricing-assignment/unit-helpers.js +19 -0
  15. package/dist/pricing-assignment/verify.d.ts +67 -0
  16. package/dist/pricing-assignment/verify.d.ts.map +1 -0
  17. package/dist/pricing-assignment/verify.js +121 -0
  18. package/dist/pricing-assignment.d.ts +4 -275
  19. package/dist/pricing-assignment.d.ts.map +1 -1
  20. package/dist/pricing-assignment.js +4 -558
  21. package/dist/routes-admin.d.ts +2894 -0
  22. package/dist/routes-admin.d.ts.map +1 -0
  23. package/dist/routes-admin.js +2111 -0
  24. package/dist/routes-public.d.ts +9 -2
  25. package/dist/routes-public.d.ts.map +1 -1
  26. package/dist/routes-public.js +8 -0
  27. package/dist/routes.d.ts +1 -2893
  28. package/dist/routes.d.ts.map +1 -1
  29. package/dist/routes.js +1 -2110
  30. package/dist/schema-core.d.ts.map +1 -1
  31. package/dist/schema-core.js +3 -1
  32. package/dist/schema-items.d.ts +1 -1
  33. package/dist/schema-items.d.ts.map +1 -1
  34. package/dist/schema-items.js +3 -1
  35. package/dist/service-core.d.ts +6240 -0
  36. package/dist/service-core.d.ts.map +1 -0
  37. package/dist/service-core.js +4350 -0
  38. package/dist/service-public-core.d.ts +907 -0
  39. package/dist/service-public-core.d.ts.map +1 -0
  40. package/dist/service-public-core.js +1176 -0
  41. package/dist/service-public.d.ts +1 -906
  42. package/dist/service-public.d.ts.map +1 -1
  43. package/dist/service-public.js +1 -1175
  44. package/dist/service.d.ts +1 -6239
  45. package/dist/service.d.ts.map +1 -1
  46. package/dist/service.js +1 -4304
  47. package/dist/workflows/refund-booking.d.ts.map +1 -1
  48. package/dist/workflows/refund-booking.js +7 -1
  49. package/package.json +6 -6
package/dist/service.js CHANGED
@@ -1,4304 +1 @@
1
- import { appendActionLedgerMutation, } from "@voyantjs/action-ledger";
2
- import { newId } from "@voyantjs/db/lib/typeid";
3
- import { authUser } from "@voyantjs/db/schema/iam";
4
- import { and, asc, desc, eq, exists, getTableColumns, gte, ilike, inArray, isNotNull, lte, ne, notInArray, or, sql, } from "drizzle-orm";
5
- import { BOOKING_STATUS_CAPABILITIES } from "./action-ledger-capabilities.js";
6
- import { availabilitySlotsRef } from "./availability-ref.js";
7
- import { exchangeRatesRef } from "./markets-ref.js";
8
- import { applyTravelDetailSnapshot, } from "./pii.js";
9
- import { bookingItemProductDetailsRef, bookingProductDetailsRef, optionUnitsRef, productCategoryProductsRef, productDayServicesRef, productDaysRef, productItinerariesRef, productOptionsRef, productsRef, productTicketSettingsRef, suppliersRef, } from "./products-ref.js";
10
- import { bookingTravelerTravelDetails } from "./schema/travel-details.js";
11
- import { bookingActivityLog, bookingAllocations, bookingDocuments, bookingFulfillments, bookingItems, bookingItemTravelers, bookingNotes, bookingRedemptionEvents, bookingStaffAssignments, bookingSupplierStatuses, bookings, bookingTravelers, } from "./schema.js";
12
- import { cleanupGroupOnBookingCancelled } from "./service-groups.js";
13
- import { canTransitionBooking, transitionBooking } from "./state-machine.js";
14
- import { bookingTransactionDetailsRef, offerItemParticipantsRef, offerItemsRef, offerParticipantsRef, offerStaffAssignmentsRef, offersRef, orderItemParticipantsRef, orderItemsRef, orderParticipantsRef, orderStaffAssignmentsRef, ordersRef, } from "./transactions-ref.js";
15
- /**
16
- * Emit `ARRAY[$1, $2, …]::text[]` instead of the naive
17
- * `${jsArray}::text[]` form. drizzle's `sql` template spreads JS
18
- * arrays into a row constructor (`($1, $2)`) which Postgres refuses
19
- * to cast to `text[]` — see issue #952.
20
- */
21
- function sqlTextArray(values) {
22
- if (values.length === 0)
23
- return sql `ARRAY[]::text[]`;
24
- return sql `ARRAY[${sql.join(values.map((value) => sql `${value}`), sql.raw(", "))}]::text[]`;
25
- }
26
- function buildBookingSearchCondition(search) {
27
- const trimmed = search.trim();
28
- if (!trimmed)
29
- return undefined;
30
- const term = `%${trimmed}%`;
31
- const normalizedPhoneTerm = trimmed.replace(/\D/g, "");
32
- const shouldSearchNormalizedPhone = normalizedPhoneTerm.length >= 7 && /^[+\d\s().-]+$/.test(trimmed);
33
- const searchConditions = [
34
- ilike(bookings.bookingNumber, term),
35
- ilike(bookings.externalBookingRef, term),
36
- ilike(bookings.internalNotes, term),
37
- ilike(bookings.contactFirstName, term),
38
- ilike(bookings.contactLastName, term),
39
- ilike(bookings.contactTaxId, term),
40
- sql `concat_ws(' ', ${bookings.contactFirstName}, ${bookings.contactLastName}) ilike ${term}`,
41
- ilike(bookings.contactEmail, term),
42
- ilike(bookings.contactPhone, term),
43
- ilike(bookings.contactCountry, term),
44
- ilike(bookings.contactRegion, term),
45
- ilike(bookings.contactCity, term),
46
- ilike(bookings.contactAddressLine1, term),
47
- ilike(bookings.contactAddressLine2, term),
48
- ilike(bookings.contactPostalCode, term),
49
- // Match line-item title + product-name snapshot so operators can
50
- // find "all bookings on Paris Sunset & Seine" from the search box
51
- // instead of having to open the product filter popover.
52
- sql `exists (
53
- select 1 from ${bookingItems}
54
- where ${bookingItems.bookingId} = ${bookings.id}
55
- and (
56
- ${bookingItems.title} ilike ${term}
57
- or ${bookingItems.productNameSnapshot} ilike ${term}
58
- )
59
- )`,
60
- ];
61
- if (shouldSearchNormalizedPhone) {
62
- searchConditions.push(sql `regexp_replace(coalesce(${bookings.contactPhone}, ''), '[^0-9]+', '', 'g') like ${`%${normalizedPhoneTerm}%`}`);
63
- }
64
- return or(...searchConditions);
65
- }
66
- export function normalizeBookingBillingPartyUpdate(data) {
67
- if (data.personId) {
68
- return { ...data, organizationId: null };
69
- }
70
- if (data.organizationId) {
71
- return { ...data, personId: null };
72
- }
73
- return data;
74
- }
75
- function pickTravelDetailFields(data) {
76
- return {
77
- nationality: data.nationality,
78
- documentType: data.documentType,
79
- documentNumber: data.documentNumber,
80
- documentExpiry: data.documentExpiry,
81
- documentIssuingCountry: data.documentIssuingCountry,
82
- documentIssuingAuthority: data.documentIssuingAuthority,
83
- documentPersonDocumentId: data.documentPersonDocumentId,
84
- dateOfBirth: data.dateOfBirth,
85
- dietaryRequirements: data.dietaryRequirements,
86
- accessibilityNeeds: data.accessibilityNeeds,
87
- isLeadTraveler: data.isLeadTraveler,
88
- sharingGroupId: data.sharingGroupId,
89
- roomTypeId: data.roomTypeId,
90
- bedPreference: data.bedPreference,
91
- allocations: data.allocations,
92
- };
93
- }
94
- function allocationStatusForBookingItemStatus(status) {
95
- if (status === "confirmed")
96
- return "confirmed";
97
- if (status === "fulfilled")
98
- return "fulfilled";
99
- if (status === "cancelled")
100
- return "cancelled";
101
- if (status === "expired")
102
- return "expired";
103
- return "held";
104
- }
105
- function terminalBookingItemStatusForOverride(status) {
106
- if (status === "cancelled")
107
- return "cancelled";
108
- if (status === "expired")
109
- return "expired";
110
- if (status === "completed")
111
- return "fulfilled";
112
- return null;
113
- }
114
- function terminalBookingAllocationStatusForOverride(status) {
115
- if (status === "cancelled")
116
- return "cancelled";
117
- if (status === "expired")
118
- return "expired";
119
- if (status === "completed")
120
- return "fulfilled";
121
- return null;
122
- }
123
- function allocationStatusConsumesSlotCapacity(status) {
124
- return status === "held" || status === "confirmed" || status === "fulfilled";
125
- }
126
- async function appendBookingStatusMutationLedger(db, runtime, input) {
127
- if (!runtime.actionLedgerContext)
128
- return;
129
- await appendActionLedgerMutation(db, {
130
- context: runtime.actionLedgerContext,
131
- actionName: input.actionName,
132
- actionVersion: "v1",
133
- actionKind: "update",
134
- status: "succeeded",
135
- evaluatedRisk: input.evaluatedRisk ?? "medium",
136
- targetType: "booking",
137
- targetId: input.bookingId,
138
- routeOrToolName: input.routeOrToolName,
139
- capabilityId: input.capabilityId,
140
- capabilityVersion: "v1",
141
- authorizationSource: runtime.actionLedgerAuthorizationSource ?? "bookings.status.route",
142
- causationActionId: runtime.actionLedgerCausationActionId ?? null,
143
- approvalId: runtime.actionLedgerApprovalId ?? null,
144
- idempotencyScope: runtime.actionLedgerIdempotencyScope ?? null,
145
- idempotencyKey: runtime.actionLedgerIdempotencyKey ?? null,
146
- idempotencyFingerprint: runtime.actionLedgerIdempotencyFingerprint ?? null,
147
- mutationDetail: {
148
- summary: `Booking status changed from ${input.fromStatus} to ${input.toStatus}`,
149
- reversalKind: "none",
150
- },
151
- });
152
- }
153
- /** Stable string identifier for the event. */
154
- export const AVAILABILITY_SLOT_CHANGED_EVENT = "availability.slot.changed";
155
- /**
156
- * Emit a batch of slot-change events through the runtime's EventBus.
157
- * No-op when no event bus is wired (the common test path). Each emit is
158
- * fire-and-forget per the EventBus contract — subscriber errors are
159
- * logged, not rethrown — but we await to keep ordering deterministic in
160
- * tests that drain the bus before assertions.
161
- */
162
- async function emitSlotChanges(runtime, changes) {
163
- const eventBus = runtime.eventBus;
164
- if (!eventBus || changes.length === 0)
165
- return;
166
- for (const change of changes) {
167
- await eventBus.emit(AVAILABILITY_SLOT_CHANGED_EVENT, change, {
168
- category: "domain",
169
- source: "service",
170
- });
171
- }
172
- }
173
- const travelerParticipantTypes = ["traveler", "occupant"];
174
- const sharingGroupBookingStatuses = [
175
- "draft",
176
- "on_hold",
177
- "confirmed",
178
- "in_progress",
179
- "completed",
180
- ];
181
- const sharingGroupAllocationStatuses = ["held", "confirmed", "fulfilled"];
182
- class BookingServiceError extends Error {
183
- code;
184
- constructor(code, message) {
185
- super(message ?? code);
186
- this.code = code;
187
- this.name = "BookingServiceError";
188
- }
189
- }
190
- function toTimestamp(value) {
191
- return value ? new Date(value) : null;
192
- }
193
- function confirmedAtForStatus(status, value, now = new Date()) {
194
- if (status !== "confirmed")
195
- return null;
196
- return value ?? now;
197
- }
198
- function confirmedAtForBookingUpdate(currentStatus, data, now = new Date()) {
199
- const nextStatus = data.status ?? currentStatus;
200
- if (nextStatus !== "confirmed")
201
- return null;
202
- if (data.confirmedAt !== undefined)
203
- return confirmedAtForStatus(nextStatus, toTimestamp(data.confirmedAt), now);
204
- return currentStatus === "confirmed" ? undefined : now;
205
- }
206
- function toDateValue(value) {
207
- return value instanceof Date ? value : new Date(value);
208
- }
209
- function toDateValueOrNull(value) {
210
- if (!value)
211
- return null;
212
- return value instanceof Date ? value : new Date(value);
213
- }
214
- function toTravelerResponse(participant) {
215
- return {
216
- id: participant.id,
217
- bookingId: participant.bookingId,
218
- participantType: participant.participantType,
219
- travelerCategory: participant.travelerCategory,
220
- firstName: participant.firstName,
221
- lastName: participant.lastName,
222
- email: participant.email,
223
- phone: participant.phone,
224
- preferredLanguage: participant.preferredLanguage,
225
- specialRequests: participant.specialRequests,
226
- isPrimary: participant.isPrimary,
227
- notes: participant.notes,
228
- createdAt: participant.createdAt,
229
- updatedAt: participant.updatedAt,
230
- };
231
- }
232
- function normalizeTravelerAllocationMap(value) {
233
- if (!value || typeof value !== "object" || Array.isArray(value))
234
- return {};
235
- const out = {};
236
- for (const [key, raw] of Object.entries(value)) {
237
- if (typeof raw === "string")
238
- out[key] = raw;
239
- }
240
- return out;
241
- }
242
- async function ensureParticipantFlags(db, bookingId, travelerId, data) {
243
- if (data.isPrimary) {
244
- await db
245
- .update(bookingTravelers)
246
- .set({ isPrimary: false, updatedAt: new Date() })
247
- .where(and(eq(bookingTravelers.bookingId, bookingId), ne(bookingTravelers.id, travelerId)));
248
- }
249
- }
250
- async function ensureBookingScopedLinks(db, bookingId, data) {
251
- if (data.bookingItemId) {
252
- const [item] = await db
253
- .select({ id: bookingItems.id })
254
- .from(bookingItems)
255
- .where(and(eq(bookingItems.id, data.bookingItemId), eq(bookingItems.bookingId, bookingId)))
256
- .limit(1);
257
- if (!item) {
258
- return { ok: false, reason: "booking_item_not_found" };
259
- }
260
- }
261
- if (data.travelerId) {
262
- const [traveler] = await db
263
- .select({ id: bookingTravelers.id })
264
- .from(bookingTravelers)
265
- .where(and(eq(bookingTravelers.id, data.travelerId), eq(bookingTravelers.bookingId, bookingId)))
266
- .limit(1);
267
- if (!traveler) {
268
- return { ok: false, reason: "traveler_not_found" };
269
- }
270
- }
271
- return { ok: true };
272
- }
273
- function isStaffParticipantType(participantType) {
274
- return participantType === "staff";
275
- }
276
- function toStaffAssignmentRole(role) {
277
- return role === "service_assignee" ? "service_assignee" : "other";
278
- }
279
- function deriveBookingDateRange(items) {
280
- const dates = items
281
- .flatMap((item) => [item.serviceDate, item.startsAt?.toISOString().slice(0, 10) ?? null])
282
- .filter((value) => Boolean(value))
283
- .sort();
284
- return {
285
- startDate: dates[0] ?? null,
286
- endDate: dates[dates.length - 1] ?? null,
287
- };
288
- }
289
- function deriveBookingPax(participants, items) {
290
- const pax = participants.filter((participant) => ["traveler", "occupant"].includes(participant.participantType)).length;
291
- if (pax > 0) {
292
- return pax;
293
- }
294
- return items
295
- .filter((item) => item.itemType === "unit")
296
- .reduce((sum, item) => sum + item.quantity, 0);
297
- }
298
- function getTransactionItemParticipantItemId(link) {
299
- return "offerItemId" in link ? link.offerItemId : link.orderItemId;
300
- }
301
- function toStaffReservationParticipant(assignment, suffix) {
302
- return {
303
- id: `staff:${suffix}:${assignment.id}`,
304
- personId: assignment.personId,
305
- participantType: "staff",
306
- travelerCategory: null,
307
- firstName: assignment.firstName,
308
- lastName: assignment.lastName,
309
- email: assignment.email,
310
- phone: assignment.phone,
311
- preferredLanguage: assignment.preferredLanguage,
312
- isPrimary: assignment.isPrimary,
313
- notes: assignment.notes,
314
- createdAt: new Date(),
315
- updatedAt: new Date(),
316
- };
317
- }
318
- function mapDeliveryFormatToFulfillment(format) {
319
- switch (format) {
320
- case "pdf":
321
- return { fulfillmentType: "pdf", deliveryChannel: "download" };
322
- case "qr_code":
323
- return { fulfillmentType: "qr_code", deliveryChannel: "download" };
324
- case "barcode":
325
- return { fulfillmentType: "barcode", deliveryChannel: "download" };
326
- case "mobile":
327
- return { fulfillmentType: "mobile", deliveryChannel: "wallet" };
328
- case "email":
329
- return { fulfillmentType: "voucher", deliveryChannel: "email" };
330
- case "ticket":
331
- return { fulfillmentType: "ticket", deliveryChannel: "download" };
332
- default:
333
- return { fulfillmentType: "voucher", deliveryChannel: "download" };
334
- }
335
- }
336
- async function getConvertProductData(db, data) {
337
- const [product] = await db
338
- .select()
339
- .from(productsRef)
340
- .where(eq(productsRef.id, data.productId))
341
- .limit(1);
342
- if (!product) {
343
- return null;
344
- }
345
- const itemLines = data.itemLines ?? [];
346
- const requestedLineOptionIds = [
347
- ...new Set(itemLines
348
- .map((line) => line.optionId ?? null)
349
- .filter((optionId) => optionId !== null)),
350
- ];
351
- const requestedUnitIds = [...new Set(itemLines.map((line) => line.optionUnitId))];
352
- let lineOptions = [];
353
- if (requestedLineOptionIds.length > 0) {
354
- lineOptions = await db
355
- .select()
356
- .from(productOptionsRef)
357
- .where(and(eq(productOptionsRef.productId, product.id), inArray(productOptionsRef.id, requestedLineOptionIds)));
358
- if (lineOptions.length !== requestedLineOptionIds.length) {
359
- return null;
360
- }
361
- }
362
- let option = null;
363
- if (data.optionId) {
364
- const [selectedOption] = await db
365
- .select()
366
- .from(productOptionsRef)
367
- .where(and(eq(productOptionsRef.id, data.optionId), eq(productOptionsRef.productId, product.id)))
368
- .limit(1);
369
- if (!selectedOption) {
370
- return null;
371
- }
372
- option = selectedOption;
373
- }
374
- else if (requestedLineOptionIds.length === 1) {
375
- option = lineOptions[0] ?? null;
376
- }
377
- else if (requestedLineOptionIds.length > 1) {
378
- option = null;
379
- }
380
- else {
381
- const [defaultOption] = await db
382
- .select()
383
- .from(productOptionsRef)
384
- .where(eq(productOptionsRef.productId, product.id))
385
- .orderBy(desc(productOptionsRef.isDefault), asc(productOptionsRef.sortOrder), asc(productOptionsRef.createdAt))
386
- .limit(1);
387
- option = defaultOption ?? null;
388
- }
389
- // product_days is keyed by itinerary_id (products re-parented days onto
390
- // product_itineraries); getConvertProductData joins through the itinerary
391
- // ref so the per-product day lookup still works for converts that want to
392
- // seed booking supplier statuses from the product's day services.
393
- const days = await db
394
- .select({ id: productDaysRef.id, dayNumber: productDaysRef.dayNumber })
395
- .from(productDaysRef)
396
- .innerJoin(productItinerariesRef, eq(productDaysRef.itineraryId, productItinerariesRef.id))
397
- .where(eq(productItinerariesRef.productId, product.id))
398
- .orderBy(asc(productDaysRef.dayNumber));
399
- const dayServices = days.length
400
- ? await db
401
- .select({
402
- supplierServiceId: productDayServicesRef.supplierServiceId,
403
- name: productDayServicesRef.name,
404
- costCurrency: productDayServicesRef.costCurrency,
405
- costAmountCents: productDayServicesRef.costAmountCents,
406
- })
407
- .from(productDayServicesRef)
408
- .where(sql `${productDayServicesRef.dayId} IN (
409
- SELECT ${productDaysRef.id}
410
- FROM ${productDaysRef}
411
- INNER JOIN ${productItinerariesRef}
412
- ON ${productDaysRef.itineraryId} = ${productItinerariesRef.id}
413
- WHERE ${productItinerariesRef.productId} = ${product.id}
414
- )`)
415
- .orderBy(asc(productDayServicesRef.sortOrder), asc(productDayServicesRef.id))
416
- : [];
417
- let units = [];
418
- if (requestedUnitIds.length > 0) {
419
- const unitRows = await db
420
- .select({ unit: optionUnitsRef })
421
- .from(optionUnitsRef)
422
- .innerJoin(productOptionsRef, eq(optionUnitsRef.optionId, productOptionsRef.id))
423
- .where(and(eq(productOptionsRef.productId, product.id), inArray(optionUnitsRef.id, requestedUnitIds)))
424
- .orderBy(asc(optionUnitsRef.sortOrder), asc(optionUnitsRef.createdAt));
425
- units = unitRows.map((row) => row.unit);
426
- }
427
- else if (option !== null) {
428
- units = await db
429
- .select()
430
- .from(optionUnitsRef)
431
- .where(eq(optionUnitsRef.optionId, option.id))
432
- .orderBy(asc(optionUnitsRef.sortOrder), asc(optionUnitsRef.createdAt));
433
- }
434
- let slot = null;
435
- if (data.slotId) {
436
- const [selectedSlot] = await db
437
- .select()
438
- .from(availabilitySlotsRef)
439
- .where(and(eq(availabilitySlotsRef.id, data.slotId), eq(availabilitySlotsRef.productId, product.id)))
440
- .limit(1);
441
- if (!selectedSlot) {
442
- return null;
443
- }
444
- if (option && selectedSlot.optionId && selectedSlot.optionId !== option.id) {
445
- return null;
446
- }
447
- // Note: per-line `optionId` is intentionally NOT required to match
448
- // `selectedSlot.optionId`. A slot's `option_id` marks which option
449
- // owns the slot's per-unit allocation tracking; other options on the
450
- // same product are still bookable on the same departure (e.g. a
451
- // tour-operator bus + hotel allotment selling SGL/DBL/TWN/TPL on one
452
- // slot). Line options are validated to live on the product above
453
- // (the `lineOptions.length !== requestedLineOptionIds.length` guard),
454
- // and `adjustSlotCapacity` enforces total pax server-side at booking
455
- // time. See issue #960.
456
- slot = {
457
- id: selectedSlot.id,
458
- dateLocal: selectedSlot.dateLocal,
459
- startsAt: selectedSlot.startsAt,
460
- endsAt: selectedSlot.endsAt,
461
- timezone: selectedSlot.timezone,
462
- };
463
- }
464
- return {
465
- product: {
466
- id: product.id,
467
- name: product.name,
468
- description: product.description,
469
- sellCurrency: product.sellCurrency,
470
- sellAmountCents: product.sellAmountCents,
471
- costAmountCents: product.costAmountCents,
472
- marginPercent: product.marginPercent,
473
- startDate: product.startDate,
474
- endDate: product.endDate,
475
- pax: product.pax,
476
- },
477
- option: option ? { id: option.id, name: option.name } : null,
478
- slot,
479
- dayServices,
480
- units: units.map((unit) => ({
481
- id: unit.id,
482
- optionId: unit.optionId,
483
- name: unit.name,
484
- description: unit.description,
485
- unitType: unit.unitType,
486
- isRequired: unit.isRequired,
487
- minQuantity: unit.minQuantity,
488
- sortOrder: unit.sortOrder,
489
- })),
490
- };
491
- }
492
- const DEFAULT_HOLD_MINUTES = 30;
493
- function positiveHoldMinutes(value) {
494
- return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null;
495
- }
496
- function isUndefinedTableError(error) {
497
- return (typeof error === "object" &&
498
- error !== null &&
499
- "code" in error &&
500
- error.code === "42P01");
501
- }
502
- /**
503
- * Catalog enrichment for a booking item, taken at item-create time so
504
- * the snapshot is authoritative. Each lookup is independently
505
- * try/catch'd so a missing catalog/availability table (catalog-less
506
- * OTA deployment) just yields `null` for that piece — the caller's
507
- * explicit values still win.
508
- */
509
- async function resolveBookingItemSnapshot(db, input) {
510
- const result = {
511
- productName: null,
512
- optionName: null,
513
- unitName: null,
514
- departureLabel: null,
515
- startsAt: null,
516
- endsAt: null,
517
- serviceDate: null,
518
- };
519
- if (input.productId) {
520
- try {
521
- const [row] = await db
522
- .select({ name: productsRef.name })
523
- .from(productsRef)
524
- .where(eq(productsRef.id, input.productId))
525
- .limit(1);
526
- if (row)
527
- result.productName = row.name;
528
- }
529
- catch (error) {
530
- if (!isUndefinedTableError(error))
531
- throw error;
532
- }
533
- }
534
- if (input.optionId) {
535
- try {
536
- const [row] = await db
537
- .select({ name: productOptionsRef.name })
538
- .from(productOptionsRef)
539
- .where(eq(productOptionsRef.id, input.optionId))
540
- .limit(1);
541
- if (row)
542
- result.optionName = row.name;
543
- }
544
- catch (error) {
545
- if (!isUndefinedTableError(error))
546
- throw error;
547
- }
548
- }
549
- if (input.optionUnitId) {
550
- try {
551
- const [row] = await db
552
- .select({ name: optionUnitsRef.name })
553
- .from(optionUnitsRef)
554
- .where(eq(optionUnitsRef.id, input.optionUnitId))
555
- .limit(1);
556
- if (row)
557
- result.unitName = row.name;
558
- }
559
- catch (error) {
560
- if (!isUndefinedTableError(error))
561
- throw error;
562
- }
563
- }
564
- if (input.availabilitySlotId) {
565
- try {
566
- const [slot] = await db
567
- .select({
568
- startsAt: availabilitySlotsRef.startsAt,
569
- endsAt: availabilitySlotsRef.endsAt,
570
- dateLocal: availabilitySlotsRef.dateLocal,
571
- timezone: availabilitySlotsRef.timezone,
572
- })
573
- .from(availabilitySlotsRef)
574
- .where(eq(availabilitySlotsRef.id, input.availabilitySlotId))
575
- .limit(1);
576
- if (slot) {
577
- result.startsAt = slot.startsAt;
578
- result.endsAt = slot.endsAt;
579
- result.serviceDate = slot.dateLocal;
580
- result.departureLabel = formatDepartureLabel(slot.startsAt, slot.timezone);
581
- }
582
- }
583
- catch (error) {
584
- if (!isUndefinedTableError(error))
585
- throw error;
586
- }
587
- }
588
- return result;
589
- }
590
- function formatDepartureLabel(startsAt, timezone) {
591
- if (!startsAt)
592
- return null;
593
- try {
594
- const formatter = new Intl.DateTimeFormat("en", {
595
- year: "numeric",
596
- month: "short",
597
- day: "numeric",
598
- hour: "2-digit",
599
- minute: "2-digit",
600
- timeZone: timezone ?? undefined,
601
- timeZoneName: timezone ? "short" : undefined,
602
- });
603
- return formatter.format(startsAt);
604
- }
605
- catch {
606
- return startsAt.toISOString();
607
- }
608
- }
609
- async function resolvePolicyHoldMinutes(db, items) {
610
- const productIds = new Set();
611
- const slotIds = new Set();
612
- for (const item of items) {
613
- if (item.productId)
614
- productIds.add(item.productId);
615
- const slotId = item.availabilitySlotId ?? item.slotId;
616
- if (slotId)
617
- slotIds.add(slotId);
618
- }
619
- if (slotIds.size > 0) {
620
- const slotRows = await db
621
- .select({ productId: availabilitySlotsRef.productId })
622
- .from(availabilitySlotsRef)
623
- .where(inArray(availabilitySlotsRef.id, [...slotIds]));
624
- for (const slot of slotRows) {
625
- if (slot.productId)
626
- productIds.add(slot.productId);
627
- }
628
- }
629
- if (productIds.size === 0) {
630
- return DEFAULT_HOLD_MINUTES;
631
- }
632
- const productRows = await db
633
- .select({
634
- id: productsRef.id,
635
- supplierId: productsRef.supplierId,
636
- reservationTimeoutMinutes: productsRef.reservationTimeoutMinutes,
637
- })
638
- .from(productsRef)
639
- .where(inArray(productsRef.id, [...productIds]))
640
- .catch((error) => {
641
- if (isUndefinedTableError(error))
642
- return [];
643
- throw error;
644
- });
645
- const supplierIds = [
646
- ...new Set(productRows.map((product) => product.supplierId).filter(Boolean)),
647
- ];
648
- const supplierRows = supplierIds.length > 0
649
- ? await db
650
- .select({
651
- id: suppliersRef.id,
652
- reservationTimeoutMinutes: suppliersRef.reservationTimeoutMinutes,
653
- })
654
- .from(suppliersRef)
655
- .where(inArray(suppliersRef.id, supplierIds))
656
- .catch((error) => {
657
- if (isUndefinedTableError(error))
658
- return [];
659
- throw error;
660
- })
661
- : [];
662
- const supplierTimeouts = new Map(supplierRows.map((supplier) => [
663
- supplier.id,
664
- positiveHoldMinutes(supplier.reservationTimeoutMinutes),
665
- ]));
666
- const candidates = [];
667
- for (const product of productRows) {
668
- const productMinutes = positiveHoldMinutes(product.reservationTimeoutMinutes);
669
- if (productMinutes !== null) {
670
- candidates.push(productMinutes);
671
- continue;
672
- }
673
- const supplierMinutes = product.supplierId ? supplierTimeouts.get(product.supplierId) : null;
674
- if (supplierMinutes !== null && supplierMinutes !== undefined) {
675
- candidates.push(supplierMinutes);
676
- }
677
- }
678
- return candidates.length > 0 ? Math.min(...candidates) : DEFAULT_HOLD_MINUTES;
679
- }
680
- async function listBookingItemsForSummaries(db, bookingIds) {
681
- if (bookingIds.length === 0)
682
- return [];
683
- // `productName` prefers the snapshot (authoritative — what was sold)
684
- // and falls back to the current catalog name only when the snapshot
685
- // is missing (legacy rows pre-dating the snapshot columns). Same
686
- // catalog-less fallback as before via the 42P01 catch.
687
- return db
688
- .select({
689
- id: bookingItems.id,
690
- bookingId: bookingItems.bookingId,
691
- title: bookingItems.title,
692
- itemType: bookingItems.itemType,
693
- productId: bookingItems.productId,
694
- productName: sql `coalesce(${bookingItems.productNameSnapshot}, ${productsRef.name})`,
695
- startsAt: bookingItems.startsAt,
696
- endsAt: bookingItems.endsAt,
697
- })
698
- .from(bookingItems)
699
- .leftJoin(productsRef, eq(productsRef.id, bookingItems.productId))
700
- .where(inArray(bookingItems.bookingId, bookingIds))
701
- .orderBy(asc(bookingItems.createdAt))
702
- .catch(async (error) => {
703
- if (!isUndefinedTableError(error))
704
- throw error;
705
- return db
706
- .select({
707
- id: bookingItems.id,
708
- bookingId: bookingItems.bookingId,
709
- title: bookingItems.title,
710
- itemType: bookingItems.itemType,
711
- productId: bookingItems.productId,
712
- productName: bookingItems.productNameSnapshot,
713
- startsAt: bookingItems.startsAt,
714
- endsAt: bookingItems.endsAt,
715
- })
716
- .from(bookingItems)
717
- .where(inArray(bookingItems.bookingId, bookingIds))
718
- .orderBy(asc(bookingItems.createdAt));
719
- });
720
- }
721
- async function computeHoldExpiresAt(db, input, items = []) {
722
- if (input.holdExpiresAt) {
723
- return new Date(input.holdExpiresAt);
724
- }
725
- const minutes = input.holdMinutes ?? (await resolvePolicyHoldMinutes(db, items));
726
- return new Date(Date.now() + minutes * 60 * 1000);
727
- }
728
- /**
729
- * Walk a booking's items, convert each line into the booking's
730
- * `baseCurrency` via the booking's `fxRateSetId`, sum.
731
- *
732
- * Returns:
733
- * - `{ status: "ok", baseSellAmountCents, baseCostAmountCents }` when
734
- * every item's currency was either already in `baseCurrency` or had
735
- * a rate row in the rate set
736
- * - `{ status: "missing_rate", currency }` when an item's
737
- * `sellCurrency` had no rate in the rate set; caller treats as
738
- * "leave base totals untouched, surface to ops"
739
- * - `{ status: "skipped" }` when the booking has no `fxRateSetId`
740
- * (multi-currency conversion isn't possible without one)
741
- *
742
- * Pure conversion math. Caller controls persistence.
743
- */
744
- async function rollupBaseTotals(db, bookingId, baseCurrency) {
745
- const [booking] = await db
746
- .select({ fxRateSetId: bookings.fxRateSetId })
747
- .from(bookings)
748
- .where(eq(bookings.id, bookingId))
749
- .limit(1);
750
- if (!booking?.fxRateSetId) {
751
- return { status: "skipped" };
752
- }
753
- // Cache for the closure — TypeScript can't narrow `booking` after
754
- // the closure boundary, so capture the id in a local.
755
- const fxRateSetId = booking.fxRateSetId;
756
- const items = await db
757
- .select({
758
- sellCurrency: bookingItems.sellCurrency,
759
- totalSellAmountCents: bookingItems.totalSellAmountCents,
760
- costCurrency: bookingItems.costCurrency,
761
- totalCostAmountCents: bookingItems.totalCostAmountCents,
762
- })
763
- .from(bookingItems)
764
- .where(eq(bookingItems.bookingId, bookingId));
765
- // Cache rates we look up to avoid N+1 within one booking.
766
- const rateCache = new Map(); // key: `${from}->${to}`, value: decimal rate or null
767
- async function rate(from, to) {
768
- if (from === to)
769
- return 1;
770
- const key = `${from}->${to}`;
771
- if (rateCache.has(key))
772
- return rateCache.get(key) ?? null;
773
- const [direct] = await db
774
- .select({ rate: exchangeRatesRef.rateDecimal })
775
- .from(exchangeRatesRef)
776
- .where(and(eq(exchangeRatesRef.fxRateSetId, fxRateSetId), eq(exchangeRatesRef.baseCurrency, from), eq(exchangeRatesRef.quoteCurrency, to)))
777
- .limit(1);
778
- if (direct) {
779
- const value = Number.parseFloat(direct.rate);
780
- rateCache.set(key, value);
781
- return value;
782
- }
783
- // Try the inverse
784
- const [inverse] = await db
785
- .select({ rate: exchangeRatesRef.inverseRateDecimal })
786
- .from(exchangeRatesRef)
787
- .where(and(eq(exchangeRatesRef.fxRateSetId, fxRateSetId), eq(exchangeRatesRef.baseCurrency, to), eq(exchangeRatesRef.quoteCurrency, from)))
788
- .limit(1);
789
- if (inverse?.rate) {
790
- const value = Number.parseFloat(inverse.rate);
791
- rateCache.set(key, value);
792
- return value;
793
- }
794
- rateCache.set(key, null);
795
- return null;
796
- }
797
- let baseSellAmountCents = 0;
798
- let baseCostAmountCents = 0;
799
- for (const item of items) {
800
- if (item.totalSellAmountCents !== null) {
801
- const r = await rate(item.sellCurrency, baseCurrency);
802
- if (r === null) {
803
- return { status: "missing_rate", currency: item.sellCurrency };
804
- }
805
- baseSellAmountCents += Math.round(item.totalSellAmountCents * r);
806
- }
807
- if (item.totalCostAmountCents !== null && item.costCurrency) {
808
- const r = await rate(item.costCurrency, baseCurrency);
809
- if (r === null) {
810
- return { status: "missing_rate", currency: item.costCurrency };
811
- }
812
- baseCostAmountCents += Math.round(item.totalCostAmountCents * r);
813
- }
814
- }
815
- return { status: "ok", baseSellAmountCents, baseCostAmountCents };
816
- }
817
- async function lockAvailabilitySlot(db, slotId) {
818
- const rows = await db.execute(sql `SELECT id, product_id, option_id, date_local, starts_at, ends_at, timezone, status, unlimited, remaining_pax
819
- FROM ${availabilitySlotsRef}
820
- WHERE ${availabilitySlotsRef.id} = ${slotId}
821
- FOR UPDATE`);
822
- const row = toRows(rows)[0];
823
- if (!row) {
824
- return null;
825
- }
826
- return {
827
- ...row,
828
- starts_at: toDateValue(row.starts_at),
829
- ends_at: toDateValueOrNull(row.ends_at),
830
- };
831
- }
832
- function buildSlotChange(slot, remainingPax, source) {
833
- return {
834
- slotId: slot.id,
835
- productId: slot.product_id,
836
- optionId: slot.option_id,
837
- startsAt: slot.starts_at,
838
- remainingPax: slot.unlimited ? null : remainingPax,
839
- unlimited: slot.unlimited,
840
- source,
841
- };
842
- }
843
- async function adjustSlotCapacity(db, slotId, delta, source = "booking") {
844
- const locked = await lockAvailabilitySlot(db, slotId);
845
- if (!locked) {
846
- return { status: "slot_not_found" };
847
- }
848
- if (locked.status !== "open" && locked.status !== "sold_out") {
849
- return { status: "slot_unavailable", slot: locked };
850
- }
851
- if (locked.unlimited) {
852
- return {
853
- status: "ok",
854
- slot: locked,
855
- remainingPax: locked.remaining_pax,
856
- slotChange: buildSlotChange(locked, locked.remaining_pax, source),
857
- };
858
- }
859
- const currentRemaining = locked.remaining_pax ?? 0;
860
- const nextRemaining = currentRemaining + delta;
861
- if (nextRemaining < 0) {
862
- return {
863
- status: "insufficient_capacity",
864
- slot: locked,
865
- remainingPax: currentRemaining,
866
- };
867
- }
868
- let nextStatus = locked.status;
869
- if (nextRemaining === 0 && locked.status === "open") {
870
- nextStatus = "sold_out";
871
- }
872
- else if (nextRemaining > 0 && locked.status === "sold_out") {
873
- nextStatus = "open";
874
- }
875
- await db
876
- .update(availabilitySlotsRef)
877
- .set({
878
- remainingPax: nextRemaining,
879
- status: nextStatus,
880
- updatedAt: new Date(),
881
- })
882
- .where(eq(availabilitySlotsRef.id, slotId));
883
- return {
884
- status: "ok",
885
- slot: locked,
886
- remainingPax: nextRemaining,
887
- slotChange: buildSlotChange(locked, nextRemaining, source),
888
- };
889
- }
890
- /**
891
- * Exported for unit tests — production callers go through
892
- * `assertResourceCapacityForAllocations` /
893
- * `persistTravelDetailsWithCapacityCheck` below.
894
- */
895
- export async function loadResourceCapacityViolations(db, travelerId, allocations) {
896
- const entries = Object.entries(allocations ?? {}).filter(([kind, resourceId]) => kind && resourceId);
897
- if (entries.length === 0)
898
- return [];
899
- const resourceIds = entries.map(([, resourceId]) => resourceId);
900
- // Lock the targeted allocation_resources rows so concurrent
901
- // assignments to the same resource serialise — otherwise two
902
- // requests can both see one seat free and both write, leaving the
903
- // resource over capacity. The caller is responsible for invoking
904
- // this inside a transaction; outside one this lock degrades to a
905
- // single-statement no-op which is the same race we had before.
906
- const resources = await db.execute(sql `
907
- SELECT id, kind, capacity, slot_id
908
- FROM allocation_resources
909
- WHERE id = ANY(${sqlTextArray(resourceIds)})
910
- FOR UPDATE
911
- `);
912
- const resourceList = resources;
913
- const resourceById = new Map(resourceList.map((row) => [row.id, row]));
914
- // Entries whose resource exists under the requested kind need a traveler
915
- // count; the rest (missing resource / kind mismatch) are violations
916
- // outright and never had a count in the per-resource form either.
917
- const countedChecks = [];
918
- for (const [kind, resourceId] of entries) {
919
- const resource = resourceById.get(resourceId);
920
- if (resource && resource.kind === kind) {
921
- countedChecks.push({ kind, resourceId, slotId: resource.slot_id });
922
- }
923
- }
924
- // ONE grouped count for all checked (kind, resource) pairs. `kind` varies
925
- // per entry (it is the jsonb key inside `btd.allocations`), so a plain
926
- // GROUP BY over a static column can't express the check — instead a
927
- // VALUES join carries each pair's kind + resource + slot and the jsonb
928
- // lookup joins against it. Replaces the COUNT-per-resource loop this
929
- // function used to run (N round trips for N allocation entries).
930
- const assignedByCheck = new Map();
931
- if (countedChecks.length > 0) {
932
- const checkTuples = sql.join(countedChecks.map((check) => sql `(${check.kind}::text, ${check.resourceId}::text, ${check.slotId}::text)`), sql.raw(", "));
933
- const counts = await db.execute(sql `
934
- SELECT checks.kind AS kind,
935
- checks.resource_id AS resource_id,
936
- COUNT(DISTINCT btd.traveler_id)::int AS count
937
- FROM (VALUES ${checkTuples}) AS checks(kind, resource_id, slot_id)
938
- JOIN booking_traveler_travel_details btd
939
- ON btd.allocations ->> checks.kind = checks.resource_id
940
- JOIN booking_travelers bt ON bt.id = btd.traveler_id
941
- JOIN booking_allocations ba
942
- ON ba.booking_id = bt.booking_id
943
- AND ba.availability_slot_id = checks.slot_id
944
- JOIN bookings b ON b.id = bt.booking_id
945
- WHERE b.status IN ('draft', 'on_hold', 'confirmed', 'in_progress', 'completed')
946
- AND ba.status IN ('held', 'confirmed', 'fulfilled')
947
- AND btd.traveler_id <> ${travelerId}
948
- GROUP BY checks.kind, checks.resource_id
949
- `);
950
- for (const row of toRows(counts)) {
951
- assignedByCheck.set(`${row.kind}\u0000${row.resource_id}`, row.count ?? 0);
952
- }
953
- }
954
- const violations = [];
955
- for (const [kind, resourceId] of entries) {
956
- const resource = resourceById.get(resourceId);
957
- if (!resource) {
958
- violations.push({
959
- slotId: "",
960
- resourceId,
961
- kind,
962
- capacity: 0,
963
- existingAssigned: 0,
964
- });
965
- continue;
966
- }
967
- if (resource.kind !== kind) {
968
- violations.push({
969
- slotId: resource.slot_id,
970
- resourceId,
971
- kind,
972
- capacity: resource.capacity,
973
- existingAssigned: 0,
974
- });
975
- continue;
976
- }
977
- // Pairs with no live assignments produce no group row — that is the
978
- // zero count, same as the old per-resource COUNT returning 0.
979
- const existingAssigned = assignedByCheck.get(`${kind}\u0000${resourceId}`) ?? 0;
980
- if (existingAssigned + 1 > resource.capacity) {
981
- violations.push({
982
- slotId: resource.slot_id,
983
- resourceId,
984
- kind,
985
- capacity: resource.capacity,
986
- existingAssigned,
987
- });
988
- }
989
- }
990
- return violations;
991
- }
992
- async function assertResourceCapacityForAllocations(db, travelerId, allocations) {
993
- if (!allocations)
994
- return;
995
- const violations = await loadResourceCapacityViolations(db, travelerId, allocations);
996
- if (violations.length > 0) {
997
- throw new BookingServiceError("resource_capacity_exhausted", `Allocation resource over capacity: ${violations
998
- .map((v) => `${v.kind}=${v.resourceId} (cap ${v.capacity}, assigned ${v.existingAssigned})`)
999
- .join(", ")}`);
1000
- }
1001
- }
1002
- /**
1003
- * Lock the targeted resource rows, validate, and write in one
1004
- * transaction so two concurrent assignments to the last seat in a
1005
- * resource cannot both pass a stale capacity check. When there are no
1006
- * allocations to enforce, we skip the wrapping transaction entirely —
1007
- * avoids the round trip for the common path and keeps the unit-test
1008
- * mocks (which don't stub `db.transaction`) working unchanged.
1009
- */
1010
- async function persistTravelDetailsWithCapacityCheck(db, travelerId, input, opts) {
1011
- const allocations = input.allocations;
1012
- const hasAllocations = allocations != null && Object.values(allocations).some((value) => Boolean(value));
1013
- if (!hasAllocations) {
1014
- return opts.pii.upsertTravelerTravelDetails(db, travelerId, input, opts.actorId);
1015
- }
1016
- return db.transaction(async (tx) => {
1017
- await assertResourceCapacityForAllocations(tx, travelerId, allocations);
1018
- return opts.pii.upsertTravelerTravelDetails(tx, travelerId, input, opts.actorId);
1019
- });
1020
- }
1021
- async function releaseAllocationCapacity(db, allocation, source = "cancel") {
1022
- if (!allocation.availabilitySlotId) {
1023
- return undefined;
1024
- }
1025
- if (!allocationStatusConsumesSlotCapacity(allocation.status)) {
1026
- return undefined;
1027
- }
1028
- const result = await adjustSlotCapacity(db, allocation.availabilitySlotId, allocation.quantity, source);
1029
- return result.status === "ok" ? result.slotChange : undefined;
1030
- }
1031
- async function reserveBookingFromTransactionSource(db, source, data, userId, runtime = {}) {
1032
- const slotChanges = [];
1033
- try {
1034
- const result = await db.transaction(async (tx) => {
1035
- const holdExpiresAt = await computeHoldExpiresAt(tx, data, source.items);
1036
- const dateRange = deriveBookingDateRange(source.items);
1037
- const pax = deriveBookingPax(source.participants, source.items);
1038
- const [booking] = await tx
1039
- .insert(bookings)
1040
- .values({
1041
- bookingNumber: data.bookingNumber,
1042
- status: "on_hold",
1043
- personId: source.personId,
1044
- organizationId: source.organizationId,
1045
- sourceType: data.sourceType,
1046
- contactFirstName: data.contactFirstName ?? source.contactFirstName,
1047
- contactLastName: data.contactLastName ?? source.contactLastName,
1048
- contactPartyType: data.contactPartyType ?? source.contactPartyType,
1049
- contactTaxId: data.contactTaxId ?? source.contactTaxId,
1050
- contactEmail: data.contactEmail ?? source.contactEmail,
1051
- contactPhone: data.contactPhone ?? source.contactPhone,
1052
- contactPreferredLanguage: data.contactPreferredLanguage ?? source.contactPreferredLanguage,
1053
- contactCountry: data.contactCountry ?? source.contactCountry,
1054
- contactRegion: data.contactRegion ?? source.contactRegion,
1055
- contactCity: data.contactCity ?? source.contactCity,
1056
- contactAddressLine1: data.contactAddressLine1 ?? source.contactAddressLine1,
1057
- contactAddressLine2: data.contactAddressLine2 ?? source.contactAddressLine2,
1058
- contactPostalCode: data.contactPostalCode ?? source.contactPostalCode,
1059
- sellCurrency: source.currency,
1060
- baseCurrency: source.baseCurrency,
1061
- sellAmountCents: source.totalAmountCents,
1062
- costAmountCents: source.costAmountCents,
1063
- startDate: dateRange.startDate,
1064
- endDate: dateRange.endDate,
1065
- pax: pax > 0 ? pax : null,
1066
- internalNotes: data.internalNotes ?? source.notes,
1067
- holdExpiresAt,
1068
- })
1069
- .returning();
1070
- if (!booking) {
1071
- throw new BookingServiceError("booking_create_failed");
1072
- }
1073
- const participantMap = new Map();
1074
- const staffParticipantMap = new Map();
1075
- if (data.includeParticipants) {
1076
- for (const participant of source.participants) {
1077
- if (isStaffParticipantType(participant.participantType)) {
1078
- staffParticipantMap.set(participant.id, participant);
1079
- continue;
1080
- }
1081
- const [createdParticipant] = await tx
1082
- .insert(bookingTravelers)
1083
- .values({
1084
- bookingId: booking.id,
1085
- personId: participant.personId ?? null,
1086
- participantType: participant.participantType,
1087
- travelerCategory: participant.travelerCategory ??
1088
- null,
1089
- firstName: participant.firstName,
1090
- lastName: participant.lastName,
1091
- email: participant.email ?? null,
1092
- phone: participant.phone ?? null,
1093
- preferredLanguage: participant.preferredLanguage ?? null,
1094
- isPrimary: participant.isPrimary,
1095
- notes: participant.notes ?? null,
1096
- })
1097
- .returning();
1098
- if (!createdParticipant) {
1099
- throw new BookingServiceError("participant_create_failed");
1100
- }
1101
- participantMap.set(participant.id, createdParticipant.id);
1102
- }
1103
- }
1104
- const bookingItemMap = new Map();
1105
- for (const item of source.items) {
1106
- if (item.slotId) {
1107
- const capacity = await adjustSlotCapacity(tx, item.slotId, -item.quantity, "booking");
1108
- if (capacity.status === "slot_not_found") {
1109
- throw new BookingServiceError("slot_not_found");
1110
- }
1111
- if (capacity.status === "slot_unavailable") {
1112
- throw new BookingServiceError("slot_unavailable");
1113
- }
1114
- if (capacity.status === "insufficient_capacity") {
1115
- throw new BookingServiceError("insufficient_capacity");
1116
- }
1117
- const slot = capacity.slot;
1118
- if (item.productId && item.productId !== slot.product_id) {
1119
- throw new BookingServiceError("slot_product_mismatch");
1120
- }
1121
- // Per issue #960, a per-item `optionId` no longer has to match
1122
- // `slot.option_id` — option-scoped slots accept items for any
1123
- // option that lives on the same product. `getConvertProductData`
1124
- // already validated each line's option belongs to the product;
1125
- // total pax is enforced by `adjustSlotCapacity` above.
1126
- if (capacity.slotChange)
1127
- slotChanges.push(capacity.slotChange);
1128
- }
1129
- const [bookingItem] = await tx
1130
- .insert(bookingItems)
1131
- .values({
1132
- bookingId: booking.id,
1133
- title: item.title,
1134
- description: item.description ?? null,
1135
- itemType: item.itemType,
1136
- status: "on_hold",
1137
- serviceDate: item.serviceDate ?? (item.slotId ? undefined : null),
1138
- startsAt: item.startsAt ?? null,
1139
- endsAt: item.endsAt ?? null,
1140
- quantity: item.quantity,
1141
- sellCurrency: item.sellCurrency,
1142
- unitSellAmountCents: item.unitSellAmountCents ?? null,
1143
- totalSellAmountCents: item.totalSellAmountCents ?? null,
1144
- costCurrency: item.costCurrency ?? null,
1145
- unitCostAmountCents: item.unitCostAmountCents ?? null,
1146
- totalCostAmountCents: item.totalCostAmountCents ?? null,
1147
- notes: item.notes ?? null,
1148
- productId: item.productId ?? null,
1149
- optionId: item.optionId ?? null,
1150
- optionUnitId: item.unitId ?? null,
1151
- sourceOfferId: source.offerId,
1152
- metadata: item.metadata ?? null,
1153
- })
1154
- .returning();
1155
- if (!bookingItem) {
1156
- throw new BookingServiceError("booking_item_create_failed");
1157
- }
1158
- bookingItemMap.set(item.id, bookingItem.id);
1159
- if (item.slotId) {
1160
- const [allocation] = await tx
1161
- .insert(bookingAllocations)
1162
- .values({
1163
- bookingId: booking.id,
1164
- bookingItemId: bookingItem.id,
1165
- productId: item.productId ?? null,
1166
- optionId: item.optionId ?? null,
1167
- optionUnitId: item.unitId ?? null,
1168
- availabilitySlotId: item.slotId,
1169
- quantity: item.quantity,
1170
- allocationType: "unit",
1171
- status: "held",
1172
- holdExpiresAt,
1173
- metadata: item.metadata ?? null,
1174
- })
1175
- .returning();
1176
- if (!allocation) {
1177
- throw new BookingServiceError("allocation_create_failed");
1178
- }
1179
- }
1180
- }
1181
- for (const link of source.itemParticipants) {
1182
- const sourceItemId = getTransactionItemParticipantItemId(link);
1183
- if (!sourceItemId) {
1184
- continue;
1185
- }
1186
- const bookingItemId = bookingItemMap.get(sourceItemId);
1187
- const travelerId = participantMap.get(link.travelerId);
1188
- if (!bookingItemId || !travelerId) {
1189
- continue;
1190
- }
1191
- await tx.insert(bookingItemTravelers).values({
1192
- bookingItemId,
1193
- travelerId,
1194
- role: link.role,
1195
- isPrimary: link.isPrimary,
1196
- });
1197
- }
1198
- if (staffParticipantMap.size > 0) {
1199
- const linkedStaffAssignments = [];
1200
- const linkedStaffParticipantIds = new Set();
1201
- for (const link of source.itemParticipants) {
1202
- const staffParticipant = staffParticipantMap.get(link.travelerId);
1203
- if (!staffParticipant) {
1204
- continue;
1205
- }
1206
- const sourceItemId = getTransactionItemParticipantItemId(link);
1207
- if (!sourceItemId) {
1208
- continue;
1209
- }
1210
- const bookingItemId = bookingItemMap.get(sourceItemId);
1211
- if (!bookingItemId) {
1212
- continue;
1213
- }
1214
- linkedStaffParticipantIds.add(staffParticipant.id);
1215
- linkedStaffAssignments.push({
1216
- bookingId: booking.id,
1217
- bookingItemId,
1218
- personId: staffParticipant.personId ?? null,
1219
- role: toStaffAssignmentRole(link.role),
1220
- firstName: staffParticipant.firstName,
1221
- lastName: staffParticipant.lastName,
1222
- email: staffParticipant.email ?? null,
1223
- phone: staffParticipant.phone ?? null,
1224
- preferredLanguage: staffParticipant.preferredLanguage ?? null,
1225
- isPrimary: link.isPrimary || staffParticipant.isPrimary,
1226
- notes: staffParticipant.notes ?? null,
1227
- metadata: {
1228
- sourceParticipantId: staffParticipant.id,
1229
- sourceItemId,
1230
- sourceRole: link.role,
1231
- },
1232
- });
1233
- }
1234
- for (const staffParticipant of staffParticipantMap.values()) {
1235
- if (linkedStaffParticipantIds.has(staffParticipant.id)) {
1236
- continue;
1237
- }
1238
- linkedStaffAssignments.push({
1239
- bookingId: booking.id,
1240
- bookingItemId: null,
1241
- personId: staffParticipant.personId ?? null,
1242
- role: "service_assignee",
1243
- firstName: staffParticipant.firstName,
1244
- lastName: staffParticipant.lastName,
1245
- email: staffParticipant.email ?? null,
1246
- phone: staffParticipant.phone ?? null,
1247
- preferredLanguage: staffParticipant.preferredLanguage ?? null,
1248
- isPrimary: staffParticipant.isPrimary,
1249
- notes: staffParticipant.notes ?? null,
1250
- metadata: {
1251
- sourceParticipantId: staffParticipant.id,
1252
- },
1253
- });
1254
- }
1255
- if (linkedStaffAssignments.length > 0) {
1256
- await tx.insert(bookingStaffAssignments).values(linkedStaffAssignments);
1257
- }
1258
- }
1259
- await tx
1260
- .insert(bookingTransactionDetailsRef)
1261
- .values({
1262
- bookingId: booking.id,
1263
- offerId: source.offerId,
1264
- orderId: source.orderId,
1265
- createdAt: new Date(),
1266
- updatedAt: new Date(),
1267
- })
1268
- .onConflictDoUpdate({
1269
- target: bookingTransactionDetailsRef.bookingId,
1270
- set: {
1271
- offerId: source.offerId,
1272
- orderId: source.orderId,
1273
- updatedAt: new Date(),
1274
- },
1275
- });
1276
- await tx.insert(bookingActivityLog).values({
1277
- bookingId: booking.id,
1278
- actorId: userId ?? "system",
1279
- activityType: "booking_reserved",
1280
- description: `Booking ${booking.bookingNumber} reserved from ${source.kind} ${source.sourceId}`,
1281
- metadata: {
1282
- sourceKind: source.kind,
1283
- sourceId: source.sourceId,
1284
- offerId: source.offerId,
1285
- orderId: source.orderId,
1286
- holdExpiresAt: holdExpiresAt.toISOString(),
1287
- itemCount: source.items.length,
1288
- },
1289
- });
1290
- if (data.note) {
1291
- await tx.insert(bookingNotes).values({
1292
- bookingId: booking.id,
1293
- authorId: userId ?? "system",
1294
- content: data.note,
1295
- });
1296
- }
1297
- return { status: "ok", booking };
1298
- });
1299
- if (result.status === "ok") {
1300
- await emitSlotChanges(runtime, slotChanges);
1301
- }
1302
- return result;
1303
- }
1304
- catch (error) {
1305
- if (error instanceof BookingServiceError) {
1306
- return { status: error.code };
1307
- }
1308
- throw error;
1309
- }
1310
- }
1311
- async function getBookingTransactionLink(db, bookingId) {
1312
- const [link] = await db
1313
- .select()
1314
- .from(bookingTransactionDetailsRef)
1315
- .where(eq(bookingTransactionDetailsRef.bookingId, bookingId))
1316
- .limit(1);
1317
- return link ?? null;
1318
- }
1319
- async function syncTransactionOnBookingConfirmed(db, bookingId) {
1320
- const link = await getBookingTransactionLink(db, bookingId);
1321
- if (!link) {
1322
- return;
1323
- }
1324
- const now = new Date();
1325
- if (link.orderId) {
1326
- await db
1327
- .update(ordersRef)
1328
- .set({
1329
- status: "confirmed",
1330
- confirmedAt: now,
1331
- updatedAt: now,
1332
- })
1333
- .where(eq(ordersRef.id, link.orderId));
1334
- }
1335
- if (link.offerId) {
1336
- await db
1337
- .update(offersRef)
1338
- .set({
1339
- status: "converted",
1340
- acceptedAt: now,
1341
- convertedAt: now,
1342
- updatedAt: now,
1343
- })
1344
- .where(eq(offersRef.id, link.offerId));
1345
- }
1346
- }
1347
- async function syncTransactionOnBookingExpired(db, bookingId) {
1348
- const link = await getBookingTransactionLink(db, bookingId);
1349
- if (!link?.orderId) {
1350
- return;
1351
- }
1352
- const now = new Date();
1353
- await db
1354
- .update(ordersRef)
1355
- .set({
1356
- status: "expired",
1357
- expiresAt: now,
1358
- updatedAt: now,
1359
- })
1360
- .where(eq(ordersRef.id, link.orderId));
1361
- }
1362
- async function syncTransactionOnBookingCancelled(db, bookingId) {
1363
- const link = await getBookingTransactionLink(db, bookingId);
1364
- if (!link?.orderId) {
1365
- return;
1366
- }
1367
- const now = new Date();
1368
- await db
1369
- .update(ordersRef)
1370
- .set({
1371
- status: "cancelled",
1372
- cancelledAt: now,
1373
- updatedAt: now,
1374
- })
1375
- .where(eq(ordersRef.id, link.orderId));
1376
- }
1377
- async function syncTransactionOnBookingRedeemed(db, bookingId) {
1378
- const link = await getBookingTransactionLink(db, bookingId);
1379
- if (!link?.orderId) {
1380
- return;
1381
- }
1382
- await db
1383
- .update(ordersRef)
1384
- .set({
1385
- status: "fulfilled",
1386
- updatedAt: new Date(),
1387
- })
1388
- .where(eq(ordersRef.id, link.orderId));
1389
- }
1390
- async function autoIssueFulfillmentsForBooking(db, bookingId, userId) {
1391
- const [booking] = await db
1392
- .select({
1393
- id: bookings.id,
1394
- bookingNumber: bookings.bookingNumber,
1395
- })
1396
- .from(bookings)
1397
- .where(eq(bookings.id, bookingId))
1398
- .limit(1);
1399
- if (!booking) {
1400
- return;
1401
- }
1402
- const existingFulfillment = await db
1403
- .select({ id: bookingFulfillments.id })
1404
- .from(bookingFulfillments)
1405
- .where(eq(bookingFulfillments.bookingId, bookingId))
1406
- .limit(1);
1407
- if (existingFulfillment.length > 0) {
1408
- return;
1409
- }
1410
- const items = await db
1411
- .select()
1412
- .from(bookingItems)
1413
- .where(and(eq(bookingItems.bookingId, bookingId), sql `${bookingItems.productId} IS NOT NULL`))
1414
- .orderBy(asc(bookingItems.createdAt));
1415
- if (items.length === 0) {
1416
- return;
1417
- }
1418
- const productIds = [
1419
- ...new Set(items.map((item) => item.productId).filter((value) => Boolean(value))),
1420
- ];
1421
- if (productIds.length === 0) {
1422
- return;
1423
- }
1424
- const settings = await db
1425
- .select()
1426
- .from(productTicketSettingsRef)
1427
- .where(inArray(productTicketSettingsRef.productId, productIds));
1428
- const settingsByProductId = new Map(settings.map((setting) => [setting.productId, setting]));
1429
- const travelerParticipants = await db
1430
- .select()
1431
- .from(bookingTravelers)
1432
- .where(and(eq(bookingTravelers.bookingId, bookingId), or(eq(bookingTravelers.participantType, "traveler"), eq(bookingTravelers.participantType, "occupant"))))
1433
- .orderBy(desc(bookingTravelers.isPrimary), asc(bookingTravelers.createdAt));
1434
- const participantLinks = await db
1435
- .select()
1436
- .from(bookingItemTravelers)
1437
- .where(sql `${bookingItemTravelers.bookingItemId} IN (
1438
- SELECT ${bookingItems.id}
1439
- FROM ${bookingItems}
1440
- WHERE ${bookingItems.bookingId} = ${bookingId}
1441
- )`);
1442
- const participantLinksByItemId = new Map();
1443
- for (const link of participantLinks) {
1444
- const links = participantLinksByItemId.get(link.bookingItemId) ?? [];
1445
- links.push(link);
1446
- participantLinksByItemId.set(link.bookingItemId, links);
1447
- }
1448
- const fulfillmentsToInsert = [];
1449
- const now = new Date();
1450
- for (const item of items) {
1451
- const productId = item.productId;
1452
- if (!productId) {
1453
- continue;
1454
- }
1455
- const setting = settingsByProductId.get(productId);
1456
- if (!setting ||
1457
- setting.fulfillmentMode === "none" ||
1458
- setting.defaultDeliveryFormat === "none") {
1459
- continue;
1460
- }
1461
- const delivery = mapDeliveryFormatToFulfillment(setting.defaultDeliveryFormat);
1462
- const payloadBase = {
1463
- bookingId,
1464
- bookingNumber: booking.bookingNumber,
1465
- productId,
1466
- bookingItemId: item.id,
1467
- };
1468
- if (setting.fulfillmentMode === "per_booking") {
1469
- if (fulfillmentsToInsert.some((row) => row.bookingItemId === item.id || row.bookingItemId === null)) {
1470
- continue;
1471
- }
1472
- fulfillmentsToInsert.push({
1473
- bookingId,
1474
- bookingItemId: item.id,
1475
- travelerId: null,
1476
- fulfillmentType: delivery.fulfillmentType,
1477
- deliveryChannel: delivery.deliveryChannel,
1478
- status: "issued",
1479
- payload: { ...payloadBase, scope: "booking" },
1480
- issuedAt: now,
1481
- });
1482
- continue;
1483
- }
1484
- if (setting.fulfillmentMode === "per_item") {
1485
- fulfillmentsToInsert.push({
1486
- bookingId,
1487
- bookingItemId: item.id,
1488
- travelerId: null,
1489
- fulfillmentType: delivery.fulfillmentType,
1490
- deliveryChannel: delivery.deliveryChannel,
1491
- status: "issued",
1492
- payload: { ...payloadBase, scope: "item" },
1493
- issuedAt: now,
1494
- });
1495
- continue;
1496
- }
1497
- const linkedParticipants = participantLinksByItemId
1498
- .get(item.id)
1499
- ?.map((link) => travelerParticipants.find((participant) => participant.id === link.travelerId))
1500
- .filter((participant) => Boolean(participant)) ?? [];
1501
- const participantsForItem = linkedParticipants.length > 0 ? linkedParticipants : travelerParticipants;
1502
- for (const participant of participantsForItem) {
1503
- fulfillmentsToInsert.push({
1504
- bookingId,
1505
- bookingItemId: item.id,
1506
- travelerId: participant.id,
1507
- fulfillmentType: delivery.fulfillmentType,
1508
- deliveryChannel: delivery.deliveryChannel,
1509
- status: "issued",
1510
- payload: {
1511
- ...payloadBase,
1512
- travelerId: participant.id,
1513
- scope: "participant",
1514
- },
1515
- issuedAt: now,
1516
- });
1517
- }
1518
- }
1519
- if (fulfillmentsToInsert.length === 0) {
1520
- return;
1521
- }
1522
- await db.insert(bookingFulfillments).values(fulfillmentsToInsert);
1523
- await db.insert(bookingActivityLog).values({
1524
- bookingId,
1525
- actorId: userId ?? "system",
1526
- activityType: "fulfillment_issued",
1527
- description: `${fulfillmentsToInsert.length} fulfillment artifact(s) issued automatically`,
1528
- metadata: { count: fulfillmentsToInsert.length },
1529
- });
1530
- }
1531
- /**
1532
- * Booking statuses that count as "active" for aggregate purposes (matches the
1533
- * slot-unit-availability counting rules — cancelled and expired drop out).
1534
- */
1535
- const AGGREGATE_ACTIVE_STATUSES = [
1536
- "draft",
1537
- "on_hold",
1538
- "confirmed",
1539
- "in_progress",
1540
- "completed",
1541
- ];
1542
- /**
1543
- * Normalize `db.execute(sql)` results across drizzle drivers.
1544
- * `drizzle-orm/postgres-js` returns rows directly (an array); the
1545
- * `node-postgres` + `neon-serverless` drivers (used by the operator
1546
- * template against local pg and Neon WS respectively) wrap them in a
1547
- * `QueryResult<T>` object with `.rows`. Casting straight to
1548
- * `Array<T>` and indexing produced silent `undefined`s on the wrapped
1549
- * shape — the symptom was "Booking not found" on freshly-created
1550
- * bookings whose status-change followup hit a FOR UPDATE SELECT.
1551
- */
1552
- function toRows(result) {
1553
- if (Array.isArray(result))
1554
- return result;
1555
- if (result && typeof result === "object" && "rows" in result) {
1556
- const rows = result.rows;
1557
- return Array.isArray(rows) ? rows : [];
1558
- }
1559
- return [];
1560
- }
1561
- export const bookingsService = {
1562
- /**
1563
- * Pre-aggregated dashboard numbers for the admin bookings surface. Replaces
1564
- * the pattern of fetching a large `listBookings` page and deriving KPIs
1565
- * client-side — which broke past the page limit and disagreed across apps
1566
- * on which statuses count.
1567
- *
1568
- * All ranges are UTC-based.
1569
- */
1570
- async getBookingAggregates(db, options = {}) {
1571
- const upcomingLimit = Math.max(0, Math.min(options.upcomingLimit ?? 8, 20));
1572
- const fromDate = options.from ? new Date(options.from) : undefined;
1573
- const toDate = options.to ? new Date(options.to) : undefined;
1574
- const rangeConditions = [];
1575
- if (fromDate)
1576
- rangeConditions.push(sql `${bookings.createdAt} >= ${fromDate.toISOString()}`);
1577
- if (toDate)
1578
- rangeConditions.push(sql `${bookings.createdAt} < ${toDate.toISOString()}`);
1579
- const rangeWhere = rangeConditions.length ? and(...rangeConditions) : undefined;
1580
- const todayUtc = new Date();
1581
- todayUtc.setUTCHours(0, 0, 0, 0);
1582
- const todayDateString = todayUtc.toISOString().slice(0, 10);
1583
- const upcomingFilter = and(inArray(bookings.status, [...AGGREGATE_ACTIVE_STATUSES]), sql `${bookings.startDate} >= ${todayDateString}`);
1584
- const [[totalRow], [totalPaxRow], statusRows, monthlyCountsRows, monthlyRevenueRows, [upcomingRow], upcomingItems,] = await Promise.all([
1585
- db.select({ count: sql `count(*)::int` }).from(bookings).where(rangeWhere),
1586
- db
1587
- .select({
1588
- totalPax: sql `coalesce(sum(${bookings.pax}), 0)::bigint`,
1589
- })
1590
- .from(bookings)
1591
- .where(and(...(rangeConditions.length ? rangeConditions : []), inArray(bookings.status, [...AGGREGATE_ACTIVE_STATUSES]))),
1592
- db
1593
- .select({
1594
- status: bookings.status,
1595
- count: sql `count(*)::int`,
1596
- })
1597
- .from(bookings)
1598
- .where(rangeWhere)
1599
- .groupBy(bookings.status),
1600
- db
1601
- .select({
1602
- yearMonth: sql `to_char(${bookings.createdAt} at time zone 'UTC', 'YYYY-MM')`,
1603
- count: sql `count(*)::int`,
1604
- })
1605
- .from(bookings)
1606
- .where(rangeWhere)
1607
- .groupBy(sql `to_char(${bookings.createdAt} at time zone 'UTC', 'YYYY-MM')`)
1608
- .orderBy(sql `to_char(${bookings.createdAt} at time zone 'UTC', 'YYYY-MM')`),
1609
- db
1610
- .select({
1611
- yearMonth: sql `to_char(${bookings.createdAt} at time zone 'UTC', 'YYYY-MM')`,
1612
- currency: bookings.sellCurrency,
1613
- sellAmountCents: sql `coalesce(sum(${bookings.sellAmountCents}), 0)::bigint`,
1614
- })
1615
- .from(bookings)
1616
- .where(and(...(rangeConditions.length ? rangeConditions : []), sql `${bookings.sellAmountCents} IS NOT NULL`, inArray(bookings.status, [...AGGREGATE_ACTIVE_STATUSES])))
1617
- .groupBy(sql `to_char(${bookings.createdAt} at time zone 'UTC', 'YYYY-MM')`, bookings.sellCurrency)
1618
- .orderBy(sql `to_char(${bookings.createdAt} at time zone 'UTC', 'YYYY-MM')`, bookings.sellCurrency),
1619
- db.select({ count: sql `count(*)::int` }).from(bookings).where(upcomingFilter),
1620
- upcomingLimit === 0
1621
- ? Promise.resolve([])
1622
- : db
1623
- .select({
1624
- id: bookings.id,
1625
- bookingNumber: bookings.bookingNumber,
1626
- status: bookings.status,
1627
- startDate: bookings.startDate,
1628
- endDate: bookings.endDate,
1629
- pax: bookings.pax,
1630
- sellCurrency: bookings.sellCurrency,
1631
- sellAmountCents: bookings.sellAmountCents,
1632
- })
1633
- .from(bookings)
1634
- .where(upcomingFilter)
1635
- .orderBy(asc(bookings.startDate), asc(bookings.id))
1636
- .limit(upcomingLimit),
1637
- ]);
1638
- const countsByStatusMap = new Map(statusRows.map((row) => [row.status, row.count]));
1639
- return {
1640
- total: totalRow?.count ?? 0,
1641
- totalPax: Number(totalPaxRow?.totalPax ?? 0),
1642
- countsByStatus: AGGREGATE_ACTIVE_STATUSES.concat(["expired", "cancelled"]).map((status) => ({
1643
- status,
1644
- count: countsByStatusMap.get(status) ?? 0,
1645
- })),
1646
- monthlyCounts: monthlyCountsRows.map((row) => ({
1647
- yearMonth: row.yearMonth,
1648
- count: row.count,
1649
- })),
1650
- monthlyRevenue: monthlyRevenueRows.map((row) => ({
1651
- yearMonth: row.yearMonth,
1652
- currency: row.currency,
1653
- sellAmountCents: Number(row.sellAmountCents),
1654
- })),
1655
- upcomingDepartures: {
1656
- count: upcomingRow?.count ?? 0,
1657
- items: upcomingItems,
1658
- },
1659
- };
1660
- },
1661
- async listBookings(db, query) {
1662
- const conditions = [];
1663
- if (query.status) {
1664
- conditions.push(eq(bookings.status, query.status));
1665
- }
1666
- const excludeStatuses = query.excludeStatuses
1667
- ? Array.isArray(query.excludeStatuses)
1668
- ? query.excludeStatuses
1669
- : [query.excludeStatuses]
1670
- : [];
1671
- if (excludeStatuses.length > 0) {
1672
- conditions.push(notInArray(bookings.status, excludeStatuses));
1673
- }
1674
- if (query.search) {
1675
- const searchCondition = buildBookingSearchCondition(query.search);
1676
- if (searchCondition) {
1677
- conditions.push(searchCondition);
1678
- }
1679
- }
1680
- if (query.personId) {
1681
- conditions.push(eq(bookings.personId, query.personId));
1682
- }
1683
- if (query.organizationId) {
1684
- conditions.push(eq(bookings.organizationId, query.organizationId));
1685
- }
1686
- if (query.dateFrom) {
1687
- conditions.push(gte(bookings.startDate, query.dateFrom));
1688
- }
1689
- if (query.dateTo) {
1690
- conditions.push(lte(bookings.startDate, query.dateTo));
1691
- }
1692
- if (query.paxMin !== undefined) {
1693
- conditions.push(gte(bookings.pax, query.paxMin));
1694
- }
1695
- if (query.paxMax !== undefined) {
1696
- conditions.push(lte(bookings.pax, query.paxMax));
1697
- }
1698
- if (query.productId ||
1699
- query.optionId ||
1700
- query.supplierId ||
1701
- query.productCategoryId ||
1702
- query.availabilitySlotId) {
1703
- const itemConditions = [eq(bookingItems.bookingId, bookings.id)];
1704
- if (query.productId) {
1705
- itemConditions.push(eq(bookingItems.productId, query.productId));
1706
- }
1707
- if (query.optionId) {
1708
- itemConditions.push(eq(bookingItems.optionId, query.optionId));
1709
- }
1710
- if (query.availabilitySlotId) {
1711
- itemConditions.push(eq(bookingItems.availabilitySlotId, query.availabilitySlotId));
1712
- }
1713
- if (query.supplierId) {
1714
- itemConditions.push(exists(db
1715
- .select({ one: sql `1` })
1716
- .from(productsRef)
1717
- .where(and(eq(productsRef.id, bookingItems.productId), eq(productsRef.supplierId, query.supplierId)))));
1718
- }
1719
- if (query.productCategoryId) {
1720
- itemConditions.push(exists(db
1721
- .select({ one: sql `1` })
1722
- .from(productCategoryProductsRef)
1723
- .where(and(eq(productCategoryProductsRef.productId, bookingItems.productId), eq(productCategoryProductsRef.categoryId, query.productCategoryId)))));
1724
- }
1725
- conditions.push(exists(db
1726
- .select({ one: sql `1` })
1727
- .from(bookingItems)
1728
- .where(and(...itemConditions))));
1729
- }
1730
- const where = conditions.length > 0 ? and(...conditions) : undefined;
1731
- const sortColumn = (() => {
1732
- switch (query.sortBy) {
1733
- case "bookingNumber":
1734
- return bookings.bookingNumber;
1735
- case "status":
1736
- return bookings.status;
1737
- case "sellAmount":
1738
- return bookings.sellAmountCents;
1739
- case "pax":
1740
- return bookings.pax;
1741
- case "startDate":
1742
- return bookings.startDate;
1743
- case "endDate":
1744
- return bookings.endDate;
1745
- default:
1746
- return bookings.createdAt;
1747
- }
1748
- })();
1749
- const sortFn = query.sortDir === "asc" ? asc : desc;
1750
- const [rows, countResult] = await Promise.all([
1751
- db
1752
- .select()
1753
- .from(bookings)
1754
- .where(where)
1755
- .limit(query.limit)
1756
- .offset(query.offset)
1757
- .orderBy(sortFn(sortColumn), desc(bookings.createdAt)),
1758
- db.select({ count: sql `count(*)::int` }).from(bookings).where(where),
1759
- ]);
1760
- const bookingIds = rows.map((row) => row.id);
1761
- const items = await listBookingItemsForSummaries(db, bookingIds);
1762
- const ranges = new Map();
1763
- const itemSummariesByBooking = new Map();
1764
- for (const item of items) {
1765
- const current = ranges.get(item.bookingId) ?? { startsAt: null, endsAt: null };
1766
- if (item.startsAt && (!current.startsAt || item.startsAt < current.startsAt)) {
1767
- current.startsAt = item.startsAt;
1768
- }
1769
- if (item.endsAt && (!current.endsAt || item.endsAt > current.endsAt)) {
1770
- current.endsAt = item.endsAt;
1771
- }
1772
- ranges.set(item.bookingId, current);
1773
- const list = itemSummariesByBooking.get(item.bookingId) ?? [];
1774
- list.push({
1775
- id: item.id,
1776
- title: item.title,
1777
- itemType: item.itemType,
1778
- productId: item.productId,
1779
- productName: item.productName,
1780
- startsAt: item.startsAt?.toISOString() ?? null,
1781
- endsAt: item.endsAt?.toISOString() ?? null,
1782
- });
1783
- itemSummariesByBooking.set(item.bookingId, list);
1784
- }
1785
- return {
1786
- data: rows.map((row) => {
1787
- const range = ranges.get(row.id);
1788
- return {
1789
- ...row,
1790
- startsAt: range?.startsAt?.toISOString() ?? null,
1791
- endsAt: range?.endsAt?.toISOString() ?? null,
1792
- items: itemSummariesByBooking.get(row.id) ?? [],
1793
- };
1794
- }),
1795
- total: countResult[0]?.count ?? 0,
1796
- limit: query.limit,
1797
- offset: query.offset,
1798
- };
1799
- },
1800
- async convertProductToBooking(db, data, productData, userId) {
1801
- const { product, option, slot, dayServices, units } = productData;
1802
- // Slot dates win over product dates so scheduled/recurring products don't
1803
- // land with null dates. endsAt is a timestamp; fall back to the slot's
1804
- // dateLocal when the slot has no explicit end timestamp.
1805
- const startDate = slot?.dateLocal ?? product.startDate;
1806
- const endDate = slot
1807
- ? slot.endsAt
1808
- ? slot.endsAt.toISOString().slice(0, 10)
1809
- : slot.dateLocal
1810
- : product.endDate;
1811
- // Caller-supplied `sellAmountCentsOverride` lets the catalog booking-
1812
- // engine pass the promotion-discounted base through to the booking
1813
- // row so the customer is charged the post-discount amount, not the
1814
- // product's list price. Per docs/architecture/promotions-architecture.md §7.1.
1815
- const confirmedSellAmountCents = data.confirmedSellAmountCents ?? null;
1816
- const catalogSellAmountCents = data.catalogSellAmountCents ?? product.sellAmountCents;
1817
- const effectiveSellAmountCents = confirmedSellAmountCents != null
1818
- ? confirmedSellAmountCents
1819
- : data.sellAmountCentsOverride != null
1820
- ? data.sellAmountCentsOverride
1821
- : product.sellAmountCents;
1822
- const priceOverrideReason = data.priceOverrideReason?.trim() ?? null;
1823
- const isManualPriceOverride = confirmedSellAmountCents != null && confirmedSellAmountCents !== catalogSellAmountCents;
1824
- const priceOverride = isManualPriceOverride
1825
- ? {
1826
- isManual: true,
1827
- originalAmountCents: catalogSellAmountCents,
1828
- overriddenAmountCents: confirmedSellAmountCents,
1829
- currency: product.sellCurrency,
1830
- reason: priceOverrideReason ?? "Manual price override",
1831
- overriddenBy: userId ?? "system",
1832
- overriddenAt: new Date().toISOString(),
1833
- }
1834
- : null;
1835
- const selectedUnits = data.itemLines && data.itemLines.length > 0 ? units : option === null ? [] : units;
1836
- const unitById = new Map(selectedUnits.map((unit) => [unit.id, unit]));
1837
- const requestedItemLines = data.itemLines
1838
- ?.map((line) => {
1839
- const unit = unitById.get(line.optionUnitId);
1840
- if (!unit)
1841
- return null;
1842
- if (line.optionId && line.optionId !== unit.optionId)
1843
- return null;
1844
- if (data.optionId && data.optionId !== unit.optionId)
1845
- return null;
1846
- return { line, unit };
1847
- })
1848
- .filter((entry) => entry !== null) ?? [];
1849
- if (data.itemLines && requestedItemLines.length !== data.itemLines.length) {
1850
- return null;
1851
- }
1852
- const initialStatus = data.initialStatus ?? "draft";
1853
- const bookingPax = Object.hasOwn(data, "pax") ? (data.pax ?? null) : product.pax;
1854
- // Map the booking lifecycle status onto the booking-item lifecycle.
1855
- // Items don't have an `awaiting_payment` state — when the booking is
1856
- // committed (confirmed / awaiting payment / in progress) the items
1857
- // are sold, so they land in `confirmed`. Holds, cancellations,
1858
- // expirations, and completions cascade their analog. Draft falls
1859
- // through as draft.
1860
- const initialItemStatus = initialStatus === "on_hold"
1861
- ? "on_hold"
1862
- : initialStatus === "confirmed" ||
1863
- initialStatus === "in_progress" ||
1864
- initialStatus === "awaiting_payment"
1865
- ? "confirmed"
1866
- : initialStatus === "cancelled"
1867
- ? "cancelled"
1868
- : initialStatus === "expired"
1869
- ? "expired"
1870
- : initialStatus === "completed"
1871
- ? "fulfilled"
1872
- : "draft";
1873
- const now = new Date();
1874
- const [booking] = await db
1875
- .insert(bookings)
1876
- .values({
1877
- bookingNumber: data.bookingNumber,
1878
- status: initialStatus,
1879
- // Mirror the lifecycle timestamps that overrideBookingStatus
1880
- // stamps when the status transition happens after-the-fact, so
1881
- // a booking that lands in `confirmed` straight from create is
1882
- // indistinguishable downstream from one that was flipped via
1883
- // the verb endpoint.
1884
- confirmedAt: initialStatus === "confirmed" ? now : null,
1885
- personId: data.personId ?? null,
1886
- organizationId: data.organizationId ?? null,
1887
- // Billing-contact snapshot — captured at create time so the
1888
- // booking detail page renders the right payer even if the
1889
- // CRM person/org record changes (or is deleted) later.
1890
- contactFirstName: data.contactFirstName ?? null,
1891
- contactLastName: data.contactLastName ?? null,
1892
- contactPartyType: data.contactPartyType ?? null,
1893
- contactTaxId: data.contactTaxId ?? null,
1894
- contactEmail: data.contactEmail ?? null,
1895
- contactPhone: data.contactPhone ?? null,
1896
- contactPreferredLanguage: data.contactPreferredLanguage ?? null,
1897
- contactCountry: data.contactCountry ?? null,
1898
- contactRegion: data.contactRegion ?? null,
1899
- contactCity: data.contactCity ?? null,
1900
- contactAddressLine1: data.contactAddressLine1 ?? null,
1901
- contactAddressLine2: data.contactAddressLine2 ?? null,
1902
- contactPostalCode: data.contactPostalCode ?? null,
1903
- sellCurrency: product.sellCurrency,
1904
- sellAmountCents: effectiveSellAmountCents,
1905
- priceOverride,
1906
- costAmountCents: product.costAmountCents,
1907
- marginPercent: product.marginPercent,
1908
- startDate,
1909
- endDate,
1910
- pax: bookingPax,
1911
- internalNotes: data.internalNotes ?? null,
1912
- })
1913
- .returning();
1914
- if (!booking) {
1915
- return null;
1916
- }
1917
- if (dayServices.length > 0) {
1918
- await db.insert(bookingSupplierStatuses).values(dayServices.map((service) => ({
1919
- bookingId: booking.id,
1920
- supplierServiceId: service.supplierServiceId,
1921
- serviceName: service.name,
1922
- status: "pending",
1923
- costCurrency: service.costCurrency,
1924
- costAmountCents: service.costAmountCents,
1925
- })));
1926
- }
1927
- const unitsToSeed = selectedUnits.filter((unit) => unit.isRequired).length > 0
1928
- ? selectedUnits.filter((unit) => unit.isRequired)
1929
- : selectedUnits.length === 1
1930
- ? selectedUnits
1931
- : [];
1932
- // Slot-derived columns + catalog snapshot. `availabilitySlotId`
1933
- // and `departureLabelSnapshot` carry the departure forward so the
1934
- // booking detail page can show "Dates" without a JOIN — and the
1935
- // snapshot survives even if the slot row is later deleted.
1936
- // `metadata.availabilitySlotId` is kept for backwards compatibility
1937
- // with the older write path; new readers should prefer the column.
1938
- const slotFields = slot
1939
- ? {
1940
- serviceDate: slot.dateLocal,
1941
- startsAt: slot.startsAt,
1942
- endsAt: slot.endsAt,
1943
- availabilitySlotId: slot.id,
1944
- departureLabelSnapshot: formatDepartureLabel(slot.startsAt, slot.timezone),
1945
- metadata: { availabilitySlotId: slot.id },
1946
- }
1947
- : {
1948
- availabilitySlotId: null,
1949
- departureLabelSnapshot: null,
1950
- metadata: null,
1951
- };
1952
- // Catalog snapshot shared across every item row (product + option
1953
- // names are the same for the whole booking; the per-row unit name
1954
- // is filled in below).
1955
- const productOptionSnapshot = {
1956
- productNameSnapshot: product.name,
1957
- optionNameSnapshot: option?.name ?? null,
1958
- };
1959
- // Stamp the wire-format `clientLineKey` into the inserted item's
1960
- // metadata so the booking-create orchestrator can find the row
1961
- // afterward and write `booking_item_travelers` linkages. The
1962
- // existing slot metadata (when present) is preserved.
1963
- const itemLineMetadata = (clientLineKey) => {
1964
- const slotMetadata = (slotFields.metadata ?? {});
1965
- return clientLineKey
1966
- ? { ...slotMetadata, bookingCreateLineKey: clientLineKey }
1967
- : slotFields.metadata;
1968
- };
1969
- // Seeded line-item totals must match the booking's `sellAmountCents`
1970
- // so checkout / payment / invoicing don't see a list-price item beneath
1971
- // a discounted booking header.
1972
- const itemRows = requestedItemLines.length > 0
1973
- ? requestedItemLines.map(({ line, unit }) => {
1974
- const totalSellAmountCents = line.totalSellAmountCents ??
1975
- (line.unitSellAmountCents != null ? line.unitSellAmountCents * line.quantity : null);
1976
- return {
1977
- bookingId: booking.id,
1978
- title: line.title?.trim() || unit.name,
1979
- description: line.description ?? unit.description,
1980
- itemType: "unit",
1981
- status: initialItemStatus,
1982
- quantity: line.quantity,
1983
- sellCurrency: product.sellCurrency,
1984
- unitSellAmountCents: line.unitSellAmountCents ?? null,
1985
- totalSellAmountCents,
1986
- costCurrency: null,
1987
- unitCostAmountCents: null,
1988
- totalCostAmountCents: null,
1989
- productId: product.id,
1990
- optionId: unit.optionId,
1991
- optionUnitId: unit.id,
1992
- ...productOptionSnapshot,
1993
- unitNameSnapshot: unit.name,
1994
- ...slotFields,
1995
- metadata: itemLineMetadata(line.clientLineKey),
1996
- };
1997
- })
1998
- : unitsToSeed.length > 0
1999
- ? unitsToSeed.map((unit, index) => {
2000
- const quantity = unit.unitType === "person" && bookingPax
2001
- ? bookingPax
2002
- : unit.minQuantity && unit.minQuantity > 0
2003
- ? unit.minQuantity
2004
- : 1;
2005
- const singleSeedItem = unitsToSeed.length === 1 && index === 0;
2006
- return {
2007
- bookingId: booking.id,
2008
- title: unit.name,
2009
- description: unit.description,
2010
- itemType: "unit",
2011
- status: initialItemStatus,
2012
- quantity,
2013
- sellCurrency: product.sellCurrency,
2014
- unitSellAmountCents: singleSeedItem &&
2015
- effectiveSellAmountCents !== null &&
2016
- effectiveSellAmountCents !== undefined
2017
- ? Math.floor(effectiveSellAmountCents / quantity)
2018
- : null,
2019
- totalSellAmountCents: singleSeedItem ? (effectiveSellAmountCents ?? null) : null,
2020
- costCurrency: singleSeedItem ? product.sellCurrency : null,
2021
- unitCostAmountCents: singleSeedItem &&
2022
- product.costAmountCents !== null &&
2023
- product.costAmountCents !== undefined
2024
- ? Math.floor(product.costAmountCents / quantity)
2025
- : null,
2026
- totalCostAmountCents: singleSeedItem ? (product.costAmountCents ?? null) : null,
2027
- productId: product.id,
2028
- optionId: option?.id ?? null,
2029
- optionUnitId: unit.id,
2030
- ...productOptionSnapshot,
2031
- unitNameSnapshot: unit.name,
2032
- ...slotFields,
2033
- };
2034
- })
2035
- : [
2036
- {
2037
- bookingId: booking.id,
2038
- title: option?.name ?? product.name,
2039
- description: product.description,
2040
- itemType: "unit",
2041
- status: initialItemStatus,
2042
- quantity: 1,
2043
- sellCurrency: product.sellCurrency,
2044
- unitSellAmountCents: effectiveSellAmountCents ?? null,
2045
- totalSellAmountCents: effectiveSellAmountCents ?? null,
2046
- costCurrency: product.sellCurrency,
2047
- unitCostAmountCents: product.costAmountCents ?? null,
2048
- totalCostAmountCents: product.costAmountCents ?? null,
2049
- productId: product.id,
2050
- optionId: option?.id ?? null,
2051
- optionUnitId: null,
2052
- ...productOptionSnapshot,
2053
- unitNameSnapshot: null,
2054
- ...slotFields,
2055
- },
2056
- ];
2057
- const insertedItems = await db.insert(bookingItems).values(itemRows).returning();
2058
- const allocationRows = insertedItems
2059
- .filter((item) => item.availabilitySlotId)
2060
- .map((item) => ({
2061
- bookingId: booking.id,
2062
- bookingItemId: item.id,
2063
- productId: item.productId ?? null,
2064
- optionId: item.optionId ?? null,
2065
- optionUnitId: item.optionUnitId ?? null,
2066
- pricingCategoryId: item.pricingCategoryId ?? null,
2067
- availabilitySlotId: item.availabilitySlotId,
2068
- quantity: item.quantity,
2069
- allocationType: "unit",
2070
- status: allocationStatusForBookingItemStatus(item.status),
2071
- holdExpiresAt: null,
2072
- metadata: item.metadata ?? null,
2073
- }));
2074
- if (allocationRows.length > 0) {
2075
- for (const allocation of allocationRows) {
2076
- if (!allocation.availabilitySlotId ||
2077
- !allocationStatusConsumesSlotCapacity(allocation.status)) {
2078
- continue;
2079
- }
2080
- const capacity = await adjustSlotCapacity(db, allocation.availabilitySlotId, -allocation.quantity, "booking");
2081
- if (capacity.status === "slot_not_found") {
2082
- throw new BookingServiceError("slot_not_found");
2083
- }
2084
- if (capacity.status === "slot_unavailable") {
2085
- throw new BookingServiceError("slot_unavailable");
2086
- }
2087
- if (capacity.status === "insufficient_capacity") {
2088
- throw new BookingServiceError("insufficient_capacity");
2089
- }
2090
- }
2091
- await db.insert(bookingAllocations).values(allocationRows);
2092
- }
2093
- await db
2094
- .insert(bookingProductDetailsRef)
2095
- .values({
2096
- bookingId: booking.id,
2097
- productId: product.id,
2098
- optionId: option?.id ?? null,
2099
- })
2100
- .onConflictDoUpdate({
2101
- target: bookingProductDetailsRef.bookingId,
2102
- set: {
2103
- productId: product.id,
2104
- optionId: option?.id ?? null,
2105
- updatedAt: new Date(),
2106
- },
2107
- });
2108
- if (insertedItems.length > 0) {
2109
- await db.insert(bookingItemProductDetailsRef).values(insertedItems.map((item) => ({
2110
- bookingItemId: item.id,
2111
- productId: item.productId ?? null,
2112
- optionId: item.optionId ?? null,
2113
- unitId: item.optionUnitId ?? null,
2114
- supplierServiceId: null,
2115
- })));
2116
- }
2117
- await db.insert(bookingActivityLog).values({
2118
- bookingId: booking.id,
2119
- actorId: userId ?? "system",
2120
- activityType: "booking_converted",
2121
- description: `Booking converted from product "${product.name}"`,
2122
- metadata: {
2123
- productId: product.id,
2124
- productName: product.name,
2125
- optionId: option?.id ?? null,
2126
- slotId: slot?.id ?? null,
2127
- },
2128
- });
2129
- if (priceOverride) {
2130
- await db.insert(bookingActivityLog).values({
2131
- bookingId: booking.id,
2132
- actorId: userId ?? "system",
2133
- activityType: "system_action",
2134
- description: "Booking sell total manually overridden during create",
2135
- metadata: { kind: "booking_price_overridden", ...priceOverride },
2136
- });
2137
- }
2138
- return booking;
2139
- },
2140
- async getBookingById(db, id) {
2141
- const [row] = await db.select().from(bookings).where(eq(bookings.id, id)).limit(1);
2142
- return row ?? null;
2143
- },
2144
- async createBookingFromProduct(db, data, userId) {
2145
- const productData = await getConvertProductData(db, data);
2146
- if (!productData) {
2147
- return null;
2148
- }
2149
- return this.convertProductToBooking(db, data, productData, userId);
2150
- },
2151
- listAllocations(db, bookingId) {
2152
- return db
2153
- .select()
2154
- .from(bookingAllocations)
2155
- .where(eq(bookingAllocations.bookingId, bookingId))
2156
- .orderBy(asc(bookingAllocations.createdAt));
2157
- },
2158
- async reserveBookingFromOffer(db, offerId, data, userId, runtime = {}) {
2159
- const [offer] = await db.select().from(offersRef).where(eq(offersRef.id, offerId)).limit(1);
2160
- if (!offer) {
2161
- return { status: "not_found" };
2162
- }
2163
- const [participants, items, itemParticipants, staffAssignments] = await Promise.all([
2164
- db
2165
- .select()
2166
- .from(offerParticipantsRef)
2167
- .where(eq(offerParticipantsRef.offerId, offerId))
2168
- .orderBy(asc(offerParticipantsRef.createdAt)),
2169
- db
2170
- .select()
2171
- .from(offerItemsRef)
2172
- .where(eq(offerItemsRef.offerId, offerId))
2173
- .orderBy(asc(offerItemsRef.createdAt)),
2174
- db
2175
- .select()
2176
- .from(offerItemParticipantsRef)
2177
- .where(sql `${offerItemParticipantsRef.offerItemId} IN (
2178
- SELECT ${offerItemsRef.id}
2179
- FROM ${offerItemsRef}
2180
- WHERE ${offerItemsRef.offerId} = ${offerId}
2181
- )`)
2182
- .orderBy(asc(offerItemParticipantsRef.createdAt)),
2183
- db
2184
- .select()
2185
- .from(offerStaffAssignmentsRef)
2186
- .where(eq(offerStaffAssignmentsRef.offerId, offerId))
2187
- .orderBy(asc(offerStaffAssignmentsRef.createdAt)),
2188
- ]);
2189
- const reservationParticipants = [...participants];
2190
- const reservationItemParticipants = itemParticipants.map((link) => ({
2191
- travelerId: link.travelerId,
2192
- role: link.role,
2193
- isPrimary: link.isPrimary,
2194
- offerItemId: link.offerItemId,
2195
- }));
2196
- for (const assignment of staffAssignments) {
2197
- const participant = toStaffReservationParticipant(assignment, "offer");
2198
- reservationParticipants.push(participant);
2199
- if (assignment.offerItemId) {
2200
- reservationItemParticipants.push({
2201
- travelerId: participant.id,
2202
- role: assignment.role,
2203
- isPrimary: assignment.isPrimary,
2204
- offerItemId: assignment.offerItemId,
2205
- });
2206
- }
2207
- }
2208
- return reserveBookingFromTransactionSource(db, {
2209
- kind: "offer",
2210
- sourceId: offerId,
2211
- offerId: offer.id,
2212
- orderId: null,
2213
- personId: offer.personId ?? null,
2214
- organizationId: offer.organizationId ?? null,
2215
- contactFirstName: offer.contactFirstName ?? null,
2216
- contactLastName: offer.contactLastName ?? null,
2217
- contactPartyType: offer.contactPartyType ?? null,
2218
- contactTaxId: offer.contactTaxId ?? null,
2219
- contactEmail: offer.contactEmail ?? null,
2220
- contactPhone: offer.contactPhone ?? null,
2221
- contactPreferredLanguage: offer.contactPreferredLanguage ?? null,
2222
- contactCountry: offer.contactCountry ?? null,
2223
- contactRegion: offer.contactRegion ?? null,
2224
- contactCity: offer.contactCity ?? null,
2225
- contactAddressLine1: offer.contactAddressLine1 ?? null,
2226
- contactAddressLine2: offer.contactAddressLine2 ?? null,
2227
- contactPostalCode: offer.contactPostalCode ?? null,
2228
- currency: offer.currency,
2229
- baseCurrency: offer.baseCurrency ?? null,
2230
- totalAmountCents: offer.totalAmountCents ?? null,
2231
- costAmountCents: offer.costAmountCents ?? null,
2232
- notes: offer.notes ?? null,
2233
- participants: reservationParticipants,
2234
- items,
2235
- itemParticipants: reservationItemParticipants,
2236
- }, data, userId, runtime);
2237
- },
2238
- async reserveBookingFromOrder(db, orderId, data, userId, runtime = {}) {
2239
- const [order] = await db.select().from(ordersRef).where(eq(ordersRef.id, orderId)).limit(1);
2240
- if (!order) {
2241
- return { status: "not_found" };
2242
- }
2243
- const [participants, items, itemParticipants, staffAssignments] = await Promise.all([
2244
- db
2245
- .select()
2246
- .from(orderParticipantsRef)
2247
- .where(eq(orderParticipantsRef.orderId, orderId))
2248
- .orderBy(asc(orderParticipantsRef.createdAt)),
2249
- db
2250
- .select()
2251
- .from(orderItemsRef)
2252
- .where(eq(orderItemsRef.orderId, orderId))
2253
- .orderBy(asc(orderItemsRef.createdAt)),
2254
- db
2255
- .select()
2256
- .from(orderItemParticipantsRef)
2257
- .where(sql `${orderItemParticipantsRef.orderItemId} IN (
2258
- SELECT ${orderItemsRef.id}
2259
- FROM ${orderItemsRef}
2260
- WHERE ${orderItemsRef.orderId} = ${orderId}
2261
- )`)
2262
- .orderBy(asc(orderItemParticipantsRef.createdAt)),
2263
- db
2264
- .select()
2265
- .from(orderStaffAssignmentsRef)
2266
- .where(eq(orderStaffAssignmentsRef.orderId, orderId))
2267
- .orderBy(asc(orderStaffAssignmentsRef.createdAt)),
2268
- ]);
2269
- const reservationParticipants = [...participants];
2270
- const reservationItemParticipants = itemParticipants.map((link) => ({
2271
- travelerId: link.travelerId,
2272
- role: link.role,
2273
- isPrimary: link.isPrimary,
2274
- orderItemId: link.orderItemId,
2275
- }));
2276
- for (const assignment of staffAssignments) {
2277
- const participant = toStaffReservationParticipant(assignment, "order");
2278
- reservationParticipants.push(participant);
2279
- if (assignment.orderItemId) {
2280
- reservationItemParticipants.push({
2281
- travelerId: participant.id,
2282
- role: assignment.role,
2283
- isPrimary: assignment.isPrimary,
2284
- orderItemId: assignment.orderItemId,
2285
- });
2286
- }
2287
- }
2288
- return reserveBookingFromTransactionSource(db, {
2289
- kind: "order",
2290
- sourceId: orderId,
2291
- offerId: order.offerId ?? null,
2292
- orderId: order.id,
2293
- personId: order.personId ?? null,
2294
- organizationId: order.organizationId ?? null,
2295
- contactFirstName: order.contactFirstName ?? null,
2296
- contactLastName: order.contactLastName ?? null,
2297
- contactPartyType: order.contactPartyType ?? null,
2298
- contactTaxId: order.contactTaxId ?? null,
2299
- contactEmail: order.contactEmail ?? null,
2300
- contactPhone: order.contactPhone ?? null,
2301
- contactPreferredLanguage: order.contactPreferredLanguage ?? null,
2302
- contactCountry: order.contactCountry ?? null,
2303
- contactRegion: order.contactRegion ?? null,
2304
- contactCity: order.contactCity ?? null,
2305
- contactAddressLine1: order.contactAddressLine1 ?? null,
2306
- contactAddressLine2: order.contactAddressLine2 ?? null,
2307
- contactPostalCode: order.contactPostalCode ?? null,
2308
- currency: order.currency,
2309
- baseCurrency: order.baseCurrency ?? null,
2310
- totalAmountCents: order.totalAmountCents ?? null,
2311
- costAmountCents: order.costAmountCents ?? null,
2312
- notes: order.notes ?? null,
2313
- participants: reservationParticipants,
2314
- items,
2315
- itemParticipants: reservationItemParticipants,
2316
- }, data, userId, runtime);
2317
- },
2318
- async reserveBooking(db, data, userId, runtime = {}) {
2319
- const slotChanges = [];
2320
- try {
2321
- // Everything that doesn't need the slot locks runs BEFORE the
2322
- // transaction opens so the `FOR UPDATE` critical section stays as
2323
- // short as possible (T7, perf RFC): hold-policy resolution and the
2324
- // catalog name snapshots are plain reads of catalog/policy data
2325
- // that the slot lock doesn't protect anyway (a product rename
2326
- // could land mid-transaction either way).
2327
- const holdExpiresAt = await computeHoldExpiresAt(db, data, data.items);
2328
- // Unlocked pre-read of slot -> product/option so items that omit
2329
- // productId/optionId can still resolve their catalog snapshot
2330
- // pre-transaction. `product_id`/`option_id` are immutable on a
2331
- // slot, so this read cannot diverge from the locked read inside
2332
- // the transaction. Missing table (catalog-less deployment) or a
2333
- // missing slot just degrades the snapshot to nulls — the locked
2334
- // read inside the transaction stays authoritative for existence
2335
- // and mismatch checks.
2336
- const slotIds = [...new Set(data.items.map((item) => item.availabilitySlotId))];
2337
- const slotInfo = new Map();
2338
- try {
2339
- const slotRows = await db
2340
- .select({
2341
- id: availabilitySlotsRef.id,
2342
- productId: availabilitySlotsRef.productId,
2343
- optionId: availabilitySlotsRef.optionId,
2344
- })
2345
- .from(availabilitySlotsRef)
2346
- .where(inArray(availabilitySlotsRef.id, slotIds));
2347
- for (const row of slotRows) {
2348
- slotInfo.set(row.id, { productId: row.productId, optionId: row.optionId });
2349
- }
2350
- }
2351
- catch (error) {
2352
- if (!isUndefinedTableError(error))
2353
- throw error;
2354
- }
2355
- const itemSnapshots = await Promise.all(data.items.map((item) => {
2356
- const preSlot = slotInfo.get(item.availabilitySlotId);
2357
- return resolveBookingItemSnapshot(db, {
2358
- productId: item.productId ?? preSlot?.productId ?? null,
2359
- optionId: item.optionId ?? preSlot?.optionId ?? null,
2360
- optionUnitId: item.optionUnitId ?? null,
2361
- availabilitySlotId: item.availabilitySlotId,
2362
- });
2363
- }));
2364
- const result = await db.transaction(async (tx) => {
2365
- const [booking] = await tx
2366
- .insert(bookings)
2367
- .values({
2368
- bookingNumber: data.bookingNumber,
2369
- status: "on_hold",
2370
- personId: data.personId ?? null,
2371
- organizationId: data.organizationId ?? null,
2372
- sourceType: data.sourceType,
2373
- externalBookingRef: data.externalBookingRef ?? null,
2374
- communicationLanguage: data.communicationLanguage ?? null,
2375
- contactFirstName: data.contactFirstName ?? null,
2376
- contactLastName: data.contactLastName ?? null,
2377
- contactPartyType: data.contactPartyType ?? null,
2378
- contactTaxId: data.contactTaxId ?? null,
2379
- contactEmail: data.contactEmail ?? null,
2380
- contactPhone: data.contactPhone ?? null,
2381
- contactPreferredLanguage: data.contactPreferredLanguage ?? null,
2382
- contactCountry: data.contactCountry ?? null,
2383
- contactRegion: data.contactRegion ?? null,
2384
- contactCity: data.contactCity ?? null,
2385
- contactAddressLine1: data.contactAddressLine1 ?? null,
2386
- contactAddressLine2: data.contactAddressLine2 ?? null,
2387
- contactPostalCode: data.contactPostalCode ?? null,
2388
- sellCurrency: data.sellCurrency,
2389
- baseCurrency: data.baseCurrency ?? null,
2390
- sellAmountCents: data.sellAmountCents ?? null,
2391
- baseSellAmountCents: data.baseSellAmountCents ?? null,
2392
- costAmountCents: data.costAmountCents ?? null,
2393
- baseCostAmountCents: data.baseCostAmountCents ?? null,
2394
- marginPercent: data.marginPercent ?? null,
2395
- startDate: data.startDate ?? null,
2396
- endDate: data.endDate ?? null,
2397
- pax: data.pax ?? null,
2398
- internalNotes: data.internalNotes ?? null,
2399
- holdExpiresAt,
2400
- })
2401
- .returning();
2402
- if (!booking) {
2403
- throw new BookingServiceError("booking_create_failed");
2404
- }
2405
- // The locked critical section: per item, only the slot lock +
2406
- // capacity adjustment runs serially (correctness — that's the
2407
- // row the `FOR UPDATE` protects). Row payloads are accumulated
2408
- // and written with ONE batched insert per table afterwards,
2409
- // instead of two inserts per item while holding the locks.
2410
- const itemRows = [];
2411
- const allocationRows = [];
2412
- for (const [index, item] of data.items.entries()) {
2413
- const capacity = await adjustSlotCapacity(tx, item.availabilitySlotId, -item.quantity, "booking");
2414
- if (capacity.status === "slot_not_found") {
2415
- throw new BookingServiceError("slot_not_found");
2416
- }
2417
- if (capacity.status === "slot_unavailable") {
2418
- throw new BookingServiceError("slot_unavailable");
2419
- }
2420
- if (capacity.status === "insufficient_capacity") {
2421
- throw new BookingServiceError("insufficient_capacity");
2422
- }
2423
- const slot = capacity.slot;
2424
- if (item.productId && item.productId !== slot.product_id) {
2425
- throw new BookingServiceError("slot_product_mismatch");
2426
- }
2427
- if (item.optionId && item.optionId !== slot.option_id) {
2428
- throw new BookingServiceError("slot_option_mismatch");
2429
- }
2430
- if (capacity.slotChange)
2431
- slotChanges.push(capacity.slotChange);
2432
- const productId = item.productId ?? slot.product_id;
2433
- const optionId = item.optionId ?? slot.option_id;
2434
- const optionUnitId = item.optionUnitId ?? null;
2435
- const snapshot = itemSnapshots[index];
2436
- // Ids are generated app-side (same `newId` the column default
2437
- // uses) so allocations can reference their item without
2438
- // depending on RETURNING order of the batched insert.
2439
- const bookingItemId = newId("booking_items");
2440
- itemRows.push({
2441
- id: bookingItemId,
2442
- bookingId: booking.id,
2443
- title: item.title,
2444
- description: item.description ?? null,
2445
- itemType: item.itemType,
2446
- status: "on_hold",
2447
- serviceDate: slot.date_local,
2448
- startsAt: slot.starts_at,
2449
- endsAt: slot.ends_at,
2450
- quantity: item.quantity,
2451
- sellCurrency: item.sellCurrency ?? booking.sellCurrency,
2452
- unitSellAmountCents: item.unitSellAmountCents ?? null,
2453
- totalSellAmountCents: item.totalSellAmountCents ?? null,
2454
- costCurrency: item.costCurrency ?? null,
2455
- unitCostAmountCents: item.unitCostAmountCents ?? null,
2456
- totalCostAmountCents: item.totalCostAmountCents ?? null,
2457
- notes: item.notes ?? null,
2458
- productId,
2459
- optionId,
2460
- optionUnitId,
2461
- pricingCategoryId: item.pricingCategoryId ?? null,
2462
- availabilitySlotId: item.availabilitySlotId,
2463
- productNameSnapshot: item.productNameSnapshot ?? snapshot?.productName ?? null,
2464
- optionNameSnapshot: item.optionNameSnapshot ?? snapshot?.optionName ?? null,
2465
- unitNameSnapshot: item.unitNameSnapshot ?? snapshot?.unitName ?? null,
2466
- departureLabelSnapshot: item.departureLabelSnapshot ?? snapshot?.departureLabel ?? null,
2467
- sourceSnapshotId: item.sourceSnapshotId ?? null,
2468
- sourceOfferId: item.sourceOfferId ?? null,
2469
- metadata: item.metadata ?? null,
2470
- });
2471
- allocationRows.push({
2472
- bookingId: booking.id,
2473
- bookingItemId,
2474
- productId,
2475
- optionId,
2476
- optionUnitId,
2477
- pricingCategoryId: item.pricingCategoryId ?? null,
2478
- availabilitySlotId: item.availabilitySlotId,
2479
- quantity: item.quantity,
2480
- allocationType: item.allocationType,
2481
- status: "held",
2482
- holdExpiresAt,
2483
- metadata: item.metadata ?? null,
2484
- });
2485
- }
2486
- const insertedItems = await tx
2487
- .insert(bookingItems)
2488
- .values(itemRows)
2489
- .returning({ id: bookingItems.id });
2490
- if (insertedItems.length !== itemRows.length) {
2491
- throw new BookingServiceError("booking_item_create_failed");
2492
- }
2493
- const insertedAllocations = await tx
2494
- .insert(bookingAllocations)
2495
- .values(allocationRows)
2496
- .returning({ id: bookingAllocations.id });
2497
- if (insertedAllocations.length !== allocationRows.length) {
2498
- throw new BookingServiceError("allocation_create_failed");
2499
- }
2500
- await tx.insert(bookingActivityLog).values({
2501
- bookingId: booking.id,
2502
- actorId: userId ?? "system",
2503
- activityType: "booking_reserved",
2504
- description: `Booking ${booking.bookingNumber} reserved and placed on hold`,
2505
- metadata: { holdExpiresAt: holdExpiresAt.toISOString(), itemCount: data.items.length },
2506
- });
2507
- return { status: "ok", booking };
2508
- });
2509
- if (result.status === "ok") {
2510
- await emitSlotChanges(runtime, slotChanges);
2511
- }
2512
- return result;
2513
- }
2514
- catch (error) {
2515
- if (error instanceof BookingServiceError) {
2516
- return { status: error.code };
2517
- }
2518
- throw error;
2519
- }
2520
- },
2521
- async createBooking(db, data, userId) {
2522
- return db.transaction(async (tx) => {
2523
- const status = data.status ?? "draft";
2524
- const [row] = await tx
2525
- .insert(bookings)
2526
- .values({
2527
- ...data,
2528
- status,
2529
- contactFirstName: data.contactFirstName ?? null,
2530
- contactLastName: data.contactLastName ?? null,
2531
- contactPartyType: data.contactPartyType ?? null,
2532
- contactTaxId: data.contactTaxId ?? null,
2533
- contactEmail: data.contactEmail ?? null,
2534
- contactPhone: data.contactPhone ?? null,
2535
- contactPreferredLanguage: data.contactPreferredLanguage ?? null,
2536
- contactCountry: data.contactCountry ?? null,
2537
- contactRegion: data.contactRegion ?? null,
2538
- contactCity: data.contactCity ?? null,
2539
- contactAddressLine1: data.contactAddressLine1 ?? null,
2540
- contactAddressLine2: data.contactAddressLine2 ?? null,
2541
- contactPostalCode: data.contactPostalCode ?? null,
2542
- holdExpiresAt: toTimestamp(data.holdExpiresAt),
2543
- confirmedAt: confirmedAtForStatus(status, toTimestamp(data.confirmedAt)),
2544
- expiredAt: toTimestamp(data.expiredAt),
2545
- cancelledAt: toTimestamp(data.cancelledAt),
2546
- completedAt: toTimestamp(data.completedAt),
2547
- redeemedAt: toTimestamp(data.redeemedAt),
2548
- })
2549
- .returning();
2550
- if (!row) {
2551
- return null;
2552
- }
2553
- await tx.insert(bookingActivityLog).values({
2554
- bookingId: row.id,
2555
- actorId: userId ?? "system",
2556
- activityType: "booking_created",
2557
- description: `Booking ${data.bookingNumber} created`,
2558
- });
2559
- return row;
2560
- });
2561
- },
2562
- async updateBooking(db, id, data) {
2563
- const normalizedData = normalizeBookingBillingPartyUpdate(data);
2564
- return db.transaction(async (tx) => {
2565
- const rows = await tx.execute(sql `SELECT status
2566
- FROM ${bookings}
2567
- WHERE ${bookings.id} = ${id}
2568
- FOR UPDATE`);
2569
- const existing = toRows(rows)[0];
2570
- if (!existing)
2571
- return null;
2572
- const [row] = await tx
2573
- .update(bookings)
2574
- .set({
2575
- ...normalizedData,
2576
- contactFirstName: data.contactFirstName === undefined ? undefined : (data.contactFirstName ?? null),
2577
- contactLastName: data.contactLastName === undefined ? undefined : (data.contactLastName ?? null),
2578
- contactPartyType: data.contactPartyType === undefined ? undefined : (data.contactPartyType ?? null),
2579
- contactTaxId: data.contactTaxId === undefined ? undefined : (data.contactTaxId ?? null),
2580
- contactEmail: data.contactEmail === undefined ? undefined : (data.contactEmail ?? null),
2581
- contactPhone: data.contactPhone === undefined ? undefined : (data.contactPhone ?? null),
2582
- contactPreferredLanguage: data.contactPreferredLanguage === undefined
2583
- ? undefined
2584
- : (data.contactPreferredLanguage ?? null),
2585
- contactCountry: data.contactCountry === undefined ? undefined : (data.contactCountry ?? null),
2586
- contactRegion: data.contactRegion === undefined ? undefined : (data.contactRegion ?? null),
2587
- contactCity: data.contactCity === undefined ? undefined : (data.contactCity ?? null),
2588
- contactAddressLine1: data.contactAddressLine1 === undefined ? undefined : (data.contactAddressLine1 ?? null),
2589
- contactAddressLine2: data.contactAddressLine2 === undefined ? undefined : (data.contactAddressLine2 ?? null),
2590
- contactPostalCode: data.contactPostalCode === undefined ? undefined : (data.contactPostalCode ?? null),
2591
- holdExpiresAt: data.holdExpiresAt === undefined ? undefined : toTimestamp(data.holdExpiresAt),
2592
- confirmedAt: confirmedAtForBookingUpdate(existing.status, data),
2593
- expiredAt: data.expiredAt === undefined ? undefined : toTimestamp(data.expiredAt),
2594
- cancelledAt: data.cancelledAt === undefined ? undefined : toTimestamp(data.cancelledAt),
2595
- completedAt: data.completedAt === undefined ? undefined : toTimestamp(data.completedAt),
2596
- redeemedAt: data.redeemedAt === undefined ? undefined : toTimestamp(data.redeemedAt),
2597
- updatedAt: new Date(),
2598
- })
2599
- .where(eq(bookings.id, id))
2600
- .returning();
2601
- return row ?? null;
2602
- });
2603
- },
2604
- async deleteBooking(db, id) {
2605
- const [row] = await db
2606
- .delete(bookings)
2607
- .where(eq(bookings.id, id))
2608
- .returning({ id: bookings.id });
2609
- return row ?? null;
2610
- },
2611
- async confirmBooking(db, id, data, userId, runtime = {}) {
2612
- try {
2613
- const result = await db.transaction(async (tx) => {
2614
- const rows = await tx.execute(sql `SELECT id, booking_number, status, hold_expires_at
2615
- FROM ${bookings}
2616
- WHERE ${bookings.id} = ${id}
2617
- FOR UPDATE`);
2618
- const booking = toRows(rows)[0];
2619
- if (!booking) {
2620
- throw new BookingServiceError("not_found");
2621
- }
2622
- if (!canTransitionBooking(booking.status, "confirmed")) {
2623
- throw new BookingServiceError("invalid_transition");
2624
- }
2625
- // Accept both the staff-brokered "on_hold" and the customer
2626
- // checkout flow's "awaiting_payment". Other statuses (draft,
2627
- // already-confirmed, expired, cancelled) reject — the state
2628
- // machine catches the rest, but we explicitly forbid the
2629
- // states that would skip a step in the lifecycle.
2630
- if (booking.status !== "on_hold" && booking.status !== "awaiting_payment") {
2631
- throw new BookingServiceError("invalid_transition");
2632
- }
2633
- if (booking.hold_expires_at && booking.hold_expires_at < new Date()) {
2634
- throw new BookingServiceError("hold_expired");
2635
- }
2636
- const patch = transitionBooking(booking.status, "confirmed");
2637
- await tx
2638
- .update(bookingAllocations)
2639
- .set({
2640
- status: "confirmed",
2641
- confirmedAt: new Date(),
2642
- updatedAt: new Date(),
2643
- })
2644
- .where(and(eq(bookingAllocations.bookingId, id), eq(bookingAllocations.status, "held")));
2645
- await tx
2646
- .update(bookingItems)
2647
- .set({ status: "confirmed", updatedAt: new Date() })
2648
- .where(and(eq(bookingItems.bookingId, id), eq(bookingItems.status, "on_hold")));
2649
- const [row] = await tx
2650
- .update(bookings)
2651
- .set({
2652
- ...patch,
2653
- holdExpiresAt: null,
2654
- updatedAt: new Date(),
2655
- })
2656
- .where(eq(bookings.id, id))
2657
- .returning();
2658
- await syncTransactionOnBookingConfirmed(tx, id);
2659
- await autoIssueFulfillmentsForBooking(tx, id, userId);
2660
- await tx.insert(bookingActivityLog).values({
2661
- bookingId: id,
2662
- actorId: userId ?? "system",
2663
- activityType: "booking_confirmed",
2664
- description: `Booking ${booking.booking_number} confirmed`,
2665
- });
2666
- if (data.note) {
2667
- await tx.insert(bookingNotes).values({
2668
- bookingId: id,
2669
- authorId: userId ?? "system",
2670
- content: data.note,
2671
- });
2672
- }
2673
- await appendBookingStatusMutationLedger(tx, runtime, {
2674
- actionName: "booking.status.confirm",
2675
- routeOrToolName: "bookings.confirm",
2676
- capabilityId: BOOKING_STATUS_CAPABILITIES.confirm.id,
2677
- bookingId: id,
2678
- fromStatus: booking.status,
2679
- toStatus: "confirmed",
2680
- });
2681
- return { status: "ok", booking: row ?? null };
2682
- });
2683
- // Emit AFTER the transaction commits so subscribers can't observe a
2684
- // confirmed state that might still roll back. `emit` is fire-and-forget
2685
- // per the EventBus contract — subscriber errors are logged, not rethrown.
2686
- if (result.status === "ok" && result.booking) {
2687
- await runtime.eventBus?.emit("booking.confirmed", {
2688
- bookingId: result.booking.id,
2689
- bookingNumber: result.booking.bookingNumber,
2690
- actorId: userId ?? null,
2691
- suppressNotifications: data.suppressNotifications === true,
2692
- }, { category: "domain", source: "service" });
2693
- }
2694
- return result;
2695
- }
2696
- catch (error) {
2697
- if (error instanceof BookingServiceError) {
2698
- return { status: error.code };
2699
- }
2700
- throw error;
2701
- }
2702
- },
2703
- async recoverExpiredPaidBooking(db, id, data = {}, userId, runtime = {}) {
2704
- const slotChanges = [];
2705
- try {
2706
- const result = await db.transaction(async (tx) => {
2707
- const rows = await tx.execute(sql `SELECT id, booking_number, status
2708
- FROM ${bookings}
2709
- WHERE ${bookings.id} = ${id}
2710
- FOR UPDATE`);
2711
- const booking = toRows(rows)[0];
2712
- if (!booking) {
2713
- throw new BookingServiceError("not_found");
2714
- }
2715
- if (booking.status !== "awaiting_payment" && booking.status !== "expired") {
2716
- throw new BookingServiceError("invalid_transition");
2717
- }
2718
- const allocations = await tx
2719
- .select()
2720
- .from(bookingAllocations)
2721
- .where(eq(bookingAllocations.bookingId, id));
2722
- for (const allocation of allocations) {
2723
- if (allocation.status === "confirmed") {
2724
- continue;
2725
- }
2726
- if (allocation.status !== "held" && allocation.status !== "expired") {
2727
- throw new BookingServiceError("invalid_transition");
2728
- }
2729
- if (!allocation.availabilitySlotId || allocation.status === "held") {
2730
- continue;
2731
- }
2732
- const capacity = await adjustSlotCapacity(tx, allocation.availabilitySlotId, -allocation.quantity, "booking");
2733
- if (capacity.status === "slot_not_found") {
2734
- throw new BookingServiceError("slot_not_found");
2735
- }
2736
- if (capacity.status === "slot_unavailable") {
2737
- throw new BookingServiceError("slot_unavailable");
2738
- }
2739
- if (capacity.status === "insufficient_capacity") {
2740
- throw new BookingServiceError("insufficient_capacity");
2741
- }
2742
- if (capacity.slotChange)
2743
- slotChanges.push(capacity.slotChange);
2744
- }
2745
- const now = new Date();
2746
- await tx
2747
- .update(bookingAllocations)
2748
- .set({
2749
- status: "confirmed",
2750
- confirmedAt: now,
2751
- releasedAt: null,
2752
- updatedAt: now,
2753
- })
2754
- .where(and(eq(bookingAllocations.bookingId, id), inArray(bookingAllocations.status, ["held", "expired"])));
2755
- await tx
2756
- .update(bookingItems)
2757
- .set({ status: "confirmed", updatedAt: now })
2758
- .where(and(eq(bookingItems.bookingId, id), inArray(bookingItems.status, ["on_hold", "expired"])));
2759
- const [row] = await tx
2760
- .update(bookings)
2761
- .set({
2762
- status: "confirmed",
2763
- confirmedAt: now,
2764
- paidAt: now,
2765
- expiredAt: null,
2766
- holdExpiresAt: null,
2767
- updatedAt: now,
2768
- })
2769
- .where(eq(bookings.id, id))
2770
- .returning();
2771
- await syncTransactionOnBookingConfirmed(tx, id);
2772
- await autoIssueFulfillmentsForBooking(tx, id, userId);
2773
- await tx.insert(bookingActivityLog).values({
2774
- bookingId: id,
2775
- actorId: userId ?? "system",
2776
- activityType: "booking_confirmed",
2777
- description: `Late payment recovered and booking ${booking.booking_number} confirmed`,
2778
- metadata: { recoveredFromStatus: booking.status },
2779
- });
2780
- if (data.note) {
2781
- await tx.insert(bookingNotes).values({
2782
- bookingId: id,
2783
- authorId: userId ?? "system",
2784
- content: data.note,
2785
- });
2786
- }
2787
- await appendBookingStatusMutationLedger(tx, runtime, {
2788
- actionName: "booking.status.expire",
2789
- routeOrToolName: "bookings.expire",
2790
- capabilityId: BOOKING_STATUS_CAPABILITIES.expire.id,
2791
- bookingId: id,
2792
- fromStatus: booking.status,
2793
- toStatus: "expired",
2794
- });
2795
- return { status: "ok", booking: row ?? null };
2796
- });
2797
- if (result.status === "ok" && result.booking) {
2798
- await runtime.eventBus?.emit("booking.confirmed", {
2799
- bookingId: result.booking.id,
2800
- bookingNumber: result.booking.bookingNumber,
2801
- actorId: userId ?? null,
2802
- }, { category: "domain", source: "service" });
2803
- await emitSlotChanges(runtime, slotChanges);
2804
- }
2805
- return result;
2806
- }
2807
- catch (error) {
2808
- if (error instanceof BookingServiceError) {
2809
- return { status: error.code };
2810
- }
2811
- throw error;
2812
- }
2813
- },
2814
- async extendBookingHold(db, id, data, userId) {
2815
- try {
2816
- return await db.transaction(async (tx) => {
2817
- const rows = await tx.execute(sql `SELECT id, status, hold_expires_at
2818
- FROM ${bookings}
2819
- WHERE ${bookings.id} = ${id}
2820
- FOR UPDATE`);
2821
- const booking = toRows(rows)[0];
2822
- if (!booking) {
2823
- throw new BookingServiceError("not_found");
2824
- }
2825
- if (booking.status !== "on_hold" && booking.status !== "awaiting_payment") {
2826
- throw new BookingServiceError("invalid_transition");
2827
- }
2828
- if (booking.hold_expires_at && booking.hold_expires_at < new Date()) {
2829
- throw new BookingServiceError("hold_expired");
2830
- }
2831
- const holdExpiresAt = await computeHoldExpiresAt(tx, data);
2832
- await tx
2833
- .update(bookingAllocations)
2834
- .set({
2835
- holdExpiresAt,
2836
- updatedAt: new Date(),
2837
- })
2838
- .where(and(eq(bookingAllocations.bookingId, id), eq(bookingAllocations.status, "held")));
2839
- const [row] = await tx
2840
- .update(bookings)
2841
- .set({
2842
- holdExpiresAt,
2843
- updatedAt: new Date(),
2844
- })
2845
- .where(eq(bookings.id, id))
2846
- .returning();
2847
- await tx.insert(bookingActivityLog).values({
2848
- bookingId: id,
2849
- actorId: userId ?? "system",
2850
- activityType: "hold_extended",
2851
- description: "Booking hold extended",
2852
- metadata: { holdExpiresAt: holdExpiresAt.toISOString() },
2853
- });
2854
- return { status: "ok", booking: row ?? null };
2855
- });
2856
- }
2857
- catch (error) {
2858
- if (error instanceof BookingServiceError) {
2859
- return { status: error.code };
2860
- }
2861
- throw error;
2862
- }
2863
- },
2864
- async expireBooking(db, id, data, userId, runtime = {}) {
2865
- const slotChanges = [];
2866
- try {
2867
- const result = await db.transaction(async (tx) => {
2868
- const rows = await tx.execute(sql `SELECT id, status, hold_expires_at
2869
- FROM ${bookings}
2870
- WHERE ${bookings.id} = ${id}
2871
- FOR UPDATE`);
2872
- const booking = toRows(rows)[0];
2873
- if (!booking) {
2874
- throw new BookingServiceError("not_found");
2875
- }
2876
- if (!canTransitionBooking(booking.status, "expired")) {
2877
- throw new BookingServiceError("invalid_transition");
2878
- }
2879
- if (booking.status !== "on_hold") {
2880
- throw new BookingServiceError("invalid_transition");
2881
- }
2882
- const patch = transitionBooking(booking.status, "expired");
2883
- const allocations = await tx
2884
- .select()
2885
- .from(bookingAllocations)
2886
- .where(eq(bookingAllocations.bookingId, id));
2887
- for (const allocation of allocations) {
2888
- const change = await releaseAllocationCapacity(tx, allocation, "expire");
2889
- if (change)
2890
- slotChanges.push(change);
2891
- }
2892
- await tx
2893
- .update(bookingAllocations)
2894
- .set({
2895
- status: "expired",
2896
- releasedAt: new Date(),
2897
- updatedAt: new Date(),
2898
- })
2899
- .where(and(eq(bookingAllocations.bookingId, id), eq(bookingAllocations.status, "held")));
2900
- await tx
2901
- .update(bookingItems)
2902
- .set({ status: "expired", updatedAt: new Date() })
2903
- .where(and(eq(bookingItems.bookingId, id), eq(bookingItems.status, "on_hold")));
2904
- const [row] = await tx
2905
- .update(bookings)
2906
- .set({
2907
- ...patch,
2908
- holdExpiresAt: null,
2909
- updatedAt: new Date(),
2910
- })
2911
- .where(eq(bookings.id, id))
2912
- .returning();
2913
- await syncTransactionOnBookingExpired(tx, id);
2914
- await runtime.closePaymentSchedulesForBooking?.(tx, id, "expired");
2915
- await tx.insert(bookingActivityLog).values({
2916
- bookingId: id,
2917
- actorId: userId ?? "system",
2918
- activityType: "hold_expired",
2919
- description: "Booking hold expired",
2920
- });
2921
- if (data.note) {
2922
- await tx.insert(bookingNotes).values({
2923
- bookingId: id,
2924
- authorId: userId ?? "system",
2925
- content: data.note,
2926
- });
2927
- }
2928
- return { status: "ok", booking: row ?? null };
2929
- });
2930
- if (result.status === "ok" && result.booking) {
2931
- await runtime.eventBus?.emit("booking.expired", {
2932
- bookingId: result.booking.id,
2933
- bookingNumber: result.booking.bookingNumber,
2934
- cause: runtime.cause ?? "route",
2935
- actorId: userId ?? null,
2936
- }, { category: "domain", source: "service" });
2937
- await emitSlotChanges(runtime, slotChanges);
2938
- await runtime.expirePaymentSessionsForBooking?.(db, result.booking.id);
2939
- }
2940
- return result;
2941
- }
2942
- catch (error) {
2943
- if (error instanceof BookingServiceError) {
2944
- return { status: error.code };
2945
- }
2946
- throw error;
2947
- }
2948
- },
2949
- async expireStaleBookings(db, data, userId, runtime = {}) {
2950
- const cutoff = data.before ? new Date(data.before) : new Date();
2951
- const staleBookings = await db
2952
- .select({ id: bookings.id })
2953
- .from(bookings)
2954
- .where(and(inArray(bookings.status, ["on_hold", "awaiting_payment"]), sql `${bookings.holdExpiresAt} IS NOT NULL`, lte(bookings.holdExpiresAt, cutoff)))
2955
- .orderBy(asc(bookings.holdExpiresAt), asc(bookings.createdAt));
2956
- const expiredIds = [];
2957
- for (const booking of staleBookings) {
2958
- const result = await this.expireBooking(db, booking.id, { note: data.note ?? "Hold expired by sweep" }, userId, { ...runtime, cause: "sweep" });
2959
- if ("booking" in result && result.booking) {
2960
- expiredIds.push(result.booking.id);
2961
- }
2962
- }
2963
- return {
2964
- expiredIds,
2965
- count: expiredIds.length,
2966
- cutoff,
2967
- };
2968
- },
2969
- async cancelBooking(db, id, data, userId, runtime = {}) {
2970
- const slotChanges = [];
2971
- try {
2972
- const result = await db.transaction(async (tx) => {
2973
- const rows = await tx.execute(sql `SELECT id, status
2974
- FROM ${bookings}
2975
- WHERE ${bookings.id} = ${id}
2976
- FOR UPDATE`);
2977
- const booking = toRows(rows)[0];
2978
- if (!booking) {
2979
- throw new BookingServiceError("not_found");
2980
- }
2981
- if (!canTransitionBooking(booking.status, "cancelled")) {
2982
- throw new BookingServiceError("invalid_transition");
2983
- }
2984
- const patch = transitionBooking(booking.status, "cancelled");
2985
- const previousStatus = booking.status;
2986
- const allocations = await tx
2987
- .select()
2988
- .from(bookingAllocations)
2989
- .where(eq(bookingAllocations.bookingId, id));
2990
- for (const allocation of allocations) {
2991
- const change = await releaseAllocationCapacity(tx, allocation, "cancel");
2992
- if (change)
2993
- slotChanges.push(change);
2994
- }
2995
- await tx
2996
- .update(bookingAllocations)
2997
- .set({
2998
- status: "cancelled",
2999
- releasedAt: new Date(),
3000
- updatedAt: new Date(),
3001
- })
3002
- .where(and(eq(bookingAllocations.bookingId, id), or(eq(bookingAllocations.status, "held"), eq(bookingAllocations.status, "confirmed"))));
3003
- await tx
3004
- .update(bookingItems)
3005
- .set({
3006
- status: "cancelled",
3007
- updatedAt: new Date(),
3008
- })
3009
- .where(and(eq(bookingItems.bookingId, id), or(eq(bookingItems.status, "draft"), eq(bookingItems.status, "on_hold"), eq(bookingItems.status, "confirmed"))));
3010
- const [row] = await tx
3011
- .update(bookings)
3012
- .set({
3013
- ...patch,
3014
- holdExpiresAt: null,
3015
- updatedAt: new Date(),
3016
- })
3017
- .where(eq(bookings.id, id))
3018
- .returning();
3019
- await syncTransactionOnBookingCancelled(tx, id);
3020
- await runtime.closePaymentSchedulesForBooking?.(tx, id, "cancelled");
3021
- await tx.insert(bookingActivityLog).values({
3022
- bookingId: id,
3023
- actorId: userId ?? "system",
3024
- activityType: "status_change",
3025
- description: `Booking cancelled from ${booking.status}`,
3026
- metadata: { oldStatus: booking.status, newStatus: "cancelled" },
3027
- });
3028
- if (data.note) {
3029
- await tx.insert(bookingNotes).values({
3030
- bookingId: id,
3031
- authorId: userId ?? "system",
3032
- content: data.note,
3033
- });
3034
- }
3035
- // Clean up any booking-group membership (dissolve if ≤1 active members remain).
3036
- await cleanupGroupOnBookingCancelled(tx, id);
3037
- await appendBookingStatusMutationLedger(tx, runtime, {
3038
- actionName: "booking.status.cancel",
3039
- routeOrToolName: "bookings.cancel",
3040
- capabilityId: BOOKING_STATUS_CAPABILITIES.cancel.id,
3041
- bookingId: id,
3042
- fromStatus: booking.status,
3043
- toStatus: "cancelled",
3044
- evaluatedRisk: "high",
3045
- });
3046
- return { status: "ok", booking: row ?? null, previousStatus };
3047
- });
3048
- if (result.status === "ok" && result.booking) {
3049
- await runtime.eventBus?.emit("booking.cancelled", {
3050
- bookingId: result.booking.id,
3051
- bookingNumber: result.booking.bookingNumber,
3052
- previousStatus: result.previousStatus,
3053
- actorId: userId ?? null,
3054
- }, { category: "domain", source: "service" });
3055
- await emitSlotChanges(runtime, slotChanges);
3056
- }
3057
- return { status: result.status, booking: result.booking };
3058
- }
3059
- catch (error) {
3060
- if (error instanceof BookingServiceError) {
3061
- return { status: error.code };
3062
- }
3063
- throw error;
3064
- }
3065
- },
3066
- async startBooking(db, id, data, userId, runtime = {}) {
3067
- try {
3068
- const result = await db.transaction(async (tx) => {
3069
- const rows = await tx.execute(sql `SELECT id, booking_number, status
3070
- FROM ${bookings}
3071
- WHERE ${bookings.id} = ${id}
3072
- FOR UPDATE`);
3073
- const booking = toRows(rows)[0];
3074
- if (!booking) {
3075
- throw new BookingServiceError("not_found");
3076
- }
3077
- if (!canTransitionBooking(booking.status, "in_progress")) {
3078
- throw new BookingServiceError("invalid_transition");
3079
- }
3080
- const patch = transitionBooking(booking.status, "in_progress");
3081
- const [row] = await tx
3082
- .update(bookings)
3083
- .set({
3084
- ...patch,
3085
- updatedAt: new Date(),
3086
- })
3087
- .where(eq(bookings.id, id))
3088
- .returning();
3089
- await tx.insert(bookingActivityLog).values({
3090
- bookingId: id,
3091
- actorId: userId ?? "system",
3092
- activityType: "booking_started",
3093
- description: `Booking ${booking.booking_number} started`,
3094
- });
3095
- if (data.note) {
3096
- await tx.insert(bookingNotes).values({
3097
- bookingId: id,
3098
- authorId: userId ?? "system",
3099
- content: data.note,
3100
- });
3101
- }
3102
- await appendBookingStatusMutationLedger(tx, runtime, {
3103
- actionName: "booking.status.start",
3104
- routeOrToolName: "bookings.start",
3105
- capabilityId: BOOKING_STATUS_CAPABILITIES.start.id,
3106
- bookingId: id,
3107
- fromStatus: booking.status,
3108
- toStatus: "in_progress",
3109
- });
3110
- return { status: "ok", booking: row ?? null };
3111
- });
3112
- if (result.status === "ok" && result.booking) {
3113
- await runtime.eventBus?.emit("booking.started", {
3114
- bookingId: result.booking.id,
3115
- bookingNumber: result.booking.bookingNumber,
3116
- actorId: userId ?? null,
3117
- }, { category: "domain", source: "service" });
3118
- }
3119
- return result;
3120
- }
3121
- catch (error) {
3122
- if (error instanceof BookingServiceError) {
3123
- return { status: error.code };
3124
- }
3125
- throw error;
3126
- }
3127
- },
3128
- async completeBooking(db, id, data, userId, runtime = {}) {
3129
- try {
3130
- const result = await db.transaction(async (tx) => {
3131
- const rows = await tx.execute(sql `SELECT id, booking_number, status
3132
- FROM ${bookings}
3133
- WHERE ${bookings.id} = ${id}
3134
- FOR UPDATE`);
3135
- const booking = toRows(rows)[0];
3136
- if (!booking) {
3137
- throw new BookingServiceError("not_found");
3138
- }
3139
- if (!canTransitionBooking(booking.status, "completed")) {
3140
- throw new BookingServiceError("invalid_transition");
3141
- }
3142
- const patch = transitionBooking(booking.status, "completed");
3143
- await tx
3144
- .update(bookingAllocations)
3145
- .set({ status: "fulfilled", updatedAt: new Date() })
3146
- .where(and(eq(bookingAllocations.bookingId, id), eq(bookingAllocations.status, "confirmed")));
3147
- await tx
3148
- .update(bookingItems)
3149
- .set({ status: "fulfilled", updatedAt: new Date() })
3150
- .where(and(eq(bookingItems.bookingId, id), eq(bookingItems.status, "confirmed")));
3151
- const [row] = await tx
3152
- .update(bookings)
3153
- .set({
3154
- ...patch,
3155
- updatedAt: new Date(),
3156
- })
3157
- .where(eq(bookings.id, id))
3158
- .returning();
3159
- await tx.insert(bookingActivityLog).values({
3160
- bookingId: id,
3161
- actorId: userId ?? "system",
3162
- activityType: "booking_completed",
3163
- description: `Booking ${booking.booking_number} completed`,
3164
- });
3165
- if (data.note) {
3166
- await tx.insert(bookingNotes).values({
3167
- bookingId: id,
3168
- authorId: userId ?? "system",
3169
- content: data.note,
3170
- });
3171
- }
3172
- await appendBookingStatusMutationLedger(tx, runtime, {
3173
- actionName: "booking.status.complete",
3174
- routeOrToolName: "bookings.complete",
3175
- capabilityId: BOOKING_STATUS_CAPABILITIES.complete.id,
3176
- bookingId: id,
3177
- fromStatus: booking.status,
3178
- toStatus: "completed",
3179
- });
3180
- return { status: "ok", booking: row ?? null };
3181
- });
3182
- if (result.status === "ok" && result.booking) {
3183
- await runtime.eventBus?.emit("booking.completed", {
3184
- bookingId: result.booking.id,
3185
- bookingNumber: result.booking.bookingNumber,
3186
- actorId: userId ?? null,
3187
- }, { category: "domain", source: "service" });
3188
- }
3189
- return result;
3190
- }
3191
- catch (error) {
3192
- if (error instanceof BookingServiceError) {
3193
- return { status: error.code };
3194
- }
3195
- throw error;
3196
- }
3197
- },
3198
- /**
3199
- * Admin-only force: bypasses the transition graph. Terminal overrides
3200
- * cascade to items and allocations so reporting and capacity stay
3201
- * consistent; non-terminal overrides remain booking-row data correction.
3202
- */
3203
- async overrideBookingStatus(db, id, data, userId, runtime = {}) {
3204
- const slotChanges = [];
3205
- try {
3206
- const result = await db.transaction(async (tx) => {
3207
- const rows = await tx.execute(sql `SELECT id, booking_number, status
3208
- FROM ${bookings}
3209
- WHERE ${bookings.id} = ${id}
3210
- FOR UPDATE`);
3211
- const booking = toRows(rows)[0];
3212
- if (!booking) {
3213
- throw new BookingServiceError("not_found");
3214
- }
3215
- const now = new Date();
3216
- const terminalItemStatus = terminalBookingItemStatusForOverride(data.status);
3217
- const terminalAllocationStatus = terminalBookingAllocationStatusForOverride(data.status);
3218
- const updates = {
3219
- status: data.status,
3220
- confirmedAt: confirmedAtForStatus(data.status, null, now),
3221
- updatedAt: now,
3222
- };
3223
- if (data.status === "expired")
3224
- updates.expiredAt = now;
3225
- if (data.status === "cancelled")
3226
- updates.cancelledAt = now;
3227
- if (data.status === "completed")
3228
- updates.completedAt = now;
3229
- if (terminalItemStatus && terminalAllocationStatus) {
3230
- if (data.status === "cancelled" || data.status === "expired") {
3231
- const allocations = await tx
3232
- .select()
3233
- .from(bookingAllocations)
3234
- .where(and(eq(bookingAllocations.bookingId, id), or(eq(bookingAllocations.status, "held"), eq(bookingAllocations.status, "confirmed"), eq(bookingAllocations.status, "fulfilled"))));
3235
- for (const allocation of allocations) {
3236
- const change = await releaseAllocationCapacity(tx, allocation, data.status === "expired" ? "expire" : "cancel");
3237
- if (change)
3238
- slotChanges.push(change);
3239
- }
3240
- }
3241
- const allocationUpdates = {
3242
- status: terminalAllocationStatus,
3243
- updatedAt: now,
3244
- };
3245
- if (data.status === "cancelled" || data.status === "expired") {
3246
- allocationUpdates.releasedAt = now;
3247
- }
3248
- await tx
3249
- .update(bookingAllocations)
3250
- .set(allocationUpdates)
3251
- .where(and(eq(bookingAllocations.bookingId, id), or(eq(bookingAllocations.status, "held"), eq(bookingAllocations.status, "confirmed"), eq(bookingAllocations.status, "fulfilled"))));
3252
- await tx
3253
- .update(bookingItems)
3254
- .set({ status: terminalItemStatus, updatedAt: now })
3255
- .where(and(eq(bookingItems.bookingId, id), or(eq(bookingItems.status, "draft"), eq(bookingItems.status, "on_hold"), eq(bookingItems.status, "confirmed"), eq(bookingItems.status, "fulfilled"))));
3256
- }
3257
- const [row] = await tx.update(bookings).set(updates).where(eq(bookings.id, id)).returning();
3258
- if (data.status === "cancelled" || data.status === "expired") {
3259
- await runtime.closePaymentSchedulesForBooking?.(tx, id, data.status);
3260
- }
3261
- await tx.insert(bookingActivityLog).values({
3262
- bookingId: id,
3263
- actorId: userId ?? "system",
3264
- activityType: "status_overridden",
3265
- description: `Booking status overridden from ${booking.status} to ${data.status}`,
3266
- metadata: {
3267
- oldStatus: booking.status,
3268
- newStatus: data.status,
3269
- reason: data.reason,
3270
- },
3271
- });
3272
- if (data.note) {
3273
- await tx.insert(bookingNotes).values({
3274
- bookingId: id,
3275
- authorId: userId ?? "system",
3276
- content: data.note,
3277
- });
3278
- }
3279
- await appendBookingStatusMutationLedger(tx, runtime, {
3280
- actionName: "booking.status.override",
3281
- routeOrToolName: "bookings.override-status",
3282
- capabilityId: BOOKING_STATUS_CAPABILITIES.override.id,
3283
- bookingId: id,
3284
- fromStatus: booking.status,
3285
- toStatus: data.status,
3286
- evaluatedRisk: "high",
3287
- });
3288
- return {
3289
- status: "ok",
3290
- booking: row ?? null,
3291
- fromStatus: booking.status,
3292
- toStatus: data.status,
3293
- };
3294
- });
3295
- if (result.status === "ok" && result.booking) {
3296
- await runtime.eventBus?.emit("booking.status_overridden", {
3297
- bookingId: result.booking.id,
3298
- bookingNumber: result.booking.bookingNumber,
3299
- fromStatus: result.fromStatus,
3300
- toStatus: result.toStatus,
3301
- reason: data.reason,
3302
- actorId: userId ?? null,
3303
- }, { category: "domain", source: "service" });
3304
- // Keep draft → confirmed overrides compatible with the create dialog,
3305
- // but let data-correction callers preserve the audit event without
3306
- // re-running the full confirm lifecycle.
3307
- if (result.toStatus === "confirmed" && data.suppressLifecycleEvents !== true) {
3308
- await runtime.eventBus?.emit("booking.confirmed", {
3309
- bookingId: result.booking.id,
3310
- bookingNumber: result.booking.bookingNumber,
3311
- actorId: userId ?? null,
3312
- suppressNotifications: data.suppressNotifications === true,
3313
- }, { category: "domain", source: "service" });
3314
- }
3315
- await emitSlotChanges(runtime, slotChanges);
3316
- }
3317
- return { status: result.status, booking: result.booking };
3318
- }
3319
- catch (error) {
3320
- if (error instanceof BookingServiceError) {
3321
- return { status: error.code };
3322
- }
3323
- throw error;
3324
- }
3325
- },
3326
- listTravelerRecords(db, bookingId) {
3327
- return db
3328
- .select()
3329
- .from(bookingTravelers)
3330
- .where(eq(bookingTravelers.bookingId, bookingId))
3331
- .orderBy(desc(bookingTravelers.isPrimary), asc(bookingTravelers.createdAt));
3332
- },
3333
- async getTravelerRecordById(db, bookingId, travelerId) {
3334
- const [row] = await db
3335
- .select()
3336
- .from(bookingTravelers)
3337
- .where(and(eq(bookingTravelers.id, travelerId), eq(bookingTravelers.bookingId, bookingId)))
3338
- .limit(1);
3339
- return row ?? null;
3340
- },
3341
- async createTravelerRecord(db, bookingId, data, userId) {
3342
- const [booking] = await db
3343
- .select({ id: bookings.id })
3344
- .from(bookings)
3345
- .where(eq(bookings.id, bookingId))
3346
- .limit(1);
3347
- if (!booking) {
3348
- return null;
3349
- }
3350
- const [row] = await db
3351
- .insert(bookingTravelers)
3352
- .values({
3353
- bookingId,
3354
- personId: data.personId ?? null,
3355
- participantType: data.participantType,
3356
- travelerCategory: data.travelerCategory ?? null,
3357
- firstName: data.firstName,
3358
- lastName: data.lastName,
3359
- email: data.email ?? null,
3360
- phone: data.phone ?? null,
3361
- preferredLanguage: data.preferredLanguage ?? null,
3362
- specialRequests: data.specialRequests ?? null,
3363
- isPrimary: data.isPrimary ?? false,
3364
- notes: data.notes ?? null,
3365
- })
3366
- .returning();
3367
- if (!row) {
3368
- return null;
3369
- }
3370
- await ensureParticipantFlags(db, bookingId, row.id, data);
3371
- await db.insert(bookingActivityLog).values({
3372
- bookingId,
3373
- actorId: userId ?? "system",
3374
- activityType: "traveler_update",
3375
- description: `Traveler ${data.firstName} ${data.lastName} added`,
3376
- metadata: { travelerId: row.id, participantType: data.participantType },
3377
- });
3378
- return row;
3379
- },
3380
- async updateTravelerRecord(db, travelerId, data) {
3381
- const [row] = await db
3382
- .update(bookingTravelers)
3383
- .set({ ...data, updatedAt: new Date() })
3384
- .where(eq(bookingTravelers.id, travelerId))
3385
- .returning();
3386
- if (!row) {
3387
- return null;
3388
- }
3389
- await ensureParticipantFlags(db, row.bookingId, row.id, data);
3390
- return row;
3391
- },
3392
- /**
3393
- * Create a traveler row and persist the encrypted travel-details envelope in
3394
- * a single call. Migration boundary helper for consumers coming from the
3395
- * pre-0.10 `createTravelerRecord({ ..., accessibilityNeeds, ... })` shape:
3396
- * the storage split (plaintext columns + encrypted bucket) is preserved, but
3397
- * the call ergonomics collapse back to one flat payload. Plaintext fields go
3398
- * to `createTravelerRecord`; encrypted fields are forwarded to
3399
- * `pii.upsertTravelerTravelDetails`. Operations are sequential, not
3400
- * transactional — a failure in the encrypted-fields write leaves the
3401
- * plaintext row in place (matching the pre-helper two-call protocol).
3402
- */
3403
- async createTravelerWithTravelDetails(db, bookingId, data, opts) {
3404
- const traveler = await this.createTravelerRecord(db, bookingId, {
3405
- personId: data.personId,
3406
- participantType: data.participantType,
3407
- travelerCategory: data.travelerCategory,
3408
- firstName: data.firstName,
3409
- lastName: data.lastName,
3410
- email: data.email,
3411
- phone: data.phone,
3412
- preferredLanguage: data.preferredLanguage,
3413
- specialRequests: data.specialRequests,
3414
- isPrimary: data.isPrimary,
3415
- notes: data.notes,
3416
- }, opts.userId);
3417
- if (!traveler) {
3418
- return null;
3419
- }
3420
- let travelDetailInput = pickTravelDetailFields(data);
3421
- if (data.personId && opts.resolveTravelSnapshot) {
3422
- const snapshot = await opts.resolveTravelSnapshot(data.personId);
3423
- travelDetailInput = applyTravelDetailSnapshot(travelDetailInput, snapshot);
3424
- }
3425
- const travelDetails = await persistTravelDetailsWithCapacityCheck(db, traveler.id, travelDetailInput, opts);
3426
- return { traveler, travelDetails };
3427
- },
3428
- /**
3429
- * Update a traveler row and (re-)upsert the encrypted travel-details
3430
- * envelope in a single call. Same migration-ergonomics motivation as
3431
- * `createTravelerWithTravelDetails`. Undefined fields are not written;
3432
- * `null` clears a column / encrypted field.
3433
- */
3434
- async updateTravelerWithTravelDetails(db, travelerId, data, opts) {
3435
- const traveler = await this.updateTravelerRecord(db, travelerId, {
3436
- personId: data.personId,
3437
- participantType: data.participantType,
3438
- travelerCategory: data.travelerCategory,
3439
- firstName: data.firstName,
3440
- lastName: data.lastName,
3441
- email: data.email,
3442
- phone: data.phone,
3443
- preferredLanguage: data.preferredLanguage,
3444
- specialRequests: data.specialRequests,
3445
- isPrimary: data.isPrimary,
3446
- notes: data.notes,
3447
- });
3448
- if (!traveler) {
3449
- return null;
3450
- }
3451
- const travelDetailInput = pickTravelDetailFields(data);
3452
- const travelDetails = await persistTravelDetailsWithCapacityCheck(db, traveler.id, travelDetailInput, opts);
3453
- return { traveler, travelDetails };
3454
- },
3455
- async deleteTravelerRecord(db, travelerId) {
3456
- const [row] = await db
3457
- .delete(bookingTravelers)
3458
- .where(eq(bookingTravelers.id, travelerId))
3459
- .returning({ id: bookingTravelers.id });
3460
- return row ?? null;
3461
- },
3462
- listTravelers(db, bookingId) {
3463
- return db
3464
- .select()
3465
- .from(bookingTravelers)
3466
- .where(and(eq(bookingTravelers.bookingId, bookingId), or(...travelerParticipantTypes.map((type) => eq(bookingTravelers.participantType, type)))))
3467
- .orderBy(asc(bookingTravelers.createdAt))
3468
- .then((rows) => rows.map(toTravelerResponse));
3469
- },
3470
- async listSharingGroupsForSlot(db, slotId) {
3471
- const rows = await db
3472
- .select({
3473
- id: bookingTravelerTravelDetails.sharingGroupId,
3474
- occupancy: sql `count(distinct ${bookingTravelers.id})::int`,
3475
- roomTypeId: sql `
3476
- case
3477
- when count(distinct ${bookingTravelerTravelDetails.roomTypeId})
3478
- filter (where ${bookingTravelerTravelDetails.roomTypeId} is not null) = 1
3479
- then min(${bookingTravelerTravelDetails.roomTypeId})
3480
- else null
3481
- end
3482
- `,
3483
- bookingIds: sql `
3484
- array_agg(distinct ${bookingTravelers.bookingId} order by ${bookingTravelers.bookingId})
3485
- `,
3486
- })
3487
- .from(bookingTravelerTravelDetails)
3488
- .innerJoin(bookingTravelers, eq(bookingTravelers.id, bookingTravelerTravelDetails.travelerId))
3489
- .innerJoin(bookings, eq(bookings.id, bookingTravelers.bookingId))
3490
- .innerJoin(bookingAllocations, eq(bookingAllocations.bookingId, bookings.id))
3491
- .where(and(eq(bookingAllocations.availabilitySlotId, slotId), isNotNull(bookingTravelerTravelDetails.sharingGroupId), ne(bookingTravelerTravelDetails.sharingGroupId, ""), inArray(bookings.status, sharingGroupBookingStatuses), inArray(bookingAllocations.status, sharingGroupAllocationStatuses), or(...travelerParticipantTypes.map((type) => eq(bookingTravelers.participantType, type)))))
3492
- .groupBy(bookingTravelerTravelDetails.sharingGroupId)
3493
- .orderBy(asc(bookingTravelerTravelDetails.sharingGroupId));
3494
- return rows.flatMap((row) => {
3495
- if (!row.id)
3496
- return [];
3497
- return [
3498
- {
3499
- id: row.id,
3500
- label: row.id,
3501
- occupancy: row.occupancy,
3502
- roomTypeId: row.roomTypeId,
3503
- bookingIds: row.bookingIds,
3504
- },
3505
- ];
3506
- });
3507
- },
3508
- async listTravelersBySharingGroup(db, slotId, sharingGroupId) {
3509
- const rows = await db
3510
- .selectDistinct({
3511
- id: bookingTravelers.id,
3512
- bookingId: bookingTravelers.bookingId,
3513
- bookingNumber: bookings.bookingNumber,
3514
- participantType: bookingTravelers.participantType,
3515
- travelerCategory: bookingTravelers.travelerCategory,
3516
- personId: bookingTravelers.personId,
3517
- firstName: bookingTravelers.firstName,
3518
- lastName: bookingTravelers.lastName,
3519
- email: bookingTravelers.email,
3520
- phone: bookingTravelers.phone,
3521
- preferredLanguage: bookingTravelers.preferredLanguage,
3522
- specialRequests: bookingTravelers.specialRequests,
3523
- isPrimary: bookingTravelers.isPrimary,
3524
- notes: bookingTravelers.notes,
3525
- isLeadTraveler: bookingTravelerTravelDetails.isLeadTraveler,
3526
- sharingGroupId: bookingTravelerTravelDetails.sharingGroupId,
3527
- roomTypeId: bookingTravelerTravelDetails.roomTypeId,
3528
- bedPreference: bookingTravelerTravelDetails.bedPreference,
3529
- allocations: bookingTravelerTravelDetails.allocations,
3530
- createdAt: bookingTravelers.createdAt,
3531
- updatedAt: bookingTravelers.updatedAt,
3532
- })
3533
- .from(bookingTravelers)
3534
- .innerJoin(bookingTravelerTravelDetails, eq(bookingTravelerTravelDetails.travelerId, bookingTravelers.id))
3535
- .innerJoin(bookings, eq(bookings.id, bookingTravelers.bookingId))
3536
- .innerJoin(bookingAllocations, eq(bookingAllocations.bookingId, bookings.id))
3537
- .where(and(eq(bookingAllocations.availabilitySlotId, slotId), eq(bookingTravelerTravelDetails.sharingGroupId, sharingGroupId), inArray(bookings.status, sharingGroupBookingStatuses), inArray(bookingAllocations.status, sharingGroupAllocationStatuses), or(...travelerParticipantTypes.map((type) => eq(bookingTravelers.participantType, type)))))
3538
- .orderBy(asc(bookings.bookingNumber), desc(bookingTravelers.isPrimary), asc(bookingTravelers.createdAt));
3539
- return rows.flatMap((row) => {
3540
- if (!row.sharingGroupId)
3541
- return [];
3542
- return [
3543
- {
3544
- ...row,
3545
- isLeadTraveler: row.isLeadTraveler,
3546
- sharingGroupId: row.sharingGroupId,
3547
- allocations: normalizeTravelerAllocationMap(row.allocations),
3548
- },
3549
- ];
3550
- });
3551
- },
3552
- async createTraveler(db, bookingId, data, userId) {
3553
- const row = await this.createTravelerRecord(db, bookingId, {
3554
- participantType: "traveler",
3555
- travelerCategory: data.travelerCategory ?? null,
3556
- firstName: data.firstName,
3557
- lastName: data.lastName,
3558
- email: data.email ?? null,
3559
- phone: data.phone ?? null,
3560
- preferredLanguage: data.preferredLanguage ?? null,
3561
- specialRequests: data.specialRequests ?? null,
3562
- isPrimary: data.isPrimary ?? false,
3563
- notes: data.notes ?? null,
3564
- }, userId);
3565
- return row ? toTravelerResponse(row) : null;
3566
- },
3567
- async updateTraveler(db, travelerId, data) {
3568
- const row = await this.updateTravelerRecord(db, travelerId, {
3569
- firstName: data.firstName,
3570
- lastName: data.lastName,
3571
- email: data.email ?? null,
3572
- phone: data.phone ?? null,
3573
- preferredLanguage: data.preferredLanguage ?? null,
3574
- specialRequests: data.specialRequests ?? null,
3575
- travelerCategory: data.travelerCategory ?? null,
3576
- isPrimary: data.isPrimary ?? undefined,
3577
- notes: data.notes ?? null,
3578
- });
3579
- return row ? toTravelerResponse(row) : null;
3580
- },
3581
- async deleteTraveler(db, travelerId) {
3582
- return this.deleteTravelerRecord(db, travelerId);
3583
- },
3584
- /**
3585
- * Lists booking items. Snapshot columns
3586
- * (`productNameSnapshot`/`optionNameSnapshot`/`unitNameSnapshot`/
3587
- * `departureLabelSnapshot`) travel with the row, so consumers see
3588
- * exactly what was sold at booking time — no JOIN required, works in
3589
- * catalog-less deployments, survives product deletion or renames.
3590
- */
3591
- listItems(db, bookingId) {
3592
- return db
3593
- .select()
3594
- .from(bookingItems)
3595
- .where(eq(bookingItems.bookingId, bookingId))
3596
- .orderBy(asc(bookingItems.createdAt));
3597
- },
3598
- /**
3599
- * Re-derive `bookings.sellAmountCents` / `costAmountCents` from
3600
- * `Σ(booking_items.total*AmountCents)`, plus — when the booking
3601
- * declares a `baseCurrency` and `fxRateSetId` — re-derive
3602
- * `baseSellAmountCents` / `baseCostAmountCents` by converting each
3603
- * item's total via the FX rate set.
3604
- *
3605
- * Called automatically inside the item-mutation methods so callers
3606
- * that go through `createItem` / `updateItem` / `deleteItem` never
3607
- * have to remember to roll the parent. Public so external flows
3608
- * (saga compensations, ad-hoc fix-ups) can also invoke it.
3609
- *
3610
- * Pass a tx-bound `db` to compose with an existing transaction; this
3611
- * method does NOT wrap its own transaction.
3612
- *
3613
- * **FX rollup behaviour**:
3614
- *
3615
- * - Single-currency booking (every item's `sellCurrency === baseCurrency`,
3616
- * or `baseCurrency === sellCurrency` on the parent): `base*Cents`
3617
- * equal `sell*Cents` / `cost*Cents` directly. No FX lookup needed.
3618
- * - Multi-currency booking with `fxRateSetId`: every item is
3619
- * converted to `baseCurrency` via `exchange_rates`. If any item's
3620
- * currency is missing from the rate set, the FX rollup short-circuits
3621
- * with `fxStatus: "missing_rate"` and `base*Cents` are LEFT
3622
- * UNCHANGED on the parent (caller chooses whether to abort).
3623
- * - No `baseCurrency` configured: FX rollup is skipped entirely
3624
- * (`fxStatus: "skipped"`), and `base*Cents` stay null.
3625
- *
3626
- * Returns `{ sellAmountCents, costAmountCents, baseSellAmountCents,
3627
- * baseCostAmountCents, fxStatus, missingCurrency? }` or `null` for a
3628
- * missing booking.
3629
- */
3630
- async recomputeBookingTotal(db, bookingId) {
3631
- const [booking] = await db
3632
- .select({
3633
- id: bookings.id,
3634
- sellCurrency: bookings.sellCurrency,
3635
- baseCurrency: bookings.baseCurrency,
3636
- })
3637
- .from(bookings)
3638
- .where(eq(bookings.id, bookingId))
3639
- .limit(1);
3640
- if (!booking) {
3641
- return null;
3642
- }
3643
- const [totals] = await db
3644
- .select({
3645
- sellAmountCents: sql `coalesce(sum(${bookingItems.totalSellAmountCents}), 0)::int`,
3646
- costAmountCents: sql `coalesce(sum(${bookingItems.totalCostAmountCents}), 0)::int`,
3647
- })
3648
- .from(bookingItems)
3649
- .where(eq(bookingItems.bookingId, bookingId));
3650
- const sellAmountCents = totals?.sellAmountCents ?? 0;
3651
- const costAmountCents = totals?.costAmountCents ?? 0;
3652
- // We need fxRateSetId from the bookings row plus per-item currency
3653
- // for the FX rollup. Refetch with those columns.
3654
- const [bookingForFx] = await db
3655
- .select({
3656
- baseCurrency: bookings.baseCurrency,
3657
- sellCurrency: bookings.sellCurrency,
3658
- })
3659
- .from(bookings)
3660
- .where(eq(bookings.id, bookingId))
3661
- .limit(1);
3662
- let fxStatus = "skipped";
3663
- let baseSellAmountCents = null;
3664
- let baseCostAmountCents = null;
3665
- let missingCurrency = null;
3666
- const baseCurrency = bookingForFx?.baseCurrency ?? null;
3667
- if (baseCurrency) {
3668
- const fxResult = await rollupBaseTotals(db, bookingId, baseCurrency);
3669
- if (fxResult.status === "ok") {
3670
- fxStatus = "ok";
3671
- baseSellAmountCents = fxResult.baseSellAmountCents;
3672
- baseCostAmountCents = fxResult.baseCostAmountCents;
3673
- }
3674
- else if (fxResult.status === "missing_rate") {
3675
- fxStatus = "missing_rate";
3676
- missingCurrency = fxResult.currency;
3677
- }
3678
- }
3679
- const patch = {
3680
- sellAmountCents,
3681
- costAmountCents,
3682
- updatedAt: new Date(),
3683
- };
3684
- if (fxStatus === "ok") {
3685
- patch.baseSellAmountCents = baseSellAmountCents;
3686
- patch.baseCostAmountCents = baseCostAmountCents;
3687
- }
3688
- await db.update(bookings).set(patch).where(eq(bookings.id, bookingId));
3689
- return {
3690
- sellAmountCents,
3691
- costAmountCents,
3692
- baseSellAmountCents,
3693
- baseCostAmountCents,
3694
- fxStatus,
3695
- ...(missingCurrency ? { missingCurrency } : {}),
3696
- };
3697
- },
3698
- async createItem(db, bookingId, data, userId) {
3699
- return db.transaction(async (tx) => {
3700
- const [booking] = await tx
3701
- .select({ id: bookings.id, sellCurrency: bookings.sellCurrency })
3702
- .from(bookings)
3703
- .where(eq(bookings.id, bookingId))
3704
- .limit(1);
3705
- if (!booking) {
3706
- return null;
3707
- }
3708
- // Look up catalog/availability data for snapshotting + auto-fill.
3709
- // Explicit input values win — callers in catalog-less deployments
3710
- // (OTA) pass their own snapshots/timings and we don't touch them.
3711
- const enrichment = await resolveBookingItemSnapshot(tx, {
3712
- productId: data.productId ?? null,
3713
- optionId: data.optionId ?? null,
3714
- optionUnitId: data.optionUnitId ?? null,
3715
- availabilitySlotId: data.availabilitySlotId ?? null,
3716
- });
3717
- const startsAtInput = data.startsAt !== undefined ? toTimestamp(data.startsAt) : enrichment.startsAt;
3718
- const endsAtInput = data.endsAt !== undefined ? toTimestamp(data.endsAt) : enrichment.endsAt;
3719
- const serviceDateInput = data.serviceDate !== undefined ? data.serviceDate : enrichment.serviceDate;
3720
- const [row] = await tx
3721
- .insert(bookingItems)
3722
- .values({
3723
- bookingId,
3724
- title: data.title,
3725
- description: data.description ?? null,
3726
- itemType: data.itemType,
3727
- status: data.status,
3728
- serviceDate: serviceDateInput ?? null,
3729
- startsAt: startsAtInput,
3730
- endsAt: endsAtInput,
3731
- quantity: data.quantity,
3732
- sellCurrency: data.sellCurrency ?? booking.sellCurrency,
3733
- unitSellAmountCents: data.unitSellAmountCents ?? null,
3734
- totalSellAmountCents: data.totalSellAmountCents ?? null,
3735
- costCurrency: data.costCurrency ?? null,
3736
- unitCostAmountCents: data.unitCostAmountCents ?? null,
3737
- totalCostAmountCents: data.totalCostAmountCents ?? null,
3738
- notes: data.notes ?? null,
3739
- productId: data.productId ?? null,
3740
- optionId: data.optionId ?? null,
3741
- optionUnitId: data.optionUnitId ?? null,
3742
- pricingCategoryId: data.pricingCategoryId ?? null,
3743
- availabilitySlotId: data.availabilitySlotId ?? null,
3744
- productNameSnapshot: data.productNameSnapshot ?? enrichment.productName ?? null,
3745
- optionNameSnapshot: data.optionNameSnapshot ?? enrichment.optionName ?? null,
3746
- unitNameSnapshot: data.unitNameSnapshot ?? enrichment.unitName ?? null,
3747
- departureLabelSnapshot: data.departureLabelSnapshot ?? enrichment.departureLabel ?? null,
3748
- sourceSnapshotId: data.sourceSnapshotId ?? null,
3749
- sourceOfferId: data.sourceOfferId ?? null,
3750
- metadata: data.metadata ?? null,
3751
- })
3752
- .returning();
3753
- if (!row) {
3754
- return null;
3755
- }
3756
- await tx.insert(bookingActivityLog).values({
3757
- bookingId,
3758
- actorId: userId ?? "system",
3759
- activityType: "item_update",
3760
- description: `Booking item "${data.title}" added`,
3761
- metadata: { bookingItemId: row.id, itemType: data.itemType },
3762
- });
3763
- await bookingsService.recomputeBookingTotal(tx, bookingId);
3764
- return row;
3765
- });
3766
- },
3767
- async updateItem(db, itemId, data) {
3768
- return db.transaction(async (tx) => {
3769
- // Refresh snapshots only when the foreign IDs change. Existing
3770
- // snapshots are the historical record — overwriting them when
3771
- // the catalog gets renamed would defeat their purpose. Callers
3772
- // that want a manual re-snapshot can pass the `*Snapshot` fields
3773
- // explicitly.
3774
- const refreshing = data.productId !== undefined ||
3775
- data.optionId !== undefined ||
3776
- data.optionUnitId !== undefined ||
3777
- data.availabilitySlotId !== undefined;
3778
- const snapshotUpdates = {};
3779
- if (refreshing) {
3780
- const [current] = await tx
3781
- .select({
3782
- productId: bookingItems.productId,
3783
- optionId: bookingItems.optionId,
3784
- optionUnitId: bookingItems.optionUnitId,
3785
- availabilitySlotId: bookingItems.availabilitySlotId,
3786
- })
3787
- .from(bookingItems)
3788
- .where(eq(bookingItems.id, itemId))
3789
- .limit(1);
3790
- if (current) {
3791
- const enrichment = await resolveBookingItemSnapshot(tx, {
3792
- productId: data.productId !== undefined ? (data.productId ?? null) : current.productId,
3793
- optionId: data.optionId !== undefined ? (data.optionId ?? null) : current.optionId,
3794
- optionUnitId: data.optionUnitId !== undefined ? (data.optionUnitId ?? null) : current.optionUnitId,
3795
- availabilitySlotId: data.availabilitySlotId !== undefined
3796
- ? (data.availabilitySlotId ?? null)
3797
- : current.availabilitySlotId,
3798
- });
3799
- if (data.productNameSnapshot !== undefined) {
3800
- snapshotUpdates.productNameSnapshot = data.productNameSnapshot;
3801
- }
3802
- else if (enrichment.productName !== null) {
3803
- snapshotUpdates.productNameSnapshot = enrichment.productName;
3804
- }
3805
- if (data.optionNameSnapshot !== undefined) {
3806
- snapshotUpdates.optionNameSnapshot = data.optionNameSnapshot;
3807
- }
3808
- else if (enrichment.optionName !== null) {
3809
- snapshotUpdates.optionNameSnapshot = enrichment.optionName;
3810
- }
3811
- if (data.unitNameSnapshot !== undefined) {
3812
- snapshotUpdates.unitNameSnapshot = data.unitNameSnapshot;
3813
- }
3814
- else if (enrichment.unitName !== null) {
3815
- snapshotUpdates.unitNameSnapshot = enrichment.unitName;
3816
- }
3817
- if (data.departureLabelSnapshot !== undefined) {
3818
- snapshotUpdates.departureLabelSnapshot = data.departureLabelSnapshot;
3819
- }
3820
- else if (enrichment.departureLabel !== null) {
3821
- snapshotUpdates.departureLabelSnapshot = enrichment.departureLabel;
3822
- }
3823
- // If the caller didn't override timing and a slot was
3824
- // (re)assigned, mirror the slot's timing onto the item.
3825
- if (data.availabilitySlotId !== undefined && enrichment.startsAt) {
3826
- if (data.startsAt === undefined)
3827
- snapshotUpdates.startsAt = enrichment.startsAt;
3828
- if (data.endsAt === undefined)
3829
- snapshotUpdates.endsAt = enrichment.endsAt;
3830
- if (data.serviceDate === undefined)
3831
- snapshotUpdates.serviceDate = enrichment.serviceDate;
3832
- }
3833
- }
3834
- }
3835
- const [row] = await tx
3836
- .update(bookingItems)
3837
- .set({
3838
- ...data,
3839
- ...snapshotUpdates,
3840
- startsAt: snapshotUpdates.startsAt !== undefined
3841
- ? snapshotUpdates.startsAt
3842
- : data.startsAt === undefined
3843
- ? undefined
3844
- : toTimestamp(data.startsAt),
3845
- endsAt: snapshotUpdates.endsAt !== undefined
3846
- ? snapshotUpdates.endsAt
3847
- : data.endsAt === undefined
3848
- ? undefined
3849
- : toTimestamp(data.endsAt),
3850
- updatedAt: new Date(),
3851
- })
3852
- .where(eq(bookingItems.id, itemId))
3853
- .returning();
3854
- if (!row)
3855
- return null;
3856
- await bookingsService.recomputeBookingTotal(tx, row.bookingId);
3857
- return row;
3858
- });
3859
- },
3860
- async deleteItem(db, itemId) {
3861
- return db.transaction(async (tx) => {
3862
- // Look up the parent booking BEFORE the delete so we can roll up
3863
- // afterwards.
3864
- const [item] = await tx
3865
- .select({ bookingId: bookingItems.bookingId })
3866
- .from(bookingItems)
3867
- .where(eq(bookingItems.id, itemId))
3868
- .limit(1);
3869
- const [row] = await tx
3870
- .delete(bookingItems)
3871
- .where(eq(bookingItems.id, itemId))
3872
- .returning({ id: bookingItems.id });
3873
- if (item) {
3874
- await bookingsService.recomputeBookingTotal(tx, item.bookingId);
3875
- }
3876
- return row ?? null;
3877
- });
3878
- },
3879
- listItemParticipants(db, itemId) {
3880
- return db
3881
- .select()
3882
- .from(bookingItemTravelers)
3883
- .where(eq(bookingItemTravelers.bookingItemId, itemId))
3884
- .orderBy(desc(bookingItemTravelers.isPrimary), asc(bookingItemTravelers.createdAt));
3885
- },
3886
- async addItemParticipant(db, itemId, data) {
3887
- const [item] = await db
3888
- .select({ id: bookingItems.id })
3889
- .from(bookingItems)
3890
- .where(eq(bookingItems.id, itemId))
3891
- .limit(1);
3892
- if (!item) {
3893
- return null;
3894
- }
3895
- const [traveler] = await db
3896
- .select({ id: bookingTravelers.id })
3897
- .from(bookingTravelers)
3898
- .where(eq(bookingTravelers.id, data.travelerId))
3899
- .limit(1);
3900
- if (!traveler) {
3901
- return null;
3902
- }
3903
- if (data.isPrimary) {
3904
- await db
3905
- .update(bookingItemTravelers)
3906
- .set({ isPrimary: false })
3907
- .where(eq(bookingItemTravelers.bookingItemId, itemId));
3908
- }
3909
- const [row] = await db
3910
- .insert(bookingItemTravelers)
3911
- .values({
3912
- bookingItemId: itemId,
3913
- travelerId: data.travelerId,
3914
- role: data.role,
3915
- isPrimary: data.isPrimary ?? false,
3916
- })
3917
- .returning();
3918
- return row;
3919
- },
3920
- async removeItemParticipant(db, linkId) {
3921
- const [row] = await db
3922
- .delete(bookingItemTravelers)
3923
- .where(eq(bookingItemTravelers.id, linkId))
3924
- .returning({ id: bookingItemTravelers.id });
3925
- return row ?? null;
3926
- },
3927
- listSupplierStatuses(db, bookingId) {
3928
- return db
3929
- .select()
3930
- .from(bookingSupplierStatuses)
3931
- .where(eq(bookingSupplierStatuses.bookingId, bookingId))
3932
- .orderBy(asc(bookingSupplierStatuses.createdAt));
3933
- },
3934
- async createSupplierStatus(db, bookingId, data, userId) {
3935
- const [booking] = await db
3936
- .select({ id: bookings.id })
3937
- .from(bookings)
3938
- .where(eq(bookings.id, bookingId))
3939
- .limit(1);
3940
- if (!booking) {
3941
- return null;
3942
- }
3943
- const [row] = await db
3944
- .insert(bookingSupplierStatuses)
3945
- .values({
3946
- bookingId,
3947
- supplierServiceId: data.supplierServiceId ?? null,
3948
- supplierId: data.supplierId ?? null,
3949
- serviceName: data.serviceName,
3950
- status: data.status ?? "pending",
3951
- supplierReference: data.supplierReference ?? null,
3952
- costCurrency: data.costCurrency,
3953
- costAmountCents: data.costAmountCents,
3954
- supplierInvoiceLineId: data.supplierInvoiceLineId ?? null,
3955
- notes: data.notes ?? null,
3956
- confirmedAt: data.status === "confirmed" ? new Date() : null,
3957
- })
3958
- .returning();
3959
- await db.insert(bookingActivityLog).values({
3960
- bookingId,
3961
- actorId: userId ?? "system",
3962
- activityType: "supplier_update",
3963
- description: `Supplier status for "${data.serviceName}" added`,
3964
- });
3965
- return row ?? null;
3966
- },
3967
- async updateSupplierStatus(db, bookingId, statusId, data, userId) {
3968
- const updateData = {
3969
- ...data,
3970
- supplierServiceId: data.supplierServiceId ?? undefined,
3971
- supplierReference: data.supplierReference ?? undefined,
3972
- confirmedAt: data.confirmedAt !== undefined
3973
- ? toTimestamp(data.confirmedAt)
3974
- : data.status === "confirmed"
3975
- ? new Date()
3976
- : undefined,
3977
- updatedAt: new Date(),
3978
- };
3979
- const [row] = await db
3980
- .update(bookingSupplierStatuses)
3981
- .set(updateData)
3982
- .where(eq(bookingSupplierStatuses.id, statusId))
3983
- .returning();
3984
- if (!row) {
3985
- return null;
3986
- }
3987
- if (data.status) {
3988
- await db.insert(bookingActivityLog).values({
3989
- bookingId,
3990
- actorId: userId ?? "system",
3991
- activityType: "supplier_update",
3992
- description: `Supplier "${row.serviceName}" status updated to ${data.status}`,
3993
- metadata: { supplierStatusId: statusId, newStatus: data.status },
3994
- });
3995
- }
3996
- return row;
3997
- },
3998
- listFulfillments(db, bookingId) {
3999
- return db
4000
- .select()
4001
- .from(bookingFulfillments)
4002
- .where(eq(bookingFulfillments.bookingId, bookingId))
4003
- .orderBy(desc(bookingFulfillments.createdAt));
4004
- },
4005
- async issueFulfillment(db, bookingId, data, userId) {
4006
- const [booking] = await db
4007
- .select({ id: bookings.id })
4008
- .from(bookings)
4009
- .where(eq(bookings.id, bookingId))
4010
- .limit(1);
4011
- if (!booking) {
4012
- return null;
4013
- }
4014
- const scoped = await ensureBookingScopedLinks(db, bookingId, data);
4015
- if (!scoped.ok) {
4016
- return null;
4017
- }
4018
- const status = data.status ?? "issued";
4019
- const issuedAt = data.issuedAt !== undefined
4020
- ? toTimestamp(data.issuedAt)
4021
- : status === "issued" || status === "reissued"
4022
- ? new Date()
4023
- : null;
4024
- const revokedAt = data.revokedAt !== undefined
4025
- ? toTimestamp(data.revokedAt)
4026
- : status === "revoked"
4027
- ? new Date()
4028
- : null;
4029
- const [row] = await db
4030
- .insert(bookingFulfillments)
4031
- .values({
4032
- bookingId,
4033
- bookingItemId: data.bookingItemId ?? null,
4034
- travelerId: data.travelerId ?? null,
4035
- fulfillmentType: data.fulfillmentType,
4036
- deliveryChannel: data.deliveryChannel,
4037
- status,
4038
- artifactUrl: data.artifactUrl ?? null,
4039
- payload: data.payload ?? null,
4040
- issuedAt,
4041
- revokedAt,
4042
- })
4043
- .returning();
4044
- await db.insert(bookingActivityLog).values({
4045
- bookingId,
4046
- actorId: userId ?? "system",
4047
- activityType: "fulfillment_issued",
4048
- description: `Booking fulfillment issued as ${data.fulfillmentType}`,
4049
- metadata: {
4050
- fulfillmentId: row?.id ?? null,
4051
- bookingItemId: data.bookingItemId ?? null,
4052
- travelerId: data.travelerId ?? null,
4053
- status,
4054
- },
4055
- });
4056
- return row ?? null;
4057
- },
4058
- async updateFulfillment(db, bookingId, fulfillmentId, data, userId) {
4059
- const [existing] = await db
4060
- .select({ id: bookingFulfillments.id })
4061
- .from(bookingFulfillments)
4062
- .where(and(eq(bookingFulfillments.id, fulfillmentId), eq(bookingFulfillments.bookingId, bookingId)))
4063
- .limit(1);
4064
- if (!existing) {
4065
- return null;
4066
- }
4067
- const scoped = await ensureBookingScopedLinks(db, bookingId, data);
4068
- if (!scoped.ok) {
4069
- return null;
4070
- }
4071
- const nextStatus = data.status;
4072
- const [row] = await db
4073
- .update(bookingFulfillments)
4074
- .set({
4075
- bookingItemId: data.bookingItemId === undefined ? undefined : (data.bookingItemId ?? null),
4076
- travelerId: data.travelerId === undefined ? undefined : (data.travelerId ?? null),
4077
- fulfillmentType: data.fulfillmentType,
4078
- deliveryChannel: data.deliveryChannel,
4079
- status: nextStatus,
4080
- artifactUrl: data.artifactUrl === undefined ? undefined : (data.artifactUrl ?? null),
4081
- payload: data.payload === undefined ? undefined : (data.payload ?? null),
4082
- issuedAt: data.issuedAt !== undefined
4083
- ? toTimestamp(data.issuedAt)
4084
- : nextStatus === "issued" || nextStatus === "reissued"
4085
- ? new Date()
4086
- : undefined,
4087
- revokedAt: data.revokedAt !== undefined
4088
- ? toTimestamp(data.revokedAt)
4089
- : nextStatus === "revoked"
4090
- ? new Date()
4091
- : undefined,
4092
- updatedAt: new Date(),
4093
- })
4094
- .where(eq(bookingFulfillments.id, fulfillmentId))
4095
- .returning();
4096
- if (row) {
4097
- await db.insert(bookingActivityLog).values({
4098
- bookingId,
4099
- actorId: userId ?? "system",
4100
- activityType: "fulfillment_updated",
4101
- description: `Booking fulfillment ${fulfillmentId} updated`,
4102
- metadata: {
4103
- fulfillmentId,
4104
- bookingItemId: row.bookingItemId,
4105
- travelerId: row.travelerId,
4106
- status: row.status,
4107
- },
4108
- });
4109
- }
4110
- return row ?? null;
4111
- },
4112
- listRedemptionEvents(db, bookingId) {
4113
- return db
4114
- .select()
4115
- .from(bookingRedemptionEvents)
4116
- .where(eq(bookingRedemptionEvents.bookingId, bookingId))
4117
- .orderBy(desc(bookingRedemptionEvents.redeemedAt), desc(bookingRedemptionEvents.createdAt));
4118
- },
4119
- async recordRedemption(db, bookingId, data, userId) {
4120
- return db.transaction(async (tx) => {
4121
- const [booking] = await tx
4122
- .select({
4123
- id: bookings.id,
4124
- redeemedAt: bookings.redeemedAt,
4125
- })
4126
- .from(bookings)
4127
- .where(eq(bookings.id, bookingId))
4128
- .limit(1);
4129
- if (!booking) {
4130
- return null;
4131
- }
4132
- const scoped = await ensureBookingScopedLinks(tx, bookingId, data);
4133
- if (!scoped.ok) {
4134
- return null;
4135
- }
4136
- const redeemedAt = toTimestamp(data.redeemedAt) ?? new Date();
4137
- const [event] = await tx
4138
- .insert(bookingRedemptionEvents)
4139
- .values({
4140
- bookingId,
4141
- bookingItemId: data.bookingItemId ?? null,
4142
- travelerId: data.travelerId ?? null,
4143
- redeemedAt,
4144
- redeemedBy: data.redeemedBy ?? userId ?? null,
4145
- location: data.location ?? null,
4146
- method: data.method,
4147
- metadata: data.metadata ?? null,
4148
- })
4149
- .returning();
4150
- if (!booking.redeemedAt || booking.redeemedAt < redeemedAt) {
4151
- await tx
4152
- .update(bookings)
4153
- .set({
4154
- redeemedAt,
4155
- updatedAt: new Date(),
4156
- })
4157
- .where(eq(bookings.id, bookingId));
4158
- }
4159
- if (data.bookingItemId) {
4160
- await tx
4161
- .update(bookingItems)
4162
- .set({
4163
- status: "fulfilled",
4164
- updatedAt: new Date(),
4165
- })
4166
- .where(and(eq(bookingItems.id, data.bookingItemId), eq(bookingItems.bookingId, bookingId), or(eq(bookingItems.status, "confirmed"), eq(bookingItems.status, "on_hold"), eq(bookingItems.status, "draft"))));
4167
- await tx
4168
- .update(bookingAllocations)
4169
- .set({
4170
- status: "fulfilled",
4171
- updatedAt: new Date(),
4172
- })
4173
- .where(and(eq(bookingAllocations.bookingId, bookingId), eq(bookingAllocations.bookingItemId, data.bookingItemId), or(eq(bookingAllocations.status, "held"), eq(bookingAllocations.status, "confirmed"))));
4174
- }
4175
- await tx.insert(bookingActivityLog).values({
4176
- bookingId,
4177
- actorId: userId ?? "system",
4178
- activityType: "redemption_recorded",
4179
- description: "Booking redemption recorded",
4180
- metadata: {
4181
- redemptionEventId: event?.id ?? null,
4182
- bookingItemId: data.bookingItemId ?? null,
4183
- travelerId: data.travelerId ?? null,
4184
- redeemedAt: redeemedAt.toISOString(),
4185
- method: data.method,
4186
- },
4187
- });
4188
- await syncTransactionOnBookingRedeemed(tx, bookingId);
4189
- return event ?? null;
4190
- });
4191
- },
4192
- listActivity(db, bookingId) {
4193
- // Surface a hydrated actor display name + email so the activity
4194
- // timeline can render "By {name}" / "By {email}" instead of the raw
4195
- // user id. `actorId` may be null (system events) or reference a
4196
- // deleted user, both of which leave the join columns null.
4197
- return db
4198
- .select({
4199
- ...getTableColumns(bookingActivityLog),
4200
- actorName: authUser.name,
4201
- actorEmail: authUser.email,
4202
- })
4203
- .from(bookingActivityLog)
4204
- .leftJoin(authUser, eq(bookingActivityLog.actorId, authUser.id))
4205
- .where(eq(bookingActivityLog.bookingId, bookingId))
4206
- .orderBy(desc(bookingActivityLog.createdAt));
4207
- },
4208
- listNotes(db, bookingId) {
4209
- return db
4210
- .select({
4211
- ...getTableColumns(bookingNotes),
4212
- authorName: authUser.name,
4213
- authorEmail: authUser.email,
4214
- })
4215
- .from(bookingNotes)
4216
- .leftJoin(authUser, eq(bookingNotes.authorId, authUser.id))
4217
- .where(eq(bookingNotes.bookingId, bookingId))
4218
- .orderBy(bookingNotes.createdAt);
4219
- },
4220
- async createNote(db, bookingId, userId, data) {
4221
- const [booking] = await db
4222
- .select({ id: bookings.id })
4223
- .from(bookings)
4224
- .where(eq(bookings.id, bookingId))
4225
- .limit(1);
4226
- if (!booking) {
4227
- return null;
4228
- }
4229
- const [row] = await db
4230
- .insert(bookingNotes)
4231
- .values({
4232
- bookingId,
4233
- authorId: userId,
4234
- content: data.content,
4235
- })
4236
- .returning();
4237
- await db.insert(bookingActivityLog).values({
4238
- bookingId,
4239
- actorId: userId,
4240
- activityType: "note_added",
4241
- description: "Note added",
4242
- });
4243
- return row;
4244
- },
4245
- async updateNote(db, bookingId, noteId, data) {
4246
- // Scope the update to (booking, note) so a stale / cross-booking
4247
- // note id can't mutate another booking's note while the route
4248
- // records an audit entry under the wrong `bookingId`. Returns null
4249
- // → route responds 404.
4250
- const [row] = await db
4251
- .update(bookingNotes)
4252
- .set({ content: data.content })
4253
- .where(and(eq(bookingNotes.id, noteId), eq(bookingNotes.bookingId, bookingId)))
4254
- .returning();
4255
- return row ?? null;
4256
- },
4257
- async deleteNote(db, bookingId, noteId) {
4258
- // Same scoping as `updateNote` — guards against deleting a note
4259
- // that belongs to a different booking when the audit entry would
4260
- // get filed under the route's `:id`.
4261
- const [row] = await db
4262
- .delete(bookingNotes)
4263
- .where(and(eq(bookingNotes.id, noteId), eq(bookingNotes.bookingId, bookingId)))
4264
- .returning({ id: bookingNotes.id });
4265
- return row ?? null;
4266
- },
4267
- listDocuments(db, bookingId) {
4268
- return db
4269
- .select()
4270
- .from(bookingDocuments)
4271
- .where(eq(bookingDocuments.bookingId, bookingId))
4272
- .orderBy(bookingDocuments.createdAt);
4273
- },
4274
- async createDocument(db, bookingId, data) {
4275
- const [booking] = await db
4276
- .select({ id: bookings.id })
4277
- .from(bookings)
4278
- .where(eq(bookings.id, bookingId))
4279
- .limit(1);
4280
- if (!booking) {
4281
- return null;
4282
- }
4283
- const [row] = await db
4284
- .insert(bookingDocuments)
4285
- .values({
4286
- bookingId,
4287
- travelerId: data.travelerId ?? null,
4288
- type: data.type,
4289
- fileName: data.fileName,
4290
- fileUrl: data.fileUrl,
4291
- expiresAt: data.expiresAt ? new Date(data.expiresAt) : null,
4292
- notes: data.notes ?? null,
4293
- })
4294
- .returning();
4295
- return row;
4296
- },
4297
- async deleteDocument(db, documentId) {
4298
- const [row] = await db
4299
- .delete(bookingDocuments)
4300
- .where(eq(bookingDocuments.id, documentId))
4301
- .returning({ id: bookingDocuments.id });
4302
- return row ?? null;
4303
- },
4304
- };
1
+ export * from "./service-core.js";