@voyantjs/bookings 0.1.0

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 (42) hide show
  1. package/LICENSE +109 -0
  2. package/README.md +42 -0
  3. package/dist/availability-ref.d.ts +418 -0
  4. package/dist/availability-ref.d.ts.map +1 -0
  5. package/dist/availability-ref.js +28 -0
  6. package/dist/extensions/suppliers.d.ts +3 -0
  7. package/dist/extensions/suppliers.d.ts.map +1 -0
  8. package/dist/extensions/suppliers.js +103 -0
  9. package/dist/index.d.ts +20 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +25 -0
  12. package/dist/pii.d.ts +29 -0
  13. package/dist/pii.d.ts.map +1 -0
  14. package/dist/pii.js +131 -0
  15. package/dist/products-ref.d.ts +1043 -0
  16. package/dist/products-ref.d.ts.map +1 -0
  17. package/dist/products-ref.js +76 -0
  18. package/dist/routes.d.ts +2171 -0
  19. package/dist/routes.d.ts.map +1 -0
  20. package/dist/routes.js +659 -0
  21. package/dist/schema/travel-details.d.ts +179 -0
  22. package/dist/schema/travel-details.d.ts.map +1 -0
  23. package/dist/schema/travel-details.js +46 -0
  24. package/dist/schema.d.ts +3180 -0
  25. package/dist/schema.d.ts.map +1 -0
  26. package/dist/schema.js +509 -0
  27. package/dist/service.d.ts +5000 -0
  28. package/dist/service.d.ts.map +1 -0
  29. package/dist/service.js +2016 -0
  30. package/dist/tasks/expire-stale-holds.d.ts +12 -0
  31. package/dist/tasks/expire-stale-holds.d.ts.map +1 -0
  32. package/dist/tasks/expire-stale-holds.js +7 -0
  33. package/dist/tasks/index.d.ts +2 -0
  34. package/dist/tasks/index.d.ts.map +1 -0
  35. package/dist/tasks/index.js +1 -0
  36. package/dist/transactions-ref.d.ts +2223 -0
  37. package/dist/transactions-ref.d.ts.map +1 -0
  38. package/dist/transactions-ref.js +147 -0
  39. package/dist/validation.d.ts +643 -0
  40. package/dist/validation.d.ts.map +1 -0
  41. package/dist/validation.js +355 -0
  42. package/package.json +68 -0
