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