@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
@@ -0,0 +1,33 @@
1
+ import type { AssignmentRoleHint, BookingDraftTraveler, PricingAssignmentUnit, TravelerRole } from "./types.js";
2
+ /**
3
+ * Compute integer age in full years from an ISO date-of-birth string.
4
+ * Returns null when the DOB is missing or unparseable.
5
+ */
6
+ export declare function computeAgeYears(dob: string | null, now?: Date): number | null;
7
+ /**
8
+ * Derive the age-banded traveler category from DOB + role hint.
9
+ * DOB-derived age wins; role hint is the fallback for travelers
10
+ * added before the operator filled in a birthday.
11
+ */
12
+ export declare function deriveDraftPaxBand(traveler: Pick<BookingDraftTraveler, "dateOfBirth" | "role">, now?: Date): "adult" | "child" | "infant" | null;
13
+ /**
14
+ * Pick the unit for a traveler pricing band. Priorities:
15
+ * 1. DOB-derived age that falls into a unit's `[minAge, maxAge]`
16
+ * 2. Role hint mapped to a representative age (infant→1, child→8,
17
+ * adult→30) matched against bands. Works for products coded
18
+ * `child_0_5` / `child_6_12` (not just literal `INFANT`/`CHILD`).
19
+ * 3. Code/name matching for legacy products with no min/max set.
20
+ */
21
+ export declare function pickUnitForAge<TUnit extends PricingAssignmentUnit>(units: ReadonlyArray<TUnit>, age: number | null, roleHint?: AssignmentRoleHint | null): TUnit | undefined;
22
+ /**
23
+ * Find the unit whose `[minAge, maxAge]` contains the DOB-derived
24
+ * age. Used by the UI to pre-pick a unit when a traveler attaches.
25
+ */
26
+ export declare function matchUnitByDob<TUnit extends PricingAssignmentUnit>(units: ReadonlyArray<TUnit>, dob: string | null): string | null;
27
+ /**
28
+ * Find the unit matching a role hint when DOB is missing. Same
29
+ * HINT_AGE mapping as `pickUnitForAge`. Returns null when the role
30
+ * carries no age signal (e.g. `lead`).
31
+ */
32
+ export declare function matchUnitByRoleHint<TUnit extends PricingAssignmentUnit>(units: ReadonlyArray<TUnit>, role: TravelerRole | null): string | null;
33
+ //# sourceMappingURL=age.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"age.d.ts","sourceRoot":"","sources":["../../src/pricing-assignment/age.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,kBAAkB,EAClB,oBAAoB,EACpB,qBAAqB,EACrB,YAAY,EACb,MAAM,YAAY,CAAA;AAEnB;;;GAGG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,EAAE,GAAG,GAAE,IAAiB,GAAG,MAAM,GAAG,IAAI,CAUzF;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAChC,QAAQ,EAAE,IAAI,CAAC,oBAAoB,EAAE,aAAa,GAAG,MAAM,CAAC,EAC5D,GAAG,GAAE,IAAiB,GACrB,OAAO,GAAG,OAAO,GAAG,QAAQ,GAAG,IAAI,CAUrC;AAED;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,KAAK,SAAS,qBAAqB,EAChE,KAAK,EAAE,aAAa,CAAC,KAAK,CAAC,EAC3B,GAAG,EAAE,MAAM,GAAG,IAAI,EAClB,QAAQ,GAAE,kBAAkB,GAAG,IAAW,GACzC,KAAK,GAAG,SAAS,CAoCnB;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAAC,KAAK,SAAS,qBAAqB,EAChE,KAAK,EAAE,aAAa,CAAC,KAAK,CAAC,EAC3B,GAAG,EAAE,MAAM,GAAG,IAAI,GACjB,MAAM,GAAG,IAAI,CASf;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,SAAS,qBAAqB,EACrE,KAAK,EAAE,aAAa,CAAC,KAAK,CAAC,EAC3B,IAAI,EAAE,YAAY,GAAG,IAAI,GACxB,MAAM,GAAG,IAAI,CAiBf"}
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Compute integer age in full years from an ISO date-of-birth string.
3
+ * Returns null when the DOB is missing or unparseable.
4
+ */
5
+ export function computeAgeYears(dob, now = new Date()) {
6
+ if (!dob)
7
+ return null;
8
+ const birth = new Date(dob);
9
+ if (Number.isNaN(birth.getTime()))
10
+ return null;
11
+ let age = now.getFullYear() - birth.getFullYear();
12
+ const beforeBirthday = now.getMonth() < birth.getMonth() ||
13
+ (now.getMonth() === birth.getMonth() && now.getDate() < birth.getDate());
14
+ if (beforeBirthday)
15
+ age -= 1;
16
+ return age >= 0 ? age : null;
17
+ }
18
+ /**
19
+ * Derive the age-banded traveler category from DOB + role hint.
20
+ * DOB-derived age wins; role hint is the fallback for travelers
21
+ * added before the operator filled in a birthday.
22
+ */
23
+ export function deriveDraftPaxBand(traveler, now = new Date()) {
24
+ const age = computeAgeYears(traveler.dateOfBirth, now);
25
+ if (age == null) {
26
+ return traveler.role === "adult" || traveler.role === "child" || traveler.role === "infant"
27
+ ? traveler.role
28
+ : null;
29
+ }
30
+ if (age < 2)
31
+ return "infant";
32
+ if (age < 18)
33
+ return "child";
34
+ return "adult";
35
+ }
36
+ /**
37
+ * Pick the unit for a traveler pricing band. Priorities:
38
+ * 1. DOB-derived age that falls into a unit's `[minAge, maxAge]`
39
+ * 2. Role hint mapped to a representative age (infant→1, child→8,
40
+ * adult→30) matched against bands. Works for products coded
41
+ * `child_0_5` / `child_6_12` (not just literal `INFANT`/`CHILD`).
42
+ * 3. Code/name matching for legacy products with no min/max set.
43
+ */
44
+ export function pickUnitForAge(units, age, roleHint = null) {
45
+ if (units.length === 0)
46
+ return undefined;
47
+ const personUnits = units.filter((u) => u.unitType == null || u.unitType === "person");
48
+ const pool = personUnits.length > 0 ? personUnits : units;
49
+ const sorted = [...pool].sort((a, b) => (a.minAge ?? 0) - (b.minAge ?? 0));
50
+ const matchByAge = (target) => sorted.find((u) => (u.minAge == null || target >= u.minAge) && (u.maxAge == null || target <= u.maxAge));
51
+ if (age != null) {
52
+ const match = matchByAge(age);
53
+ if (match)
54
+ return match;
55
+ }
56
+ if (roleHint) {
57
+ const HINT_AGE = { adult: 30, child: 8, infant: 1 };
58
+ const hintAge = HINT_AGE[roleHint];
59
+ // Only consider units with at least one explicit age bound.
60
+ // Without this, legacy units with null min/max (just bare
61
+ // ADULT/CHILD codes) would all match every hint age and collapse
62
+ // onto the first sorted entry. Code-matching below handles those.
63
+ const banded = sorted.filter((u) => u.minAge != null || u.maxAge != null);
64
+ const match = banded.find((u) => (u.minAge == null || hintAge >= u.minAge) && (u.maxAge == null || hintAge <= u.maxAge));
65
+ if (match)
66
+ return match;
67
+ }
68
+ const findByCode = (code) => sorted.find((u) => (u.unitCode ?? "").toUpperCase() === code) ??
69
+ sorted.find((u) => new RegExp(`\\b${code}\\b`, "i").test(u.unitName));
70
+ if (roleHint === "child")
71
+ return findByCode("CHILD") ?? sorted[0];
72
+ if (roleHint === "infant")
73
+ return findByCode("INFANT") ?? sorted[0];
74
+ return findByCode("ADULT") ?? sorted[sorted.length - 1] ?? sorted[0];
75
+ }
76
+ /**
77
+ * Find the unit whose `[minAge, maxAge]` contains the DOB-derived
78
+ * age. Used by the UI to pre-pick a unit when a traveler attaches.
79
+ */
80
+ export function matchUnitByDob(units, dob) {
81
+ if (!dob)
82
+ return null;
83
+ const age = computeAgeYears(dob);
84
+ if (age == null)
85
+ return null;
86
+ const personUnits = units.filter((u) => u.unitType == null || u.unitType === "person");
87
+ const match = personUnits.find((u) => (u.minAge == null || age >= u.minAge) && (u.maxAge == null || age <= u.maxAge));
88
+ return match?.optionUnitId ?? null;
89
+ }
90
+ /**
91
+ * Find the unit matching a role hint when DOB is missing. Same
92
+ * HINT_AGE mapping as `pickUnitForAge`. Returns null when the role
93
+ * carries no age signal (e.g. `lead`).
94
+ */
95
+ export function matchUnitByRoleHint(units, role) {
96
+ if (!role || role === "lead")
97
+ return null;
98
+ const HINT_AGE = {
99
+ adult: 30,
100
+ child: 8,
101
+ infant: 1,
102
+ };
103
+ const hintAge = HINT_AGE[role];
104
+ if (hintAge == null)
105
+ return null;
106
+ const banded = units.filter((u) => (u.unitType == null || u.unitType === "person") && (u.minAge != null || u.maxAge != null));
107
+ const match = banded.find((u) => (u.minAge == null || hintAge >= u.minAge) && (u.maxAge == null || hintAge <= u.maxAge));
108
+ return match?.optionUnitId ?? null;
109
+ }
@@ -0,0 +1,69 @@
1
+ import type { BookingDraftQuantities, BookingDraftTraveler, PricingAssignmentUnit, ResolvableExtraLine, ResolvedBookingDraft } from "./types.js";
2
+ /**
3
+ * Resolve a booking-create draft into the per-unit quantities and
4
+ * per-traveler unit assignments the submit pipeline + price preview
5
+ * both need. Single source of truth for traveler→unit mapping.
6
+ *
7
+ * Shape branching:
8
+ * - **Person-priced options** (only `person` units, no `room`):
9
+ * quantities are derived from traveler assignments (1 adult + 1
10
+ * child + 1 infant = 3 line items, not "3 x Adult"). Auto-source
11
+ * travelers are re-derived against the current option's bands.
12
+ * - **Accommodation options** (any `room` unit): operator-picked
13
+ * stepper quantities are preserved (1 DBL room is 1 line). Per-
14
+ * traveler room assignments still update `travelerIndexesByUnitId`
15
+ * so `booking_item_travelers` rows can be created.
16
+ *
17
+ * Respects assignment sources:
18
+ * - `none` → the corresponding field stays null
19
+ * - `manual` → the existing unit is kept when valid for the chosen option
20
+ * - `auto` → re-derived against current options
21
+ */
22
+ export declare function resolveBookingDraft<TTraveler extends BookingDraftTraveler>(options: {
23
+ quantities: BookingDraftQuantities;
24
+ travelers: readonly TTraveler[];
25
+ units: readonly PricingAssignmentUnit[];
26
+ now?: Date;
27
+ }): ResolvedBookingDraft<TTraveler>;
28
+ /**
29
+ * Normalize per-person extras to charged traveler quantity and
30
+ * stamp traveler links + `clientLineKey` so the server can link
31
+ * each extra line to the travelers it applies to via
32
+ * `booking_item_travelers`.
33
+ *
34
+ * Per-person mode (`pricingMode === "per_person"` or
35
+ * `pricedPerPerson === true`): quantity multiplied by travelerCount,
36
+ * `travelerKeys` set to all travelers when stable keys are supplied;
37
+ * otherwise `travelerIndexes` is set as a deprecated fallback.
38
+ * Non-per-person lines pass through with `clientLineKey` only.
39
+ */
40
+ export declare function resolveBookingExtraLines<TLine extends ResolvableExtraLine>(options: {
41
+ extraLines: readonly TLine[];
42
+ travelerCount: number;
43
+ travelerKeys?: readonly (string | null | undefined)[];
44
+ }): TLine[];
45
+ /**
46
+ * Project a resolved draft's traveler list into the wire-format
47
+ * `BookingCreateTravelerInput[]` shape the dialog submits. Derives
48
+ * the `travelerCategory` from DOB / role.
49
+ *
50
+ * `roomUnitId` is a deprecated compatibility alias for the pricing tier
51
+ * option unit. Inventory placement is expressed only by item lines and their
52
+ * `travelerKeys`; the server accepts this field but does not persist it.
53
+ */
54
+ export declare function travelersToRows(value: {
55
+ travelers: readonly BookingDraftTraveler[];
56
+ }, now?: Date): Array<{
57
+ clientTravelerKey: string | null;
58
+ personId: string | null;
59
+ firstName: string;
60
+ lastName: string;
61
+ email: string | null;
62
+ phone: string | null;
63
+ preferredLanguage: string | null;
64
+ participantType: "traveler";
65
+ travelerCategory: "adult" | "child" | "infant" | null;
66
+ isPrimary: boolean;
67
+ roomUnitId: string | null;
68
+ }>;
69
+ //# sourceMappingURL=draft.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"draft.d.ts","sourceRoot":"","sources":["../../src/pricing-assignment/draft.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,sBAAsB,EACtB,oBAAoB,EACpB,qBAAqB,EACrB,mBAAmB,EACnB,oBAAoB,EACrB,MAAM,YAAY,CAAA;AAInB;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,mBAAmB,CAAC,SAAS,SAAS,oBAAoB,EAAE,OAAO,EAAE;IACnF,UAAU,EAAE,sBAAsB,CAAA;IAClC,SAAS,EAAE,SAAS,SAAS,EAAE,CAAA;IAC/B,KAAK,EAAE,SAAS,qBAAqB,EAAE,CAAA;IACvC,GAAG,CAAC,EAAE,IAAI,CAAA;CACX,GAAG,oBAAoB,CAAC,SAAS,CAAC,CAkOlC;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,wBAAwB,CAAC,KAAK,SAAS,mBAAmB,EAAE,OAAO,EAAE;IACnF,UAAU,EAAE,SAAS,KAAK,EAAE,CAAA;IAC5B,aAAa,EAAE,MAAM,CAAA;IACrB,YAAY,CAAC,EAAE,SAAS,CAAC,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC,EAAE,CAAA;CACtD,GAAG,KAAK,EAAE,CA0BV;AAED;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAC7B,KAAK,EAAE;IAAE,SAAS,EAAE,SAAS,oBAAoB,EAAE,CAAA;CAAE,EACrD,GAAG,GAAE,IAAiB,GACrB,KAAK,CAAC;IACP,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAA;IAChC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB,SAAS,EAAE,MAAM,CAAA;IACjB,QAAQ,EAAE,MAAM,CAAA;IAChB,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;IACpB,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;IACpB,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAA;IAChC,eAAe,EAAE,UAAU,CAAA;IAC3B,gBAAgB,EAAE,OAAO,GAAG,OAAO,GAAG,QAAQ,GAAG,IAAI,CAAA;IACrD,SAAS,EAAE,OAAO,CAAA;IAClB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;CAC1B,CAAC,CAcD"}
@@ -0,0 +1,284 @@
1
+ import { computeAgeYears, deriveDraftPaxBand, pickUnitForAge } from "./age.js";
2
+ import { isInventoryUnit, isPersonUnit, optionKey, roleHintForTraveler } from "./unit-helpers.js";
3
+ /**
4
+ * Resolve a booking-create draft into the per-unit quantities and
5
+ * per-traveler unit assignments the submit pipeline + price preview
6
+ * both need. Single source of truth for traveler→unit mapping.
7
+ *
8
+ * Shape branching:
9
+ * - **Person-priced options** (only `person` units, no `room`):
10
+ * quantities are derived from traveler assignments (1 adult + 1
11
+ * child + 1 infant = 3 line items, not "3 x Adult"). Auto-source
12
+ * travelers are re-derived against the current option's bands.
13
+ * - **Accommodation options** (any `room` unit): operator-picked
14
+ * stepper quantities are preserved (1 DBL room is 1 line). Per-
15
+ * traveler room assignments still update `travelerIndexesByUnitId`
16
+ * so `booking_item_travelers` rows can be created.
17
+ *
18
+ * Respects assignment sources:
19
+ * - `none` → the corresponding field stays null
20
+ * - `manual` → the existing unit is kept when valid for the chosen option
21
+ * - `auto` → re-derived against current options
22
+ */
23
+ export function resolveBookingDraft(options) {
24
+ const { quantities, travelers, now = new Date() } = options;
25
+ const units = [...options.units];
26
+ if (units.length === 0) {
27
+ return { quantities, travelers: [...travelers], travelerIndexesByUnitId: {} };
28
+ }
29
+ const unitsByOption = new Map();
30
+ const unitById = new Map();
31
+ const unitToOption = new Map();
32
+ for (const unit of units) {
33
+ const key = optionKey(unit);
34
+ unitById.set(unit.optionUnitId, unit);
35
+ unitToOption.set(unit.optionUnitId, key);
36
+ const list = unitsByOption.get(key);
37
+ if (list)
38
+ list.push(unit);
39
+ else
40
+ unitsByOption.set(key, [unit]);
41
+ }
42
+ const primaryInventoryByOption = new Map();
43
+ for (const [key, optionUnits] of unitsByOption) {
44
+ const inventoryUnit = optionUnits.find(isInventoryUnit);
45
+ if (inventoryUnit)
46
+ primaryInventoryByOption.set(key, inventoryUnit);
47
+ }
48
+ // An option is "person-priced" when it has at least one person unit
49
+ // and no inventory unit (room/vehicle). That's the excursion shape:
50
+ // line quantities derive from travelers, not from the stepper's
51
+ // primary-unit count. Multi-day packages with both a room AND a
52
+ // person-typed adult fee fall outside this set — the room
53
+ // quantity passes through unchanged.
54
+ const personPricedOptions = new Set();
55
+ for (const [key, optionUnits] of unitsByOption) {
56
+ const hasPerson = optionUnits.some(isPersonUnit);
57
+ const hasInventory = optionUnits.some(isInventoryUnit);
58
+ if (hasPerson && !hasInventory)
59
+ personPricedOptions.add(key);
60
+ }
61
+ const totalByOption = new Map();
62
+ for (const [unitId, quantity] of Object.entries(quantities)) {
63
+ if (quantity <= 0)
64
+ continue;
65
+ const key = unitToOption.get(unitId);
66
+ if (!key)
67
+ continue;
68
+ totalByOption.set(key, (totalByOption.get(key) ?? 0) + quantity);
69
+ }
70
+ const assignedForDefaulting = new Map();
71
+ for (const traveler of travelers) {
72
+ const inventorySource = traveler.inventoryUnitSource ?? "auto";
73
+ const pricingSource = traveler.pricingUnitSource ?? "auto";
74
+ const assignedUnitId = inventorySource !== "none" && traveler.inventoryUnitId
75
+ ? traveler.inventoryUnitId
76
+ : pricingSource !== "none"
77
+ ? traveler.pricingUnitId
78
+ : null;
79
+ if (!assignedUnitId)
80
+ continue;
81
+ const key = unitToOption.get(assignedUnitId);
82
+ if (!key)
83
+ continue;
84
+ assignedForDefaulting.set(key, (assignedForDefaulting.get(key) ?? 0) + 1);
85
+ }
86
+ const optionDemand = Array.from(totalByOption.entries());
87
+ const pickOptionWithDemand = () => optionDemand.find(([candidate, total]) => (assignedForDefaulting.get(candidate) ?? 0) < total)?.[0] ?? optionDemand[0]?.[0];
88
+ const resolvePricingUnitForTraveler = (traveler, key) => pickUnitForAge((unitsByOption.get(key) ?? []).filter(isPersonUnit), computeAgeYears(traveler.dateOfBirth, now), roleHintForTraveler(traveler));
89
+ const resolveTargetOption = (traveler) => {
90
+ const inventorySource = traveler.inventoryUnitSource ?? "auto";
91
+ if (inventorySource !== "none" && traveler.inventoryUnitId) {
92
+ const inventoryUnit = unitById.get(traveler.inventoryUnitId);
93
+ const key = unitToOption.get(traveler.inventoryUnitId);
94
+ if (key && inventoryUnit && isInventoryUnit(inventoryUnit))
95
+ return { key, fromDemand: false };
96
+ }
97
+ const pricingSource = traveler.pricingUnitSource ?? "auto";
98
+ if (pricingSource !== "none" && traveler.pricingUnitId) {
99
+ const pricingUnit = unitById.get(traveler.pricingUnitId);
100
+ const key = unitToOption.get(traveler.pricingUnitId);
101
+ if (key && pricingUnit && isPersonUnit(pricingUnit))
102
+ return { key, fromDemand: false };
103
+ }
104
+ const key = pickOptionWithDemand();
105
+ return key ? { key, fromDemand: true } : null;
106
+ };
107
+ const nextTravelers = travelers.map((traveler) => {
108
+ const target = resolveTargetOption(traveler);
109
+ const pricingSource = traveler.pricingUnitSource ?? "auto";
110
+ const inventorySource = traveler.inventoryUnitSource ?? "auto";
111
+ if (!target) {
112
+ return {
113
+ ...traveler,
114
+ pricingUnitId: null,
115
+ inventoryUnitId: null,
116
+ pricingUnitSource: pricingSource === "none" ? "none" : "auto",
117
+ inventoryUnitSource: inventorySource === "none" ? "none" : "auto",
118
+ };
119
+ }
120
+ const targetKey = target.key;
121
+ const currentPricingUnit = traveler.pricingUnitId
122
+ ? unitById.get(traveler.pricingUnitId)
123
+ : undefined;
124
+ const currentPricingKey = traveler.pricingUnitId
125
+ ? unitToOption.get(traveler.pricingUnitId)
126
+ : undefined;
127
+ const keepManualPricing = pricingSource === "manual" &&
128
+ currentPricingUnit &&
129
+ isPersonUnit(currentPricingUnit) &&
130
+ currentPricingKey === targetKey;
131
+ const nextPricingUnit = pricingSource === "none"
132
+ ? null
133
+ : keepManualPricing
134
+ ? currentPricingUnit
135
+ : (resolvePricingUnitForTraveler(traveler, targetKey) ?? null);
136
+ const currentInventoryUnit = traveler.inventoryUnitId
137
+ ? unitById.get(traveler.inventoryUnitId)
138
+ : undefined;
139
+ const currentInventoryKey = traveler.inventoryUnitId
140
+ ? unitToOption.get(traveler.inventoryUnitId)
141
+ : undefined;
142
+ const keepManualInventory = inventorySource === "manual" &&
143
+ currentInventoryUnit &&
144
+ isInventoryUnit(currentInventoryUnit) &&
145
+ currentInventoryKey === targetKey;
146
+ const targetInventoryUnit = primaryInventoryByOption.get(targetKey) ?? null;
147
+ const nextInventoryUnit = inventorySource === "none"
148
+ ? null
149
+ : keepManualInventory
150
+ ? currentInventoryUnit
151
+ : targetInventoryUnit;
152
+ if (target.fromDemand && (nextPricingUnit || nextInventoryUnit)) {
153
+ assignedForDefaulting.set(targetKey, (assignedForDefaulting.get(targetKey) ?? 0) + 1);
154
+ }
155
+ return {
156
+ ...traveler,
157
+ pricingUnitId: nextPricingUnit?.optionUnitId ?? null,
158
+ inventoryUnitId: nextInventoryUnit?.optionUnitId ?? null,
159
+ pricingUnitSource: pricingSource === "none" ? "none" : keepManualPricing ? "manual" : "auto",
160
+ inventoryUnitSource: inventorySource === "none" ? "none" : keepManualInventory ? "manual" : "auto",
161
+ };
162
+ });
163
+ const next = {};
164
+ const travelerIndexesByUnitId = {};
165
+ // Accommodation quantities pass through unchanged — 1 DBL room
166
+ // stays 1 line. Person-priced quantities are rebuilt from traveler
167
+ // assignments below.
168
+ for (const [unitId, quantity] of Object.entries(quantities)) {
169
+ if (quantity <= 0)
170
+ continue;
171
+ const key = unitToOption.get(unitId);
172
+ if (!key || personPricedOptions.has(key))
173
+ continue;
174
+ const submittedUnit = unitById.get(unitId);
175
+ const targetUnitId = submittedUnit && isInventoryUnit(submittedUnit)
176
+ ? unitId
177
+ : (primaryInventoryByOption.get(key)?.optionUnitId ?? unitId);
178
+ next[targetUnitId] = (next[targetUnitId] ?? 0) + quantity;
179
+ }
180
+ const assignedByOption = new Map();
181
+ for (const [index, traveler] of nextTravelers.entries()) {
182
+ const pricingSource = traveler.pricingUnitSource ?? "auto";
183
+ const inventorySource = traveler.inventoryUnitSource ?? "auto";
184
+ const pricingKey = pricingSource !== "none" && traveler.pricingUnitId
185
+ ? unitToOption.get(traveler.pricingUnitId)
186
+ : undefined;
187
+ const inventoryKey = inventorySource !== "none" && traveler.inventoryUnitId
188
+ ? unitToOption.get(traveler.inventoryUnitId)
189
+ : undefined;
190
+ const key = inventoryKey ?? pricingKey;
191
+ if (!key)
192
+ continue;
193
+ if (personPricedOptions.has(key)) {
194
+ if (!traveler.pricingUnitId || pricingSource === "none")
195
+ continue;
196
+ const unitIndexes = travelerIndexesByUnitId[traveler.pricingUnitId] ?? [];
197
+ unitIndexes.push(index);
198
+ travelerIndexesByUnitId[traveler.pricingUnitId] = unitIndexes;
199
+ next[traveler.pricingUnitId] = (next[traveler.pricingUnitId] ?? 0) + 1;
200
+ assignedByOption.set(key, (assignedByOption.get(key) ?? 0) + 1);
201
+ }
202
+ else if (traveler.inventoryUnitId && inventorySource !== "none") {
203
+ const unitIndexes = travelerIndexesByUnitId[traveler.inventoryUnitId] ?? [];
204
+ unitIndexes.push(index);
205
+ travelerIndexesByUnitId[traveler.inventoryUnitId] = unitIndexes;
206
+ }
207
+ }
208
+ // Person-priced residual: operator picked N seats but only added M
209
+ // travelers (M < N) → put the leftover on the option's adult unit
210
+ // so the line-item total matches the stepper.
211
+ for (const [key, total] of totalByOption) {
212
+ if (!personPricedOptions.has(key))
213
+ continue;
214
+ const assigned = assignedByOption.get(key) ?? 0;
215
+ const residual = Math.max(0, total - assigned);
216
+ if (residual === 0)
217
+ continue;
218
+ const adult = pickUnitForAge(unitsByOption.get(key) ?? [], null, "adult");
219
+ if (!adult)
220
+ continue;
221
+ next[adult.optionUnitId] = (next[adult.optionUnitId] ?? 0) + residual;
222
+ }
223
+ return { quantities: next, travelers: nextTravelers, travelerIndexesByUnitId };
224
+ }
225
+ /**
226
+ * Normalize per-person extras to charged traveler quantity and
227
+ * stamp traveler links + `clientLineKey` so the server can link
228
+ * each extra line to the travelers it applies to via
229
+ * `booking_item_travelers`.
230
+ *
231
+ * Per-person mode (`pricingMode === "per_person"` or
232
+ * `pricedPerPerson === true`): quantity multiplied by travelerCount,
233
+ * `travelerKeys` set to all travelers when stable keys are supplied;
234
+ * otherwise `travelerIndexes` is set as a deprecated fallback.
235
+ * Non-per-person lines pass through with `clientLineKey` only.
236
+ */
237
+ export function resolveBookingExtraLines(options) {
238
+ const travelerIndexes = Array.from({ length: options.travelerCount }, (_, index) => index);
239
+ const travelerKeys = (options.travelerKeys ?? []).filter((key) => typeof key === "string" && key.trim().length > 0);
240
+ const useTravelerKeys = travelerKeys.length === options.travelerCount;
241
+ return options.extraLines.map((line) => {
242
+ const perPerson = line.pricingMode === "per_person" || line.pricedPerPerson === true;
243
+ if (!perPerson) {
244
+ return {
245
+ ...line,
246
+ clientLineKey: line.clientLineKey ?? `extra:${line.productExtraId}`,
247
+ };
248
+ }
249
+ const quantity = Math.max(1, options.travelerCount) * line.quantity;
250
+ return {
251
+ ...line,
252
+ clientLineKey: line.clientLineKey ?? `extra:${line.productExtraId}`,
253
+ quantity,
254
+ totalSellAmountCents: line.unitSellAmountCents == null
255
+ ? line.totalSellAmountCents
256
+ : line.unitSellAmountCents * quantity,
257
+ ...(useTravelerKeys ? { travelerKeys } : { travelerIndexes }),
258
+ };
259
+ });
260
+ }
261
+ /**
262
+ * Project a resolved draft's traveler list into the wire-format
263
+ * `BookingCreateTravelerInput[]` shape the dialog submits. Derives
264
+ * the `travelerCategory` from DOB / role.
265
+ *
266
+ * `roomUnitId` is a deprecated compatibility alias for the pricing tier
267
+ * option unit. Inventory placement is expressed only by item lines and their
268
+ * `travelerKeys`; the server accepts this field but does not persist it.
269
+ */
270
+ export function travelersToRows(value, now = new Date()) {
271
+ return value.travelers.map((traveler) => ({
272
+ clientTravelerKey: traveler.clientTravelerKey?.trim() || null,
273
+ personId: traveler.personId,
274
+ firstName: traveler.firstName.trim(),
275
+ lastName: traveler.lastName.trim(),
276
+ email: traveler.email.trim() || null,
277
+ phone: traveler.phone.trim() || null,
278
+ preferredLanguage: traveler.preferredLanguage.trim() || null,
279
+ participantType: "traveler",
280
+ travelerCategory: deriveDraftPaxBand(traveler, now),
281
+ isPrimary: traveler.role === "lead",
282
+ roomUnitId: traveler.pricingUnitSource === "none" ? null : traveler.pricingUnitId,
283
+ }));
284
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Pure, transport-agnostic logic for mapping travelers onto option_units
3
+ * at booking-create time. Lives in `@voyantjs/bookings` so the
4
+ * booking-create dialog (preview + submit) is the only call site today,
5
+ * but the server can import the same module to validate or re-resolve
6
+ * submit payloads in a follow-up — that wiring is not yet in place.
7
+ *
8
+ * Vocabulary:
9
+ * - A **pricing tier** (`unit_type='person'`) is a per-pax price band
10
+ * a traveler is billed as (Adult / Child 6-12 / Infant 0-5 / …).
11
+ * - An **inventory unit** (`'room' | 'vehicle' | 'seat'`) is a finite
12
+ * container a traveler is placed into (one DBL room holds 2 pax).
13
+ * - "Person-priced options" are options that only have pricing tiers
14
+ * (no inventory). Excursions. For these, line-item quantities
15
+ * **derive from the traveler list** (1 adult + 1 child + 1 infant).
16
+ * - "Accommodation options" have inventory units (and usually a
17
+ * paired person unit for per-pax fees). For these, line-item
18
+ * quantities **stay as the operator picked them** (1 DBL room is
19
+ * still 1 line, not 2).
20
+ *
21
+ * The `pricingUnitSource` and `inventoryUnitSource` enums on the
22
+ * traveler track operator intent so the resolver knows when to
23
+ * re-derive ("auto") versus respect an explicit choice ("manual" /
24
+ * "none" for No room).
25
+ *
26
+ * No React, no DB, no HTTP — just the assignment math.
27
+ *
28
+ * Tracking: voyantjs/voyant#1267.
29
+ */
30
+ export type TravelerRole = "lead" | "adult" | "child" | "infant";
31
+ export type AssignmentRoleHint = "adult" | "child" | "infant";
32
+ export type BookingDraftUnitAssignmentSource = "auto" | "manual" | "none";
33
+ export interface BookingDraftTraveler {
34
+ /** Stable client-side traveler key used by wire-format travelerKeys links. */
35
+ clientTravelerKey?: string | null;
36
+ personId: string | null;
37
+ firstName: string;
38
+ lastName: string;
39
+ email: string;
40
+ phone: string;
41
+ preferredLanguage: string;
42
+ role: TravelerRole;
43
+ dateOfBirth: string | null;
44
+ /** option_unit_id of the person pricing tier this traveler is billed as. */
45
+ pricingUnitId: string | null;
46
+ /** option_unit_id of the room/vehicle this traveler occupies, when applicable. */
47
+ inventoryUnitId: string | null;
48
+ /**
49
+ * Tracks operator intent around `pricingUnitId`.
50
+ *
51
+ * - `auto`: derived from product shape, DOB, role hints, and
52
+ * selected quantity. Eligible for re-derivation on every render.
53
+ * - `manual`: operator explicitly clicked a category button. The
54
+ * resolver respects the value when it's in the current unit set;
55
+ * otherwise it re-derives.
56
+ * - `none`: no pricing unit should be assigned.
57
+ */
58
+ pricingUnitSource?: BookingDraftUnitAssignmentSource;
59
+ /**
60
+ * Tracks operator intent around `inventoryUnitId`.
61
+ *
62
+ * - `auto`: derived from selected quantity and product shape.
63
+ * - `manual`: operator explicitly clicked a room/vehicle control.
64
+ * - `none`: operator explicitly picked "No room". Stays null.
65
+ */
66
+ inventoryUnitSource?: BookingDraftUnitAssignmentSource;
67
+ }
68
+ export interface PricingAssignmentUnit {
69
+ /** Option the unit belongs to. Null for product-level units. */
70
+ optionId?: string | null;
71
+ /** Stable id of the unit (option_unit primary key). */
72
+ optionUnitId: string;
73
+ /** Display name (e.g. "Adult", "Child 6-12", "DBL room"). */
74
+ unitName: string;
75
+ /** Stable code from the products schema (`ADULT`, `child_6_12`, …) when present. */
76
+ unitCode?: string | null;
77
+ /** Inclusive lower age bound for this unit, when configured. */
78
+ minAge?: number | null;
79
+ /** Inclusive upper age bound for this unit, when configured. */
80
+ maxAge?: number | null;
81
+ /** Unit category — drives the pricing-tier vs inventory split. */
82
+ unitType?: "person" | "group" | "room" | "vehicle" | "service" | "other" | null;
83
+ }
84
+ export type BookingDraftQuantities = Record<string, number>;
85
+ export interface ResolvedBookingDraft<TTraveler extends BookingDraftTraveler> {
86
+ quantities: BookingDraftQuantities;
87
+ travelers: TTraveler[];
88
+ /**
89
+ * For each unit that ended up assigned, the indexes (into the input
90
+ * traveler array) of travelers mapped to it. Used at submit time to
91
+ * stamp stable `travelerKeys` on `booking_item` lines so the server
92
+ * can link items to travelers through `booking_item_travelers`.
93
+ */
94
+ travelerIndexesByUnitId: Record<string, number[]>;
95
+ }
96
+ /**
97
+ * Minimal structural shape `resolveBookingExtraLines` needs from an
98
+ * extra line. Real callers (booking-create dialog) pass the full
99
+ * `BookingCreateExtraLineInput` from `@voyantjs/bookings-react`; this
100
+ * type avoids a cyclic dependency.
101
+ */
102
+ export interface ResolvableExtraLine {
103
+ productExtraId: string;
104
+ pricingMode?: string | null;
105
+ pricedPerPerson?: boolean | null;
106
+ quantity: number;
107
+ unitSellAmountCents?: number | null;
108
+ totalSellAmountCents?: number | null;
109
+ clientLineKey?: string | null;
110
+ travelerKeys?: string[] | null;
111
+ travelerIndexes?: number[] | null;
112
+ }
113
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/pricing-assignment/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH,MAAM,MAAM,YAAY,GAAG,MAAM,GAAG,OAAO,GAAG,OAAO,GAAG,QAAQ,CAAA;AAChE,MAAM,MAAM,kBAAkB,GAAG,OAAO,GAAG,OAAO,GAAG,QAAQ,CAAA;AAC7D,MAAM,MAAM,gCAAgC,GAAG,MAAM,GAAG,QAAQ,GAAG,MAAM,CAAA;AAEzE,MAAM,WAAW,oBAAoB;IACnC,8EAA8E;IAC9E,iBAAiB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACjC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB,SAAS,EAAE,MAAM,CAAA;IACjB,QAAQ,EAAE,MAAM,CAAA;IAChB,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;IACb,iBAAiB,EAAE,MAAM,CAAA;IACzB,IAAI,EAAE,YAAY,CAAA;IAClB,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,4EAA4E;IAC5E,aAAa,EAAE,MAAM,GAAG,IAAI,CAAA;IAC5B,kFAAkF;IAClF,eAAe,EAAE,MAAM,GAAG,IAAI,CAAA;IAC9B;;;;;;;;;OASG;IACH,iBAAiB,CAAC,EAAE,gCAAgC,CAAA;IACpD;;;;;;OAMG;IACH,mBAAmB,CAAC,EAAE,gCAAgC,CAAA;CACvD;AAED,MAAM,WAAW,qBAAqB;IACpC,gEAAgE;IAChE,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,uDAAuD;IACvD,YAAY,EAAE,MAAM,CAAA;IACpB,6DAA6D;IAC7D,QAAQ,EAAE,MAAM,CAAA;IAChB,oFAAoF;IACpF,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,gEAAgE;IAChE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACtB,gEAAgE;IAChE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACtB,kEAAkE;IAClE,QAAQ,CAAC,EAAE,QAAQ,GAAG,OAAO,GAAG,MAAM,GAAG,SAAS,GAAG,SAAS,GAAG,OAAO,GAAG,IAAI,CAAA;CAChF;AAED,MAAM,MAAM,sBAAsB,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;AAE3D,MAAM,WAAW,oBAAoB,CAAC,SAAS,SAAS,oBAAoB;IAC1E,UAAU,EAAE,sBAAsB,CAAA;IAClC,SAAS,EAAE,SAAS,EAAE,CAAA;IACtB;;;;;OAKG;IACH,uBAAuB,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAA;CAClD;AAED;;;;;GAKG;AACH,MAAM,WAAW,mBAAmB;IAClC,cAAc,EAAE,MAAM,CAAA;IACtB,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3B,eAAe,CAAC,EAAE,OAAO,GAAG,IAAI,CAAA;IAChC,QAAQ,EAAE,MAAM,CAAA;IAChB,mBAAmB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACnC,oBAAoB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACpC,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC7B,YAAY,CAAC,EAAE,MAAM,EAAE,GAAG,IAAI,CAAA;IAC9B,eAAe,CAAC,EAAE,MAAM,EAAE,GAAG,IAAI,CAAA;CAClC"}
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Pure, transport-agnostic logic for mapping travelers onto option_units
3
+ * at booking-create time. Lives in `@voyantjs/bookings` so the
4
+ * booking-create dialog (preview + submit) is the only call site today,
5
+ * but the server can import the same module to validate or re-resolve
6
+ * submit payloads in a follow-up — that wiring is not yet in place.
7
+ *
8
+ * Vocabulary:
9
+ * - A **pricing tier** (`unit_type='person'`) is a per-pax price band
10
+ * a traveler is billed as (Adult / Child 6-12 / Infant 0-5 / …).
11
+ * - An **inventory unit** (`'room' | 'vehicle' | 'seat'`) is a finite
12
+ * container a traveler is placed into (one DBL room holds 2 pax).
13
+ * - "Person-priced options" are options that only have pricing tiers
14
+ * (no inventory). Excursions. For these, line-item quantities
15
+ * **derive from the traveler list** (1 adult + 1 child + 1 infant).
16
+ * - "Accommodation options" have inventory units (and usually a
17
+ * paired person unit for per-pax fees). For these, line-item
18
+ * quantities **stay as the operator picked them** (1 DBL room is
19
+ * still 1 line, not 2).
20
+ *
21
+ * The `pricingUnitSource` and `inventoryUnitSource` enums on the
22
+ * traveler track operator intent so the resolver knows when to
23
+ * re-derive ("auto") versus respect an explicit choice ("manual" /
24
+ * "none" for No room).
25
+ *
26
+ * No React, no DB, no HTTP — just the assignment math.
27
+ *
28
+ * Tracking: voyantjs/voyant#1267.
29
+ */
30
+ export {};
@@ -0,0 +1,11 @@
1
+ import type { AssignmentRoleHint, BookingDraftTraveler, PricingAssignmentUnit } from "./types.js";
2
+ /**
3
+ * Inventory units are finite containers a traveler is placed into.
4
+ * Rooms (hotels), vehicles (transfers), seats (where modeled
5
+ * explicitly). Distinct from pricing tiers (`person`).
6
+ */
7
+ export declare function optionKey(unit: PricingAssignmentUnit): string;
8
+ export declare function isPersonUnit(unit: PricingAssignmentUnit): boolean;
9
+ export declare function isInventoryUnit(unit: PricingAssignmentUnit): boolean;
10
+ export declare function roleHintForTraveler(traveler: BookingDraftTraveler): AssignmentRoleHint | null;
11
+ //# sourceMappingURL=unit-helpers.d.ts.map