@voyantjs/bookings 0.119.2 → 0.119.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/pricing-assignment/age.d.ts +33 -0
  2. package/dist/pricing-assignment/age.d.ts.map +1 -0
  3. package/dist/pricing-assignment/age.js +109 -0
  4. package/dist/pricing-assignment/draft.d.ts +69 -0
  5. package/dist/pricing-assignment/draft.d.ts.map +1 -0
  6. package/dist/pricing-assignment/draft.js +284 -0
  7. package/dist/pricing-assignment/types.d.ts +113 -0
  8. package/dist/pricing-assignment/types.d.ts.map +1 -0
  9. package/dist/pricing-assignment/types.js +30 -0
  10. package/dist/pricing-assignment/unit-helpers.d.ts +11 -0
  11. package/dist/pricing-assignment/unit-helpers.d.ts.map +1 -0
  12. package/dist/pricing-assignment/unit-helpers.js +19 -0
  13. package/dist/pricing-assignment/verify.d.ts +67 -0
  14. package/dist/pricing-assignment/verify.d.ts.map +1 -0
  15. package/dist/pricing-assignment/verify.js +121 -0
  16. package/dist/pricing-assignment.d.ts +4 -275
  17. package/dist/pricing-assignment.d.ts.map +1 -1
  18. package/dist/pricing-assignment.js +4 -559
  19. package/dist/routes-admin.d.ts +2894 -0
  20. package/dist/routes-admin.d.ts.map +1 -0
  21. package/dist/routes-admin.js +2111 -0
  22. package/dist/routes.d.ts +1 -2893
  23. package/dist/routes.d.ts.map +1 -1
  24. package/dist/routes.js +1 -2111
  25. package/dist/service-core.d.ts +6240 -0
  26. package/dist/service-core.d.ts.map +1 -0
  27. package/dist/service-core.js +4350 -0
  28. package/dist/service-public-core.d.ts +907 -0
  29. package/dist/service-public-core.d.ts.map +1 -0
  30. package/dist/service-public-core.js +1176 -0
  31. package/dist/service-public.d.ts +1 -906
  32. package/dist/service-public.d.ts.map +1 -1
  33. package/dist/service-public.js +1 -1176
  34. package/dist/service.d.ts +1 -6239
  35. package/dist/service.d.ts.map +1 -1
  36. package/dist/service.js +1 -4350
  37. package/package.json +5 -5
