@voyantjs/bookings 0.119.1 → 0.119.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/action-ledger-drift.d.ts.map +1 -1
  2. package/dist/action-ledger-drift.js +7 -2
  3. package/dist/pricing-assignment/age.d.ts +33 -0
  4. package/dist/pricing-assignment/age.d.ts.map +1 -0
  5. package/dist/pricing-assignment/age.js +109 -0
  6. package/dist/pricing-assignment/draft.d.ts +69 -0
  7. package/dist/pricing-assignment/draft.d.ts.map +1 -0
  8. package/dist/pricing-assignment/draft.js +284 -0
  9. package/dist/pricing-assignment/types.d.ts +113 -0
  10. package/dist/pricing-assignment/types.d.ts.map +1 -0
  11. package/dist/pricing-assignment/types.js +30 -0
  12. package/dist/pricing-assignment/unit-helpers.d.ts +11 -0
  13. package/dist/pricing-assignment/unit-helpers.d.ts.map +1 -0
  14. package/dist/pricing-assignment/unit-helpers.js +19 -0
  15. package/dist/pricing-assignment/verify.d.ts +67 -0
  16. package/dist/pricing-assignment/verify.d.ts.map +1 -0
  17. package/dist/pricing-assignment/verify.js +121 -0
  18. package/dist/pricing-assignment.d.ts +4 -275
  19. package/dist/pricing-assignment.d.ts.map +1 -1
  20. package/dist/pricing-assignment.js +4 -558
  21. package/dist/routes-admin.d.ts +2894 -0
  22. package/dist/routes-admin.d.ts.map +1 -0
  23. package/dist/routes-admin.js +2111 -0
  24. package/dist/routes-public.d.ts +9 -2
  25. package/dist/routes-public.d.ts.map +1 -1
  26. package/dist/routes-public.js +8 -0
  27. package/dist/routes.d.ts +1 -2893
  28. package/dist/routes.d.ts.map +1 -1
  29. package/dist/routes.js +1 -2110
  30. package/dist/schema-core.d.ts.map +1 -1
  31. package/dist/schema-core.js +3 -1
  32. package/dist/schema-items.d.ts +1 -1
  33. package/dist/schema-items.d.ts.map +1 -1
  34. package/dist/schema-items.js +3 -1
  35. package/dist/service-core.d.ts +6240 -0
  36. package/dist/service-core.d.ts.map +1 -0
  37. package/dist/service-core.js +4350 -0
  38. package/dist/service-public-core.d.ts +907 -0
  39. package/dist/service-public-core.d.ts.map +1 -0
  40. package/dist/service-public-core.js +1176 -0
  41. package/dist/service-public.d.ts +1 -906
  42. package/dist/service-public.d.ts.map +1 -1
  43. package/dist/service-public.js +1 -1175
  44. package/dist/service.d.ts +1 -6239
  45. package/dist/service.d.ts.map +1 -1
  46. package/dist/service.js +1 -4304
  47. package/dist/workflows/refund-booking.d.ts.map +1 -1
  48. package/dist/workflows/refund-booking.js +7 -1
  49. package/package.json +6 -6
