@voyantjs/bookings 0.3.0 → 0.4.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.
@@ -0,0 +1,900 @@
1
+ import { and, asc, desc, eq, inArray, or } from "drizzle-orm";
2
+ import { optionPriceRulesRef, optionUnitPriceRulesRef, optionUnitTiersRef, priceCatalogsRef, } from "./pricing-ref.js";
3
+ import { optionUnitsRef, productOptionsRef, productsRef } from "./products-ref.js";
4
+ import { bookingAllocations, bookingDocuments, bookingFulfillments, bookingItemParticipants, bookingItems, bookingParticipants, bookingSessionStates, bookings, } from "./schema.js";
5
+ import { bookingsService } from "./service.js";
6
+ const travelerParticipantTypes = new Set(["traveler", "occupant"]);
7
+ const WIZARD_STATE_KEY = "wizard";
8
+ function normalizeDate(value) {
9
+ if (!value) {
10
+ return null;
11
+ }
12
+ if (value instanceof Date) {
13
+ return value.toISOString().slice(0, 10);
14
+ }
15
+ return value;
16
+ }
17
+ function normalizeDateTime(value) {
18
+ if (!value) {
19
+ return null;
20
+ }
21
+ return value instanceof Date ? value.toISOString() : value;
22
+ }
23
+ function countTravelerParticipants(participants) {
24
+ return participants.filter((participant) => travelerParticipantTypes.has(participant.participantType)).length;
25
+ }
26
+ function normalizeSessionState(bookingId, state) {
27
+ if (!state) {
28
+ return null;
29
+ }
30
+ return {
31
+ sessionId: bookingId,
32
+ stateKey: WIZARD_STATE_KEY,
33
+ currentStep: state.currentStep ?? null,
34
+ completedSteps: state.completedSteps ?? [],
35
+ payload: state.payload ?? {},
36
+ version: state.version,
37
+ createdAt: normalizeDateTime(state.createdAt),
38
+ updatedAt: normalizeDateTime(state.updatedAt),
39
+ };
40
+ }
41
+ async function getWizardSessionState(db, bookingId) {
42
+ const [state] = await db
43
+ .select()
44
+ .from(bookingSessionStates)
45
+ .where(and(eq(bookingSessionStates.bookingId, bookingId), eq(bookingSessionStates.stateKey, WIZARD_STATE_KEY)))
46
+ .limit(1);
47
+ return normalizeSessionState(bookingId, state);
48
+ }
49
+ async function upsertWizardSessionState(db, bookingId, input) {
50
+ const [existing] = await db
51
+ .select()
52
+ .from(bookingSessionStates)
53
+ .where(and(eq(bookingSessionStates.bookingId, bookingId), eq(bookingSessionStates.stateKey, WIZARD_STATE_KEY)))
54
+ .limit(1);
55
+ const payload = input.replacePayload
56
+ ? input.payload
57
+ : {
58
+ ...(existing?.payload ?? {}),
59
+ ...input.payload,
60
+ };
61
+ const completedSteps = input.completedSteps ?? existing?.completedSteps ?? [];
62
+ const currentStep = input.currentStep === undefined ? (existing?.currentStep ?? null) : (input.currentStep ?? null);
63
+ if (existing) {
64
+ const [updated] = await db
65
+ .update(bookingSessionStates)
66
+ .set({
67
+ currentStep,
68
+ completedSteps,
69
+ payload,
70
+ version: existing.version + 1,
71
+ updatedAt: new Date(),
72
+ })
73
+ .where(eq(bookingSessionStates.id, existing.id))
74
+ .returning();
75
+ return normalizeSessionState(bookingId, updated ?? existing);
76
+ }
77
+ const [created] = await db
78
+ .insert(bookingSessionStates)
79
+ .values({
80
+ bookingId,
81
+ stateKey: WIZARD_STATE_KEY,
82
+ currentStep,
83
+ completedSteps,
84
+ payload,
85
+ version: 1,
86
+ })
87
+ .returning();
88
+ return normalizeSessionState(bookingId, created);
89
+ }
90
+ function resolveTierAmount(tiers, quantity, fallbackAmount) {
91
+ const tier = tiers.find((candidate) => quantity >= candidate.minQuantity &&
92
+ (candidate.maxQuantity === null || quantity <= candidate.maxQuantity));
93
+ return tier?.sellAmountCents ?? fallbackAmount;
94
+ }
95
+ function computeLineTotal(pricingMode, unitSellAmountCents, quantity, fallbackAmount) {
96
+ switch (pricingMode) {
97
+ case "free":
98
+ case "included":
99
+ return 0;
100
+ case "on_request":
101
+ return null;
102
+ case "per_unit":
103
+ case "per_person":
104
+ return unitSellAmountCents === null ? null : unitSellAmountCents * quantity;
105
+ default:
106
+ return unitSellAmountCents ?? fallbackAmount;
107
+ }
108
+ }
109
+ async function generateBookingNumber(db) {
110
+ const now = new Date();
111
+ const y = now.getFullYear().toString().slice(-2);
112
+ const m = String(now.getMonth() + 1).padStart(2, "0");
113
+ for (let attempt = 0; attempt < 10; attempt += 1) {
114
+ const suffix = String(Math.floor(Math.random() * 900000) + 100000);
115
+ const bookingNumber = `BK-${y}${m}-${suffix}`;
116
+ const [existing] = await db
117
+ .select({ id: bookings.id })
118
+ .from(bookings)
119
+ .where(eq(bookings.bookingNumber, bookingNumber))
120
+ .limit(1);
121
+ if (!existing) {
122
+ return bookingNumber;
123
+ }
124
+ }
125
+ throw new Error("Unable to generate a unique booking number");
126
+ }
127
+ async function buildOverviewSnapshot(db, query) {
128
+ const bookingLookupNumber = query.bookingNumber ?? query.bookingCode ?? null;
129
+ const [booking] = await db
130
+ .select()
131
+ .from(bookings)
132
+ .where(query.bookingId
133
+ ? eq(bookings.id, query.bookingId)
134
+ : bookingLookupNumber
135
+ ? eq(bookings.bookingNumber, bookingLookupNumber)
136
+ : eq(bookings.id, "__missing__"))
137
+ .limit(1);
138
+ if (!booking) {
139
+ return null;
140
+ }
141
+ const [participants, items, itemParticipantLinks, documents, fulfillments] = await Promise.all([
142
+ db
143
+ .select()
144
+ .from(bookingParticipants)
145
+ .where(eq(bookingParticipants.bookingId, booking.id))
146
+ .orderBy(asc(bookingParticipants.createdAt)),
147
+ db
148
+ .select()
149
+ .from(bookingItems)
150
+ .where(eq(bookingItems.bookingId, booking.id))
151
+ .orderBy(asc(bookingItems.createdAt)),
152
+ db
153
+ .select({
154
+ id: bookingItemParticipants.id,
155
+ bookingItemId: bookingItemParticipants.bookingItemId,
156
+ participantId: bookingItemParticipants.participantId,
157
+ role: bookingItemParticipants.role,
158
+ isPrimary: bookingItemParticipants.isPrimary,
159
+ })
160
+ .from(bookingItemParticipants)
161
+ .innerJoin(bookingItems, eq(bookingItems.id, bookingItemParticipants.bookingItemId))
162
+ .where(eq(bookingItems.bookingId, booking.id))
163
+ .orderBy(asc(bookingItemParticipants.createdAt)),
164
+ db
165
+ .select()
166
+ .from(bookingDocuments)
167
+ .where(eq(bookingDocuments.bookingId, booking.id))
168
+ .orderBy(asc(bookingDocuments.createdAt)),
169
+ db
170
+ .select()
171
+ .from(bookingFulfillments)
172
+ .where(eq(bookingFulfillments.bookingId, booking.id))
173
+ .orderBy(asc(bookingFulfillments.createdAt)),
174
+ ]);
175
+ const email = query.email?.trim().toLowerCase() ?? null;
176
+ if (email) {
177
+ const authorized = participants.some((participant) => participant.email?.toLowerCase() === email);
178
+ if (!authorized) {
179
+ return null;
180
+ }
181
+ }
182
+ const itemLinksByItemId = new Map();
183
+ for (const link of itemParticipantLinks) {
184
+ const existing = itemLinksByItemId.get(link.bookingItemId) ?? [];
185
+ existing.push({
186
+ id: link.id,
187
+ participantId: link.participantId,
188
+ role: link.role,
189
+ isPrimary: link.isPrimary,
190
+ });
191
+ itemLinksByItemId.set(link.bookingItemId, existing);
192
+ }
193
+ return {
194
+ bookingId: booking.id,
195
+ bookingNumber: booking.bookingNumber,
196
+ status: booking.status,
197
+ sellCurrency: booking.sellCurrency,
198
+ sellAmountCents: booking.sellAmountCents ?? null,
199
+ startDate: normalizeDate(booking.startDate),
200
+ endDate: normalizeDate(booking.endDate),
201
+ pax: booking.pax ?? null,
202
+ confirmedAt: normalizeDateTime(booking.confirmedAt),
203
+ cancelledAt: normalizeDateTime(booking.cancelledAt),
204
+ completedAt: normalizeDateTime(booking.completedAt),
205
+ participants: participants.map((participant) => ({
206
+ id: participant.id,
207
+ participantType: participant.participantType,
208
+ firstName: participant.firstName,
209
+ lastName: participant.lastName,
210
+ isPrimary: participant.isPrimary,
211
+ })),
212
+ items: items.map((item) => ({
213
+ id: item.id,
214
+ title: item.title,
215
+ description: item.description ?? null,
216
+ itemType: item.itemType,
217
+ status: item.status,
218
+ serviceDate: normalizeDate(item.serviceDate),
219
+ startsAt: normalizeDateTime(item.startsAt),
220
+ endsAt: normalizeDateTime(item.endsAt),
221
+ quantity: item.quantity,
222
+ sellCurrency: item.sellCurrency,
223
+ unitSellAmountCents: item.unitSellAmountCents ?? null,
224
+ totalSellAmountCents: item.totalSellAmountCents ?? null,
225
+ costCurrency: item.costCurrency ?? null,
226
+ unitCostAmountCents: item.unitCostAmountCents ?? null,
227
+ totalCostAmountCents: item.totalCostAmountCents ?? null,
228
+ notes: item.notes ?? null,
229
+ productId: item.productId ?? null,
230
+ optionId: item.optionId ?? null,
231
+ optionUnitId: item.optionUnitId ?? null,
232
+ pricingCategoryId: item.pricingCategoryId ?? null,
233
+ participantLinks: itemLinksByItemId.get(item.id) ?? [],
234
+ })),
235
+ documents: documents.map((document) => ({
236
+ id: document.id,
237
+ participantId: document.participantId ?? null,
238
+ type: document.type,
239
+ fileName: document.fileName,
240
+ fileUrl: document.fileUrl,
241
+ })),
242
+ fulfillments: fulfillments.map((fulfillment) => ({
243
+ id: fulfillment.id,
244
+ bookingItemId: fulfillment.bookingItemId ?? null,
245
+ participantId: fulfillment.participantId ?? null,
246
+ fulfillmentType: fulfillment.fulfillmentType,
247
+ deliveryChannel: fulfillment.deliveryChannel,
248
+ status: fulfillment.status,
249
+ artifactUrl: fulfillment.artifactUrl ?? null,
250
+ })),
251
+ };
252
+ }
253
+ function buildUnitWarnings(unit, quantity, sessionPax) {
254
+ const warnings = [];
255
+ if (!unit) {
256
+ return warnings;
257
+ }
258
+ if (unit.minQuantity !== null && quantity < unit.minQuantity) {
259
+ warnings.push(`Selected quantity is below the minimum for ${unit.name}.`);
260
+ }
261
+ if (unit.maxQuantity !== null && quantity > unit.maxQuantity) {
262
+ warnings.push(`Selected quantity is above the maximum for ${unit.name}.`);
263
+ }
264
+ if (sessionPax && unit.occupancyMin !== null && quantity * unit.occupancyMin > sessionPax) {
265
+ warnings.push(`Selected ${unit.name} quantity exceeds the current traveler count.`);
266
+ }
267
+ if (sessionPax && unit.occupancyMax !== null && quantity * unit.occupancyMax < sessionPax) {
268
+ warnings.push(`Selected ${unit.name} quantity does not cover the current traveler count.`);
269
+ }
270
+ return warnings;
271
+ }
272
+ async function resolveSessionPricingSnapshot(db, productId, input) {
273
+ const [product] = await db
274
+ .select({
275
+ id: productsRef.id,
276
+ })
277
+ .from(productsRef)
278
+ .where(and(eq(productsRef.id, productId), eq(productsRef.status, "active"), eq(productsRef.activated, true), eq(productsRef.visibility, "public")))
279
+ .limit(1);
280
+ if (!product) {
281
+ return null;
282
+ }
283
+ const catalog = input.catalogId
284
+ ? await db
285
+ .select({
286
+ id: priceCatalogsRef.id,
287
+ currencyCode: priceCatalogsRef.currencyCode,
288
+ })
289
+ .from(priceCatalogsRef)
290
+ .where(and(eq(priceCatalogsRef.id, input.catalogId), eq(priceCatalogsRef.catalogType, "public"), eq(priceCatalogsRef.active, true)))
291
+ .limit(1)
292
+ .then((rows) => rows[0] ?? null)
293
+ : await db
294
+ .select({
295
+ id: priceCatalogsRef.id,
296
+ currencyCode: priceCatalogsRef.currencyCode,
297
+ })
298
+ .from(priceCatalogsRef)
299
+ .where(and(eq(priceCatalogsRef.catalogType, "public"), eq(priceCatalogsRef.active, true)))
300
+ .orderBy(desc(priceCatalogsRef.isDefault), asc(priceCatalogsRef.name))
301
+ .limit(1)
302
+ .then((rows) => rows[0] ?? null);
303
+ if (!catalog) {
304
+ return null;
305
+ }
306
+ const optionConditions = [
307
+ eq(productOptionsRef.productId, productId),
308
+ eq(productOptionsRef.status, "active"),
309
+ ];
310
+ if (input.optionId) {
311
+ optionConditions.push(eq(productOptionsRef.id, input.optionId));
312
+ }
313
+ const options = await db
314
+ .select({
315
+ id: productOptionsRef.id,
316
+ name: productOptionsRef.name,
317
+ isDefault: productOptionsRef.isDefault,
318
+ })
319
+ .from(productOptionsRef)
320
+ .where(and(...optionConditions))
321
+ .orderBy(desc(productOptionsRef.isDefault), asc(productOptionsRef.sortOrder));
322
+ if (options.length === 0) {
323
+ return null;
324
+ }
325
+ const optionIds = options.map((option) => option.id);
326
+ const [rules, unitPrices] = await Promise.all([
327
+ db
328
+ .select({
329
+ id: optionPriceRulesRef.id,
330
+ optionId: optionPriceRulesRef.optionId,
331
+ pricingMode: optionPriceRulesRef.pricingMode,
332
+ baseSellAmountCents: optionPriceRulesRef.baseSellAmountCents,
333
+ isDefault: optionPriceRulesRef.isDefault,
334
+ })
335
+ .from(optionPriceRulesRef)
336
+ .where(and(eq(optionPriceRulesRef.productId, productId), inArray(optionPriceRulesRef.optionId, optionIds), eq(optionPriceRulesRef.priceCatalogId, catalog.id), eq(optionPriceRulesRef.active, true)))
337
+ .orderBy(desc(optionPriceRulesRef.isDefault), asc(optionPriceRulesRef.name)),
338
+ db
339
+ .select({
340
+ id: optionUnitPriceRulesRef.id,
341
+ optionPriceRuleId: optionUnitPriceRulesRef.optionPriceRuleId,
342
+ unitId: optionUnitPriceRulesRef.unitId,
343
+ unitName: optionUnitsRef.name,
344
+ unitType: optionUnitsRef.unitType,
345
+ pricingCategoryId: optionUnitPriceRulesRef.pricingCategoryId,
346
+ pricingMode: optionUnitPriceRulesRef.pricingMode,
347
+ sellAmountCents: optionUnitPriceRulesRef.sellAmountCents,
348
+ minQuantity: optionUnitPriceRulesRef.minQuantity,
349
+ maxQuantity: optionUnitPriceRulesRef.maxQuantity,
350
+ })
351
+ .from(optionUnitPriceRulesRef)
352
+ .innerJoin(optionUnitsRef, eq(optionUnitsRef.id, optionUnitPriceRulesRef.unitId))
353
+ .where(and(inArray(optionUnitPriceRulesRef.optionId, optionIds), eq(optionUnitPriceRulesRef.active, true)))
354
+ .orderBy(asc(optionUnitPriceRulesRef.sortOrder), asc(optionUnitPriceRulesRef.createdAt)),
355
+ ]);
356
+ const tiers = unitPrices.length > 0
357
+ ? await db
358
+ .select({
359
+ id: optionUnitTiersRef.id,
360
+ optionUnitPriceRuleId: optionUnitTiersRef.optionUnitPriceRuleId,
361
+ minQuantity: optionUnitTiersRef.minQuantity,
362
+ maxQuantity: optionUnitTiersRef.maxQuantity,
363
+ sellAmountCents: optionUnitTiersRef.sellAmountCents,
364
+ sortOrder: optionUnitTiersRef.sortOrder,
365
+ })
366
+ .from(optionUnitTiersRef)
367
+ .where(and(inArray(optionUnitTiersRef.optionUnitPriceRuleId, unitPrices.map((row) => row.id)), eq(optionUnitTiersRef.active, true)))
368
+ .orderBy(asc(optionUnitTiersRef.sortOrder), asc(optionUnitTiersRef.minQuantity))
369
+ : [];
370
+ const tiersByUnitPriceId = new Map();
371
+ for (const tier of tiers) {
372
+ const existing = tiersByUnitPriceId.get(tier.optionUnitPriceRuleId) ?? [];
373
+ existing.push(tier);
374
+ tiersByUnitPriceId.set(tier.optionUnitPriceRuleId, existing);
375
+ }
376
+ return {
377
+ catalog: catalog,
378
+ options: options,
379
+ rules: rules,
380
+ unitPrices: unitPrices.map((row) => ({
381
+ ...row,
382
+ tiers: tiersByUnitPriceId.get(row.id) ?? [],
383
+ })),
384
+ };
385
+ }
386
+ async function buildSessionSnapshot(db, bookingId) {
387
+ const [booking, participants, items, allocations, itemParticipantLinks, state] = await Promise.all([
388
+ bookingsService.getBookingById(db, bookingId),
389
+ db
390
+ .select()
391
+ .from(bookingParticipants)
392
+ .where(eq(bookingParticipants.bookingId, bookingId))
393
+ .orderBy(asc(bookingParticipants.createdAt)),
394
+ db
395
+ .select()
396
+ .from(bookingItems)
397
+ .where(eq(bookingItems.bookingId, bookingId))
398
+ .orderBy(asc(bookingItems.createdAt)),
399
+ db
400
+ .select()
401
+ .from(bookingAllocations)
402
+ .where(eq(bookingAllocations.bookingId, bookingId))
403
+ .orderBy(asc(bookingAllocations.createdAt)),
404
+ db
405
+ .select({
406
+ id: bookingItemParticipants.id,
407
+ bookingItemId: bookingItemParticipants.bookingItemId,
408
+ participantId: bookingItemParticipants.participantId,
409
+ role: bookingItemParticipants.role,
410
+ isPrimary: bookingItemParticipants.isPrimary,
411
+ })
412
+ .from(bookingItemParticipants)
413
+ .innerJoin(bookingItems, eq(bookingItems.id, bookingItemParticipants.bookingItemId))
414
+ .where(eq(bookingItems.bookingId, bookingId))
415
+ .orderBy(asc(bookingItemParticipants.createdAt)),
416
+ getWizardSessionState(db, bookingId),
417
+ ]);
418
+ if (!booking) {
419
+ return null;
420
+ }
421
+ const itemLinksByItemId = new Map();
422
+ for (const link of itemParticipantLinks) {
423
+ const existing = itemLinksByItemId.get(link.bookingItemId) ?? [];
424
+ existing.push({
425
+ id: link.id,
426
+ participantId: link.participantId,
427
+ role: link.role,
428
+ isPrimary: link.isPrimary,
429
+ });
430
+ itemLinksByItemId.set(link.bookingItemId, existing);
431
+ }
432
+ const hasParticipants = participants.length > 0;
433
+ const hasTraveler = countTravelerParticipants(participants) > 0;
434
+ const hasPrimaryParticipant = participants.some((participant) => participant.isPrimary);
435
+ const hasItems = items.length > 0;
436
+ const hasAllocations = allocations.length > 0;
437
+ return {
438
+ sessionId: booking.id,
439
+ bookingNumber: booking.bookingNumber,
440
+ status: booking.status,
441
+ externalBookingRef: booking.externalBookingRef ?? null,
442
+ communicationLanguage: booking.communicationLanguage ?? null,
443
+ sellCurrency: booking.sellCurrency,
444
+ sellAmountCents: booking.sellAmountCents ?? null,
445
+ startDate: normalizeDate(booking.startDate),
446
+ endDate: normalizeDate(booking.endDate),
447
+ pax: booking.pax ?? null,
448
+ holdExpiresAt: normalizeDateTime(booking.holdExpiresAt),
449
+ confirmedAt: normalizeDateTime(booking.confirmedAt),
450
+ expiredAt: normalizeDateTime(booking.expiredAt),
451
+ cancelledAt: normalizeDateTime(booking.cancelledAt),
452
+ completedAt: normalizeDateTime(booking.completedAt),
453
+ participants: participants.map((participant) => ({
454
+ id: participant.id,
455
+ participantType: participant.participantType,
456
+ travelerCategory: participant.travelerCategory ?? null,
457
+ firstName: participant.firstName,
458
+ lastName: participant.lastName,
459
+ email: participant.email ?? null,
460
+ phone: participant.phone ?? null,
461
+ preferredLanguage: participant.preferredLanguage ?? null,
462
+ accessibilityNeeds: participant.accessibilityNeeds ?? null,
463
+ specialRequests: participant.specialRequests ?? null,
464
+ isPrimary: participant.isPrimary,
465
+ notes: participant.notes ?? null,
466
+ })),
467
+ items: items.map((item) => ({
468
+ id: item.id,
469
+ title: item.title,
470
+ description: item.description ?? null,
471
+ itemType: item.itemType,
472
+ status: item.status,
473
+ serviceDate: normalizeDate(item.serviceDate),
474
+ startsAt: normalizeDateTime(item.startsAt),
475
+ endsAt: normalizeDateTime(item.endsAt),
476
+ quantity: item.quantity,
477
+ sellCurrency: item.sellCurrency,
478
+ unitSellAmountCents: item.unitSellAmountCents ?? null,
479
+ totalSellAmountCents: item.totalSellAmountCents ?? null,
480
+ costCurrency: item.costCurrency ?? null,
481
+ unitCostAmountCents: item.unitCostAmountCents ?? null,
482
+ totalCostAmountCents: item.totalCostAmountCents ?? null,
483
+ notes: item.notes ?? null,
484
+ productId: item.productId ?? null,
485
+ optionId: item.optionId ?? null,
486
+ optionUnitId: item.optionUnitId ?? null,
487
+ pricingCategoryId: item.pricingCategoryId ?? null,
488
+ participantLinks: itemLinksByItemId.get(item.id) ?? [],
489
+ })),
490
+ allocations: allocations.map((allocation) => ({
491
+ id: allocation.id,
492
+ bookingItemId: allocation.bookingItemId ?? null,
493
+ productId: allocation.productId ?? null,
494
+ optionId: allocation.optionId ?? null,
495
+ optionUnitId: allocation.optionUnitId ?? null,
496
+ pricingCategoryId: allocation.pricingCategoryId ?? null,
497
+ availabilitySlotId: allocation.availabilitySlotId ?? null,
498
+ quantity: allocation.quantity,
499
+ allocationType: allocation.allocationType,
500
+ status: allocation.status,
501
+ holdExpiresAt: normalizeDateTime(allocation.holdExpiresAt),
502
+ confirmedAt: normalizeDateTime(allocation.confirmedAt),
503
+ releasedAt: normalizeDateTime(allocation.releasedAt),
504
+ })),
505
+ checklist: {
506
+ hasParticipants,
507
+ hasTraveler,
508
+ hasPrimaryParticipant,
509
+ hasItems,
510
+ hasAllocations,
511
+ readyForConfirmation: booking.status === "on_hold" &&
512
+ hasParticipants &&
513
+ hasTraveler &&
514
+ hasPrimaryParticipant &&
515
+ hasItems &&
516
+ hasAllocations,
517
+ },
518
+ state,
519
+ };
520
+ }
521
+ export const publicBookingsService = {
522
+ async createSession(db, input, userId) {
523
+ const travelerCount = countTravelerParticipants(input.participants);
524
+ const bookingNumber = await generateBookingNumber(db);
525
+ const result = await bookingsService.reserveBooking(db, {
526
+ bookingNumber,
527
+ sourceType: "direct",
528
+ externalBookingRef: input.externalBookingRef ?? null,
529
+ communicationLanguage: input.communicationLanguage ?? null,
530
+ sellCurrency: input.sellCurrency,
531
+ baseCurrency: input.baseCurrency ?? null,
532
+ sellAmountCents: input.sellAmountCents ?? null,
533
+ baseSellAmountCents: input.baseSellAmountCents ?? null,
534
+ costAmountCents: input.costAmountCents ?? null,
535
+ baseCostAmountCents: input.baseCostAmountCents ?? null,
536
+ marginPercent: input.marginPercent ?? null,
537
+ startDate: input.startDate ?? null,
538
+ endDate: input.endDate ?? null,
539
+ pax: input.pax ?? (travelerCount > 0 ? travelerCount : null),
540
+ holdMinutes: input.holdMinutes,
541
+ holdExpiresAt: input.holdExpiresAt ?? null,
542
+ items: input.items.map((item) => ({
543
+ ...item,
544
+ sellCurrency: item.sellCurrency ?? input.sellCurrency,
545
+ costCurrency: item.costCurrency ?? null,
546
+ description: item.description ?? null,
547
+ notes: item.notes ?? null,
548
+ productId: item.productId ?? null,
549
+ optionId: item.optionId ?? null,
550
+ optionUnitId: item.optionUnitId ?? null,
551
+ pricingCategoryId: item.pricingCategoryId ?? null,
552
+ sourceSnapshotId: item.sourceSnapshotId ?? null,
553
+ sourceOfferId: null,
554
+ metadata: item.metadata ?? null,
555
+ })),
556
+ internalNotes: null,
557
+ }, userId);
558
+ if (!("booking" in result) || !result.booking) {
559
+ return result;
560
+ }
561
+ for (const participant of input.participants) {
562
+ await bookingsService.createParticipant(db, result.booking.id, {
563
+ participantType: participant.participantType,
564
+ travelerCategory: participant.travelerCategory ?? null,
565
+ firstName: participant.firstName,
566
+ lastName: participant.lastName,
567
+ email: participant.email ?? null,
568
+ phone: participant.phone ?? null,
569
+ preferredLanguage: participant.preferredLanguage ?? null,
570
+ accessibilityNeeds: participant.accessibilityNeeds ?? null,
571
+ specialRequests: participant.specialRequests ?? null,
572
+ isPrimary: participant.isPrimary,
573
+ notes: participant.notes ?? null,
574
+ personId: null,
575
+ }, userId);
576
+ }
577
+ const session = await buildSessionSnapshot(db, result.booking.id);
578
+ return session ? { status: "ok", session } : { status: "not_found" };
579
+ },
580
+ getSessionById(db, bookingId) {
581
+ return buildSessionSnapshot(db, bookingId);
582
+ },
583
+ async getSessionState(db, bookingId) {
584
+ const booking = await bookingsService.getBookingById(db, bookingId);
585
+ if (!booking) {
586
+ return null;
587
+ }
588
+ return getWizardSessionState(db, bookingId);
589
+ },
590
+ async updateSessionState(db, bookingId, input) {
591
+ const booking = await bookingsService.getBookingById(db, bookingId);
592
+ if (!booking) {
593
+ return { status: "not_found" };
594
+ }
595
+ const state = await upsertWizardSessionState(db, bookingId, input);
596
+ return { status: "ok", state };
597
+ },
598
+ async updateSession(db, bookingId, input, userId) {
599
+ const booking = await bookingsService.getBookingById(db, bookingId);
600
+ if (!booking) {
601
+ return { status: "not_found" };
602
+ }
603
+ if (input.externalBookingRef !== undefined ||
604
+ input.communicationLanguage !== undefined ||
605
+ input.pax !== undefined) {
606
+ await bookingsService.updateBooking(db, bookingId, {
607
+ externalBookingRef: input.externalBookingRef,
608
+ communicationLanguage: input.communicationLanguage,
609
+ pax: input.pax,
610
+ });
611
+ }
612
+ for (const participantId of input.removedParticipantIds) {
613
+ const participant = await bookingsService.getParticipantById(db, bookingId, participantId);
614
+ if (participant) {
615
+ await bookingsService.deleteParticipant(db, participant.id);
616
+ }
617
+ }
618
+ if (input.participants) {
619
+ for (const participant of input.participants) {
620
+ if (participant.id) {
621
+ const existing = await bookingsService.getParticipantById(db, bookingId, participant.id);
622
+ if (!existing) {
623
+ return { status: "participant_not_found" };
624
+ }
625
+ await bookingsService.updateParticipant(db, participant.id, {
626
+ participantType: participant.participantType,
627
+ travelerCategory: participant.travelerCategory ?? null,
628
+ firstName: participant.firstName,
629
+ lastName: participant.lastName,
630
+ email: participant.email ?? null,
631
+ phone: participant.phone ?? null,
632
+ preferredLanguage: participant.preferredLanguage ?? null,
633
+ accessibilityNeeds: participant.accessibilityNeeds ?? null,
634
+ specialRequests: participant.specialRequests ?? null,
635
+ isPrimary: participant.isPrimary,
636
+ notes: participant.notes ?? null,
637
+ });
638
+ continue;
639
+ }
640
+ await bookingsService.createParticipant(db, bookingId, {
641
+ participantType: participant.participantType,
642
+ travelerCategory: participant.travelerCategory ?? null,
643
+ firstName: participant.firstName,
644
+ lastName: participant.lastName,
645
+ email: participant.email ?? null,
646
+ phone: participant.phone ?? null,
647
+ preferredLanguage: participant.preferredLanguage ?? null,
648
+ accessibilityNeeds: participant.accessibilityNeeds ?? null,
649
+ specialRequests: participant.specialRequests ?? null,
650
+ isPrimary: participant.isPrimary,
651
+ notes: participant.notes ?? null,
652
+ personId: null,
653
+ }, userId);
654
+ }
655
+ }
656
+ if (input.holdMinutes !== undefined || input.holdExpiresAt !== undefined) {
657
+ const holdResult = await bookingsService.extendBookingHold(db, bookingId, {
658
+ holdMinutes: input.holdMinutes,
659
+ holdExpiresAt: input.holdExpiresAt,
660
+ }, userId);
661
+ if (holdResult.status !== "ok") {
662
+ return holdResult;
663
+ }
664
+ }
665
+ if (input.pax === undefined && (input.participants || input.removedParticipantIds.length > 0)) {
666
+ const participants = await db
667
+ .select({ participantType: bookingParticipants.participantType })
668
+ .from(bookingParticipants)
669
+ .where(eq(bookingParticipants.bookingId, bookingId));
670
+ const travelerCount = countTravelerParticipants(participants);
671
+ await bookingsService.updateBooking(db, bookingId, {
672
+ pax: travelerCount > 0 ? travelerCount : null,
673
+ });
674
+ }
675
+ const session = await buildSessionSnapshot(db, bookingId);
676
+ return session ? { status: "ok", session } : { status: "not_found" };
677
+ },
678
+ async repriceSession(db, bookingId, input) {
679
+ const [booking, items] = await Promise.all([
680
+ bookingsService.getBookingById(db, bookingId),
681
+ db
682
+ .select()
683
+ .from(bookingItems)
684
+ .where(eq(bookingItems.bookingId, bookingId))
685
+ .orderBy(asc(bookingItems.createdAt)),
686
+ ]);
687
+ if (!booking) {
688
+ return { status: "not_found" };
689
+ }
690
+ const selectedItemIds = input.selections.map((selection) => selection.itemId);
691
+ const itemById = new Map(items.map((item) => [item.id, item]));
692
+ for (const selection of input.selections) {
693
+ if (!itemById.has(selection.itemId)) {
694
+ return { status: "invalid_selection" };
695
+ }
696
+ }
697
+ const requestedUnitIds = Array.from(new Set(input.selections
698
+ .map((selection) => selection.optionUnitId)
699
+ .filter((value) => Boolean(value))));
700
+ const requestedUnits = requestedUnitIds.length > 0
701
+ ? await db.select().from(optionUnitsRef).where(inArray(optionUnitsRef.id, requestedUnitIds))
702
+ : [];
703
+ const requestedUnitById = new Map(requestedUnits.map((unit) => [unit.id, unit]));
704
+ const pricingWarnings = [];
705
+ const pricedItems = [];
706
+ let resolvedCatalogId = input.catalogId ?? null;
707
+ let resolvedCurrency = booking.sellCurrency;
708
+ for (const selection of input.selections) {
709
+ const item = itemById.get(selection.itemId);
710
+ if (!item?.productId) {
711
+ return { status: "invalid_selection" };
712
+ }
713
+ const optionId = selection.optionId === undefined
714
+ ? (item.optionId ?? undefined)
715
+ : (selection.optionId ?? undefined);
716
+ const quantity = selection.quantity ?? item.quantity;
717
+ const pricingCategoryId = selection.pricingCategoryId === undefined
718
+ ? (item.pricingCategoryId ?? null)
719
+ : (selection.pricingCategoryId ?? null);
720
+ const selectedUnitId = selection.optionUnitId === undefined
721
+ ? (item.optionUnitId ?? null)
722
+ : (selection.optionUnitId ?? null);
723
+ const snapshot = await resolveSessionPricingSnapshot(db, item.productId, {
724
+ catalogId: input.catalogId,
725
+ optionId,
726
+ });
727
+ if (!snapshot) {
728
+ return { status: "pricing_unavailable" };
729
+ }
730
+ resolvedCatalogId = snapshot.catalog.id;
731
+ resolvedCurrency = snapshot.catalog.currencyCode ?? booking.sellCurrency;
732
+ const option = snapshot.options.find((candidate) => candidate.id === optionId) ??
733
+ snapshot.options[0] ??
734
+ null;
735
+ if (!option) {
736
+ return { status: "pricing_unavailable" };
737
+ }
738
+ const rule = snapshot.rules.find((candidate) => candidate.optionId === option.id && candidate.isDefault) ??
739
+ snapshot.rules.find((candidate) => candidate.optionId === option.id) ??
740
+ null;
741
+ if (!rule) {
742
+ return { status: "pricing_unavailable" };
743
+ }
744
+ const ruleUnitPrices = snapshot.unitPrices.filter((candidate) => candidate.optionPriceRuleId === rule.id);
745
+ const unitPriceCandidates = ruleUnitPrices.filter((candidate) => {
746
+ if (selectedUnitId && candidate.unitId !== selectedUnitId) {
747
+ return false;
748
+ }
749
+ if (pricingCategoryId && candidate.pricingCategoryId !== pricingCategoryId) {
750
+ return false;
751
+ }
752
+ if (candidate.minQuantity !== null && quantity < candidate.minQuantity) {
753
+ return false;
754
+ }
755
+ if (candidate.maxQuantity !== null && quantity > candidate.maxQuantity) {
756
+ return false;
757
+ }
758
+ return true;
759
+ });
760
+ const fallbackUnitPrice = !pricingCategoryId && !selectedUnitId
761
+ ? (ruleUnitPrices.find((candidate) => candidate.pricingCategoryId === null &&
762
+ (candidate.minQuantity === null || quantity >= candidate.minQuantity) &&
763
+ (candidate.maxQuantity === null || quantity <= candidate.maxQuantity)) ?? null)
764
+ : null;
765
+ const unitPrice = unitPriceCandidates[0] ?? fallbackUnitPrice;
766
+ if ((selectedUnitId || ruleUnitPrices.length > 0) &&
767
+ !unitPrice &&
768
+ rule.pricingMode !== "per_booking") {
769
+ return { status: "pricing_unavailable" };
770
+ }
771
+ const unit = selectedUnitId ? (requestedUnitById.get(selectedUnitId) ?? null) : null;
772
+ const unitSellAmountCents = unitPrice
773
+ ? resolveTierAmount(unitPrice.tiers, quantity, unitPrice.sellAmountCents)
774
+ : rule.baseSellAmountCents;
775
+ const pricingMode = unitPrice?.pricingMode ?? rule.pricingMode;
776
+ const totalSellAmountCents = computeLineTotal(pricingMode, unitSellAmountCents, quantity, rule.baseSellAmountCents);
777
+ const warnings = buildUnitWarnings(unit, quantity, booking.pax ?? null);
778
+ if (selectedUnitId && !unit) {
779
+ warnings.push("Selected room/unit metadata is not available in the current catalog.");
780
+ }
781
+ if (pricingMode === "on_request") {
782
+ warnings.push("Selected option requires manual pricing confirmation.");
783
+ }
784
+ pricingWarnings.push(...warnings);
785
+ pricedItems.push({
786
+ itemId: item.id,
787
+ title: item.title,
788
+ productId: item.productId ?? null,
789
+ optionId: option.id,
790
+ optionUnitId: selectedUnitId,
791
+ optionUnitName: unit?.name ?? unitPrice?.unitName ?? null,
792
+ optionUnitType: unit?.unitType ?? unitPrice?.unitType ?? null,
793
+ pricingCategoryId,
794
+ quantity,
795
+ pricingMode,
796
+ unitSellAmountCents,
797
+ totalSellAmountCents,
798
+ warnings,
799
+ });
800
+ }
801
+ const totalSellAmountCents = items.reduce((total, item) => {
802
+ const repriced = pricedItems.find((candidate) => candidate.itemId === item.id);
803
+ return total + (repriced?.totalSellAmountCents ?? item.totalSellAmountCents ?? 0);
804
+ }, 0);
805
+ let session = null;
806
+ if (input.applyToSession) {
807
+ const activeAllocations = selectedItemIds.length > 0
808
+ ? await db
809
+ .select()
810
+ .from(bookingAllocations)
811
+ .where(and(eq(bookingAllocations.bookingId, bookingId), inArray(bookingAllocations.bookingItemId, selectedItemIds), or(eq(bookingAllocations.status, "held"), eq(bookingAllocations.status, "confirmed"))))
812
+ : [];
813
+ const activeAllocationsByItemId = new Map();
814
+ for (const allocation of activeAllocations) {
815
+ const existing = activeAllocationsByItemId.get(allocation.bookingItemId) ?? [];
816
+ existing.push(allocation);
817
+ activeAllocationsByItemId.set(allocation.bookingItemId, existing);
818
+ }
819
+ const quantityChangedWithActiveAllocation = pricedItems.some((pricedItem) => {
820
+ const item = itemById.get(pricedItem.itemId);
821
+ return Boolean(item &&
822
+ item.quantity !== pricedItem.quantity &&
823
+ (activeAllocationsByItemId.get(pricedItem.itemId)?.length ?? 0) > 0);
824
+ });
825
+ if (quantityChangedWithActiveAllocation) {
826
+ return { status: "quantity_change_requires_reallocation" };
827
+ }
828
+ await db.transaction(async (tx) => {
829
+ for (const pricedItem of pricedItems) {
830
+ await tx
831
+ .update(bookingItems)
832
+ .set({
833
+ optionId: pricedItem.optionId,
834
+ optionUnitId: pricedItem.optionUnitId,
835
+ pricingCategoryId: pricedItem.pricingCategoryId,
836
+ quantity: pricedItem.quantity,
837
+ sellCurrency: resolvedCurrency,
838
+ unitSellAmountCents: pricedItem.unitSellAmountCents,
839
+ totalSellAmountCents: pricedItem.totalSellAmountCents,
840
+ updatedAt: new Date(),
841
+ })
842
+ .where(eq(bookingItems.id, pricedItem.itemId));
843
+ await tx
844
+ .update(bookingAllocations)
845
+ .set({
846
+ optionId: pricedItem.optionId,
847
+ optionUnitId: pricedItem.optionUnitId,
848
+ pricingCategoryId: pricedItem.pricingCategoryId,
849
+ updatedAt: new Date(),
850
+ })
851
+ .where(and(eq(bookingAllocations.bookingId, bookingId), eq(bookingAllocations.bookingItemId, pricedItem.itemId), or(eq(bookingAllocations.status, "held"), eq(bookingAllocations.status, "confirmed"))));
852
+ }
853
+ await tx
854
+ .update(bookings)
855
+ .set({
856
+ sellCurrency: resolvedCurrency,
857
+ sellAmountCents: totalSellAmountCents,
858
+ updatedAt: new Date(),
859
+ })
860
+ .where(eq(bookings.id, bookingId));
861
+ });
862
+ session = await buildSessionSnapshot(db, bookingId);
863
+ }
864
+ return {
865
+ status: "ok",
866
+ pricing: {
867
+ sessionId: bookingId,
868
+ catalogId: resolvedCatalogId,
869
+ currencyCode: resolvedCurrency,
870
+ totalSellAmountCents,
871
+ items: pricedItems,
872
+ warnings: Array.from(new Set(pricingWarnings)),
873
+ appliedToSession: input.applyToSession,
874
+ },
875
+ session,
876
+ };
877
+ },
878
+ async confirmSession(db, bookingId, input, userId) {
879
+ const result = await bookingsService.confirmBooking(db, bookingId, input, userId);
880
+ if (result.status !== "ok") {
881
+ return result;
882
+ }
883
+ const session = await buildSessionSnapshot(db, bookingId);
884
+ return session ? { status: "ok", session } : { status: "not_found" };
885
+ },
886
+ async expireSession(db, bookingId, input, userId) {
887
+ const result = await bookingsService.expireBooking(db, bookingId, input, userId);
888
+ if (result.status !== "ok") {
889
+ return result;
890
+ }
891
+ const session = await buildSessionSnapshot(db, bookingId);
892
+ return session ? { status: "ok", session } : { status: "not_found" };
893
+ },
894
+ async getOverview(db, query) {
895
+ return buildOverviewSnapshot(db, query);
896
+ },
897
+ async getOverviewByLookup(db, query) {
898
+ return buildOverviewSnapshot(db, query);
899
+ },
900
+ };