@@ -1,559 +1,4 @@
1
- // agent-quality: file-size exception -- owner: bookings; existing module stays co-located until a dedicated split preserves behavior and tests.
2
- /**
3
- * Pure, transport-agnostic logic for mapping travelers onto option_units
4
- * at booking-create time. Lives in `@voyantjs/bookings` so the
5
- * booking-create dialog (preview + submit) is the only call site today,
6
- * but the server can import the same module to validate or re-resolve
7
- * submit payloads in a follow-up — that wiring is not yet in place.
8
- *
9
- * Vocabulary:
10
- * - A **pricing tier** (`unit_type='person'`) is a per-pax price band
11
- * a traveler is billed as (Adult / Child 6-12 / Infant 0-5 / …).
12
- * - An **inventory unit** (`'room' | 'vehicle' | 'seat'`) is a finite
13
- * container a traveler is placed into (one DBL room holds 2 pax).
14
- * - "Person-priced options" are options that only have pricing tiers
15
- * (no inventory). Excursions. For these, line-item quantities
16
- * **derive from the traveler list** (1 adult + 1 child + 1 infant).
17
- * - "Accommodation options" have inventory units (and usually a
18
- * paired person unit for per-pax fees). For these, line-item
19
- * quantities **stay as the operator picked them** (1 DBL room is
20
- * still 1 line, not 2).
21
- *
22
- * The `pricingUnitSource` and `inventoryUnitSource` enums on the
23
- * traveler track operator intent so the resolver knows when to
24
- * re-derive ("auto") versus respect an explicit choice ("manual" /
25
- * "none" for No room).
26
- *
27
- * No React, no DB, no HTTP — just the assignment math.
28
- *
29
- * Tracking: voyantjs/voyant#1267.
30
- */
31
- /**
32
- * Compute integer age in full years from an ISO date-of-birth string.
33
- * Returns null when the DOB is missing or unparseable.
34
- */
35
- export function computeAgeYears(dob, now = new Date()) {
36
- if (!dob)
37
- return null;
38
- const birth = new Date(dob);
39
- if (Number.isNaN(birth.getTime()))
40
- return null;
41
- let age = now.getFullYear() - birth.getFullYear();
42
- const beforeBirthday = now.getMonth() < birth.getMonth() ||
43
- (now.getMonth() === birth.getMonth() && now.getDate() < birth.getDate());
44
- if (beforeBirthday)
45
- age -= 1;
46
- return age >= 0 ? age : null;
47
- }
48
- /**
49
- * Derive the age-banded traveler category from DOB + role hint.
50
- * DOB-derived age wins; role hint is the fallback for travelers
51
- * added before the operator filled in a birthday.
52
- */
53
- export function deriveDraftPaxBand(traveler, now = new Date()) {
54
- const age = computeAgeYears(traveler.dateOfBirth, now);
55
- if (age == null) {
56
- return traveler.role === "adult" || traveler.role === "child" || traveler.role === "infant"
57
- ? traveler.role
58
- : null;
59
- }
60
- if (age < 2)
61
- return "infant";
62
- if (age < 18)
63
- return "child";
64
- return "adult";
65
- }
66
- /**
67
- * Pick the unit for a traveler pricing band. Priorities:
68
- * 1. DOB-derived age that falls into a unit's `[minAge, maxAge]`
69
- * 2. Role hint mapped to a representative age (infant→1, child→8,
70
- * adult→30) matched against bands. Works for products coded
71
- * `child_0_5` / `child_6_12` (not just literal `INFANT`/`CHILD`).
72
- * 3. Code/name matching for legacy products with no min/max set.
73
- */
74
- export function pickUnitForAge(units, age, roleHint = null) {
75
- if (units.length === 0)
76
- return undefined;
77
- const personUnits = units.filter((u) => u.unitType == null || u.unitType === "person");
78
- const pool = personUnits.length > 0 ? personUnits : units;
79
- const sorted = [...pool].sort((a, b) => (a.minAge ?? 0) - (b.minAge ?? 0));
80
- const matchByAge = (target) => sorted.find((u) => (u.minAge == null || target >= u.minAge) && (u.maxAge == null || target <= u.maxAge));
81
- if (age != null) {
82
- const match = matchByAge(age);
83
- if (match)
84
- return match;
85
- }
86
- if (roleHint) {
87
- const HINT_AGE = { adult: 30, child: 8, infant: 1 };
88
- const hintAge = HINT_AGE[roleHint];
89
- // Only consider units with at least one explicit age bound.
90
- // Without this, legacy units with null min/max (just bare
91
- // ADULT/CHILD codes) would all match every hint age and collapse
92
- // onto the first sorted entry. Code-matching below handles those.
93
- const banded = sorted.filter((u) => u.minAge != null || u.maxAge != null);
94
- const match = banded.find((u) => (u.minAge == null || hintAge >= u.minAge) && (u.maxAge == null || hintAge <= u.maxAge));
95
- if (match)
96
- return match;
97
- }
98
- const findByCode = (code) => sorted.find((u) => (u.unitCode ?? "").toUpperCase() === code) ??
99
- sorted.find((u) => new RegExp(`\\b${code}\\b`, "i").test(u.unitName));
100
- if (roleHint === "child")
101
- return findByCode("CHILD") ?? sorted[0];
102
- if (roleHint === "infant")
103
- return findByCode("INFANT") ?? sorted[0];
104
- return findByCode("ADULT") ?? sorted[sorted.length - 1] ?? sorted[0];
105
- }
106
- /**
107
- * Find the unit whose `[minAge, maxAge]` contains the DOB-derived
108
- * age. Used by the UI to pre-pick a unit when a traveler attaches.
109
- */
110
- export function matchUnitByDob(units, dob) {
111
- if (!dob)
112
- return null;
113
- const age = computeAgeYears(dob);
114
- if (age == null)
115
- return null;
116
- const personUnits = units.filter((u) => u.unitType == null || u.unitType === "person");
117
- const match = personUnits.find((u) => (u.minAge == null || age >= u.minAge) && (u.maxAge == null || age <= u.maxAge));
118
- return match?.optionUnitId ?? null;
119
- }
120
- /**
121
- * Find the unit matching a role hint when DOB is missing. Same
122
- * HINT_AGE mapping as `pickUnitForAge`. Returns null when the role
123
- * carries no age signal (e.g. `lead`).
124
- */
125
- export function matchUnitByRoleHint(units, role) {
126
- if (!role || role === "lead")
127
- return null;
128
- const HINT_AGE = {
129
- adult: 30,
130
- child: 8,
131
- infant: 1,
132
- };
133
- const hintAge = HINT_AGE[role];
134
- if (hintAge == null)
135
- return null;
136
- const banded = units.filter((u) => (u.unitType == null || u.unitType === "person") && (u.minAge != null || u.maxAge != null));
137
- const match = banded.find((u) => (u.minAge == null || hintAge >= u.minAge) && (u.maxAge == null || hintAge <= u.maxAge));
138
- return match?.optionUnitId ?? null;
139
- }
140
- function optionKey(unit) {
141
- return unit.optionId ?? unit.optionUnitId;
142
- }
143
- function isPersonUnit(unit) {
144
- return unit.unitType == null || unit.unitType === "person";
145
- }
146
- /**
147
- * Inventory units are finite containers a traveler is placed into.
148
- * Rooms (hotels), vehicles (transfers), seats (where modeled
149
- * explicitly). Distinct from pricing tiers (`person`).
150
- */
151
- function isInventoryUnit(unit) {
152
- return unit.unitType === "room" || unit.unitType === "vehicle";
153
- }
154
- function roleHintForTraveler(traveler) {
155
- return traveler.role === "adult" || traveler.role === "child" || traveler.role === "infant"
156
- ? traveler.role
157
- : null;
158
- }
159
- /**
160
- * Resolve a booking-create draft into the per-unit quantities and
161
- * per-traveler unit assignments the submit pipeline + price preview
162
- * both need. Single source of truth for traveler→unit mapping.
163
- *
164
- * Shape branching:
165
- * - **Person-priced options** (only `person` units, no `room`):
166
- * quantities are derived from traveler assignments (1 adult + 1
167
- * child + 1 infant = 3 line items, not "3 x Adult"). Auto-source
168
- * travelers are re-derived against the current option's bands.
169
- * - **Accommodation options** (any `room` unit): operator-picked
170
- * stepper quantities are preserved (1 DBL room is 1 line). Per-
171
- * traveler room assignments still update `travelerIndexesByUnitId`
172
- * so `booking_item_travelers` rows can be created.
173
- *
174
- * Respects assignment sources:
175
- * - `none` → the corresponding field stays null
176
- * - `manual` → the existing unit is kept when valid for the chosen option
177
- * - `auto` → re-derived against current options
178
- */
179
- export function resolveBookingDraft(options) {
180
- const { quantities, travelers, now = new Date() } = options;
181
- const units = [...options.units];
182
- if (units.length === 0) {
183
- return { quantities, travelers: [...travelers], travelerIndexesByUnitId: {} };
184
- }
185
- const unitsByOption = new Map();
186
- const unitById = new Map();
187
- const unitToOption = new Map();
188
- for (const unit of units) {
189
- const key = optionKey(unit);
190
- unitById.set(unit.optionUnitId, unit);
191
- unitToOption.set(unit.optionUnitId, key);
192
- const list = unitsByOption.get(key);
193
- if (list)
194
- list.push(unit);
195
- else
196
- unitsByOption.set(key, [unit]);
197
- }
198
- const primaryInventoryByOption = new Map();
199
- for (const [key, optionUnits] of unitsByOption) {
200
- const inventoryUnit = optionUnits.find(isInventoryUnit);
201
- if (inventoryUnit)
202
- primaryInventoryByOption.set(key, inventoryUnit);
203
- }
204
- // An option is "person-priced" when it has at least one person unit
205
- // and no inventory unit (room/vehicle). That's the excursion shape:
206
- // line quantities derive from travelers, not from the stepper's
207
- // primary-unit count. Multi-day packages with both a room AND a
208
- // person-typed adult fee fall outside this set — the room
209
- // quantity passes through unchanged.
210
- const personPricedOptions = new Set();
211
- for (const [key, optionUnits] of unitsByOption) {
212
- const hasPerson = optionUnits.some(isPersonUnit);
213
- const hasInventory = optionUnits.some(isInventoryUnit);
214
- if (hasPerson && !hasInventory)
215
- personPricedOptions.add(key);
216
- }
217
- const totalByOption = new Map();
218
- for (const [unitId, quantity] of Object.entries(quantities)) {
219
- if (quantity <= 0)
220
- continue;
221
- const key = unitToOption.get(unitId);
222
- if (!key)
223
- continue;
224
- totalByOption.set(key, (totalByOption.get(key) ?? 0) + quantity);
225
- }
226
- const assignedForDefaulting = new Map();
227
- for (const traveler of travelers) {
228
- const inventorySource = traveler.inventoryUnitSource ?? "auto";
229
- const pricingSource = traveler.pricingUnitSource ?? "auto";
230
- const assignedUnitId = inventorySource !== "none" && traveler.inventoryUnitId
231
- ? traveler.inventoryUnitId
232
- : pricingSource !== "none"
233
- ? traveler.pricingUnitId
234
- : null;
235
- if (!assignedUnitId)
236
- continue;
237
- const key = unitToOption.get(assignedUnitId);
238
- if (!key)
239
- continue;
240
- assignedForDefaulting.set(key, (assignedForDefaulting.get(key) ?? 0) + 1);
241
- }
242
- const optionDemand = Array.from(totalByOption.entries());
243
- const pickOptionWithDemand = () => optionDemand.find(([candidate, total]) => (assignedForDefaulting.get(candidate) ?? 0) < total)?.[0] ?? optionDemand[0]?.[0];
244
- const resolvePricingUnitForTraveler = (traveler, key) => pickUnitForAge((unitsByOption.get(key) ?? []).filter(isPersonUnit), computeAgeYears(traveler.dateOfBirth, now), roleHintForTraveler(traveler));
245
- const resolveTargetOption = (traveler) => {
246
- const inventorySource = traveler.inventoryUnitSource ?? "auto";
247
- if (inventorySource !== "none" && traveler.inventoryUnitId) {
248
- const inventoryUnit = unitById.get(traveler.inventoryUnitId);
249
- const key = unitToOption.get(traveler.inventoryUnitId);
250
- if (key && inventoryUnit && isInventoryUnit(inventoryUnit))
251
- return { key, fromDemand: false };
252
- }
253
- const pricingSource = traveler.pricingUnitSource ?? "auto";
254
- if (pricingSource !== "none" && traveler.pricingUnitId) {
255
- const pricingUnit = unitById.get(traveler.pricingUnitId);
256
- const key = unitToOption.get(traveler.pricingUnitId);
257
- if (key && pricingUnit && isPersonUnit(pricingUnit))
258
- return { key, fromDemand: false };
259
- }
260
- const key = pickOptionWithDemand();
261
- return key ? { key, fromDemand: true } : null;
262
- };
263
- const nextTravelers = travelers.map((traveler) => {
264
- const target = resolveTargetOption(traveler);
265
- const pricingSource = traveler.pricingUnitSource ?? "auto";
266
- const inventorySource = traveler.inventoryUnitSource ?? "auto";
267
- if (!target) {
268
- return {
269
- ...traveler,
270
- pricingUnitId: null,
271
- inventoryUnitId: null,
272
- pricingUnitSource: pricingSource === "none" ? "none" : "auto",
273
- inventoryUnitSource: inventorySource === "none" ? "none" : "auto",
274
- };
275
- }
276
- const targetKey = target.key;
277
- const currentPricingUnit = traveler.pricingUnitId
278
- ? unitById.get(traveler.pricingUnitId)
279
- : undefined;
280
- const currentPricingKey = traveler.pricingUnitId
281
- ? unitToOption.get(traveler.pricingUnitId)
282
- : undefined;
283
- const keepManualPricing = pricingSource === "manual" &&
284
- currentPricingUnit &&
285
- isPersonUnit(currentPricingUnit) &&
286
- currentPricingKey === targetKey;
287
- const nextPricingUnit = pricingSource === "none"
288
- ? null
289
- : keepManualPricing
290
- ? currentPricingUnit
291
- : (resolvePricingUnitForTraveler(traveler, targetKey) ?? null);
292
- const currentInventoryUnit = traveler.inventoryUnitId
293
- ? unitById.get(traveler.inventoryUnitId)
294
- : undefined;
295
- const currentInventoryKey = traveler.inventoryUnitId
296
- ? unitToOption.get(traveler.inventoryUnitId)
297
- : undefined;
298
- const keepManualInventory = inventorySource === "manual" &&
299
- currentInventoryUnit &&
300
- isInventoryUnit(currentInventoryUnit) &&
301
- currentInventoryKey === targetKey;
302
- const targetInventoryUnit = primaryInventoryByOption.get(targetKey) ?? null;
303
- const nextInventoryUnit = inventorySource === "none"
304
- ? null
305
- : keepManualInventory
306
- ? currentInventoryUnit
307
- : targetInventoryUnit;
308
- if (target.fromDemand && (nextPricingUnit || nextInventoryUnit)) {
309
- assignedForDefaulting.set(targetKey, (assignedForDefaulting.get(targetKey) ?? 0) + 1);
310
- }
311
- return {
312
- ...traveler,
313
- pricingUnitId: nextPricingUnit?.optionUnitId ?? null,
314
- inventoryUnitId: nextInventoryUnit?.optionUnitId ?? null,
315
- pricingUnitSource: pricingSource === "none" ? "none" : keepManualPricing ? "manual" : "auto",
316
- inventoryUnitSource: inventorySource === "none" ? "none" : keepManualInventory ? "manual" : "auto",
317
- };
318
- });
319
- const next = {};
320
- const travelerIndexesByUnitId = {};
321
- // Accommodation quantities pass through unchanged — 1 DBL room
322
- // stays 1 line. Person-priced quantities are rebuilt from traveler
323
- // assignments below.
324
- for (const [unitId, quantity] of Object.entries(quantities)) {
325
- if (quantity <= 0)
326
- continue;
327
- const key = unitToOption.get(unitId);
328
- if (!key || personPricedOptions.has(key))
329
- continue;
330
- const submittedUnit = unitById.get(unitId);
331
- const targetUnitId = submittedUnit && isInventoryUnit(submittedUnit)
332
- ? unitId
333
- : (primaryInventoryByOption.get(key)?.optionUnitId ?? unitId);
334
- next[targetUnitId] = (next[targetUnitId] ?? 0) + quantity;
335
- }
336
- const assignedByOption = new Map();
337
- for (const [index, traveler] of nextTravelers.entries()) {
338
- const pricingSource = traveler.pricingUnitSource ?? "auto";
339
- const inventorySource = traveler.inventoryUnitSource ?? "auto";
340
- const pricingKey = pricingSource !== "none" && traveler.pricingUnitId
341
- ? unitToOption.get(traveler.pricingUnitId)
342
- : undefined;
343
- const inventoryKey = inventorySource !== "none" && traveler.inventoryUnitId
344
- ? unitToOption.get(traveler.inventoryUnitId)
345
- : undefined;
346
- const key = inventoryKey ?? pricingKey;
347
- if (!key)
348
- continue;
349
- if (personPricedOptions.has(key)) {
350
- if (!traveler.pricingUnitId || pricingSource === "none")
351
- continue;
352
- const unitIndexes = travelerIndexesByUnitId[traveler.pricingUnitId] ?? [];
353
- unitIndexes.push(index);
354
- travelerIndexesByUnitId[traveler.pricingUnitId] = unitIndexes;
355
- next[traveler.pricingUnitId] = (next[traveler.pricingUnitId] ?? 0) + 1;
356
- assignedByOption.set(key, (assignedByOption.get(key) ?? 0) + 1);
357
- }
358
- else if (traveler.inventoryUnitId && inventorySource !== "none") {
359
- const unitIndexes = travelerIndexesByUnitId[traveler.inventoryUnitId] ?? [];
360
- unitIndexes.push(index);
361
- travelerIndexesByUnitId[traveler.inventoryUnitId] = unitIndexes;
362
- }
363
- }
364
- // Person-priced residual: operator picked N seats but only added M
365
- // travelers (M < N) → put the leftover on the option's adult unit
366
- // so the line-item total matches the stepper.
367
- for (const [key, total] of totalByOption) {
368
- if (!personPricedOptions.has(key))
369
- continue;
370
- const assigned = assignedByOption.get(key) ?? 0;
371
- const residual = Math.max(0, total - assigned);
372
- if (residual === 0)
373
- continue;
374
- const adult = pickUnitForAge(unitsByOption.get(key) ?? [], null, "adult");
375
- if (!adult)
376
- continue;
377
- next[adult.optionUnitId] = (next[adult.optionUnitId] ?? 0) + residual;
378
- }
379
- return { quantities: next, travelers: nextTravelers, travelerIndexesByUnitId };
380
- }
381
- /**
382
- * Normalize per-person extras to charged traveler quantity and
383
- * stamp traveler links + `clientLineKey` so the server can link
384
- * each extra line to the travelers it applies to via
385
- * `booking_item_travelers`.
386
- *
387
- * Per-person mode (`pricingMode === "per_person"` or
388
- * `pricedPerPerson === true`): quantity multiplied by travelerCount,
389
- * `travelerKeys` set to all travelers when stable keys are supplied;
390
- * otherwise `travelerIndexes` is set as a deprecated fallback.
391
- * Non-per-person lines pass through with `clientLineKey` only.
392
- */
393
- export function resolveBookingExtraLines(options) {
394
- const travelerIndexes = Array.from({ length: options.travelerCount }, (_, index) => index);
395
- const travelerKeys = (options.travelerKeys ?? []).filter((key) => typeof key === "string" && key.trim().length > 0);
396
- const useTravelerKeys = travelerKeys.length === options.travelerCount;
397
- return options.extraLines.map((line) => {
398
- const perPerson = line.pricingMode === "per_person" || line.pricedPerPerson === true;
399
- if (!perPerson) {
400
- return {
401
- ...line,
402
- clientLineKey: line.clientLineKey ?? `extra:${line.productExtraId}`,
403
- };
404
- }
405
- const quantity = Math.max(1, options.travelerCount) * line.quantity;
406
- return {
407
- ...line,
408
- clientLineKey: line.clientLineKey ?? `extra:${line.productExtraId}`,
409
- quantity,
410
- totalSellAmountCents: line.unitSellAmountCents == null
411
- ? line.totalSellAmountCents
412
- : line.unitSellAmountCents * quantity,
413
- ...(useTravelerKeys ? { travelerKeys } : { travelerIndexes }),
414
- };
415
- });
416
- }
417
- /**
418
- * Project a resolved draft's traveler list into the wire-format
419
- * `BookingCreateTravelerInput[]` shape the dialog submits. Derives
420
- * the `travelerCategory` from DOB / role.
421
- *
422
- * `roomUnitId` is a deprecated compatibility alias for the pricing tier
423
- * option unit. Inventory placement is expressed only by item lines and their
424
- * `travelerKeys`; the server accepts this field but does not persist it.
425
- */
426
- export function travelersToRows(value, now = new Date()) {
427
- return value.travelers.map((traveler) => ({
428
- clientTravelerKey: traveler.clientTravelerKey?.trim() || null,
429
- personId: traveler.personId,
430
- firstName: traveler.firstName.trim(),
431
- lastName: traveler.lastName.trim(),
432
- email: traveler.email.trim() || null,
433
- phone: traveler.phone.trim() || null,
434
- preferredLanguage: traveler.preferredLanguage.trim() || null,
435
- participantType: "traveler",
436
- travelerCategory: deriveDraftPaxBand(traveler, now),
437
- isPrimary: traveler.role === "lead",
438
- roomUnitId: traveler.pricingUnitSource === "none" ? null : traveler.pricingUnitId,
439
- }));
440
- }
441
- /**
442
- * Re-derive what `resolveBookingDraft` would have produced from the
443
- * submitted wire-format payload and compare against the submitted
444
- * itemLines. Used server-side as a sanity check on the client's
445
- * draft resolution. Returns `ok: true` when the submitted lines
446
- * match the resolver, else lists per-unit mismatches.
447
- *
448
- * Behavior of this helper is *non-rejecting* — callers decide
449
- * whether to log, warn, or fail. The orchestrator currently logs;
450
- * a follow-up will flip to rejection once observability confirms
451
- * no legitimate clients trip the warning.
452
- *
453
- * Notes on the reconstruction:
454
- * - The wire format doesn't carry split per-traveler assignment
455
- * fields (the join-table model encodes them through
456
- * `itemLines[].travelerKeys`), so we reconstruct the relevant
457
- * pricing or inventory unit by walking item lines and matching
458
- * them to traveler keys. Deprecated `travelerIndexes` remain a
459
- * fallback for older clients.
460
- * - DOB doesn't round-trip on the wire today either; we feed the
461
- * resolver the `travelerCategory` as a role hint so age-banded
462
- * options can still be re-derived.
463
- */
464
- export function verifyBookingDraft(input) {
465
- if (input.itemLines.length === 0 || input.units.length === 0) {
466
- return { ok: true, mismatches: [] };
467
- }
468
- // Walk the submitted itemLines to recover the per-traveler unit
469
- // assignment the client intended. Prefer stable `travelerKeys`
470
- // when present; fall back to deprecated `travelerIndexes` for
471
- // legacy clients. If neither is present, we can't verify
472
- // per-traveler assignment — just compare aggregate quantities.
473
- // Booking-create schema validation rejects unknown traveler keys
474
- // before this verifier runs.
475
- const unitById = new Map(input.units.map((unit) => [unit.optionUnitId, unit]));
476
- const travelerIndexByKey = new Map();
477
- for (const [index, traveler] of input.travelers.entries()) {
478
- const key = traveler.clientTravelerKey?.trim();
479
- if (key && !travelerIndexByKey.has(key))
480
- travelerIndexByKey.set(key, index);
481
- }
482
- const unitByTravelerIndex = new Map();
483
- for (const line of input.itemLines) {
484
- const travelerKeys = (line.travelerKeys ?? []).filter((key) => key.trim().length > 0);
485
- const keyedIndexes = travelerKeys
486
- .map((key) => travelerIndexByKey.get(key.trim()))
487
- .filter((index) => typeof index === "number");
488
- const indexes = travelerKeys.length > 0 ? keyedIndexes : (line.travelerIndexes ?? []);
489
- for (const idx of indexes) {
490
- unitByTravelerIndex.set(idx, line.optionUnitId);
491
- }
492
- }
493
- const travelerDrafts = input.travelers.map((t, i) => {
494
- const cat = t.travelerCategory;
495
- const role = t.isPrimary === true
496
- ? "lead"
497
- : cat === "child" || cat === "infant" || cat === "adult"
498
- ? cat
499
- : "adult";
500
- const assigned = unitByTravelerIndex.get(i);
501
- const assignedUnit = assigned ? unitById.get(assigned) : undefined;
502
- return {
503
- personId: null,
504
- firstName: "",
505
- lastName: "",
506
- email: "",
507
- phone: "",
508
- preferredLanguage: "",
509
- role,
510
- dateOfBirth: null,
511
- // We deliberately use `auto` (not `manual`) here even though
512
- // the client effectively committed to an assignment when it
513
- // serialized the line. The point of verification is to ask
514
- // "would a fresh resolver, given the same role/category
515
- // signals, produce these quantities?" — i.e. allow the
516
- // resolver to re-derive instead of treating the submitted
517
- // value as ground truth. Otherwise the day-tour bug shape
518
- // (3 travelers all manually frozen to Adult) would round-trip
519
- // through the verifier unchanged.
520
- pricingUnitId: assigned && assignedUnit && isPersonUnit(assignedUnit) ? assigned : null,
521
- inventoryUnitId: assigned && assignedUnit && isInventoryUnit(assignedUnit) ? assigned : null,
522
- pricingUnitSource: "auto",
523
- inventoryUnitSource: "auto",
524
- };
525
- });
526
- // Aggregate submitted quantities for resolver input. For
527
- // accommodation options the resolver passes these through
528
- // unchanged; for person-priced options it rebuilds them from
529
- // traveler assignments.
530
- const submittedQuantities = {};
531
- for (const line of input.itemLines) {
532
- submittedQuantities[line.optionUnitId] =
533
- (submittedQuantities[line.optionUnitId] ?? 0) + line.quantity;
534
- }
535
- const resolved = resolveBookingDraft({
536
- quantities: submittedQuantities,
537
- travelers: travelerDrafts,
538
- units: input.units,
539
- });
540
- const mismatches = [];
541
- const allUnitIds = new Set([
542
- ...Object.keys(submittedQuantities),
543
- ...Object.keys(resolved.quantities),
544
- ]);
545
- for (const unitId of allUnitIds) {
546
- const submitted = submittedQuantities[unitId] ?? 0;
547
- const resolvedQty = resolved.quantities[unitId] ?? 0;
548
- if (submitted === resolvedQty)
549
- continue;
550
- const kind = submitted === 0 ? "missing" : resolvedQty === 0 ? "extra" : "qty";
551
- mismatches.push({
552
- kind,
553
- optionUnitId: unitId,
554
- submittedQuantity: submitted,
555
- resolvedQuantity: resolvedQty,
556
- });
557
- }
558
- return { ok: mismatches.length === 0, mismatches };
559
- }
1
+ export * from "./pricing-assignment/age.js";
2
+ export * from "./pricing-assignment/draft.js";
3
+ export * from "./pricing-assignment/types.js";
4
+ export * from "./pricing-assignment/verify.js";