@@ -1,1175 +1 @@
1
- import { and, asc, desc, eq, inArray, or } from "drizzle-orm";
2
- import { departurePriceOverridesRef, optionPriceRulesRef, optionUnitPriceRulesRef, optionUnitTiersRef, priceCatalogsRef, pricingCategoriesRef, } from "./pricing-ref.js";
3
- import { optionUnitsRef, productOptionsRef, productsRef } from "./products-ref.js";
4
- import { bookingAllocations, bookingDocuments, bookingFulfillments, bookingItems, bookingItemTravelers, bookingSessionStates, bookings, bookingTravelers, } from "./schema.js";
5
- import { bookingsService } from "./service.js";
6
- import { publicBookingSessionTravelerInputSchema } from "./validation-public.js";
7
- const BOOKING_PERSON_SOURCE = "storefront-booking";
8
- async function safeResolveTravelerPerson(db, resolver, participant, bookingId) {
9
- if (!resolver)
10
- return null;
11
- const contact = {
12
- firstName: participant.firstName,
13
- lastName: participant.lastName,
14
- email: participant.email,
15
- phone: participant.phone,
16
- preferredLanguage: participant.preferredLanguage,
17
- };
18
- try {
19
- return await resolver(db, contact, {
20
- bookingId,
21
- source: BOOKING_PERSON_SOURCE,
22
- sourceRef: bookingId,
23
- });
24
- }
25
- catch (error) {
26
- // Person resolution is best-effort — a CRM hiccup must not block
27
- // the actual booking. Log and fall back to `null` so the traveler
28
- // row still lands without a person link.
29
- console.error("[bookings] resolveTravelerPerson failed for booking", bookingId, error);
30
- return null;
31
- }
32
- }
33
- async function safeResolveBillingPerson(db, resolver, contact, bookingId) {
34
- if (!resolver)
35
- return null;
36
- try {
37
- return await resolver(db, contact, {
38
- bookingId,
39
- source: BOOKING_PERSON_SOURCE,
40
- sourceRef: bookingId,
41
- });
42
- }
43
- catch (error) {
44
- console.error("[bookings] resolveBillingPerson failed for booking", bookingId, error);
45
- return null;
46
- }
47
- }
48
- const travelerParticipantTypeValues = ["traveler", "occupant"];
49
- const travelerParticipantTypes = new Set(travelerParticipantTypeValues);
50
- const WIZARD_STATE_KEY = "wizard";
51
- function normalizeDate(value) {
52
- if (!value) {
53
- return null;
54
- }
55
- if (value instanceof Date) {
56
- return value.toISOString().slice(0, 10);
57
- }
58
- return value;
59
- }
60
- function normalizeDateTime(value) {
61
- if (!value) {
62
- return null;
63
- }
64
- return value instanceof Date ? value.toISOString() : value;
65
- }
66
- function getRecord(value) {
67
- return value && typeof value === "object" && !Array.isArray(value)
68
- ? value
69
- : null;
70
- }
71
- function getNestedRecord(record, keys) {
72
- for (const key of keys) {
73
- const value = getRecord(record?.[key]);
74
- if (value) {
75
- return value;
76
- }
77
- }
78
- return null;
79
- }
80
- function getRecordString(record, keys) {
81
- for (const key of keys) {
82
- const value = record?.[key];
83
- if (typeof value === "string" && value.length > 0) {
84
- return value;
85
- }
86
- }
87
- return null;
88
- }
89
- function getBillingPartyType(record) {
90
- const value = getRecordString(record, ["partyType", "type"]);
91
- return value === "individual" || value === "company" ? value : null;
92
- }
93
- function getArray(value) {
94
- return Array.isArray(value) ? value : null;
95
- }
96
- function extractBookingContactFromStatePayload(payload) {
97
- const root = getRecord(payload);
98
- const stepData = getNestedRecord(root, ["stepData", "steps"]);
99
- const billingRecord = getNestedRecord(root, ["billing", "billingContact", "contact"]) ??
100
- getNestedRecord(stepData, ["billing", "billingContact", "contact"]);
101
- const billing = getNestedRecord(billingRecord, ["billing", "contact"]) ?? billingRecord;
102
- if (!billing) {
103
- return null;
104
- }
105
- return {
106
- contactFirstName: getRecordString(billing, ["firstName"]),
107
- contactLastName: getRecordString(billing, ["lastName"]),
108
- contactPartyType: getBillingPartyType(billing),
109
- contactTaxId: getRecordString(billing, ["taxId", "vatNumber", "vatId"]),
110
- contactEmail: getRecordString(billing, ["email"]),
111
- contactPhone: getRecordString(billing, ["phone"]),
112
- contactCountry: getRecordString(billing, ["country"]),
113
- contactRegion: getRecordString(billing, ["state", "region"]),
114
- contactCity: getRecordString(billing, ["city"]),
115
- contactAddressLine1: getRecordString(billing, ["addressLine1", "address1", "line1"]),
116
- contactAddressLine2: getRecordString(billing, ["addressLine2", "address2", "line2"]),
117
- contactPostalCode: getRecordString(billing, ["postalCode", "postal", "zip"]),
118
- };
119
- }
120
- function extractBookingTravelersFromStatePayload(payload) {
121
- const root = getRecord(payload);
122
- const stepData = getNestedRecord(root, ["stepData", "steps"]);
123
- const travelersRecord = getNestedRecord(root, ["travelers"]) ?? getNestedRecord(stepData, ["travelers"]);
124
- const travelers = getArray(travelersRecord?.travelers) ??
125
- getArray(root?.travelers) ??
126
- getArray(stepData?.travelers);
127
- if (!travelers) {
128
- return null;
129
- }
130
- const parsed = publicBookingSessionTravelerInputSchema.array().safeParse(travelers);
131
- return parsed.success ? parsed.data : null;
132
- }
133
- function countTravelerParticipants(participants) {
134
- return participants.filter((participant) => travelerParticipantTypes.has(participant.participantType)).length;
135
- }
136
- async function resolveTravelerPersonForParticipant(db, resolver, participant, bookingId, existingPersonId) {
137
- if (existingPersonId) {
138
- return existingPersonId;
139
- }
140
- return safeResolveTravelerPerson(db, resolver, {
141
- firstName: participant.firstName,
142
- lastName: participant.lastName,
143
- email: participant.email ?? null,
144
- phone: participant.phone ?? null,
145
- preferredLanguage: participant.preferredLanguage ?? null,
146
- }, bookingId);
147
- }
148
- async function syncTravelerRowsFromStatePayload(db, bookingId, payload, resolvers, userId) {
149
- const travelers = extractBookingTravelersFromStatePayload(payload);
150
- if (!travelers) {
151
- return;
152
- }
153
- const existingTravelers = await db
154
- .select()
155
- .from(bookingTravelers)
156
- .where(and(eq(bookingTravelers.bookingId, bookingId), inArray(bookingTravelers.participantType, [...travelerParticipantTypeValues])))
157
- .orderBy(asc(bookingTravelers.createdAt));
158
- const existingById = new Map(existingTravelers.map((traveler) => [traveler.id, traveler]));
159
- const usedExistingIds = new Set();
160
- for (const participant of travelers) {
161
- const matchingExisting = (participant.id ? existingById.get(participant.id) : undefined) ??
162
- (participant.id
163
- ? undefined
164
- : existingTravelers.find((traveler) => !usedExistingIds.has(traveler.id)));
165
- const hasStableContactPoint = Boolean(participant.email?.trim() || participant.phone?.trim());
166
- const reusablePersonId = participant.id || !hasStableContactPoint ? (matchingExisting?.personId ?? null) : null;
167
- const personId = await resolveTravelerPersonForParticipant(db, resolvers.resolveTravelerPerson, participant, bookingId, reusablePersonId);
168
- const travelerRecord = {
169
- participantType: participant.participantType,
170
- travelerCategory: participant.travelerCategory ?? null,
171
- firstName: participant.firstName,
172
- lastName: participant.lastName,
173
- email: participant.email ?? null,
174
- phone: participant.phone ?? null,
175
- preferredLanguage: participant.preferredLanguage ?? null,
176
- specialRequests: participant.specialRequests ?? null,
177
- isPrimary: participant.isPrimary,
178
- notes: participant.notes ?? null,
179
- personId,
180
- };
181
- if (matchingExisting) {
182
- await bookingsService.updateTravelerRecord(db, matchingExisting.id, travelerRecord);
183
- usedExistingIds.add(matchingExisting.id);
184
- continue;
185
- }
186
- const created = await bookingsService.createTravelerRecord(db, bookingId, travelerRecord, userId);
187
- if (created) {
188
- usedExistingIds.add(created.id);
189
- }
190
- }
191
- for (const existing of existingTravelers) {
192
- if (!usedExistingIds.has(existing.id)) {
193
- await bookingsService.deleteTravelerRecord(db, existing.id);
194
- }
195
- }
196
- const travelerCount = countTravelerParticipants(travelers);
197
- await bookingsService.updateBooking(db, bookingId, {
198
- pax: travelerCount > 0 ? travelerCount : null,
199
- });
200
- }
201
- function normalizeSessionState(bookingId, state) {
202
- if (!state) {
203
- return null;
204
- }
205
- return {
206
- sessionId: bookingId,
207
- stateKey: WIZARD_STATE_KEY,
208
- currentStep: state.currentStep ?? null,
209
- completedSteps: state.completedSteps ?? [],
210
- payload: state.payload ?? {},
211
- version: state.version,
212
- createdAt: normalizeDateTime(state.createdAt),
213
- updatedAt: normalizeDateTime(state.updatedAt),
214
- };
215
- }
216
- async function getWizardSessionState(db, bookingId) {
217
- const [state] = await db
218
- .select()
219
- .from(bookingSessionStates)
220
- .where(and(eq(bookingSessionStates.bookingId, bookingId), eq(bookingSessionStates.stateKey, WIZARD_STATE_KEY)))
221
- .limit(1);
222
- return normalizeSessionState(bookingId, state);
223
- }
224
- async function upsertWizardSessionState(db, bookingId, input) {
225
- const [existing] = await db
226
- .select()
227
- .from(bookingSessionStates)
228
- .where(and(eq(bookingSessionStates.bookingId, bookingId), eq(bookingSessionStates.stateKey, WIZARD_STATE_KEY)))
229
- .limit(1);
230
- const payload = input.replacePayload
231
- ? input.payload
232
- : {
233
- ...(existing?.payload ?? {}),
234
- ...input.payload,
235
- };
236
- const completedSteps = input.completedSteps ?? existing?.completedSteps ?? [];
237
- const currentStep = input.currentStep === undefined ? (existing?.currentStep ?? null) : (input.currentStep ?? null);
238
- if (existing) {
239
- const [updated] = await db
240
- .update(bookingSessionStates)
241
- .set({
242
- currentStep,
243
- completedSteps,
244
- payload,
245
- version: existing.version + 1,
246
- updatedAt: new Date(),
247
- })
248
- .where(eq(bookingSessionStates.id, existing.id))
249
- .returning();
250
- return normalizeSessionState(bookingId, updated ?? existing);
251
- }
252
- const [created] = await db
253
- .insert(bookingSessionStates)
254
- .values({
255
- bookingId,
256
- stateKey: WIZARD_STATE_KEY,
257
- currentStep,
258
- completedSteps,
259
- payload,
260
- version: 1,
261
- })
262
- .returning();
263
- return normalizeSessionState(bookingId, created);
264
- }
265
- function resolveTierAmount(tiers, quantity, fallbackAmount) {
266
- const tier = tiers.find((candidate) => quantity >= candidate.minQuantity &&
267
- (candidate.maxQuantity === null || quantity <= candidate.maxQuantity));
268
- return tier?.sellAmountCents ?? fallbackAmount;
269
- }
270
- function computeLineTotal(pricingMode, unitSellAmountCents, quantity, fallbackAmount) {
271
- switch (pricingMode) {
272
- case "free":
273
- case "included":
274
- return 0;
275
- case "on_request":
276
- return null;
277
- case "per_unit":
278
- case "per_person":
279
- return unitSellAmountCents === null ? null : unitSellAmountCents * quantity;
280
- default:
281
- return unitSellAmountCents ?? fallbackAmount;
282
- }
283
- }
284
- async function generateBookingNumber(db) {
285
- const now = new Date();
286
- const y = now.getFullYear().toString().slice(-2);
287
- const m = String(now.getMonth() + 1).padStart(2, "0");
288
- for (let attempt = 0; attempt < 10; attempt += 1) {
289
- const suffix = String(Math.floor(Math.random() * 900000) + 100000);
290
- const bookingNumber = `BK-${y}${m}-${suffix}`;
291
- const [existing] = await db
292
- .select({ id: bookings.id })
293
- .from(bookings)
294
- .where(eq(bookings.bookingNumber, bookingNumber))
295
- .limit(1);
296
- if (!existing) {
297
- return bookingNumber;
298
- }
299
- }
300
- throw new Error("Unable to generate a unique booking number");
301
- }
302
- async function buildOverviewSnapshot(db, query) {
303
- const bookingLookupNumber = query.bookingNumber ?? query.bookingCode ?? null;
304
- const [booking] = await db
305
- .select()
306
- .from(bookings)
307
- .where(query.bookingId
308
- ? eq(bookings.id, query.bookingId)
309
- : bookingLookupNumber
310
- ? eq(bookings.bookingNumber, bookingLookupNumber)
311
- : eq(bookings.id, "__missing__"))
312
- .limit(1);
313
- if (!booking) {
314
- return null;
315
- }
316
- const [participants, items, itemParticipantLinks, documents, fulfillments] = await Promise.all([
317
- db
318
- .select()
319
- .from(bookingTravelers)
320
- .where(eq(bookingTravelers.bookingId, booking.id))
321
- .orderBy(asc(bookingTravelers.createdAt)),
322
- db
323
- .select()
324
- .from(bookingItems)
325
- .where(eq(bookingItems.bookingId, booking.id))
326
- .orderBy(asc(bookingItems.createdAt)),
327
- db
328
- .select({
329
- id: bookingItemTravelers.id,
330
- bookingItemId: bookingItemTravelers.bookingItemId,
331
- travelerId: bookingItemTravelers.travelerId,
332
- role: bookingItemTravelers.role,
333
- isPrimary: bookingItemTravelers.isPrimary,
334
- })
335
- .from(bookingItemTravelers)
336
- .innerJoin(bookingItems, eq(bookingItems.id, bookingItemTravelers.bookingItemId))
337
- .where(eq(bookingItems.bookingId, booking.id))
338
- .orderBy(asc(bookingItemTravelers.createdAt)),
339
- db
340
- .select()
341
- .from(bookingDocuments)
342
- .where(eq(bookingDocuments.bookingId, booking.id))
343
- .orderBy(asc(bookingDocuments.createdAt)),
344
- db
345
- .select()
346
- .from(bookingFulfillments)
347
- .where(eq(bookingFulfillments.bookingId, booking.id))
348
- .orderBy(asc(bookingFulfillments.createdAt)),
349
- ]);
350
- const email = query.email?.trim().toLowerCase() ?? null;
351
- if (email) {
352
- const authorized = participants.some((participant) => constantTimeEqualString(participant.email?.trim().toLowerCase() ?? "", email));
353
- if (!authorized) {
354
- return null;
355
- }
356
- }
357
- const itemLinksByItemId = new Map();
358
- for (const link of itemParticipantLinks) {
359
- const existing = itemLinksByItemId.get(link.bookingItemId) ?? [];
360
- existing.push({
361
- id: link.id,
362
- travelerId: link.travelerId,
363
- role: link.role,
364
- isPrimary: link.isPrimary,
365
- });
366
- itemLinksByItemId.set(link.bookingItemId, existing);
367
- }
368
- return {
369
- bookingId: booking.id,
370
- bookingNumber: booking.bookingNumber,
371
- status: booking.status,
372
- sellCurrency: booking.sellCurrency,
373
- sellAmountCents: booking.sellAmountCents ?? null,
374
- startDate: normalizeDate(booking.startDate),
375
- endDate: normalizeDate(booking.endDate),
376
- pax: booking.pax ?? null,
377
- confirmedAt: normalizeDateTime(booking.confirmedAt),
378
- cancelledAt: normalizeDateTime(booking.cancelledAt),
379
- completedAt: normalizeDateTime(booking.completedAt),
380
- travelers: participants.map((participant) => ({
381
- id: participant.id,
382
- participantType: participant.participantType,
383
- firstName: participant.firstName,
384
- lastName: participant.lastName,
385
- isPrimary: participant.isPrimary,
386
- })),
387
- items: items.map((item) => ({
388
- id: item.id,
389
- title: item.title,
390
- description: item.description ?? null,
391
- itemType: item.itemType,
392
- status: item.status,
393
- serviceDate: normalizeDate(item.serviceDate),
394
- startsAt: normalizeDateTime(item.startsAt),
395
- endsAt: normalizeDateTime(item.endsAt),
396
- quantity: item.quantity,
397
- sellCurrency: item.sellCurrency,
398
- unitSellAmountCents: item.unitSellAmountCents ?? null,
399
- totalSellAmountCents: item.totalSellAmountCents ?? null,
400
- costCurrency: item.costCurrency ?? null,
401
- unitCostAmountCents: item.unitCostAmountCents ?? null,
402
- totalCostAmountCents: item.totalCostAmountCents ?? null,
403
- notes: item.notes ?? null,
404
- productId: item.productId ?? null,
405
- optionId: item.optionId ?? null,
406
- optionUnitId: item.optionUnitId ?? null,
407
- pricingCategoryId: item.pricingCategoryId ?? null,
408
- travelerLinks: itemLinksByItemId.get(item.id) ?? [],
409
- })),
410
- documents: documents.map((document) => ({
411
- id: document.id,
412
- travelerId: document.travelerId ?? null,
413
- type: document.type,
414
- fileName: document.fileName,
415
- fileUrl: document.fileUrl,
416
- })),
417
- fulfillments: fulfillments.map((fulfillment) => ({
418
- id: fulfillment.id,
419
- bookingItemId: fulfillment.bookingItemId ?? null,
420
- travelerId: fulfillment.travelerId ?? null,
421
- fulfillmentType: fulfillment.fulfillmentType,
422
- deliveryChannel: fulfillment.deliveryChannel,
423
- status: fulfillment.status,
424
- artifactUrl: fulfillment.artifactUrl ?? null,
425
- })),
426
- };
427
- }
428
- function constantTimeEqualString(left, right) {
429
- let result = left.length ^ right.length;
430
- const length = Math.max(left.length, right.length);
431
- for (let index = 0; index < length; index += 1) {
432
- result |= (left.charCodeAt(index) || 0) ^ (right.charCodeAt(index) || 0);
433
- }
434
- return result === 0;
435
- }
436
- function buildUnitWarnings(unit, quantity, sessionPax) {
437
- const warnings = [];
438
- if (!unit) {
439
- return warnings;
440
- }
441
- if (unit.minQuantity !== null && quantity < unit.minQuantity) {
442
- warnings.push(`Selected quantity is below the minimum for ${unit.name}.`);
443
- }
444
- if (unit.maxQuantity !== null && quantity > unit.maxQuantity) {
445
- warnings.push(`Selected quantity is above the maximum for ${unit.name}.`);
446
- }
447
- if (sessionPax && unit.occupancyMin !== null && quantity * unit.occupancyMin > sessionPax) {
448
- warnings.push(`Selected ${unit.name} quantity exceeds the current traveler count.`);
449
- }
450
- if (sessionPax && unit.occupancyMax !== null && quantity * unit.occupancyMax < sessionPax) {
451
- warnings.push(`Selected ${unit.name} quantity does not cover the current traveler count.`);
452
- }
453
- return warnings;
454
- }
455
- /**
456
- * Resolves the catalog-scoped pricing snapshot for a product (options → option
457
- * price rules → per-unit price rules → tiers). The snapshot is the same data
458
- * the storefront booking session uses to compute a total — exposing it as a
459
- * standalone admin preview lets operator dialogs, tour-sheet exports, and
460
- * reconciliation flows see the same numbers the customer would see, without
461
- * creating a throwaway session.
462
- *
463
- * Returns `null` when the product isn't publicly visible or there's no active
464
- * catalog / matching option (caller can decide whether to 404 or surface a
465
- * "pricing unavailable for this selection" message).
466
- */
467
- export async function resolveSessionPricingSnapshot(db, productId, input) {
468
- const productConditions = [eq(productsRef.id, productId), eq(productsRef.status, "active")];
469
- if (input.requirePublicProduct ?? true) {
470
- productConditions.push(eq(productsRef.activated, true), eq(productsRef.visibility, "public"));
471
- }
472
- const [product] = await db
473
- .select({
474
- id: productsRef.id,
475
- })
476
- .from(productsRef)
477
- .where(and(...productConditions))
478
- .limit(1);
479
- if (!product) {
480
- return null;
481
- }
482
- const catalog = input.catalogId
483
- ? await db
484
- .select({
485
- id: priceCatalogsRef.id,
486
- currencyCode: priceCatalogsRef.currencyCode,
487
- })
488
- .from(priceCatalogsRef)
489
- .where(and(eq(priceCatalogsRef.id, input.catalogId), eq(priceCatalogsRef.catalogType, "public"), eq(priceCatalogsRef.active, true)))
490
- .limit(1)
491
- .then((rows) => rows[0] ?? null)
492
- : await db
493
- .select({
494
- id: priceCatalogsRef.id,
495
- currencyCode: priceCatalogsRef.currencyCode,
496
- })
497
- .from(priceCatalogsRef)
498
- .where(and(eq(priceCatalogsRef.catalogType, "public"), eq(priceCatalogsRef.active, true)))
499
- .orderBy(desc(priceCatalogsRef.isDefault), asc(priceCatalogsRef.name))
500
- .limit(1)
501
- .then((rows) => rows[0] ?? null);
502
- if (!catalog) {
503
- return null;
504
- }
505
- const optionConditions = [
506
- eq(productOptionsRef.productId, productId),
507
- eq(productOptionsRef.status, "active"),
508
- ];
509
- if (input.optionId) {
510
- optionConditions.push(eq(productOptionsRef.id, input.optionId));
511
- }
512
- const options = await db
513
- .select({
514
- id: productOptionsRef.id,
515
- name: productOptionsRef.name,
516
- isDefault: productOptionsRef.isDefault,
517
- })
518
- .from(productOptionsRef)
519
- .where(and(...optionConditions))
520
- .orderBy(desc(productOptionsRef.isDefault), asc(productOptionsRef.sortOrder));
521
- if (options.length === 0) {
522
- return null;
523
- }
524
- const optionIds = options.map((option) => option.id);
525
- const [rules, unitPrices] = await Promise.all([
526
- db
527
- .select({
528
- id: optionPriceRulesRef.id,
529
- optionId: optionPriceRulesRef.optionId,
530
- pricingMode: optionPriceRulesRef.pricingMode,
531
- baseSellAmountCents: optionPriceRulesRef.baseSellAmountCents,
532
- isDefault: optionPriceRulesRef.isDefault,
533
- })
534
- .from(optionPriceRulesRef)
535
- .where(and(eq(optionPriceRulesRef.productId, productId), inArray(optionPriceRulesRef.optionId, optionIds), eq(optionPriceRulesRef.priceCatalogId, catalog.id), eq(optionPriceRulesRef.active, true)))
536
- .orderBy(desc(optionPriceRulesRef.isDefault), asc(optionPriceRulesRef.name)),
537
- db
538
- .select({
539
- id: optionUnitPriceRulesRef.id,
540
- optionPriceRuleId: optionUnitPriceRulesRef.optionPriceRuleId,
541
- optionId: optionUnitPriceRulesRef.optionId,
542
- unitId: optionUnitPriceRulesRef.unitId,
543
- unitName: optionUnitsRef.name,
544
- unitType: optionUnitsRef.unitType,
545
- occupancyMax: optionUnitsRef.occupancyMax,
546
- pricingCategoryId: optionUnitPriceRulesRef.pricingCategoryId,
547
- pricingMode: optionUnitPriceRulesRef.pricingMode,
548
- sellAmountCents: optionUnitPriceRulesRef.sellAmountCents,
549
- minQuantity: optionUnitPriceRulesRef.minQuantity,
550
- maxQuantity: optionUnitPriceRulesRef.maxQuantity,
551
- })
552
- .from(optionUnitPriceRulesRef)
553
- .innerJoin(optionUnitsRef, eq(optionUnitsRef.id, optionUnitPriceRulesRef.unitId))
554
- .where(and(inArray(optionUnitPriceRulesRef.optionId, optionIds), eq(optionUnitPriceRulesRef.active, true)))
555
- .orderBy(asc(optionUnitPriceRulesRef.sortOrder), asc(optionUnitPriceRulesRef.createdAt)),
556
- ]);
557
- const pricingCategoryIds = Array.from(new Set(unitPrices.flatMap((row) => (row.pricingCategoryId ? [row.pricingCategoryId] : []))));
558
- const [tiers, departureOverrides, pricingCategories] = await Promise.all([
559
- unitPrices.length > 0
560
- ? db
561
- .select({
562
- id: optionUnitTiersRef.id,
563
- optionUnitPriceRuleId: optionUnitTiersRef.optionUnitPriceRuleId,
564
- minQuantity: optionUnitTiersRef.minQuantity,
565
- maxQuantity: optionUnitTiersRef.maxQuantity,
566
- sellAmountCents: optionUnitTiersRef.sellAmountCents,
567
- sortOrder: optionUnitTiersRef.sortOrder,
568
- })
569
- .from(optionUnitTiersRef)
570
- .where(and(inArray(optionUnitTiersRef.optionUnitPriceRuleId, unitPrices.map((row) => row.id)), eq(optionUnitTiersRef.active, true)))
571
- .orderBy(asc(optionUnitTiersRef.sortOrder), asc(optionUnitTiersRef.minQuantity))
572
- : Promise.resolve([]),
573
- input.departureId
574
- ? db
575
- .select({
576
- optionUnitId: departurePriceOverridesRef.optionUnitId,
577
- sellAmountCents: departurePriceOverridesRef.sellAmountCents,
578
- })
579
- .from(departurePriceOverridesRef)
580
- .where(and(eq(departurePriceOverridesRef.departureId, input.departureId), eq(departurePriceOverridesRef.priceCatalogId, catalog.id), eq(departurePriceOverridesRef.active, true)))
581
- : Promise.resolve([]),
582
- pricingCategoryIds.length > 0
583
- ? db
584
- .select({
585
- id: pricingCategoriesRef.id,
586
- name: pricingCategoriesRef.name,
587
- code: pricingCategoriesRef.code,
588
- categoryType: pricingCategoriesRef.categoryType,
589
- minAge: pricingCategoriesRef.minAge,
590
- maxAge: pricingCategoriesRef.maxAge,
591
- metadata: pricingCategoriesRef.metadata,
592
- sortOrder: pricingCategoriesRef.sortOrder,
593
- })
594
- .from(pricingCategoriesRef)
595
- .where(and(inArray(pricingCategoriesRef.id, pricingCategoryIds), eq(pricingCategoriesRef.active, true)))
596
- .orderBy(asc(pricingCategoriesRef.sortOrder), asc(pricingCategoriesRef.name))
597
- : Promise.resolve([]),
598
- ]);
599
- const tiersByUnitPriceId = new Map();
600
- for (const tier of tiers) {
601
- const existing = tiersByUnitPriceId.get(tier.optionUnitPriceRuleId) ?? [];
602
- existing.push(tier);
603
- tiersByUnitPriceId.set(tier.optionUnitPriceRuleId, existing);
604
- }
605
- const departureOverrideByUnitId = new Map(departureOverrides.map((row) => [row.optionUnitId, row]));
606
- return {
607
- catalog: catalog,
608
- options: options,
609
- rules: rules,
610
- pricingCategories: pricingCategories,
611
- unitPrices: unitPrices.map((row) => {
612
- const override = departureOverrideByUnitId.get(row.unitId);
613
- return {
614
- ...row,
615
- sellAmountCents: override?.sellAmountCents ?? row.sellAmountCents,
616
- tiers: override ? [] : (tiersByUnitPriceId.get(row.id) ?? []),
617
- };
618
- }),
619
- };
620
- }
621
- async function buildSessionSnapshot(db, bookingId) {
622
- const [booking, participants, items, allocations, itemParticipantLinks, state] = await Promise.all([
623
- bookingsService.getBookingById(db, bookingId),
624
- db
625
- .select()
626
- .from(bookingTravelers)
627
- .where(eq(bookingTravelers.bookingId, bookingId))
628
- .orderBy(asc(bookingTravelers.createdAt)),
629
- db
630
- .select()
631
- .from(bookingItems)
632
- .where(eq(bookingItems.bookingId, bookingId))
633
- .orderBy(asc(bookingItems.createdAt)),
634
- db
635
- .select()
636
- .from(bookingAllocations)
637
- .where(eq(bookingAllocations.bookingId, bookingId))
638
- .orderBy(asc(bookingAllocations.createdAt)),
639
- db
640
- .select({
641
- id: bookingItemTravelers.id,
642
- bookingItemId: bookingItemTravelers.bookingItemId,
643
- travelerId: bookingItemTravelers.travelerId,
644
- role: bookingItemTravelers.role,
645
- isPrimary: bookingItemTravelers.isPrimary,
646
- })
647
- .from(bookingItemTravelers)
648
- .innerJoin(bookingItems, eq(bookingItems.id, bookingItemTravelers.bookingItemId))
649
- .where(eq(bookingItems.bookingId, bookingId))
650
- .orderBy(asc(bookingItemTravelers.createdAt)),
651
- getWizardSessionState(db, bookingId),
652
- ]);
653
- if (!booking) {
654
- return null;
655
- }
656
- const itemLinksByItemId = new Map();
657
- for (const link of itemParticipantLinks) {
658
- const existing = itemLinksByItemId.get(link.bookingItemId) ?? [];
659
- existing.push({
660
- id: link.id,
661
- travelerId: link.travelerId,
662
- role: link.role,
663
- isPrimary: link.isPrimary,
664
- });
665
- itemLinksByItemId.set(link.bookingItemId, existing);
666
- }
667
- const hasTraveler = countTravelerParticipants(participants) > 0;
668
- const hasTravelers = participants.length > 0;
669
- const hasPrimaryTraveler = participants.some((participant) => participant.isPrimary);
670
- const hasItems = items.length > 0;
671
- const hasAllocations = allocations.length > 0;
672
- return {
673
- sessionId: booking.id,
674
- bookingNumber: booking.bookingNumber,
675
- status: booking.status,
676
- externalBookingRef: booking.externalBookingRef ?? null,
677
- communicationLanguage: booking.communicationLanguage ?? null,
678
- sellCurrency: booking.sellCurrency,
679
- sellAmountCents: booking.sellAmountCents ?? null,
680
- startDate: normalizeDate(booking.startDate),
681
- endDate: normalizeDate(booking.endDate),
682
- pax: booking.pax ?? null,
683
- holdExpiresAt: normalizeDateTime(booking.holdExpiresAt),
684
- confirmedAt: normalizeDateTime(booking.confirmedAt),
685
- expiredAt: normalizeDateTime(booking.expiredAt),
686
- cancelledAt: normalizeDateTime(booking.cancelledAt),
687
- completedAt: normalizeDateTime(booking.completedAt),
688
- travelers: participants.map((participant) => ({
689
- id: participant.id,
690
- participantType: participant.participantType,
691
- travelerCategory: participant.travelerCategory ?? null,
692
- firstName: participant.firstName,
693
- lastName: participant.lastName,
694
- email: participant.email ?? null,
695
- phone: participant.phone ?? null,
696
- preferredLanguage: participant.preferredLanguage ?? null,
697
- specialRequests: participant.specialRequests ?? null,
698
- isPrimary: participant.isPrimary,
699
- notes: participant.notes ?? null,
700
- })),
701
- items: items.map((item) => ({
702
- id: item.id,
703
- title: item.title,
704
- description: item.description ?? null,
705
- itemType: item.itemType,
706
- status: item.status,
707
- serviceDate: normalizeDate(item.serviceDate),
708
- startsAt: normalizeDateTime(item.startsAt),
709
- endsAt: normalizeDateTime(item.endsAt),
710
- quantity: item.quantity,
711
- sellCurrency: item.sellCurrency,
712
- unitSellAmountCents: item.unitSellAmountCents ?? null,
713
- totalSellAmountCents: item.totalSellAmountCents ?? null,
714
- costCurrency: item.costCurrency ?? null,
715
- unitCostAmountCents: item.unitCostAmountCents ?? null,
716
- totalCostAmountCents: item.totalCostAmountCents ?? null,
717
- notes: item.notes ?? null,
718
- productId: item.productId ?? null,
719
- optionId: item.optionId ?? null,
720
- optionUnitId: item.optionUnitId ?? null,
721
- pricingCategoryId: item.pricingCategoryId ?? null,
722
- travelerLinks: itemLinksByItemId.get(item.id) ?? [],
723
- })),
724
- allocations: allocations.map((allocation) => ({
725
- id: allocation.id,
726
- bookingItemId: allocation.bookingItemId ?? null,
727
- productId: allocation.productId ?? null,
728
- optionId: allocation.optionId ?? null,
729
- optionUnitId: allocation.optionUnitId ?? null,
730
- pricingCategoryId: allocation.pricingCategoryId ?? null,
731
- availabilitySlotId: allocation.availabilitySlotId ?? null,
732
- quantity: allocation.quantity,
733
- allocationType: allocation.allocationType,
734
- status: allocation.status,
735
- holdExpiresAt: normalizeDateTime(allocation.holdExpiresAt),
736
- confirmedAt: normalizeDateTime(allocation.confirmedAt),
737
- releasedAt: normalizeDateTime(allocation.releasedAt),
738
- })),
739
- checklist: {
740
- hasTravelers,
741
- hasPrimaryTraveler,
742
- hasItems,
743
- hasAllocations,
744
- readyForConfirmation: booking.status === "on_hold" &&
745
- hasTravelers &&
746
- hasTraveler &&
747
- hasPrimaryTraveler &&
748
- hasItems &&
749
- hasAllocations,
750
- },
751
- state,
752
- };
753
- }
754
- export const publicBookingsService = {
755
- async createSession(db, input, userId, resolvers = {}) {
756
- const travelers = input.travelers ?? [];
757
- const travelerCount = countTravelerParticipants(travelers);
758
- const bookingNumber = await generateBookingNumber(db);
759
- const result = await bookingsService.reserveBooking(db, {
760
- bookingNumber,
761
- sourceType: "direct",
762
- externalBookingRef: input.externalBookingRef ?? null,
763
- communicationLanguage: input.communicationLanguage ?? null,
764
- sellCurrency: input.sellCurrency,
765
- baseCurrency: input.baseCurrency ?? null,
766
- sellAmountCents: input.sellAmountCents ?? null,
767
- baseSellAmountCents: input.baseSellAmountCents ?? null,
768
- costAmountCents: input.costAmountCents ?? null,
769
- baseCostAmountCents: input.baseCostAmountCents ?? null,
770
- marginPercent: input.marginPercent ?? null,
771
- startDate: input.startDate ?? null,
772
- endDate: input.endDate ?? null,
773
- pax: input.pax ?? (travelerCount > 0 ? travelerCount : null),
774
- holdMinutes: input.holdMinutes,
775
- holdExpiresAt: input.holdExpiresAt ?? null,
776
- items: input.items.map((item) => ({
777
- ...item,
778
- sellCurrency: item.sellCurrency ?? input.sellCurrency,
779
- costCurrency: item.costCurrency ?? null,
780
- description: item.description ?? null,
781
- notes: item.notes ?? null,
782
- productId: item.productId ?? null,
783
- optionId: item.optionId ?? null,
784
- optionUnitId: item.optionUnitId ?? null,
785
- pricingCategoryId: item.pricingCategoryId ?? null,
786
- sourceSnapshotId: item.sourceSnapshotId ?? null,
787
- sourceOfferId: null,
788
- metadata: item.metadata ?? null,
789
- })),
790
- internalNotes: null,
791
- }, userId);
792
- if (!("booking" in result) || !result.booking) {
793
- return result;
794
- }
795
- for (const participant of travelers) {
796
- const personId = await safeResolveTravelerPerson(db, resolvers.resolveTravelerPerson, {
797
- firstName: participant.firstName,
798
- lastName: participant.lastName,
799
- email: participant.email ?? null,
800
- phone: participant.phone ?? null,
801
- preferredLanguage: participant.preferredLanguage ?? null,
802
- }, result.booking.id);
803
- await bookingsService.createTravelerRecord(db, result.booking.id, {
804
- participantType: participant.participantType,
805
- travelerCategory: participant.travelerCategory ?? null,
806
- firstName: participant.firstName,
807
- lastName: participant.lastName,
808
- email: participant.email ?? null,
809
- phone: participant.phone ?? null,
810
- preferredLanguage: participant.preferredLanguage ?? null,
811
- specialRequests: participant.specialRequests ?? null,
812
- isPrimary: participant.isPrimary,
813
- notes: participant.notes ?? null,
814
- personId,
815
- }, userId);
816
- }
817
- const session = await buildSessionSnapshot(db, result.booking.id);
818
- return session ? { status: "ok", session } : { status: "not_found" };
819
- },
820
- getSessionById(db, bookingId) {
821
- return buildSessionSnapshot(db, bookingId);
822
- },
823
- async getSessionState(db, bookingId) {
824
- const booking = await bookingsService.getBookingById(db, bookingId);
825
- if (!booking) {
826
- return null;
827
- }
828
- return getWizardSessionState(db, bookingId);
829
- },
830
- async updateSessionState(db, bookingId, input, resolvers = {}, userId) {
831
- const booking = await bookingsService.getBookingById(db, bookingId);
832
- if (!booking) {
833
- return { status: "not_found" };
834
- }
835
- const state = await upsertWizardSessionState(db, bookingId, input);
836
- if (!state) {
837
- return { status: "not_found" };
838
- }
839
- const bookingContact = extractBookingContactFromStatePayload(state.payload);
840
- if (bookingContact) {
841
- // Resolve a CRM person from the billing snapshot when a resolver
842
- // is wired — issue #961 calls out that storefront bookings used
843
- // to land with `person_id = NULL`, leaving every customer outside
844
- // the CRM until each operator wired their own bridge.
845
- const personId = booking.personId
846
- ? booking.personId
847
- : await safeResolveBillingPerson(db, resolvers.resolveBillingPerson, {
848
- firstName: bookingContact.contactFirstName,
849
- lastName: bookingContact.contactLastName,
850
- email: bookingContact.contactEmail,
851
- phone: bookingContact.contactPhone,
852
- preferredLanguage: null,
853
- }, bookingId);
854
- await bookingsService.updateBooking(db, bookingId, {
855
- ...bookingContact,
856
- ...(personId && booking.personId !== personId ? { personId } : {}),
857
- });
858
- }
859
- await syncTravelerRowsFromStatePayload(db, bookingId, state.payload, resolvers, userId);
860
- return { status: "ok", state };
861
- },
862
- async updateSession(db, bookingId, input, userId, resolvers = {}) {
863
- const booking = await bookingsService.getBookingById(db, bookingId);
864
- if (!booking) {
865
- return { status: "not_found" };
866
- }
867
- if (input.externalBookingRef !== undefined ||
868
- input.communicationLanguage !== undefined ||
869
- input.pax !== undefined) {
870
- await bookingsService.updateBooking(db, bookingId, {
871
- externalBookingRef: input.externalBookingRef,
872
- communicationLanguage: input.communicationLanguage,
873
- pax: input.pax,
874
- });
875
- }
876
- const travelers = input.travelers;
877
- const removedTravelerIds = input.removedTravelerIds ?? [];
878
- for (const travelerId of removedTravelerIds) {
879
- const participant = await bookingsService.getTravelerRecordById(db, bookingId, travelerId);
880
- if (participant) {
881
- await bookingsService.deleteTravelerRecord(db, participant.id);
882
- }
883
- }
884
- if (travelers) {
885
- for (const participant of travelers) {
886
- if (participant.id) {
887
- const existing = await bookingsService.getTravelerRecordById(db, bookingId, participant.id);
888
- if (!existing) {
889
- return { status: "participant_not_found" };
890
- }
891
- await bookingsService.updateTravelerRecord(db, participant.id, {
892
- participantType: participant.participantType,
893
- travelerCategory: participant.travelerCategory ?? null,
894
- firstName: participant.firstName,
895
- lastName: participant.lastName,
896
- email: participant.email ?? null,
897
- phone: participant.phone ?? null,
898
- preferredLanguage: participant.preferredLanguage ?? null,
899
- specialRequests: participant.specialRequests ?? null,
900
- isPrimary: participant.isPrimary,
901
- notes: participant.notes ?? null,
902
- });
903
- continue;
904
- }
905
- const personId = await safeResolveTravelerPerson(db, resolvers.resolveTravelerPerson, {
906
- firstName: participant.firstName,
907
- lastName: participant.lastName,
908
- email: participant.email ?? null,
909
- phone: participant.phone ?? null,
910
- preferredLanguage: participant.preferredLanguage ?? null,
911
- }, bookingId);
912
- await bookingsService.createTravelerRecord(db, bookingId, {
913
- participantType: participant.participantType,
914
- travelerCategory: participant.travelerCategory ?? null,
915
- firstName: participant.firstName,
916
- lastName: participant.lastName,
917
- email: participant.email ?? null,
918
- phone: participant.phone ?? null,
919
- preferredLanguage: participant.preferredLanguage ?? null,
920
- specialRequests: participant.specialRequests ?? null,
921
- isPrimary: participant.isPrimary,
922
- notes: participant.notes ?? null,
923
- personId,
924
- }, userId);
925
- }
926
- }
927
- if (input.holdMinutes !== undefined || input.holdExpiresAt !== undefined) {
928
- const holdResult = await bookingsService.extendBookingHold(db, bookingId, {
929
- holdMinutes: input.holdMinutes,
930
- holdExpiresAt: input.holdExpiresAt,
931
- }, userId);
932
- if (holdResult.status !== "ok") {
933
- return holdResult;
934
- }
935
- }
936
- if (input.pax === undefined && (travelers || removedTravelerIds.length > 0)) {
937
- const participants = await db
938
- .select({ participantType: bookingTravelers.participantType })
939
- .from(bookingTravelers)
940
- .where(eq(bookingTravelers.bookingId, bookingId));
941
- const travelerCount = countTravelerParticipants(participants);
942
- await bookingsService.updateBooking(db, bookingId, {
943
- pax: travelerCount > 0 ? travelerCount : null,
944
- });
945
- }
946
- const session = await buildSessionSnapshot(db, bookingId);
947
- return session ? { status: "ok", session } : { status: "not_found" };
948
- },
949
- async repriceSession(db, bookingId, input) {
950
- const [booking, items] = await Promise.all([
951
- bookingsService.getBookingById(db, bookingId),
952
- db
953
- .select()
954
- .from(bookingItems)
955
- .where(eq(bookingItems.bookingId, bookingId))
956
- .orderBy(asc(bookingItems.createdAt)),
957
- ]);
958
- if (!booking) {
959
- return { status: "not_found" };
960
- }
961
- const selectedItemIds = input.selections.map((selection) => selection.itemId);
962
- const itemById = new Map(items.map((item) => [item.id, item]));
963
- for (const selection of input.selections) {
964
- if (!itemById.has(selection.itemId)) {
965
- return { status: "invalid_selection" };
966
- }
967
- }
968
- const requestedUnitIds = Array.from(new Set(input.selections
969
- .map((selection) => selection.optionUnitId)
970
- .filter((value) => Boolean(value))));
971
- const requestedUnits = requestedUnitIds.length > 0
972
- ? await db.select().from(optionUnitsRef).where(inArray(optionUnitsRef.id, requestedUnitIds))
973
- : [];
974
- const requestedUnitById = new Map(requestedUnits.map((unit) => [unit.id, unit]));
975
- const pricingWarnings = [];
976
- const pricedItems = [];
977
- let resolvedCatalogId = input.catalogId ?? null;
978
- let resolvedCurrency = booking.sellCurrency;
979
- for (const selection of input.selections) {
980
- const item = itemById.get(selection.itemId);
981
- if (!item?.productId) {
982
- return { status: "invalid_selection" };
983
- }
984
- const optionId = selection.optionId === undefined
985
- ? (item.optionId ?? undefined)
986
- : (selection.optionId ?? undefined);
987
- const quantity = selection.quantity ?? item.quantity;
988
- const pricingCategoryId = selection.pricingCategoryId === undefined
989
- ? (item.pricingCategoryId ?? null)
990
- : (selection.pricingCategoryId ?? null);
991
- const selectedUnitId = selection.optionUnitId === undefined
992
- ? (item.optionUnitId ?? null)
993
- : (selection.optionUnitId ?? null);
994
- const snapshot = await resolveSessionPricingSnapshot(db, item.productId, {
995
- catalogId: input.catalogId,
996
- departureId: item.availabilitySlotId ?? undefined,
997
- optionId,
998
- });
999
- if (!snapshot) {
1000
- return { status: "pricing_unavailable" };
1001
- }
1002
- resolvedCatalogId = snapshot.catalog.id;
1003
- resolvedCurrency = snapshot.catalog.currencyCode ?? booking.sellCurrency;
1004
- const option = snapshot.options.find((candidate) => candidate.id === optionId) ??
1005
- snapshot.options[0] ??
1006
- null;
1007
- if (!option) {
1008
- return { status: "pricing_unavailable" };
1009
- }
1010
- const rule = snapshot.rules.find((candidate) => candidate.optionId === option.id && candidate.isDefault) ??
1011
- snapshot.rules.find((candidate) => candidate.optionId === option.id) ??
1012
- null;
1013
- if (!rule) {
1014
- return { status: "pricing_unavailable" };
1015
- }
1016
- const ruleUnitPrices = snapshot.unitPrices.filter((candidate) => candidate.optionPriceRuleId === rule.id);
1017
- const unitPriceCandidates = ruleUnitPrices.filter((candidate) => {
1018
- if (selectedUnitId && candidate.unitId !== selectedUnitId) {
1019
- return false;
1020
- }
1021
- if (pricingCategoryId && candidate.pricingCategoryId !== pricingCategoryId) {
1022
- return false;
1023
- }
1024
- if (candidate.minQuantity !== null && quantity < candidate.minQuantity) {
1025
- return false;
1026
- }
1027
- if (candidate.maxQuantity !== null && quantity > candidate.maxQuantity) {
1028
- return false;
1029
- }
1030
- return true;
1031
- });
1032
- const fallbackUnitPrice = !pricingCategoryId && !selectedUnitId
1033
- ? (ruleUnitPrices.find((candidate) => candidate.pricingCategoryId === null &&
1034
- (candidate.minQuantity === null || quantity >= candidate.minQuantity) &&
1035
- (candidate.maxQuantity === null || quantity <= candidate.maxQuantity)) ?? null)
1036
- : null;
1037
- const unitPrice = unitPriceCandidates[0] ?? fallbackUnitPrice;
1038
- if ((selectedUnitId || ruleUnitPrices.length > 0) &&
1039
- !unitPrice &&
1040
- rule.pricingMode !== "per_booking") {
1041
- return { status: "pricing_unavailable" };
1042
- }
1043
- const unit = selectedUnitId ? (requestedUnitById.get(selectedUnitId) ?? null) : null;
1044
- const unitSellAmountCents = unitPrice
1045
- ? resolveTierAmount(unitPrice.tiers, quantity, unitPrice.sellAmountCents)
1046
- : rule.baseSellAmountCents;
1047
- const pricingMode = unitPrice?.pricingMode ?? rule.pricingMode;
1048
- const totalSellAmountCents = computeLineTotal(pricingMode, unitSellAmountCents, quantity, rule.baseSellAmountCents);
1049
- const warnings = buildUnitWarnings(unit, quantity, booking.pax ?? null);
1050
- if (selectedUnitId && !unit) {
1051
- warnings.push("Selected room/unit metadata is not available in the current catalog.");
1052
- }
1053
- if (pricingMode === "on_request") {
1054
- warnings.push("Selected option requires manual pricing confirmation.");
1055
- }
1056
- pricingWarnings.push(...warnings);
1057
- pricedItems.push({
1058
- itemId: item.id,
1059
- title: item.title,
1060
- productId: item.productId ?? null,
1061
- optionId: option.id,
1062
- optionUnitId: selectedUnitId,
1063
- optionUnitName: unit?.name ?? unitPrice?.unitName ?? null,
1064
- optionUnitType: unit?.unitType ?? unitPrice?.unitType ?? null,
1065
- pricingCategoryId,
1066
- quantity,
1067
- pricingMode,
1068
- unitSellAmountCents,
1069
- totalSellAmountCents,
1070
- warnings,
1071
- });
1072
- }
1073
- const totalSellAmountCents = items.reduce((total, item) => {
1074
- const repriced = pricedItems.find((candidate) => candidate.itemId === item.id);
1075
- return total + (repriced?.totalSellAmountCents ?? item.totalSellAmountCents ?? 0);
1076
- }, 0);
1077
- let session = null;
1078
- if (input.applyToSession) {
1079
- const activeAllocations = selectedItemIds.length > 0
1080
- ? await db
1081
- .select()
1082
- .from(bookingAllocations)
1083
- .where(and(eq(bookingAllocations.bookingId, bookingId), inArray(bookingAllocations.bookingItemId, selectedItemIds), or(eq(bookingAllocations.status, "held"), eq(bookingAllocations.status, "confirmed"))))
1084
- : [];
1085
- const activeAllocationsByItemId = new Map();
1086
- for (const allocation of activeAllocations) {
1087
- const existing = activeAllocationsByItemId.get(allocation.bookingItemId) ?? [];
1088
- existing.push(allocation);
1089
- activeAllocationsByItemId.set(allocation.bookingItemId, existing);
1090
- }
1091
- const quantityChangedWithActiveAllocation = pricedItems.some((pricedItem) => {
1092
- const item = itemById.get(pricedItem.itemId);
1093
- return Boolean(item &&
1094
- item.quantity !== pricedItem.quantity &&
1095
- (activeAllocationsByItemId.get(pricedItem.itemId)?.length ?? 0) > 0);
1096
- });
1097
- if (quantityChangedWithActiveAllocation) {
1098
- return { status: "quantity_change_requires_reallocation" };
1099
- }
1100
- await db.transaction(async (tx) => {
1101
- for (const pricedItem of pricedItems) {
1102
- await tx
1103
- .update(bookingItems)
1104
- .set({
1105
- optionId: pricedItem.optionId,
1106
- optionUnitId: pricedItem.optionUnitId,
1107
- pricingCategoryId: pricedItem.pricingCategoryId,
1108
- quantity: pricedItem.quantity,
1109
- sellCurrency: resolvedCurrency,
1110
- unitSellAmountCents: pricedItem.unitSellAmountCents,
1111
- totalSellAmountCents: pricedItem.totalSellAmountCents,
1112
- updatedAt: new Date(),
1113
- })
1114
- .where(eq(bookingItems.id, pricedItem.itemId));
1115
- await tx
1116
- .update(bookingAllocations)
1117
- .set({
1118
- optionId: pricedItem.optionId,
1119
- optionUnitId: pricedItem.optionUnitId,
1120
- pricingCategoryId: pricedItem.pricingCategoryId,
1121
- updatedAt: new Date(),
1122
- })
1123
- .where(and(eq(bookingAllocations.bookingId, bookingId), eq(bookingAllocations.bookingItemId, pricedItem.itemId), or(eq(bookingAllocations.status, "held"), eq(bookingAllocations.status, "confirmed"))));
1124
- }
1125
- await tx
1126
- .update(bookings)
1127
- .set({
1128
- sellCurrency: resolvedCurrency,
1129
- sellAmountCents: totalSellAmountCents,
1130
- updatedAt: new Date(),
1131
- })
1132
- .where(eq(bookings.id, bookingId));
1133
- });
1134
- session = await buildSessionSnapshot(db, bookingId);
1135
- }
1136
- return {
1137
- status: "ok",
1138
- pricing: {
1139
- sessionId: bookingId,
1140
- catalogId: resolvedCatalogId,
1141
- currencyCode: resolvedCurrency,
1142
- totalSellAmountCents,
1143
- items: pricedItems,
1144
- warnings: Array.from(new Set(pricingWarnings)),
1145
- appliedToSession: input.applyToSession,
1146
- },
1147
- session,
1148
- };
1149
- },
1150
- async confirmSession(db, bookingId, input, userId) {
1151
- const result = await bookingsService.confirmBooking(db, bookingId, input, userId);
1152
- if (result.status !== "ok") {
1153
- return result;
1154
- }
1155
- const session = await buildSessionSnapshot(db, bookingId);
1156
- return session ? { status: "ok", session } : { status: "not_found" };
1157
- },
1158
- async expireSession(db, bookingId, input, userId, runtime = {}) {
1159
- const result = await bookingsService.expireBooking(db, bookingId, input, userId, runtime);
1160
- if (result.status !== "ok") {
1161
- return result;
1162
- }
1163
- const session = await buildSessionSnapshot(db, bookingId);
1164
- return session ? { status: "ok", session } : { status: "not_found" };
1165
- },
1166
- async getOverview(db, query) {
1167
- return buildOverviewSnapshot(db, query);
1168
- },
1169
- async getOverviewByGuestAccess(db, query) {
1170
- return buildOverviewSnapshot(db, query);
1171
- },
1172
- async getOverviewByLookup(db, query) {
1173
- return buildOverviewSnapshot(db, query);
1174
- },
1175
- };
1
+ export * from "./service-public-core.js";