@@ -0,0 +1,2016 @@
1
+ import { and, asc, desc, eq, ilike, inArray, lte, ne, or, sql } from "drizzle-orm";
2
+ import { availabilitySlotsRef } from "./availability-ref.js";
3
+ import { bookingItemProductDetailsRef, bookingProductDetailsRef, optionUnitsRef, productDayServicesRef, productDaysRef, productOptionsRef, productTicketSettingsRef, productsRef, } from "./products-ref.js";
4
+ import { bookingActivityLog, bookingAllocations, bookingDocuments, bookingFulfillments, bookingItemParticipants, bookingItems, bookingNotes, bookingParticipants, bookingRedemptionEvents, bookingSupplierStatuses, bookings, } from "./schema.js";
5
+ import { bookingTransactionDetailsRef, offerItemParticipantsRef, offerItemsRef, offerParticipantsRef, offersRef, orderItemParticipantsRef, orderItemsRef, orderParticipantsRef, ordersRef, } from "./transactions-ref.js";
6
+ const travelerParticipantTypes = ["traveler", "occupant"];
7
+ class BookingServiceError extends Error {
8
+ code;
9
+ constructor(code, message) {
10
+ super(message ?? code);
11
+ this.code = code;
12
+ this.name = "BookingServiceError";
13
+ }
14
+ }
15
+ function toTimestamp(value) {
16
+ return value ? new Date(value) : null;
17
+ }
18
+ function toDateValue(value) {
19
+ return value instanceof Date ? value : new Date(value);
20
+ }
21
+ function toDateValueOrNull(value) {
22
+ if (!value)
23
+ return null;
24
+ return value instanceof Date ? value : new Date(value);
25
+ }
26
+ function toPassengerResponse(participant) {
27
+ return {
28
+ id: participant.id,
29
+ bookingId: participant.bookingId,
30
+ firstName: participant.firstName,
31
+ lastName: participant.lastName,
32
+ email: participant.email,
33
+ phone: participant.phone,
34
+ specialRequests: participant.specialRequests,
35
+ isLeadPassenger: participant.isPrimary,
36
+ createdAt: participant.createdAt,
37
+ updatedAt: participant.updatedAt,
38
+ };
39
+ }
40
+ function toCreateParticipantFromPassenger(data) {
41
+ return {
42
+ participantType: "traveler",
43
+ firstName: data.firstName,
44
+ lastName: data.lastName,
45
+ email: data.email ?? null,
46
+ phone: data.phone ?? null,
47
+ specialRequests: data.specialRequests ?? null,
48
+ isPrimary: data.isLeadPassenger ?? false,
49
+ };
50
+ }
51
+ function toUpdateParticipantFromPassenger(data) {
52
+ return {
53
+ firstName: data.firstName,
54
+ lastName: data.lastName,
55
+ email: data.email ?? null,
56
+ phone: data.phone ?? null,
57
+ specialRequests: data.specialRequests ?? null,
58
+ isPrimary: data.isLeadPassenger ?? undefined,
59
+ };
60
+ }
61
+ async function ensureParticipantFlags(db, bookingId, participantId, data) {
62
+ if (data.isPrimary) {
63
+ await db
64
+ .update(bookingParticipants)
65
+ .set({ isPrimary: false, updatedAt: new Date() })
66
+ .where(and(eq(bookingParticipants.bookingId, bookingId), ne(bookingParticipants.id, participantId)));
67
+ }
68
+ }
69
+ async function ensureBookingScopedLinks(db, bookingId, data) {
70
+ if (data.bookingItemId) {
71
+ const [item] = await db
72
+ .select({ id: bookingItems.id })
73
+ .from(bookingItems)
74
+ .where(and(eq(bookingItems.id, data.bookingItemId), eq(bookingItems.bookingId, bookingId)))
75
+ .limit(1);
76
+ if (!item) {
77
+ return { ok: false, reason: "booking_item_not_found" };
78
+ }
79
+ }
80
+ if (data.participantId) {
81
+ const [participant] = await db
82
+ .select({ id: bookingParticipants.id })
83
+ .from(bookingParticipants)
84
+ .where(and(eq(bookingParticipants.id, data.participantId), eq(bookingParticipants.bookingId, bookingId)))
85
+ .limit(1);
86
+ if (!participant) {
87
+ return { ok: false, reason: "participant_not_found" };
88
+ }
89
+ }
90
+ return { ok: true };
91
+ }
92
+ function deriveBookingDateRange(items) {
93
+ const dates = items
94
+ .flatMap((item) => [item.serviceDate, item.startsAt?.toISOString().slice(0, 10) ?? null])
95
+ .filter((value) => Boolean(value))
96
+ .sort();
97
+ return {
98
+ startDate: dates[0] ?? null,
99
+ endDate: dates[dates.length - 1] ?? null,
100
+ };
101
+ }
102
+ function deriveBookingPax(participants, items) {
103
+ const pax = participants.filter((participant) => ["traveler", "occupant"].includes(participant.participantType)).length;
104
+ if (pax > 0) {
105
+ return pax;
106
+ }
107
+ return items
108
+ .filter((item) => item.itemType === "unit")
109
+ .reduce((sum, item) => sum + item.quantity, 0);
110
+ }
111
+ function getTransactionItemParticipantItemId(link) {
112
+ return "offerItemId" in link ? link.offerItemId : link.orderItemId;
113
+ }
114
+ function mapDeliveryFormatToFulfillment(format) {
115
+ switch (format) {
116
+ case "pdf":
117
+ return { fulfillmentType: "pdf", deliveryChannel: "download" };
118
+ case "qr_code":
119
+ return { fulfillmentType: "qr_code", deliveryChannel: "download" };
120
+ case "barcode":
121
+ return { fulfillmentType: "barcode", deliveryChannel: "download" };
122
+ case "mobile":
123
+ return { fulfillmentType: "mobile", deliveryChannel: "wallet" };
124
+ case "email":
125
+ return { fulfillmentType: "voucher", deliveryChannel: "email" };
126
+ case "ticket":
127
+ return { fulfillmentType: "ticket", deliveryChannel: "download" };
128
+ case "voucher":
129
+ default:
130
+ return { fulfillmentType: "voucher", deliveryChannel: "download" };
131
+ }
132
+ }
133
+ async function getConvertProductData(db, data) {
134
+ const [product] = await db
135
+ .select()
136
+ .from(productsRef)
137
+ .where(eq(productsRef.id, data.productId))
138
+ .limit(1);
139
+ if (!product) {
140
+ return null;
141
+ }
142
+ let option = null;
143
+ if (data.optionId) {
144
+ const [selectedOption] = await db
145
+ .select()
146
+ .from(productOptionsRef)
147
+ .where(and(eq(productOptionsRef.id, data.optionId), eq(productOptionsRef.productId, product.id)))
148
+ .limit(1);
149
+ if (!selectedOption) {
150
+ return null;
151
+ }
152
+ option = selectedOption;
153
+ }
154
+ else {
155
+ const [defaultOption] = await db
156
+ .select()
157
+ .from(productOptionsRef)
158
+ .where(eq(productOptionsRef.productId, product.id))
159
+ .orderBy(desc(productOptionsRef.isDefault), asc(productOptionsRef.sortOrder), asc(productOptionsRef.createdAt))
160
+ .limit(1);
161
+ option = defaultOption ?? null;
162
+ }
163
+ const days = await db
164
+ .select()
165
+ .from(productDaysRef)
166
+ .where(eq(productDaysRef.productId, product.id))
167
+ .orderBy(asc(productDaysRef.dayNumber));
168
+ const dayServices = days.length
169
+ ? await db
170
+ .select({
171
+ supplierServiceId: productDayServicesRef.supplierServiceId,
172
+ name: productDayServicesRef.name,
173
+ costCurrency: productDayServicesRef.costCurrency,
174
+ costAmountCents: productDayServicesRef.costAmountCents,
175
+ })
176
+ .from(productDayServicesRef)
177
+ .where(sql `${productDayServicesRef.dayId} IN (
178
+ SELECT ${productDaysRef.id}
179
+ FROM ${productDaysRef}
180
+ WHERE ${productDaysRef.productId} = ${product.id}
181
+ )`)
182
+ .orderBy(asc(productDayServicesRef.sortOrder), asc(productDayServicesRef.id))
183
+ : [];
184
+ const units = option === null
185
+ ? []
186
+ : await db
187
+ .select()
188
+ .from(optionUnitsRef)
189
+ .where(eq(optionUnitsRef.optionId, option.id))
190
+ .orderBy(asc(optionUnitsRef.sortOrder), asc(optionUnitsRef.createdAt));
191
+ return {
192
+ product: {
193
+ id: product.id,
194
+ name: product.name,
195
+ description: product.description,
196
+ sellCurrency: product.sellCurrency,
197
+ sellAmountCents: product.sellAmountCents,
198
+ costAmountCents: product.costAmountCents,
199
+ marginPercent: product.marginPercent,
200
+ startDate: product.startDate,
201
+ endDate: product.endDate,
202
+ pax: product.pax,
203
+ },
204
+ option: option ? { id: option.id, name: option.name } : null,
205
+ dayServices,
206
+ units: units.map((unit) => ({
207
+ id: unit.id,
208
+ name: unit.name,
209
+ description: unit.description,
210
+ unitType: unit.unitType,
211
+ isRequired: unit.isRequired,
212
+ minQuantity: unit.minQuantity,
213
+ sortOrder: unit.sortOrder,
214
+ })),
215
+ };
216
+ }
217
+ const VALID_BOOKING_TRANSITIONS = {
218
+ draft: ["on_hold", "confirmed", "cancelled"],
219
+ on_hold: ["confirmed", "expired", "cancelled"],
220
+ confirmed: ["in_progress", "cancelled"],
221
+ in_progress: ["completed", "cancelled"],
222
+ completed: [],
223
+ expired: [],
224
+ cancelled: [],
225
+ };
226
+ function isValidBookingTransition(from, to) {
227
+ return VALID_BOOKING_TRANSITIONS[from].includes(to);
228
+ }
229
+ function computeHoldExpiresAt(input) {
230
+ if (input.holdExpiresAt) {
231
+ return new Date(input.holdExpiresAt);
232
+ }
233
+ const now = Date.now();
234
+ const minutes = input.holdMinutes ?? 30;
235
+ return new Date(now + minutes * 60 * 1000);
236
+ }
237
+ function toBookingStatusTimestamps(status) {
238
+ const now = new Date();
239
+ return {
240
+ confirmedAt: status === "confirmed" ? now : undefined,
241
+ expiredAt: status === "expired" ? now : undefined,
242
+ cancelledAt: status === "cancelled" ? now : undefined,
243
+ completedAt: status === "completed" ? now : undefined,
244
+ };
245
+ }
246
+ async function lockAvailabilitySlot(db, slotId) {
247
+ const rows = await db.execute(sql `SELECT id, product_id, option_id, date_local, starts_at, ends_at, timezone, status, unlimited, remaining_pax
248
+ FROM ${availabilitySlotsRef}
249
+ WHERE ${availabilitySlotsRef.id} = ${slotId}
250
+ FOR UPDATE`);
251
+ const row = rows[0];
252
+ if (!row) {
253
+ return null;
254
+ }
255
+ return {
256
+ ...row,
257
+ starts_at: toDateValue(row.starts_at),
258
+ ends_at: toDateValueOrNull(row.ends_at),
259
+ };
260
+ }
261
+ async function adjustSlotCapacity(db, slotId, delta) {
262
+ const locked = await lockAvailabilitySlot(db, slotId);
263
+ if (!locked) {
264
+ return { status: "slot_not_found" };
265
+ }
266
+ if (locked.status !== "open" && locked.status !== "sold_out") {
267
+ return { status: "slot_unavailable", slot: locked };
268
+ }
269
+ if (locked.unlimited) {
270
+ return { status: "ok", slot: locked, remainingPax: locked.remaining_pax };
271
+ }
272
+ const currentRemaining = locked.remaining_pax ?? 0;
273
+ const nextRemaining = currentRemaining + delta;
274
+ if (nextRemaining < 0) {
275
+ return { status: "insufficient_capacity", slot: locked, remainingPax: currentRemaining };
276
+ }
277
+ let nextStatus = locked.status;
278
+ if (nextRemaining === 0 && locked.status === "open") {
279
+ nextStatus = "sold_out";
280
+ }
281
+ else if (nextRemaining > 0 && locked.status === "sold_out") {
282
+ nextStatus = "open";
283
+ }
284
+ await db
285
+ .update(availabilitySlotsRef)
286
+ .set({
287
+ remainingPax: nextRemaining,
288
+ status: nextStatus,
289
+ updatedAt: new Date(),
290
+ })
291
+ .where(eq(availabilitySlotsRef.id, slotId));
292
+ return { status: "ok", slot: locked, remainingPax: nextRemaining };
293
+ }
294
+ async function releaseAllocationCapacity(db, allocation) {
295
+ if (!allocation.availabilitySlotId) {
296
+ return;
297
+ }
298
+ if (allocation.status !== "held" && allocation.status !== "confirmed") {
299
+ return;
300
+ }
301
+ await adjustSlotCapacity(db, allocation.availabilitySlotId, allocation.quantity);
302
+ }
303
+ async function reserveBookingFromTransactionSource(db, source, data, userId) {
304
+ try {
305
+ return await db.transaction(async (tx) => {
306
+ const holdExpiresAt = computeHoldExpiresAt(data);
307
+ const dateRange = deriveBookingDateRange(source.items);
308
+ const pax = deriveBookingPax(source.participants, source.items);
309
+ const [booking] = await tx
310
+ .insert(bookings)
311
+ .values({
312
+ bookingNumber: data.bookingNumber,
313
+ status: "on_hold",
314
+ personId: source.personId,
315
+ organizationId: source.organizationId,
316
+ sourceType: data.sourceType,
317
+ sellCurrency: source.currency,
318
+ baseCurrency: source.baseCurrency,
319
+ sellAmountCents: source.totalAmountCents,
320
+ costAmountCents: source.costAmountCents,
321
+ startDate: dateRange.startDate,
322
+ endDate: dateRange.endDate,
323
+ pax: pax > 0 ? pax : null,
324
+ internalNotes: data.internalNotes ?? source.notes,
325
+ holdExpiresAt,
326
+ })
327
+ .returning();
328
+ if (!booking) {
329
+ throw new BookingServiceError("booking_create_failed");
330
+ }
331
+ const participantMap = new Map();
332
+ if (data.includeParticipants) {
333
+ for (const participant of source.participants) {
334
+ const [createdParticipant] = await tx
335
+ .insert(bookingParticipants)
336
+ .values({
337
+ bookingId: booking.id,
338
+ personId: participant.personId ?? null,
339
+ participantType: participant.participantType,
340
+ travelerCategory: participant.travelerCategory ?? null,
341
+ firstName: participant.firstName,
342
+ lastName: participant.lastName,
343
+ email: participant.email ?? null,
344
+ phone: participant.phone ?? null,
345
+ preferredLanguage: participant.preferredLanguage ?? null,
346
+ isPrimary: participant.isPrimary,
347
+ notes: participant.notes ?? null,
348
+ })
349
+ .returning();
350
+ if (!createdParticipant) {
351
+ throw new BookingServiceError("participant_create_failed");
352
+ }
353
+ participantMap.set(participant.id, createdParticipant.id);
354
+ }
355
+ }
356
+ const bookingItemMap = new Map();
357
+ for (const item of source.items) {
358
+ if (item.slotId) {
359
+ const capacity = await adjustSlotCapacity(tx, item.slotId, -item.quantity);
360
+ if (capacity.status === "slot_not_found") {
361
+ throw new BookingServiceError("slot_not_found");
362
+ }
363
+ if (capacity.status === "slot_unavailable") {
364
+ throw new BookingServiceError("slot_unavailable");
365
+ }
366
+ if (capacity.status === "insufficient_capacity") {
367
+ throw new BookingServiceError("insufficient_capacity");
368
+ }
369
+ const slot = capacity.slot;
370
+ if (item.productId && item.productId !== slot.product_id) {
371
+ throw new BookingServiceError("slot_product_mismatch");
372
+ }
373
+ if (item.optionId && item.optionId !== slot.option_id) {
374
+ throw new BookingServiceError("slot_option_mismatch");
375
+ }
376
+ }
377
+ const [bookingItem] = await tx
378
+ .insert(bookingItems)
379
+ .values({
380
+ bookingId: booking.id,
381
+ title: item.title,
382
+ description: item.description ?? null,
383
+ itemType: item.itemType,
384
+ status: "on_hold",
385
+ serviceDate: item.serviceDate ?? (item.slotId ? undefined : null),
386
+ startsAt: item.startsAt ?? null,
387
+ endsAt: item.endsAt ?? null,
388
+ quantity: item.quantity,
389
+ sellCurrency: item.sellCurrency,
390
+ unitSellAmountCents: item.unitSellAmountCents ?? null,
391
+ totalSellAmountCents: item.totalSellAmountCents ?? null,
392
+ costCurrency: item.costCurrency ?? null,
393
+ unitCostAmountCents: item.unitCostAmountCents ?? null,
394
+ totalCostAmountCents: item.totalCostAmountCents ?? null,
395
+ notes: item.notes ?? null,
396
+ productId: item.productId ?? null,
397
+ optionId: item.optionId ?? null,
398
+ optionUnitId: item.unitId ?? null,
399
+ sourceOfferId: source.offerId,
400
+ metadata: item.metadata ?? null,
401
+ })
402
+ .returning();
403
+ if (!bookingItem) {
404
+ throw new BookingServiceError("booking_item_create_failed");
405
+ }
406
+ bookingItemMap.set(item.id, bookingItem.id);
407
+ if (item.slotId) {
408
+ const [allocation] = await tx
409
+ .insert(bookingAllocations)
410
+ .values({
411
+ bookingId: booking.id,
412
+ bookingItemId: bookingItem.id,
413
+ productId: item.productId ?? null,
414
+ optionId: item.optionId ?? null,
415
+ optionUnitId: item.unitId ?? null,
416
+ availabilitySlotId: item.slotId,
417
+ quantity: item.quantity,
418
+ allocationType: "unit",
419
+ status: "held",
420
+ holdExpiresAt,
421
+ metadata: item.metadata ?? null,
422
+ })
423
+ .returning();
424
+ if (!allocation) {
425
+ throw new BookingServiceError("allocation_create_failed");
426
+ }
427
+ }
428
+ }
429
+ for (const link of source.itemParticipants) {
430
+ const sourceItemId = getTransactionItemParticipantItemId(link);
431
+ if (!sourceItemId) {
432
+ continue;
433
+ }
434
+ const bookingItemId = bookingItemMap.get(sourceItemId);
435
+ const participantId = participantMap.get(link.participantId);
436
+ if (!bookingItemId || !participantId) {
437
+ continue;
438
+ }
439
+ await tx.insert(bookingItemParticipants).values({
440
+ bookingItemId,
441
+ participantId,
442
+ role: link.role,
443
+ isPrimary: link.isPrimary,
444
+ });
445
+ }
446
+ await tx
447
+ .insert(bookingTransactionDetailsRef)
448
+ .values({
449
+ bookingId: booking.id,
450
+ offerId: source.offerId,
451
+ orderId: source.orderId,
452
+ createdAt: new Date(),
453
+ updatedAt: new Date(),
454
+ })
455
+ .onConflictDoUpdate({
456
+ target: bookingTransactionDetailsRef.bookingId,
457
+ set: {
458
+ offerId: source.offerId,
459
+ orderId: source.orderId,
460
+ updatedAt: new Date(),
461
+ },
462
+ });
463
+ await tx.insert(bookingActivityLog).values({
464
+ bookingId: booking.id,
465
+ actorId: userId ?? "system",
466
+ activityType: "booking_reserved",
467
+ description: `Booking ${booking.bookingNumber} reserved from ${source.kind} ${source.sourceId}`,
468
+ metadata: {
469
+ sourceKind: source.kind,
470
+ sourceId: source.sourceId,
471
+ offerId: source.offerId,
472
+ orderId: source.orderId,
473
+ holdExpiresAt: holdExpiresAt.toISOString(),
474
+ itemCount: source.items.length,
475
+ },
476
+ });
477
+ if (data.note) {
478
+ await tx.insert(bookingNotes).values({
479
+ bookingId: booking.id,
480
+ authorId: userId ?? "system",
481
+ content: data.note,
482
+ });
483
+ }
484
+ return { status: "ok", booking };
485
+ });
486
+ }
487
+ catch (error) {
488
+ if (error instanceof BookingServiceError) {
489
+ return { status: error.code };
490
+ }
491
+ throw error;
492
+ }
493
+ }
494
+ async function getBookingTransactionLink(db, bookingId) {
495
+ const [link] = await db
496
+ .select()
497
+ .from(bookingTransactionDetailsRef)
498
+ .where(eq(bookingTransactionDetailsRef.bookingId, bookingId))
499
+ .limit(1);
500
+ return link ?? null;
501
+ }
502
+ async function syncTransactionOnBookingConfirmed(db, bookingId) {
503
+ const link = await getBookingTransactionLink(db, bookingId);
504
+ if (!link) {
505
+ return;
506
+ }
507
+ const now = new Date();
508
+ if (link.orderId) {
509
+ await db
510
+ .update(ordersRef)
511
+ .set({
512
+ status: "confirmed",
513
+ confirmedAt: now,
514
+ updatedAt: now,
515
+ })
516
+ .where(eq(ordersRef.id, link.orderId));
517
+ }
518
+ if (link.offerId) {
519
+ await db
520
+ .update(offersRef)
521
+ .set({
522
+ status: "converted",
523
+ acceptedAt: now,
524
+ convertedAt: now,
525
+ updatedAt: now,
526
+ })
527
+ .where(eq(offersRef.id, link.offerId));
528
+ }
529
+ }
530
+ async function syncTransactionOnBookingExpired(db, bookingId) {
531
+ const link = await getBookingTransactionLink(db, bookingId);
532
+ if (!link?.orderId) {
533
+ return;
534
+ }
535
+ const now = new Date();
536
+ await db
537
+ .update(ordersRef)
538
+ .set({
539
+ status: "expired",
540
+ expiresAt: now,
541
+ updatedAt: now,
542
+ })
543
+ .where(eq(ordersRef.id, link.orderId));
544
+ }
545
+ async function syncTransactionOnBookingCancelled(db, bookingId) {
546
+ const link = await getBookingTransactionLink(db, bookingId);
547
+ if (!link?.orderId) {
548
+ return;
549
+ }
550
+ const now = new Date();
551
+ await db
552
+ .update(ordersRef)
553
+ .set({
554
+ status: "cancelled",
555
+ cancelledAt: now,
556
+ updatedAt: now,
557
+ })
558
+ .where(eq(ordersRef.id, link.orderId));
559
+ }
560
+ async function syncTransactionOnBookingRedeemed(db, bookingId) {
561
+ const link = await getBookingTransactionLink(db, bookingId);
562
+ if (!link?.orderId) {
563
+ return;
564
+ }
565
+ await db
566
+ .update(ordersRef)
567
+ .set({
568
+ status: "fulfilled",
569
+ updatedAt: new Date(),
570
+ })
571
+ .where(eq(ordersRef.id, link.orderId));
572
+ }
573
+ async function autoIssueFulfillmentsForBooking(db, bookingId, userId) {
574
+ const [booking] = await db
575
+ .select({
576
+ id: bookings.id,
577
+ bookingNumber: bookings.bookingNumber,
578
+ })
579
+ .from(bookings)
580
+ .where(eq(bookings.id, bookingId))
581
+ .limit(1);
582
+ if (!booking) {
583
+ return;
584
+ }
585
+ const existingFulfillment = await db
586
+ .select({ id: bookingFulfillments.id })
587
+ .from(bookingFulfillments)
588
+ .where(eq(bookingFulfillments.bookingId, bookingId))
589
+ .limit(1);
590
+ if (existingFulfillment.length > 0) {
591
+ return;
592
+ }
593
+ const items = await db
594
+ .select()
595
+ .from(bookingItems)
596
+ .where(and(eq(bookingItems.bookingId, bookingId), sql `${bookingItems.productId} IS NOT NULL`))
597
+ .orderBy(asc(bookingItems.createdAt));
598
+ if (items.length === 0) {
599
+ return;
600
+ }
601
+ const productIds = [...new Set(items.map((item) => item.productId).filter((value) => Boolean(value)))];
602
+ if (productIds.length === 0) {
603
+ return;
604
+ }
605
+ const settings = await db
606
+ .select()
607
+ .from(productTicketSettingsRef)
608
+ .where(inArray(productTicketSettingsRef.productId, productIds));
609
+ const settingsByProductId = new Map(settings.map((setting) => [setting.productId, setting]));
610
+ const travelerParticipants = await db
611
+ .select()
612
+ .from(bookingParticipants)
613
+ .where(and(eq(bookingParticipants.bookingId, bookingId), or(eq(bookingParticipants.participantType, "traveler"), eq(bookingParticipants.participantType, "occupant"))))
614
+ .orderBy(desc(bookingParticipants.isPrimary), asc(bookingParticipants.createdAt));
615
+ const participantLinks = await db
616
+ .select()
617
+ .from(bookingItemParticipants)
618
+ .where(sql `${bookingItemParticipants.bookingItemId} IN (
619
+ SELECT ${bookingItems.id}
620
+ FROM ${bookingItems}
621
+ WHERE ${bookingItems.bookingId} = ${bookingId}
622
+ )`);
623
+ const participantLinksByItemId = new Map();
624
+ for (const link of participantLinks) {
625
+ const links = participantLinksByItemId.get(link.bookingItemId) ?? [];
626
+ links.push(link);
627
+ participantLinksByItemId.set(link.bookingItemId, links);
628
+ }
629
+ const fulfillmentsToInsert = [];
630
+ const now = new Date();
631
+ for (const item of items) {
632
+ const productId = item.productId;
633
+ if (!productId) {
634
+ continue;
635
+ }
636
+ const setting = settingsByProductId.get(productId);
637
+ if (!setting || setting.fulfillmentMode === "none" || setting.defaultDeliveryFormat === "none") {
638
+ continue;
639
+ }
640
+ const delivery = mapDeliveryFormatToFulfillment(setting.defaultDeliveryFormat);
641
+ const payloadBase = {
642
+ bookingId,
643
+ bookingNumber: booking.bookingNumber,
644
+ productId,
645
+ bookingItemId: item.id,
646
+ };
647
+ if (setting.fulfillmentMode === "per_booking") {
648
+ if (fulfillmentsToInsert.some((row) => row.bookingItemId === item.id || row.bookingItemId === null)) {
649
+ continue;
650
+ }
651
+ fulfillmentsToInsert.push({
652
+ bookingId,
653
+ bookingItemId: item.id,
654
+ participantId: null,
655
+ fulfillmentType: delivery.fulfillmentType,
656
+ deliveryChannel: delivery.deliveryChannel,
657
+ status: "issued",
658
+ payload: { ...payloadBase, scope: "booking" },
659
+ issuedAt: now,
660
+ });
661
+ continue;
662
+ }
663
+ if (setting.fulfillmentMode === "per_item") {
664
+ fulfillmentsToInsert.push({
665
+ bookingId,
666
+ bookingItemId: item.id,
667
+ participantId: null,
668
+ fulfillmentType: delivery.fulfillmentType,
669
+ deliveryChannel: delivery.deliveryChannel,
670
+ status: "issued",
671
+ payload: { ...payloadBase, scope: "item" },
672
+ issuedAt: now,
673
+ });
674
+ continue;
675
+ }
676
+ const linkedParticipants = participantLinksByItemId
677
+ .get(item.id)
678
+ ?.map((link) => travelerParticipants.find((participant) => participant.id === link.participantId))
679
+ .filter((participant) => Boolean(participant)) ??
680
+ [];
681
+ const participantsForItem = linkedParticipants.length > 0 ? linkedParticipants : travelerParticipants;
682
+ for (const participant of participantsForItem) {
683
+ fulfillmentsToInsert.push({
684
+ bookingId,
685
+ bookingItemId: item.id,
686
+ participantId: participant.id,
687
+ fulfillmentType: delivery.fulfillmentType,
688
+ deliveryChannel: delivery.deliveryChannel,
689
+ status: "issued",
690
+ payload: {
691
+ ...payloadBase,
692
+ participantId: participant.id,
693
+ scope: "participant",
694
+ },
695
+ issuedAt: now,
696
+ });
697
+ }
698
+ }
699
+ if (fulfillmentsToInsert.length === 0) {
700
+ return;
701
+ }
702
+ await db.insert(bookingFulfillments).values(fulfillmentsToInsert);
703
+ await db.insert(bookingActivityLog).values({
704
+ bookingId,
705
+ actorId: userId ?? "system",
706
+ activityType: "fulfillment_issued",
707
+ description: `${fulfillmentsToInsert.length} fulfillment artifact(s) issued automatically`,
708
+ metadata: { count: fulfillmentsToInsert.length },
709
+ });
710
+ }
711
+ export const bookingsService = {
712
+ async listBookings(db, query) {
713
+ const conditions = [];
714
+ if (query.status) {
715
+ conditions.push(eq(bookings.status, query.status));
716
+ }
717
+ if (query.search) {
718
+ const term = `%${query.search}%`;
719
+ conditions.push(or(ilike(bookings.bookingNumber, term), ilike(bookings.internalNotes, term)));
720
+ }
721
+ const where = conditions.length > 0 ? and(...conditions) : undefined;
722
+ const [rows, countResult] = await Promise.all([
723
+ db
724
+ .select()
725
+ .from(bookings)
726
+ .where(where)
727
+ .limit(query.limit)
728
+ .offset(query.offset)
729
+ .orderBy(desc(bookings.createdAt)),
730
+ db.select({ count: sql `count(*)::int` }).from(bookings).where(where),
731
+ ]);
732
+ return {
733
+ data: rows,
734
+ total: countResult[0]?.count ?? 0,
735
+ limit: query.limit,
736
+ offset: query.offset,
737
+ };
738
+ },
739
+ async convertProductToBooking(db, data, productData, userId) {
740
+ const { product, option, dayServices, units } = productData;
741
+ const [booking] = await db
742
+ .insert(bookings)
743
+ .values({
744
+ bookingNumber: data.bookingNumber,
745
+ status: "draft",
746
+ personId: data.personId ?? null,
747
+ organizationId: data.organizationId ?? null,
748
+ sellCurrency: product.sellCurrency,
749
+ sellAmountCents: product.sellAmountCents,
750
+ costAmountCents: product.costAmountCents,
751
+ marginPercent: product.marginPercent,
752
+ startDate: product.startDate,
753
+ endDate: product.endDate,
754
+ pax: product.pax,
755
+ internalNotes: data.internalNotes ?? null,
756
+ })
757
+ .returning();
758
+ if (!booking) {
759
+ return null;
760
+ }
761
+ if (dayServices.length > 0) {
762
+ await db.insert(bookingSupplierStatuses).values(dayServices.map((service) => ({
763
+ bookingId: booking.id,
764
+ supplierServiceId: service.supplierServiceId,
765
+ serviceName: service.name,
766
+ status: "pending",
767
+ costCurrency: service.costCurrency,
768
+ costAmountCents: service.costAmountCents,
769
+ })));
770
+ }
771
+ const selectedUnits = option === null ? [] : units;
772
+ const unitsToSeed = selectedUnits.filter((unit) => unit.isRequired).length > 0
773
+ ? selectedUnits.filter((unit) => unit.isRequired)
774
+ : selectedUnits.length === 1
775
+ ? selectedUnits
776
+ : [];
777
+ const itemRows = unitsToSeed.length > 0
778
+ ? unitsToSeed.map((unit, index) => {
779
+ const quantity = unit.unitType === "person" && product.pax
780
+ ? product.pax
781
+ : unit.minQuantity && unit.minQuantity > 0
782
+ ? unit.minQuantity
783
+ : 1;
784
+ const singleSeedItem = unitsToSeed.length === 1 && index === 0;
785
+ return {
786
+ bookingId: booking.id,
787
+ title: unit.name,
788
+ description: unit.description,
789
+ itemType: "unit",
790
+ status: "draft",
791
+ quantity,
792
+ sellCurrency: product.sellCurrency,
793
+ unitSellAmountCents: singleSeedItem &&
794
+ product.sellAmountCents !== null &&
795
+ product.sellAmountCents !== undefined
796
+ ? Math.floor(product.sellAmountCents / quantity)
797
+ : null,
798
+ totalSellAmountCents: singleSeedItem ? (product.sellAmountCents ?? null) : null,
799
+ costCurrency: singleSeedItem ? product.sellCurrency : null,
800
+ unitCostAmountCents: singleSeedItem &&
801
+ product.costAmountCents !== null &&
802
+ product.costAmountCents !== undefined
803
+ ? Math.floor(product.costAmountCents / quantity)
804
+ : null,
805
+ totalCostAmountCents: singleSeedItem ? (product.costAmountCents ?? null) : null,
806
+ productId: product.id,
807
+ optionId: option?.id ?? null,
808
+ optionUnitId: unit.id,
809
+ };
810
+ })
811
+ : [
812
+ {
813
+ bookingId: booking.id,
814
+ title: option?.name ?? product.name,
815
+ description: product.description,
816
+ itemType: "unit",
817
+ status: "draft",
818
+ quantity: 1,
819
+ sellCurrency: product.sellCurrency,
820
+ unitSellAmountCents: product.sellAmountCents ?? null,
821
+ totalSellAmountCents: product.sellAmountCents ?? null,
822
+ costCurrency: product.sellCurrency,
823
+ unitCostAmountCents: product.costAmountCents ?? null,
824
+ totalCostAmountCents: product.costAmountCents ?? null,
825
+ productId: product.id,
826
+ optionId: option?.id ?? null,
827
+ optionUnitId: null,
828
+ },
829
+ ];
830
+ const insertedItems = await db.insert(bookingItems).values(itemRows).returning();
831
+ await db
832
+ .insert(bookingProductDetailsRef)
833
+ .values({
834
+ bookingId: booking.id,
835
+ productId: product.id,
836
+ optionId: option?.id ?? null,
837
+ })
838
+ .onConflictDoUpdate({
839
+ target: bookingProductDetailsRef.bookingId,
840
+ set: {
841
+ productId: product.id,
842
+ optionId: option?.id ?? null,
843
+ updatedAt: new Date(),
844
+ },
845
+ });
846
+ if (insertedItems.length > 0) {
847
+ await db.insert(bookingItemProductDetailsRef).values(insertedItems.map((item) => ({
848
+ bookingItemId: item.id,
849
+ productId: item.productId ?? null,
850
+ optionId: item.optionId ?? null,
851
+ unitId: item.optionUnitId ?? null,
852
+ supplierServiceId: null,
853
+ })));
854
+ }
855
+ await db.insert(bookingActivityLog).values({
856
+ bookingId: booking.id,
857
+ actorId: userId ?? "system",
858
+ activityType: "booking_converted",
859
+ description: `Booking converted from product "${product.name}"`,
860
+ metadata: { productId: product.id, productName: product.name, optionId: option?.id ?? null },
861
+ });
862
+ return booking;
863
+ },
864
+ async getBookingById(db, id) {
865
+ const [row] = await db.select().from(bookings).where(eq(bookings.id, id)).limit(1);
866
+ return row ?? null;
867
+ },
868
+ async createBookingFromProduct(db, data, userId) {
869
+ const productData = await getConvertProductData(db, data);
870
+ if (!productData) {
871
+ return null;
872
+ }
873
+ return this.convertProductToBooking(db, data, productData, userId);
874
+ },
875
+ listAllocations(db, bookingId) {
876
+ return db
877
+ .select()
878
+ .from(bookingAllocations)
879
+ .where(eq(bookingAllocations.bookingId, bookingId))
880
+ .orderBy(asc(bookingAllocations.createdAt));
881
+ },
882
+ async reserveBookingFromOffer(db, offerId, data, userId) {
883
+ const [offer] = await db.select().from(offersRef).where(eq(offersRef.id, offerId)).limit(1);
884
+ if (!offer) {
885
+ return { status: "not_found" };
886
+ }
887
+ const [participants, items, itemParticipants] = await Promise.all([
888
+ db
889
+ .select()
890
+ .from(offerParticipantsRef)
891
+ .where(eq(offerParticipantsRef.offerId, offerId))
892
+ .orderBy(asc(offerParticipantsRef.createdAt)),
893
+ db
894
+ .select()
895
+ .from(offerItemsRef)
896
+ .where(eq(offerItemsRef.offerId, offerId))
897
+ .orderBy(asc(offerItemsRef.createdAt)),
898
+ db
899
+ .select()
900
+ .from(offerItemParticipantsRef)
901
+ .where(sql `${offerItemParticipantsRef.offerItemId} IN (
902
+ SELECT ${offerItemsRef.id}
903
+ FROM ${offerItemsRef}
904
+ WHERE ${offerItemsRef.offerId} = ${offerId}
905
+ )`)
906
+ .orderBy(asc(offerItemParticipantsRef.createdAt)),
907
+ ]);
908
+ return reserveBookingFromTransactionSource(db, {
909
+ kind: "offer",
910
+ sourceId: offerId,
911
+ offerId: offer.id,
912
+ orderId: null,
913
+ personId: offer.personId ?? null,
914
+ organizationId: offer.organizationId ?? null,
915
+ currency: offer.currency,
916
+ baseCurrency: offer.baseCurrency ?? null,
917
+ totalAmountCents: offer.totalAmountCents ?? null,
918
+ costAmountCents: offer.costAmountCents ?? null,
919
+ notes: offer.notes ?? null,
920
+ participants,
921
+ items,
922
+ itemParticipants,
923
+ }, data, userId);
924
+ },
925
+ async reserveBookingFromOrder(db, orderId, data, userId) {
926
+ const [order] = await db.select().from(ordersRef).where(eq(ordersRef.id, orderId)).limit(1);
927
+ if (!order) {
928
+ return { status: "not_found" };
929
+ }
930
+ const [participants, items, itemParticipants] = await Promise.all([
931
+ db
932
+ .select()
933
+ .from(orderParticipantsRef)
934
+ .where(eq(orderParticipantsRef.orderId, orderId))
935
+ .orderBy(asc(orderParticipantsRef.createdAt)),
936
+ db
937
+ .select()
938
+ .from(orderItemsRef)
939
+ .where(eq(orderItemsRef.orderId, orderId))
940
+ .orderBy(asc(orderItemsRef.createdAt)),
941
+ db
942
+ .select()
943
+ .from(orderItemParticipantsRef)
944
+ .where(sql `${orderItemParticipantsRef.orderItemId} IN (
945
+ SELECT ${orderItemsRef.id}
946
+ FROM ${orderItemsRef}
947
+ WHERE ${orderItemsRef.orderId} = ${orderId}
948
+ )`)
949
+ .orderBy(asc(orderItemParticipantsRef.createdAt)),
950
+ ]);
951
+ return reserveBookingFromTransactionSource(db, {
952
+ kind: "order",
953
+ sourceId: orderId,
954
+ offerId: order.offerId ?? null,
955
+ orderId: order.id,
956
+ personId: order.personId ?? null,
957
+ organizationId: order.organizationId ?? null,
958
+ currency: order.currency,
959
+ baseCurrency: order.baseCurrency ?? null,
960
+ totalAmountCents: order.totalAmountCents ?? null,
961
+ costAmountCents: order.costAmountCents ?? null,
962
+ notes: order.notes ?? null,
963
+ participants,
964
+ items,
965
+ itemParticipants,
966
+ }, data, userId);
967
+ },
968
+ async reserveBooking(db, data, userId) {
969
+ try {
970
+ return await db.transaction(async (tx) => {
971
+ const holdExpiresAt = computeHoldExpiresAt(data);
972
+ const [booking] = await tx
973
+ .insert(bookings)
974
+ .values({
975
+ bookingNumber: data.bookingNumber,
976
+ status: "on_hold",
977
+ personId: data.personId ?? null,
978
+ organizationId: data.organizationId ?? null,
979
+ sourceType: data.sourceType,
980
+ externalBookingRef: data.externalBookingRef ?? null,
981
+ communicationLanguage: data.communicationLanguage ?? null,
982
+ sellCurrency: data.sellCurrency,
983
+ baseCurrency: data.baseCurrency ?? null,
984
+ sellAmountCents: data.sellAmountCents ?? null,
985
+ baseSellAmountCents: data.baseSellAmountCents ?? null,
986
+ costAmountCents: data.costAmountCents ?? null,
987
+ baseCostAmountCents: data.baseCostAmountCents ?? null,
988
+ marginPercent: data.marginPercent ?? null,
989
+ startDate: data.startDate ?? null,
990
+ endDate: data.endDate ?? null,
991
+ pax: data.pax ?? null,
992
+ internalNotes: data.internalNotes ?? null,
993
+ holdExpiresAt,
994
+ })
995
+ .returning();
996
+ if (!booking) {
997
+ throw new BookingServiceError("booking_create_failed");
998
+ }
999
+ for (const item of data.items) {
1000
+ const capacity = await adjustSlotCapacity(tx, item.availabilitySlotId, -item.quantity);
1001
+ if (capacity.status === "slot_not_found") {
1002
+ throw new BookingServiceError("slot_not_found");
1003
+ }
1004
+ if (capacity.status === "slot_unavailable") {
1005
+ throw new BookingServiceError("slot_unavailable");
1006
+ }
1007
+ if (capacity.status === "insufficient_capacity") {
1008
+ throw new BookingServiceError("insufficient_capacity");
1009
+ }
1010
+ const slot = capacity.slot;
1011
+ if (item.productId && item.productId !== slot.product_id) {
1012
+ throw new BookingServiceError("slot_product_mismatch");
1013
+ }
1014
+ if (item.optionId && item.optionId !== slot.option_id) {
1015
+ throw new BookingServiceError("slot_option_mismatch");
1016
+ }
1017
+ const [bookingItem] = await tx
1018
+ .insert(bookingItems)
1019
+ .values({
1020
+ bookingId: booking.id,
1021
+ title: item.title,
1022
+ description: item.description ?? null,
1023
+ itemType: item.itemType,
1024
+ status: "on_hold",
1025
+ serviceDate: slot.date_local,
1026
+ startsAt: slot.starts_at,
1027
+ endsAt: slot.ends_at,
1028
+ quantity: item.quantity,
1029
+ sellCurrency: item.sellCurrency ?? booking.sellCurrency,
1030
+ unitSellAmountCents: item.unitSellAmountCents ?? null,
1031
+ totalSellAmountCents: item.totalSellAmountCents ?? null,
1032
+ costCurrency: item.costCurrency ?? null,
1033
+ unitCostAmountCents: item.unitCostAmountCents ?? null,
1034
+ totalCostAmountCents: item.totalCostAmountCents ?? null,
1035
+ notes: item.notes ?? null,
1036
+ productId: item.productId ?? slot.product_id,
1037
+ optionId: item.optionId ?? slot.option_id,
1038
+ optionUnitId: item.optionUnitId ?? null,
1039
+ pricingCategoryId: item.pricingCategoryId ?? null,
1040
+ sourceSnapshotId: item.sourceSnapshotId ?? null,
1041
+ sourceOfferId: item.sourceOfferId ?? null,
1042
+ metadata: item.metadata ?? null,
1043
+ })
1044
+ .returning();
1045
+ if (!bookingItem) {
1046
+ throw new BookingServiceError("booking_item_create_failed");
1047
+ }
1048
+ const [allocation] = await tx
1049
+ .insert(bookingAllocations)
1050
+ .values({
1051
+ bookingId: booking.id,
1052
+ bookingItemId: bookingItem.id,
1053
+ productId: item.productId ?? slot.product_id,
1054
+ optionId: item.optionId ?? slot.option_id,
1055
+ optionUnitId: item.optionUnitId ?? null,
1056
+ pricingCategoryId: item.pricingCategoryId ?? null,
1057
+ availabilitySlotId: item.availabilitySlotId,
1058
+ quantity: item.quantity,
1059
+ allocationType: item.allocationType,
1060
+ status: "held",
1061
+ holdExpiresAt,
1062
+ metadata: item.metadata ?? null,
1063
+ })
1064
+ .returning();
1065
+ if (!allocation) {
1066
+ throw new BookingServiceError("allocation_create_failed");
1067
+ }
1068
+ }
1069
+ await tx.insert(bookingActivityLog).values({
1070
+ bookingId: booking.id,
1071
+ actorId: userId ?? "system",
1072
+ activityType: "booking_reserved",
1073
+ description: `Booking ${booking.bookingNumber} reserved and placed on hold`,
1074
+ metadata: { holdExpiresAt: holdExpiresAt.toISOString(), itemCount: data.items.length },
1075
+ });
1076
+ return { status: "ok", booking };
1077
+ });
1078
+ }
1079
+ catch (error) {
1080
+ if (error instanceof BookingServiceError) {
1081
+ return { status: error.code };
1082
+ }
1083
+ throw error;
1084
+ }
1085
+ },
1086
+ async createBooking(db, data, userId) {
1087
+ return db.transaction(async (tx) => {
1088
+ const [row] = await tx
1089
+ .insert(bookings)
1090
+ .values({
1091
+ ...data,
1092
+ holdExpiresAt: toTimestamp(data.holdExpiresAt),
1093
+ confirmedAt: toTimestamp(data.confirmedAt),
1094
+ expiredAt: toTimestamp(data.expiredAt),
1095
+ cancelledAt: toTimestamp(data.cancelledAt),
1096
+ completedAt: toTimestamp(data.completedAt),
1097
+ redeemedAt: toTimestamp(data.redeemedAt),
1098
+ })
1099
+ .returning();
1100
+ if (!row) {
1101
+ return null;
1102
+ }
1103
+ await tx.insert(bookingActivityLog).values({
1104
+ bookingId: row.id,
1105
+ actorId: userId ?? "system",
1106
+ activityType: "booking_created",
1107
+ description: `Booking ${data.bookingNumber} created`,
1108
+ });
1109
+ return row;
1110
+ });
1111
+ },
1112
+ async updateBooking(db, id, data) {
1113
+ const [row] = await db
1114
+ .update(bookings)
1115
+ .set({
1116
+ ...data,
1117
+ holdExpiresAt: data.holdExpiresAt === undefined ? undefined : toTimestamp(data.holdExpiresAt),
1118
+ confirmedAt: data.confirmedAt === undefined ? undefined : toTimestamp(data.confirmedAt),
1119
+ expiredAt: data.expiredAt === undefined ? undefined : toTimestamp(data.expiredAt),
1120
+ cancelledAt: data.cancelledAt === undefined ? undefined : toTimestamp(data.cancelledAt),
1121
+ completedAt: data.completedAt === undefined ? undefined : toTimestamp(data.completedAt),
1122
+ redeemedAt: data.redeemedAt === undefined ? undefined : toTimestamp(data.redeemedAt),
1123
+ updatedAt: new Date(),
1124
+ })
1125
+ .where(eq(bookings.id, id))
1126
+ .returning();
1127
+ return row ?? null;
1128
+ },
1129
+ async deleteBooking(db, id) {
1130
+ const [row] = await db
1131
+ .delete(bookings)
1132
+ .where(eq(bookings.id, id))
1133
+ .returning({ id: bookings.id });
1134
+ return row ?? null;
1135
+ },
1136
+ async updateBookingStatus(db, id, data, userId) {
1137
+ const [current] = await db
1138
+ .select({ id: bookings.id, status: bookings.status })
1139
+ .from(bookings)
1140
+ .where(eq(bookings.id, id))
1141
+ .limit(1);
1142
+ if (!current) {
1143
+ return { status: "not_found" };
1144
+ }
1145
+ if (current.status === "on_hold" && data.status === "confirmed") {
1146
+ return bookingsService.confirmBooking(db, id, { note: data.note }, userId);
1147
+ }
1148
+ if (current.status === "on_hold" && data.status === "expired") {
1149
+ return bookingsService.expireBooking(db, id, { note: data.note }, userId);
1150
+ }
1151
+ if (data.status === "cancelled") {
1152
+ return bookingsService.cancelBooking(db, id, { note: data.note }, userId);
1153
+ }
1154
+ if (data.status === "on_hold") {
1155
+ return { status: "invalid_transition" };
1156
+ }
1157
+ if (!isValidBookingTransition(current.status, data.status)) {
1158
+ return { status: "invalid_transition" };
1159
+ }
1160
+ const [row] = await db
1161
+ .update(bookings)
1162
+ .set({
1163
+ status: data.status,
1164
+ ...toBookingStatusTimestamps(data.status),
1165
+ updatedAt: new Date(),
1166
+ })
1167
+ .where(eq(bookings.id, id))
1168
+ .returning();
1169
+ await db.insert(bookingActivityLog).values({
1170
+ bookingId: id,
1171
+ actorId: userId ?? "system",
1172
+ activityType: "status_change",
1173
+ description: `Status changed from ${current.status} to ${data.status}`,
1174
+ metadata: { oldStatus: current.status, newStatus: data.status },
1175
+ });
1176
+ if (data.note) {
1177
+ await db.insert(bookingNotes).values({
1178
+ bookingId: id,
1179
+ authorId: userId ?? "system",
1180
+ content: data.note,
1181
+ });
1182
+ }
1183
+ if (data.status === "confirmed") {
1184
+ await autoIssueFulfillmentsForBooking(db, id, userId);
1185
+ }
1186
+ return { status: "ok", booking: row ?? null };
1187
+ },
1188
+ async confirmBooking(db, id, data, userId) {
1189
+ try {
1190
+ return await db.transaction(async (tx) => {
1191
+ const rows = await tx.execute(sql `SELECT id, booking_number, status, hold_expires_at
1192
+ FROM ${bookings}
1193
+ WHERE ${bookings.id} = ${id}
1194
+ FOR UPDATE`);
1195
+ const booking = rows[0];
1196
+ if (!booking) {
1197
+ throw new BookingServiceError("not_found");
1198
+ }
1199
+ if (booking.status !== "on_hold") {
1200
+ throw new BookingServiceError("invalid_transition");
1201
+ }
1202
+ if (booking.hold_expires_at && booking.hold_expires_at < new Date()) {
1203
+ throw new BookingServiceError("hold_expired");
1204
+ }
1205
+ await tx
1206
+ .update(bookingAllocations)
1207
+ .set({
1208
+ status: "confirmed",
1209
+ confirmedAt: new Date(),
1210
+ updatedAt: new Date(),
1211
+ })
1212
+ .where(and(eq(bookingAllocations.bookingId, id), eq(bookingAllocations.status, "held")));
1213
+ await tx
1214
+ .update(bookingItems)
1215
+ .set({ status: "confirmed", updatedAt: new Date() })
1216
+ .where(and(eq(bookingItems.bookingId, id), eq(bookingItems.status, "on_hold")));
1217
+ const [row] = await tx
1218
+ .update(bookings)
1219
+ .set({
1220
+ status: "confirmed",
1221
+ holdExpiresAt: null,
1222
+ confirmedAt: new Date(),
1223
+ updatedAt: new Date(),
1224
+ })
1225
+ .where(eq(bookings.id, id))
1226
+ .returning();
1227
+ await syncTransactionOnBookingConfirmed(tx, id);
1228
+ await autoIssueFulfillmentsForBooking(tx, id, userId);
1229
+ await tx.insert(bookingActivityLog).values({
1230
+ bookingId: id,
1231
+ actorId: userId ?? "system",
1232
+ activityType: "booking_confirmed",
1233
+ description: `Booking ${booking.booking_number} confirmed`,
1234
+ });
1235
+ if (data.note) {
1236
+ await tx.insert(bookingNotes).values({
1237
+ bookingId: id,
1238
+ authorId: userId ?? "system",
1239
+ content: data.note,
1240
+ });
1241
+ }
1242
+ return { status: "ok", booking: row ?? null };
1243
+ });
1244
+ }
1245
+ catch (error) {
1246
+ if (error instanceof BookingServiceError) {
1247
+ return { status: error.code };
1248
+ }
1249
+ throw error;
1250
+ }
1251
+ },
1252
+ async extendBookingHold(db, id, data, userId) {
1253
+ try {
1254
+ return await db.transaction(async (tx) => {
1255
+ const rows = await tx.execute(sql `SELECT id, status, hold_expires_at
1256
+ FROM ${bookings}
1257
+ WHERE ${bookings.id} = ${id}
1258
+ FOR UPDATE`);
1259
+ const booking = rows[0];
1260
+ if (!booking) {
1261
+ throw new BookingServiceError("not_found");
1262
+ }
1263
+ if (booking.status !== "on_hold") {
1264
+ throw new BookingServiceError("invalid_transition");
1265
+ }
1266
+ if (booking.hold_expires_at && booking.hold_expires_at < new Date()) {
1267
+ throw new BookingServiceError("hold_expired");
1268
+ }
1269
+ const holdExpiresAt = computeHoldExpiresAt(data);
1270
+ await tx
1271
+ .update(bookingAllocations)
1272
+ .set({
1273
+ holdExpiresAt,
1274
+ updatedAt: new Date(),
1275
+ })
1276
+ .where(and(eq(bookingAllocations.bookingId, id), eq(bookingAllocations.status, "held")));
1277
+ const [row] = await tx
1278
+ .update(bookings)
1279
+ .set({
1280
+ holdExpiresAt,
1281
+ updatedAt: new Date(),
1282
+ })
1283
+ .where(eq(bookings.id, id))
1284
+ .returning();
1285
+ await tx.insert(bookingActivityLog).values({
1286
+ bookingId: id,
1287
+ actorId: userId ?? "system",
1288
+ activityType: "hold_extended",
1289
+ description: "Booking hold extended",
1290
+ metadata: { holdExpiresAt: holdExpiresAt.toISOString() },
1291
+ });
1292
+ return { status: "ok", booking: row ?? null };
1293
+ });
1294
+ }
1295
+ catch (error) {
1296
+ if (error instanceof BookingServiceError) {
1297
+ return { status: error.code };
1298
+ }
1299
+ throw error;
1300
+ }
1301
+ },
1302
+ async expireBooking(db, id, data, userId) {
1303
+ try {
1304
+ return await db.transaction(async (tx) => {
1305
+ const rows = await tx.execute(sql `SELECT id, status, hold_expires_at
1306
+ FROM ${bookings}
1307
+ WHERE ${bookings.id} = ${id}
1308
+ FOR UPDATE`);
1309
+ const booking = rows[0];
1310
+ if (!booking) {
1311
+ throw new BookingServiceError("not_found");
1312
+ }
1313
+ if (booking.status !== "on_hold") {
1314
+ throw new BookingServiceError("invalid_transition");
1315
+ }
1316
+ const allocations = await tx
1317
+ .select()
1318
+ .from(bookingAllocations)
1319
+ .where(eq(bookingAllocations.bookingId, id));
1320
+ for (const allocation of allocations) {
1321
+ await releaseAllocationCapacity(tx, allocation);
1322
+ }
1323
+ await tx
1324
+ .update(bookingAllocations)
1325
+ .set({
1326
+ status: "expired",
1327
+ releasedAt: new Date(),
1328
+ updatedAt: new Date(),
1329
+ })
1330
+ .where(and(eq(bookingAllocations.bookingId, id), eq(bookingAllocations.status, "held")));
1331
+ await tx
1332
+ .update(bookingItems)
1333
+ .set({ status: "expired", updatedAt: new Date() })
1334
+ .where(and(eq(bookingItems.bookingId, id), eq(bookingItems.status, "on_hold")));
1335
+ const [row] = await tx
1336
+ .update(bookings)
1337
+ .set({
1338
+ status: "expired",
1339
+ holdExpiresAt: null,
1340
+ expiredAt: new Date(),
1341
+ updatedAt: new Date(),
1342
+ })
1343
+ .where(eq(bookings.id, id))
1344
+ .returning();
1345
+ await syncTransactionOnBookingExpired(tx, id);
1346
+ await tx.insert(bookingActivityLog).values({
1347
+ bookingId: id,
1348
+ actorId: userId ?? "system",
1349
+ activityType: "hold_expired",
1350
+ description: "Booking hold expired",
1351
+ });
1352
+ if (data.note) {
1353
+ await tx.insert(bookingNotes).values({
1354
+ bookingId: id,
1355
+ authorId: userId ?? "system",
1356
+ content: data.note,
1357
+ });
1358
+ }
1359
+ return { status: "ok", booking: row ?? null };
1360
+ });
1361
+ }
1362
+ catch (error) {
1363
+ if (error instanceof BookingServiceError) {
1364
+ return { status: error.code };
1365
+ }
1366
+ throw error;
1367
+ }
1368
+ },
1369
+ async expireStaleBookings(db, data, userId) {
1370
+ const cutoff = data.before ? new Date(data.before) : new Date();
1371
+ const staleBookings = await db
1372
+ .select({ id: bookings.id })
1373
+ .from(bookings)
1374
+ .where(and(eq(bookings.status, "on_hold"), sql `${bookings.holdExpiresAt} IS NOT NULL`, lte(bookings.holdExpiresAt, cutoff)))
1375
+ .orderBy(asc(bookings.holdExpiresAt), asc(bookings.createdAt));
1376
+ const expiredIds = [];
1377
+ for (const booking of staleBookings) {
1378
+ const result = await this.expireBooking(db, booking.id, { note: data.note ?? "Hold expired by sweep" }, userId);
1379
+ if ("booking" in result && result.booking) {
1380
+ expiredIds.push(result.booking.id);
1381
+ }
1382
+ }
1383
+ return {
1384
+ expiredIds,
1385
+ count: expiredIds.length,
1386
+ cutoff,
1387
+ };
1388
+ },
1389
+ async cancelBooking(db, id, data, userId) {
1390
+ try {
1391
+ return await db.transaction(async (tx) => {
1392
+ const rows = await tx.execute(sql `SELECT id, status
1393
+ FROM ${bookings}
1394
+ WHERE ${bookings.id} = ${id}
1395
+ FOR UPDATE`);
1396
+ const booking = rows[0];
1397
+ if (!booking) {
1398
+ throw new BookingServiceError("not_found");
1399
+ }
1400
+ if (!["draft", "on_hold", "confirmed", "in_progress"].includes(booking.status)) {
1401
+ throw new BookingServiceError("invalid_transition");
1402
+ }
1403
+ const allocations = await tx
1404
+ .select()
1405
+ .from(bookingAllocations)
1406
+ .where(eq(bookingAllocations.bookingId, id));
1407
+ for (const allocation of allocations) {
1408
+ await releaseAllocationCapacity(tx, allocation);
1409
+ }
1410
+ await tx
1411
+ .update(bookingAllocations)
1412
+ .set({
1413
+ status: "cancelled",
1414
+ releasedAt: new Date(),
1415
+ updatedAt: new Date(),
1416
+ })
1417
+ .where(and(eq(bookingAllocations.bookingId, id), or(eq(bookingAllocations.status, "held"), eq(bookingAllocations.status, "confirmed"))));
1418
+ await tx
1419
+ .update(bookingItems)
1420
+ .set({
1421
+ status: "cancelled",
1422
+ updatedAt: new Date(),
1423
+ })
1424
+ .where(and(eq(bookingItems.bookingId, id), or(eq(bookingItems.status, "draft"), eq(bookingItems.status, "on_hold"), eq(bookingItems.status, "confirmed"))));
1425
+ const [row] = await tx
1426
+ .update(bookings)
1427
+ .set({
1428
+ status: "cancelled",
1429
+ holdExpiresAt: null,
1430
+ cancelledAt: new Date(),
1431
+ updatedAt: new Date(),
1432
+ })
1433
+ .where(eq(bookings.id, id))
1434
+ .returning();
1435
+ await syncTransactionOnBookingCancelled(tx, id);
1436
+ await tx.insert(bookingActivityLog).values({
1437
+ bookingId: id,
1438
+ actorId: userId ?? "system",
1439
+ activityType: "status_change",
1440
+ description: `Booking cancelled from ${booking.status}`,
1441
+ metadata: { oldStatus: booking.status, newStatus: "cancelled" },
1442
+ });
1443
+ if (data.note) {
1444
+ await tx.insert(bookingNotes).values({
1445
+ bookingId: id,
1446
+ authorId: userId ?? "system",
1447
+ content: data.note,
1448
+ });
1449
+ }
1450
+ return { status: "ok", booking: row ?? null };
1451
+ });
1452
+ }
1453
+ catch (error) {
1454
+ if (error instanceof BookingServiceError) {
1455
+ return { status: error.code };
1456
+ }
1457
+ throw error;
1458
+ }
1459
+ },
1460
+ listParticipants(db, bookingId) {
1461
+ return db
1462
+ .select()
1463
+ .from(bookingParticipants)
1464
+ .where(eq(bookingParticipants.bookingId, bookingId))
1465
+ .orderBy(desc(bookingParticipants.isPrimary), asc(bookingParticipants.createdAt));
1466
+ },
1467
+ async getParticipantById(db, bookingId, participantId) {
1468
+ const [row] = await db
1469
+ .select()
1470
+ .from(bookingParticipants)
1471
+ .where(and(eq(bookingParticipants.id, participantId), eq(bookingParticipants.bookingId, bookingId)))
1472
+ .limit(1);
1473
+ return row ?? null;
1474
+ },
1475
+ async createParticipant(db, bookingId, data, userId) {
1476
+ const [booking] = await db
1477
+ .select({ id: bookings.id })
1478
+ .from(bookings)
1479
+ .where(eq(bookings.id, bookingId))
1480
+ .limit(1);
1481
+ if (!booking) {
1482
+ return null;
1483
+ }
1484
+ const [row] = await db
1485
+ .insert(bookingParticipants)
1486
+ .values({
1487
+ bookingId,
1488
+ personId: data.personId ?? null,
1489
+ participantType: data.participantType,
1490
+ travelerCategory: data.travelerCategory ?? null,
1491
+ firstName: data.firstName,
1492
+ lastName: data.lastName,
1493
+ email: data.email ?? null,
1494
+ phone: data.phone ?? null,
1495
+ preferredLanguage: data.preferredLanguage ?? null,
1496
+ accessibilityNeeds: data.accessibilityNeeds ?? null,
1497
+ specialRequests: data.specialRequests ?? null,
1498
+ isPrimary: data.isPrimary ?? false,
1499
+ notes: data.notes ?? null,
1500
+ })
1501
+ .returning();
1502
+ if (!row) {
1503
+ return null;
1504
+ }
1505
+ await ensureParticipantFlags(db, bookingId, row.id, data);
1506
+ await db.insert(bookingActivityLog).values({
1507
+ bookingId,
1508
+ actorId: userId ?? "system",
1509
+ activityType: "passenger_update",
1510
+ description: `Participant ${data.firstName} ${data.lastName} added`,
1511
+ metadata: { participantId: row.id, participantType: data.participantType },
1512
+ });
1513
+ return row;
1514
+ },
1515
+ async updateParticipant(db, participantId, data) {
1516
+ const [row] = await db
1517
+ .update(bookingParticipants)
1518
+ .set({ ...data, updatedAt: new Date() })
1519
+ .where(eq(bookingParticipants.id, participantId))
1520
+ .returning();
1521
+ if (!row) {
1522
+ return null;
1523
+ }
1524
+ await ensureParticipantFlags(db, row.bookingId, row.id, data);
1525
+ return row;
1526
+ },
1527
+ async deleteParticipant(db, participantId) {
1528
+ const [row] = await db
1529
+ .delete(bookingParticipants)
1530
+ .where(eq(bookingParticipants.id, participantId))
1531
+ .returning({ id: bookingParticipants.id });
1532
+ return row ?? null;
1533
+ },
1534
+ listPassengers(db, bookingId) {
1535
+ return db
1536
+ .select()
1537
+ .from(bookingParticipants)
1538
+ .where(and(eq(bookingParticipants.bookingId, bookingId), or(...travelerParticipantTypes.map((type) => eq(bookingParticipants.participantType, type)))))
1539
+ .orderBy(asc(bookingParticipants.createdAt))
1540
+ .then((rows) => rows.map(toPassengerResponse));
1541
+ },
1542
+ async createPassenger(db, bookingId, data, userId) {
1543
+ const row = await this.createParticipant(db, bookingId, toCreateParticipantFromPassenger(data), userId);
1544
+ return row ? toPassengerResponse(row) : null;
1545
+ },
1546
+ async updatePassenger(db, passengerId, data) {
1547
+ const row = await this.updateParticipant(db, passengerId, toUpdateParticipantFromPassenger(data));
1548
+ return row ? toPassengerResponse(row) : null;
1549
+ },
1550
+ async deletePassenger(db, passengerId) {
1551
+ return this.deleteParticipant(db, passengerId);
1552
+ },
1553
+ listItems(db, bookingId) {
1554
+ return db
1555
+ .select()
1556
+ .from(bookingItems)
1557
+ .where(eq(bookingItems.bookingId, bookingId))
1558
+ .orderBy(asc(bookingItems.createdAt));
1559
+ },
1560
+ async createItem(db, bookingId, data, userId) {
1561
+ const [booking] = await db
1562
+ .select({ id: bookings.id, sellCurrency: bookings.sellCurrency })
1563
+ .from(bookings)
1564
+ .where(eq(bookings.id, bookingId))
1565
+ .limit(1);
1566
+ if (!booking) {
1567
+ return null;
1568
+ }
1569
+ const [row] = await db
1570
+ .insert(bookingItems)
1571
+ .values({
1572
+ bookingId,
1573
+ title: data.title,
1574
+ description: data.description ?? null,
1575
+ itemType: data.itemType,
1576
+ status: data.status,
1577
+ serviceDate: data.serviceDate ?? null,
1578
+ startsAt: toTimestamp(data.startsAt),
1579
+ endsAt: toTimestamp(data.endsAt),
1580
+ quantity: data.quantity,
1581
+ sellCurrency: data.sellCurrency ?? booking.sellCurrency,
1582
+ unitSellAmountCents: data.unitSellAmountCents ?? null,
1583
+ totalSellAmountCents: data.totalSellAmountCents ?? null,
1584
+ costCurrency: data.costCurrency ?? null,
1585
+ unitCostAmountCents: data.unitCostAmountCents ?? null,
1586
+ totalCostAmountCents: data.totalCostAmountCents ?? null,
1587
+ notes: data.notes ?? null,
1588
+ productId: data.productId ?? null,
1589
+ optionId: data.optionId ?? null,
1590
+ optionUnitId: data.optionUnitId ?? null,
1591
+ pricingCategoryId: data.pricingCategoryId ?? null,
1592
+ sourceSnapshotId: data.sourceSnapshotId ?? null,
1593
+ sourceOfferId: data.sourceOfferId ?? null,
1594
+ metadata: data.metadata ?? null,
1595
+ })
1596
+ .returning();
1597
+ if (!row) {
1598
+ return null;
1599
+ }
1600
+ await db.insert(bookingActivityLog).values({
1601
+ bookingId,
1602
+ actorId: userId ?? "system",
1603
+ activityType: "item_update",
1604
+ description: `Booking item "${data.title}" added`,
1605
+ metadata: { bookingItemId: row.id, itemType: data.itemType },
1606
+ });
1607
+ return row;
1608
+ },
1609
+ async updateItem(db, itemId, data) {
1610
+ const [row] = await db
1611
+ .update(bookingItems)
1612
+ .set({
1613
+ ...data,
1614
+ startsAt: data.startsAt === undefined ? undefined : toTimestamp(data.startsAt),
1615
+ endsAt: data.endsAt === undefined ? undefined : toTimestamp(data.endsAt),
1616
+ updatedAt: new Date(),
1617
+ })
1618
+ .where(eq(bookingItems.id, itemId))
1619
+ .returning();
1620
+ return row ?? null;
1621
+ },
1622
+ async deleteItem(db, itemId) {
1623
+ const [row] = await db
1624
+ .delete(bookingItems)
1625
+ .where(eq(bookingItems.id, itemId))
1626
+ .returning({ id: bookingItems.id });
1627
+ return row ?? null;
1628
+ },
1629
+ listItemParticipants(db, itemId) {
1630
+ return db
1631
+ .select()
1632
+ .from(bookingItemParticipants)
1633
+ .where(eq(bookingItemParticipants.bookingItemId, itemId))
1634
+ .orderBy(desc(bookingItemParticipants.isPrimary), asc(bookingItemParticipants.createdAt));
1635
+ },
1636
+ async addItemParticipant(db, itemId, data) {
1637
+ const [item] = await db
1638
+ .select({ id: bookingItems.id })
1639
+ .from(bookingItems)
1640
+ .where(eq(bookingItems.id, itemId))
1641
+ .limit(1);
1642
+ if (!item) {
1643
+ return null;
1644
+ }
1645
+ const [participant] = await db
1646
+ .select({ id: bookingParticipants.id })
1647
+ .from(bookingParticipants)
1648
+ .where(eq(bookingParticipants.id, data.participantId))
1649
+ .limit(1);
1650
+ if (!participant) {
1651
+ return null;
1652
+ }
1653
+ if (data.isPrimary) {
1654
+ await db
1655
+ .update(bookingItemParticipants)
1656
+ .set({ isPrimary: false })
1657
+ .where(eq(bookingItemParticipants.bookingItemId, itemId));
1658
+ }
1659
+ const [row] = await db
1660
+ .insert(bookingItemParticipants)
1661
+ .values({
1662
+ bookingItemId: itemId,
1663
+ participantId: data.participantId,
1664
+ role: data.role,
1665
+ isPrimary: data.isPrimary ?? false,
1666
+ })
1667
+ .returning();
1668
+ return row;
1669
+ },
1670
+ async removeItemParticipant(db, linkId) {
1671
+ const [row] = await db
1672
+ .delete(bookingItemParticipants)
1673
+ .where(eq(bookingItemParticipants.id, linkId))
1674
+ .returning({ id: bookingItemParticipants.id });
1675
+ return row ?? null;
1676
+ },
1677
+ listSupplierStatuses(db, bookingId) {
1678
+ return db
1679
+ .select()
1680
+ .from(bookingSupplierStatuses)
1681
+ .where(eq(bookingSupplierStatuses.bookingId, bookingId))
1682
+ .orderBy(asc(bookingSupplierStatuses.createdAt));
1683
+ },
1684
+ async createSupplierStatus(db, bookingId, data, userId) {
1685
+ const [booking] = await db
1686
+ .select({ id: bookings.id })
1687
+ .from(bookings)
1688
+ .where(eq(bookings.id, bookingId))
1689
+ .limit(1);
1690
+ if (!booking) {
1691
+ return null;
1692
+ }
1693
+ const [row] = await db
1694
+ .insert(bookingSupplierStatuses)
1695
+ .values({
1696
+ bookingId,
1697
+ supplierServiceId: data.supplierServiceId ?? null,
1698
+ serviceName: data.serviceName,
1699
+ status: data.status ?? "pending",
1700
+ supplierReference: data.supplierReference ?? null,
1701
+ costCurrency: data.costCurrency,
1702
+ costAmountCents: data.costAmountCents,
1703
+ notes: data.notes ?? null,
1704
+ confirmedAt: data.status === "confirmed" ? new Date() : null,
1705
+ })
1706
+ .returning();
1707
+ await db.insert(bookingActivityLog).values({
1708
+ bookingId,
1709
+ actorId: userId ?? "system",
1710
+ activityType: "supplier_update",
1711
+ description: `Supplier status for "${data.serviceName}" added`,
1712
+ });
1713
+ return row ?? null;
1714
+ },
1715
+ async updateSupplierStatus(db, bookingId, statusId, data, userId) {
1716
+ const updateData = {
1717
+ ...data,
1718
+ supplierServiceId: data.supplierServiceId ?? undefined,
1719
+ supplierReference: data.supplierReference ?? undefined,
1720
+ confirmedAt: data.confirmedAt !== undefined
1721
+ ? toTimestamp(data.confirmedAt)
1722
+ : data.status === "confirmed"
1723
+ ? new Date()
1724
+ : undefined,
1725
+ updatedAt: new Date(),
1726
+ };
1727
+ const [row] = await db
1728
+ .update(bookingSupplierStatuses)
1729
+ .set(updateData)
1730
+ .where(eq(bookingSupplierStatuses.id, statusId))
1731
+ .returning();
1732
+ if (!row) {
1733
+ return null;
1734
+ }
1735
+ if (data.status) {
1736
+ await db.insert(bookingActivityLog).values({
1737
+ bookingId,
1738
+ actorId: userId ?? "system",
1739
+ activityType: "supplier_update",
1740
+ description: `Supplier "${row.serviceName}" status updated to ${data.status}`,
1741
+ metadata: { supplierStatusId: statusId, newStatus: data.status },
1742
+ });
1743
+ }
1744
+ return row;
1745
+ },
1746
+ listFulfillments(db, bookingId) {
1747
+ return db
1748
+ .select()
1749
+ .from(bookingFulfillments)
1750
+ .where(eq(bookingFulfillments.bookingId, bookingId))
1751
+ .orderBy(desc(bookingFulfillments.createdAt));
1752
+ },
1753
+ async issueFulfillment(db, bookingId, data, userId) {
1754
+ const [booking] = await db
1755
+ .select({ id: bookings.id })
1756
+ .from(bookings)
1757
+ .where(eq(bookings.id, bookingId))
1758
+ .limit(1);
1759
+ if (!booking) {
1760
+ return null;
1761
+ }
1762
+ const scoped = await ensureBookingScopedLinks(db, bookingId, data);
1763
+ if (!scoped.ok) {
1764
+ return null;
1765
+ }
1766
+ const status = data.status ?? "issued";
1767
+ const issuedAt = data.issuedAt !== undefined
1768
+ ? toTimestamp(data.issuedAt)
1769
+ : status === "issued" || status === "reissued"
1770
+ ? new Date()
1771
+ : null;
1772
+ const revokedAt = data.revokedAt !== undefined
1773
+ ? toTimestamp(data.revokedAt)
1774
+ : status === "revoked"
1775
+ ? new Date()
1776
+ : null;
1777
+ const [row] = await db
1778
+ .insert(bookingFulfillments)
1779
+ .values({
1780
+ bookingId,
1781
+ bookingItemId: data.bookingItemId ?? null,
1782
+ participantId: data.participantId ?? null,
1783
+ fulfillmentType: data.fulfillmentType,
1784
+ deliveryChannel: data.deliveryChannel,
1785
+ status,
1786
+ artifactUrl: data.artifactUrl ?? null,
1787
+ payload: data.payload ?? null,
1788
+ issuedAt,
1789
+ revokedAt,
1790
+ })
1791
+ .returning();
1792
+ await db.insert(bookingActivityLog).values({
1793
+ bookingId,
1794
+ actorId: userId ?? "system",
1795
+ activityType: "fulfillment_issued",
1796
+ description: `Booking fulfillment issued as ${data.fulfillmentType}`,
1797
+ metadata: {
1798
+ fulfillmentId: row?.id ?? null,
1799
+ bookingItemId: data.bookingItemId ?? null,
1800
+ participantId: data.participantId ?? null,
1801
+ status,
1802
+ },
1803
+ });
1804
+ return row ?? null;
1805
+ },
1806
+ async updateFulfillment(db, bookingId, fulfillmentId, data, userId) {
1807
+ const [existing] = await db
1808
+ .select({ id: bookingFulfillments.id })
1809
+ .from(bookingFulfillments)
1810
+ .where(and(eq(bookingFulfillments.id, fulfillmentId), eq(bookingFulfillments.bookingId, bookingId)))
1811
+ .limit(1);
1812
+ if (!existing) {
1813
+ return null;
1814
+ }
1815
+ const scoped = await ensureBookingScopedLinks(db, bookingId, data);
1816
+ if (!scoped.ok) {
1817
+ return null;
1818
+ }
1819
+ const nextStatus = data.status;
1820
+ const [row] = await db
1821
+ .update(bookingFulfillments)
1822
+ .set({
1823
+ bookingItemId: data.bookingItemId === undefined ? undefined : (data.bookingItemId ?? null),
1824
+ participantId: data.participantId === undefined ? undefined : (data.participantId ?? null),
1825
+ fulfillmentType: data.fulfillmentType,
1826
+ deliveryChannel: data.deliveryChannel,
1827
+ status: nextStatus,
1828
+ artifactUrl: data.artifactUrl === undefined ? undefined : (data.artifactUrl ?? null),
1829
+ payload: data.payload === undefined ? undefined : (data.payload ?? null),
1830
+ issuedAt: data.issuedAt !== undefined
1831
+ ? toTimestamp(data.issuedAt)
1832
+ : nextStatus === "issued" || nextStatus === "reissued"
1833
+ ? new Date()
1834
+ : undefined,
1835
+ revokedAt: data.revokedAt !== undefined
1836
+ ? toTimestamp(data.revokedAt)
1837
+ : nextStatus === "revoked"
1838
+ ? new Date()
1839
+ : undefined,
1840
+ updatedAt: new Date(),
1841
+ })
1842
+ .where(eq(bookingFulfillments.id, fulfillmentId))
1843
+ .returning();
1844
+ if (row) {
1845
+ await db.insert(bookingActivityLog).values({
1846
+ bookingId,
1847
+ actorId: userId ?? "system",
1848
+ activityType: "fulfillment_updated",
1849
+ description: `Booking fulfillment ${fulfillmentId} updated`,
1850
+ metadata: {
1851
+ fulfillmentId,
1852
+ bookingItemId: row.bookingItemId,
1853
+ participantId: row.participantId,
1854
+ status: row.status,
1855
+ },
1856
+ });
1857
+ }
1858
+ return row ?? null;
1859
+ },
1860
+ listRedemptionEvents(db, bookingId) {
1861
+ return db
1862
+ .select()
1863
+ .from(bookingRedemptionEvents)
1864
+ .where(eq(bookingRedemptionEvents.bookingId, bookingId))
1865
+ .orderBy(desc(bookingRedemptionEvents.redeemedAt), desc(bookingRedemptionEvents.createdAt));
1866
+ },
1867
+ async recordRedemption(db, bookingId, data, userId) {
1868
+ return db.transaction(async (tx) => {
1869
+ const [booking] = await tx
1870
+ .select({
1871
+ id: bookings.id,
1872
+ redeemedAt: bookings.redeemedAt,
1873
+ })
1874
+ .from(bookings)
1875
+ .where(eq(bookings.id, bookingId))
1876
+ .limit(1);
1877
+ if (!booking) {
1878
+ return null;
1879
+ }
1880
+ const scoped = await ensureBookingScopedLinks(tx, bookingId, data);
1881
+ if (!scoped.ok) {
1882
+ return null;
1883
+ }
1884
+ const redeemedAt = toTimestamp(data.redeemedAt) ?? new Date();
1885
+ const [event] = await tx
1886
+ .insert(bookingRedemptionEvents)
1887
+ .values({
1888
+ bookingId,
1889
+ bookingItemId: data.bookingItemId ?? null,
1890
+ participantId: data.participantId ?? null,
1891
+ redeemedAt,
1892
+ redeemedBy: data.redeemedBy ?? userId ?? null,
1893
+ location: data.location ?? null,
1894
+ method: data.method,
1895
+ metadata: data.metadata ?? null,
1896
+ })
1897
+ .returning();
1898
+ if (!booking.redeemedAt || booking.redeemedAt < redeemedAt) {
1899
+ await tx
1900
+ .update(bookings)
1901
+ .set({
1902
+ redeemedAt,
1903
+ updatedAt: new Date(),
1904
+ })
1905
+ .where(eq(bookings.id, bookingId));
1906
+ }
1907
+ if (data.bookingItemId) {
1908
+ await tx
1909
+ .update(bookingItems)
1910
+ .set({
1911
+ status: "fulfilled",
1912
+ updatedAt: new Date(),
1913
+ })
1914
+ .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"))));
1915
+ await tx
1916
+ .update(bookingAllocations)
1917
+ .set({
1918
+ status: "fulfilled",
1919
+ updatedAt: new Date(),
1920
+ })
1921
+ .where(and(eq(bookingAllocations.bookingId, bookingId), eq(bookingAllocations.bookingItemId, data.bookingItemId), or(eq(bookingAllocations.status, "held"), eq(bookingAllocations.status, "confirmed"))));
1922
+ }
1923
+ await tx.insert(bookingActivityLog).values({
1924
+ bookingId,
1925
+ actorId: userId ?? "system",
1926
+ activityType: "redemption_recorded",
1927
+ description: "Booking redemption recorded",
1928
+ metadata: {
1929
+ redemptionEventId: event?.id ?? null,
1930
+ bookingItemId: data.bookingItemId ?? null,
1931
+ participantId: data.participantId ?? null,
1932
+ redeemedAt: redeemedAt.toISOString(),
1933
+ method: data.method,
1934
+ },
1935
+ });
1936
+ await syncTransactionOnBookingRedeemed(tx, bookingId);
1937
+ return event ?? null;
1938
+ });
1939
+ },
1940
+ listActivity(db, bookingId) {
1941
+ return db
1942
+ .select()
1943
+ .from(bookingActivityLog)
1944
+ .where(eq(bookingActivityLog.bookingId, bookingId))
1945
+ .orderBy(desc(bookingActivityLog.createdAt));
1946
+ },
1947
+ listNotes(db, bookingId) {
1948
+ return db
1949
+ .select()
1950
+ .from(bookingNotes)
1951
+ .where(eq(bookingNotes.bookingId, bookingId))
1952
+ .orderBy(bookingNotes.createdAt);
1953
+ },
1954
+ async createNote(db, bookingId, userId, data) {
1955
+ const [booking] = await db
1956
+ .select({ id: bookings.id })
1957
+ .from(bookings)
1958
+ .where(eq(bookings.id, bookingId))
1959
+ .limit(1);
1960
+ if (!booking) {
1961
+ return null;
1962
+ }
1963
+ const [row] = await db
1964
+ .insert(bookingNotes)
1965
+ .values({
1966
+ bookingId,
1967
+ authorId: userId,
1968
+ content: data.content,
1969
+ })
1970
+ .returning();
1971
+ await db.insert(bookingActivityLog).values({
1972
+ bookingId,
1973
+ actorId: userId,
1974
+ activityType: "note_added",
1975
+ description: "Note added",
1976
+ });
1977
+ return row;
1978
+ },
1979
+ listDocuments(db, bookingId) {
1980
+ return db
1981
+ .select()
1982
+ .from(bookingDocuments)
1983
+ .where(eq(bookingDocuments.bookingId, bookingId))
1984
+ .orderBy(bookingDocuments.createdAt);
1985
+ },
1986
+ async createDocument(db, bookingId, data) {
1987
+ const [booking] = await db
1988
+ .select({ id: bookings.id })
1989
+ .from(bookings)
1990
+ .where(eq(bookings.id, bookingId))
1991
+ .limit(1);
1992
+ if (!booking) {
1993
+ return null;
1994
+ }
1995
+ const [row] = await db
1996
+ .insert(bookingDocuments)
1997
+ .values({
1998
+ bookingId,
1999
+ participantId: data.participantId ?? data.passengerId ?? null,
2000
+ type: data.type,
2001
+ fileName: data.fileName,
2002
+ fileUrl: data.fileUrl,
2003
+ expiresAt: data.expiresAt ? new Date(data.expiresAt) : null,
2004
+ notes: data.notes ?? null,
2005
+ })
2006
+ .returning();
2007
+ return row;
2008
+ },
2009
+ async deleteDocument(db, documentId) {
2010
+ const [row] = await db
2011
+ .delete(bookingDocuments)
2012
+ .where(eq(bookingDocuments.id, documentId))
2013
+ .returning({ id: bookingDocuments.id });
2014
+ return row ?? null;
2015
+ },
2016
+ };