@voyant-travel/storefront 0.120.1

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 (126) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +231 -0
  3. package/dist/booking-intents.d.ts +42 -0
  4. package/dist/booking-intents.d.ts.map +1 -0
  5. package/dist/booking-intents.js +83 -0
  6. package/dist/customer-portal/index.d.ts +16 -0
  7. package/dist/customer-portal/index.d.ts.map +1 -0
  8. package/dist/customer-portal/index.js +23 -0
  9. package/dist/customer-portal/route-runtime.d.ts +16 -0
  10. package/dist/customer-portal/route-runtime.d.ts.map +1 -0
  11. package/dist/customer-portal/route-runtime.js +27 -0
  12. package/dist/customer-portal/routes-public.d.ts +1936 -0
  13. package/dist/customer-portal/routes-public.d.ts.map +1 -0
  14. package/dist/customer-portal/routes-public.js +165 -0
  15. package/dist/customer-portal/routes.d.ts +43 -0
  16. package/dist/customer-portal/routes.d.ts.map +1 -0
  17. package/dist/customer-portal/routes.js +17 -0
  18. package/dist/customer-portal/service-public-impl.d.ts +138 -0
  19. package/dist/customer-portal/service-public-impl.d.ts.map +1 -0
  20. package/dist/customer-portal/service-public-impl.js +1808 -0
  21. package/dist/customer-portal/service-public.d.ts +2 -0
  22. package/dist/customer-portal/service-public.d.ts.map +1 -0
  23. package/dist/customer-portal/service-public.js +1 -0
  24. package/dist/customer-portal/validation-public/bookings.d.ts +551 -0
  25. package/dist/customer-portal/validation-public/bookings.d.ts.map +1 -0
  26. package/dist/customer-portal/validation-public/bookings.js +132 -0
  27. package/dist/customer-portal/validation-public/common.d.ts +162 -0
  28. package/dist/customer-portal/validation-public/common.d.ts.map +1 -0
  29. package/dist/customer-portal/validation-public/common.js +139 -0
  30. package/dist/customer-portal/validation-public/profile.d.ts +749 -0
  31. package/dist/customer-portal/validation-public/profile.d.ts.map +1 -0
  32. package/dist/customer-portal/validation-public/profile.js +308 -0
  33. package/dist/customer-portal/validation-public.d.ts +3 -0
  34. package/dist/customer-portal/validation-public.d.ts.map +1 -0
  35. package/dist/customer-portal/validation-public.js +2 -0
  36. package/dist/guest-booking-guard.d.ts +24 -0
  37. package/dist/guest-booking-guard.d.ts.map +1 -0
  38. package/dist/guest-booking-guard.js +55 -0
  39. package/dist/index.d.ts +23 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +41 -0
  42. package/dist/product-extra-ref.d.ts +238 -0
  43. package/dist/product-extra-ref.d.ts.map +1 -0
  44. package/dist/product-extra-ref.js +22 -0
  45. package/dist/routes-admin.d.ts +220 -0
  46. package/dist/routes-admin.d.ts.map +1 -0
  47. package/dist/routes-admin.js +28 -0
  48. package/dist/routes-public.d.ts +1475 -0
  49. package/dist/routes-public.d.ts.map +1 -0
  50. package/dist/routes-public.js +362 -0
  51. package/dist/service-booking-session-bootstrap.d.ts +227 -0
  52. package/dist/service-booking-session-bootstrap.d.ts.map +1 -0
  53. package/dist/service-booking-session-bootstrap.js +287 -0
  54. package/dist/service-boundary-resource-sql.d.ts +18 -0
  55. package/dist/service-boundary-resource-sql.d.ts.map +1 -0
  56. package/dist/service-boundary-resource-sql.js +73 -0
  57. package/dist/service-boundary-sql.d.ts +103 -0
  58. package/dist/service-boundary-sql.d.ts.map +1 -0
  59. package/dist/service-boundary-sql.js +307 -0
  60. package/dist/service-departures-core.d.ts +41 -0
  61. package/dist/service-departures-core.d.ts.map +1 -0
  62. package/dist/service-departures-core.js +92 -0
  63. package/dist/service-departures-extensions.d.ts +46 -0
  64. package/dist/service-departures-extensions.d.ts.map +1 -0
  65. package/dist/service-departures-extensions.js +81 -0
  66. package/dist/service-departures-offers.d.ts +220 -0
  67. package/dist/service-departures-offers.d.ts.map +1 -0
  68. package/dist/service-departures-offers.js +177 -0
  69. package/dist/service-departures-price-preview.d.ts +306 -0
  70. package/dist/service-departures-price-preview.d.ts.map +1 -0
  71. package/dist/service-departures-price-preview.js +383 -0
  72. package/dist/service-departures-pricing-context.d.ts +115 -0
  73. package/dist/service-departures-pricing-context.d.ts.map +1 -0
  74. package/dist/service-departures-pricing-context.js +237 -0
  75. package/dist/service-departures-pricing.d.ts +5 -0
  76. package/dist/service-departures-pricing.d.ts.map +1 -0
  77. package/dist/service-departures-pricing.js +4 -0
  78. package/dist/service-departures.d.ts +192 -0
  79. package/dist/service-departures.d.ts.map +1 -0
  80. package/dist/service-departures.js +213 -0
  81. package/dist/service-intake.d.ts +130 -0
  82. package/dist/service-intake.d.ts.map +1 -0
  83. package/dist/service-intake.js +274 -0
  84. package/dist/service-transport-eligibility.d.ts +10 -0
  85. package/dist/service-transport-eligibility.d.ts.map +1 -0
  86. package/dist/service-transport-eligibility.js +198 -0
  87. package/dist/service.d.ts +1062 -0
  88. package/dist/service.d.ts.map +1 -0
  89. package/dist/service.js +332 -0
  90. package/dist/transport-eligibility.d.ts +4 -0
  91. package/dist/transport-eligibility.d.ts.map +1 -0
  92. package/dist/transport-eligibility.js +2 -0
  93. package/dist/validation/departures.d.ts +1669 -0
  94. package/dist/validation/departures.d.ts.map +1 -0
  95. package/dist/validation/departures.js +397 -0
  96. package/dist/validation/intake.d.ts +147 -0
  97. package/dist/validation/intake.d.ts.map +1 -0
  98. package/dist/validation/intake.js +69 -0
  99. package/dist/validation/offers.d.ts +340 -0
  100. package/dist/validation/offers.d.ts.map +1 -0
  101. package/dist/validation/offers.js +117 -0
  102. package/dist/validation-settings.d.ts +609 -0
  103. package/dist/validation-settings.d.ts.map +1 -0
  104. package/dist/validation-settings.js +235 -0
  105. package/dist/validation-transport-eligibility.d.ts +314 -0
  106. package/dist/validation-transport-eligibility.d.ts.map +1 -0
  107. package/dist/validation-transport-eligibility.js +97 -0
  108. package/dist/validation.d.ts +6 -0
  109. package/dist/validation.d.ts.map +1 -0
  110. package/dist/validation.js +4 -0
  111. package/dist/verification/index.d.ts +12 -0
  112. package/dist/verification/index.d.ts.map +1 -0
  113. package/dist/verification/index.js +18 -0
  114. package/dist/verification/routes-public.d.ts +121 -0
  115. package/dist/verification/routes-public.d.ts.map +1 -0
  116. package/dist/verification/routes-public.js +125 -0
  117. package/dist/verification/schema.d.ts +273 -0
  118. package/dist/verification/schema.d.ts.map +1 -0
  119. package/dist/verification/schema.js +50 -0
  120. package/dist/verification/service.d.ts +114 -0
  121. package/dist/verification/service.d.ts.map +1 -0
  122. package/dist/verification/service.js +283 -0
  123. package/dist/verification/validation.d.ts +98 -0
  124. package/dist/verification/validation.d.ts.map +1 -0
  125. package/dist/verification/validation.js +54 -0
  126. package/package.json +148 -0
@@ -0,0 +1,306 @@
1
+ import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
2
+ import { type StorefrontDeparturePricePreviewOfferResolvers } from "./service-departures-offers.js";
3
+ import type { StorefrontDeparturePricePreviewInput } from "./validation.js";
4
+ export declare function previewStorefrontDeparturePrice(db: PostgresJsDatabase, departureId: string, input: StorefrontDeparturePricePreviewInput, offerResolvers?: StorefrontDeparturePricePreviewOfferResolvers): Promise<{
5
+ departureId: string;
6
+ productId: string;
7
+ optionId: string | null;
8
+ currencyCode: string;
9
+ basePrice: number;
10
+ taxAmount: number;
11
+ total: number;
12
+ notes: string | null;
13
+ lineItems: {
14
+ name: string;
15
+ total: number;
16
+ quantity: number;
17
+ unitPrice: number;
18
+ }[];
19
+ allocation: {
20
+ slot: {
21
+ id: string;
22
+ productId: string;
23
+ optionId: string | null;
24
+ dateLocal: string | null;
25
+ startAt: string | null;
26
+ endAt: string | null;
27
+ timezone: string;
28
+ status: "on_request" | import("./service-boundary-sql.js").StorefrontSlotStatus;
29
+ availabilityState: import("./service-departures-core.js").StorefrontProductAvailabilityState;
30
+ capacity: number | null;
31
+ remaining: number | null;
32
+ pastCutoff: boolean;
33
+ tooEarly: boolean;
34
+ resourceManifest: {
35
+ kinds: {
36
+ capacity: number;
37
+ assigned: number;
38
+ available: number;
39
+ kind: string;
40
+ }[];
41
+ resources: {
42
+ id: string;
43
+ kind: string;
44
+ label: string | null;
45
+ refType: string | null;
46
+ refId: string | null;
47
+ capacity: number;
48
+ assigned: number;
49
+ available: number;
50
+ parentId: string | null;
51
+ flags: Record<string, unknown>;
52
+ }[];
53
+ } | null;
54
+ };
55
+ pax: {
56
+ adults: number;
57
+ children: number;
58
+ infants: number;
59
+ total: number;
60
+ };
61
+ requestedUnits: {
62
+ unitId: string | null;
63
+ requestRef: string | null;
64
+ name: string;
65
+ unitType: string | null;
66
+ quantity: number;
67
+ pricingMode: string | null;
68
+ unitPrice: number;
69
+ total: number;
70
+ currencyCode: string;
71
+ tierId: string | null;
72
+ }[];
73
+ rooms: {
74
+ unitId: string;
75
+ name: string;
76
+ occupancy: number;
77
+ quantity: number;
78
+ pax: number;
79
+ pricingMode: string | null;
80
+ unitPrice: number;
81
+ total: number;
82
+ currencyCode: string;
83
+ tierId: string | null;
84
+ }[];
85
+ };
86
+ units: {
87
+ unitId: string | null;
88
+ requestRef: string | null;
89
+ name: string;
90
+ unitType: string | null;
91
+ quantity: number;
92
+ pricingMode: string | null;
93
+ unitPrice: number;
94
+ total: number;
95
+ currencyCode: string;
96
+ tierId: string | null;
97
+ }[];
98
+ rooms: {
99
+ unitId: string;
100
+ name: string;
101
+ occupancy: number;
102
+ quantity: number;
103
+ pax: number;
104
+ pricingMode: string | null;
105
+ unitPrice: number;
106
+ total: number;
107
+ currencyCode: string;
108
+ tierId: string | null;
109
+ }[];
110
+ extras: {
111
+ extraId: string;
112
+ name: string;
113
+ required: boolean;
114
+ selectable: boolean;
115
+ selected: boolean;
116
+ pricingMode: string;
117
+ quantity: number;
118
+ unitPrice: number;
119
+ total: number;
120
+ currencyCode: string;
121
+ }[];
122
+ offers: {
123
+ available: {
124
+ offer: {
125
+ id: string;
126
+ name: string;
127
+ slug: string | null;
128
+ description: string | null;
129
+ discountType: "percentage" | "fixed_amount";
130
+ discountValue: string;
131
+ currency: string | null;
132
+ applicableProductIds: string[];
133
+ applicableDepartureIds: string[];
134
+ validFrom: string | null;
135
+ validTo: string | null;
136
+ minTravelers: number | null;
137
+ imageMobileUrl: string | null;
138
+ imageDesktopUrl: string | null;
139
+ stackable: boolean;
140
+ createdAt: string;
141
+ updatedAt: string;
142
+ };
143
+ status: string;
144
+ reason: string | null;
145
+ selected: boolean;
146
+ discountAppliedCents: number;
147
+ discountedPriceCents: number;
148
+ }[];
149
+ requested: ({
150
+ kind: "slug";
151
+ value: string;
152
+ result: {
153
+ status: "applied" | "not_applicable" | "invalid" | "conflict";
154
+ reason: "currency" | "scope" | "booking_mismatch" | "conflict" | "offer_not_found" | "offer_expired" | "offer_not_yet_valid" | "code_not_found" | "code_required" | "code_expired" | "code_not_yet_valid" | "min_pax" | "eligibility" | "no_discount" | "session_mismatch" | null;
155
+ offer: {
156
+ id: string;
157
+ name: string;
158
+ slug: string | null;
159
+ description: string | null;
160
+ discountType: "percentage" | "fixed_amount";
161
+ discountValue: string;
162
+ currency: string | null;
163
+ applicableProductIds: string[];
164
+ applicableDepartureIds: string[];
165
+ validFrom: string | null;
166
+ validTo: string | null;
167
+ minTravelers: number | null;
168
+ imageMobileUrl: string | null;
169
+ imageDesktopUrl: string | null;
170
+ stackable: boolean;
171
+ createdAt: string;
172
+ updatedAt: string;
173
+ } | null;
174
+ target: {
175
+ bookingId: string | null;
176
+ sessionId: string | null;
177
+ productId: string;
178
+ departureId: string | null;
179
+ };
180
+ pricing: {
181
+ basePriceCents: number;
182
+ currency: string;
183
+ discountAppliedCents: number;
184
+ discountedPriceCents: number;
185
+ };
186
+ appliedOffers: {
187
+ offerId: string;
188
+ offerName: string;
189
+ discountAppliedCents: number;
190
+ discountedPriceCents: number;
191
+ currency: string;
192
+ discountKind: "percentage" | "fixed_amount";
193
+ discountPercent: number | null;
194
+ discountAmountCents: number | null;
195
+ appliedCode: string | null;
196
+ stackable: boolean;
197
+ }[];
198
+ conflict: {
199
+ policy: "best_discount_wins" | "stackable_compose";
200
+ autoAppliedOfferIds: string[];
201
+ manualOfferId: string | null;
202
+ selectedOfferIds: string[];
203
+ message: string;
204
+ } | null;
205
+ } | null;
206
+ } | {
207
+ kind: "code";
208
+ value: string;
209
+ result: {
210
+ status: "applied" | "not_applicable" | "invalid" | "conflict";
211
+ reason: "currency" | "scope" | "booking_mismatch" | "conflict" | "offer_not_found" | "offer_expired" | "offer_not_yet_valid" | "code_not_found" | "code_required" | "code_expired" | "code_not_yet_valid" | "min_pax" | "eligibility" | "no_discount" | "session_mismatch" | null;
212
+ offer: {
213
+ id: string;
214
+ name: string;
215
+ slug: string | null;
216
+ description: string | null;
217
+ discountType: "percentage" | "fixed_amount";
218
+ discountValue: string;
219
+ currency: string | null;
220
+ applicableProductIds: string[];
221
+ applicableDepartureIds: string[];
222
+ validFrom: string | null;
223
+ validTo: string | null;
224
+ minTravelers: number | null;
225
+ imageMobileUrl: string | null;
226
+ imageDesktopUrl: string | null;
227
+ stackable: boolean;
228
+ createdAt: string;
229
+ updatedAt: string;
230
+ } | null;
231
+ target: {
232
+ bookingId: string | null;
233
+ sessionId: string | null;
234
+ productId: string;
235
+ departureId: string | null;
236
+ };
237
+ pricing: {
238
+ basePriceCents: number;
239
+ currency: string;
240
+ discountAppliedCents: number;
241
+ discountedPriceCents: number;
242
+ };
243
+ appliedOffers: {
244
+ offerId: string;
245
+ offerName: string;
246
+ discountAppliedCents: number;
247
+ discountedPriceCents: number;
248
+ currency: string;
249
+ discountKind: "percentage" | "fixed_amount";
250
+ discountPercent: number | null;
251
+ discountAmountCents: number | null;
252
+ appliedCode: string | null;
253
+ stackable: boolean;
254
+ }[];
255
+ conflict: {
256
+ policy: "best_discount_wins" | "stackable_compose";
257
+ autoAppliedOfferIds: string[];
258
+ manualOfferId: string | null;
259
+ selectedOfferIds: string[];
260
+ message: string;
261
+ } | null;
262
+ } | null;
263
+ })[];
264
+ applied: {
265
+ offerId: string;
266
+ offerName: string;
267
+ discountAppliedCents: number;
268
+ discountedPriceCents: number;
269
+ currency: string;
270
+ discountKind: "percentage" | "fixed_amount";
271
+ discountPercent: number | null;
272
+ discountAmountCents: number | null;
273
+ appliedCode: string | null;
274
+ stackable: boolean;
275
+ }[];
276
+ conflict: {
277
+ policy: "best_discount_wins" | "stackable_compose";
278
+ autoAppliedOfferIds: string[];
279
+ manualOfferId: string | null;
280
+ selectedOfferIds: string[];
281
+ message: string;
282
+ } | {
283
+ policy: string;
284
+ autoAppliedOfferIds: string[];
285
+ manualOfferId: null;
286
+ selectedOfferIds: string[];
287
+ message: string;
288
+ } | null;
289
+ discountTotal: number;
290
+ discountTotalCents: number;
291
+ totalAfterDiscount: number;
292
+ currencyCode: string;
293
+ };
294
+ totals: {
295
+ currencyCode: string;
296
+ base: number;
297
+ extras: number;
298
+ subtotal: number;
299
+ discount: number;
300
+ tax: number;
301
+ total: number;
302
+ perPerson: number;
303
+ perBooking: number;
304
+ };
305
+ } | null>;
306
+ //# sourceMappingURL=service-departures-price-preview.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"service-departures-price-preview.d.ts","sourceRoot":"","sources":["../src/service-departures-price-preview.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAA;AAWjE,OAAO,EAEL,KAAK,6CAA6C,EACnD,MAAM,gCAAgC,CAAA;AAavC,OAAO,KAAK,EAAE,oCAAoC,EAAE,MAAM,iBAAiB,CAAA;AA8T3E,wBAAsB,+BAA+B,CACnD,EAAE,EAAE,kBAAkB,EACtB,WAAW,EAAE,MAAM,EACnB,KAAK,EAAE,oCAAoC,EAC3C,cAAc,CAAC,EAAE,6CAA6C;;;;;;;;;;cA3BrC,MAAM;eAAS,MAAM;kBAAY,MAAM;mBAAa,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;UAiNpF"}
@@ -0,0 +1,383 @@
1
+ import { sellabilityService } from "@voyant-travel/commerce";
2
+ import { and, asc, eq } from "drizzle-orm";
3
+ import { productExtrasRef } from "./product-extra-ref.js";
4
+ import { getStorefrontSlotResourceAvailability } from "./service-boundary-sql.js";
5
+ import { buildAvailabilityState, buildResourceManifest, listSlots, normalizeIso, normalizeLocalDate, } from "./service-departures-core.js";
6
+ import { buildOfferPreview, } from "./service-departures-offers.js";
7
+ import { amountToCents, buildDepartureStatus, buildTravelerRequestedUnits, centsToAmount, convertedAmount, getPreferredCurrency, resolvePricingContext, selectUnitAmount, selectUnitTier, } from "./service-departures-pricing-context.js";
8
+ function computeFallbackLineItems(args) {
9
+ const lineItems = [];
10
+ const currencyCode = getPreferredCurrency(args.context);
11
+ let total = 0;
12
+ if (args.rooms.length > 0) {
13
+ for (const room of args.rooms) {
14
+ const unitRule = args.context.unitRules.find((row) => row.unitId === room.unitId);
15
+ if (!unitRule) {
16
+ continue;
17
+ }
18
+ const amountCents = selectUnitAmount(args.context, unitRule, Math.max(1, room.occupancy * room.quantity));
19
+ const unitAmount = centsToAmount(amountCents) ?? 0;
20
+ const quantity = unitRule.pricingMode === "per_person"
21
+ ? Math.max(1, room.occupancy * room.quantity)
22
+ : Math.max(1, room.quantity);
23
+ const totalAmount = Number((unitAmount * quantity).toFixed(2));
24
+ total += totalAmount;
25
+ const unit = args.context.units.find((row) => row.id === room.unitId);
26
+ lineItems.push({
27
+ name: unit?.name ?? room.unitId,
28
+ total: totalAmount,
29
+ quantity,
30
+ unitPrice: unitAmount,
31
+ });
32
+ }
33
+ }
34
+ else {
35
+ const requested = buildTravelerRequestedUnits({
36
+ units: args.context.units,
37
+ adults: args.adults,
38
+ children: args.children,
39
+ infants: args.infants,
40
+ });
41
+ for (const request of requested) {
42
+ const unitRule = request.unitId
43
+ ? args.context.unitRules.find((row) => row.unitId === request.unitId)
44
+ : args.context.unitRules[0];
45
+ if (!unitRule) {
46
+ continue;
47
+ }
48
+ const unitAmount = centsToAmount(selectUnitAmount(args.context, unitRule, request.quantity)) ?? 0;
49
+ const totalAmount = Number((unitAmount * request.quantity).toFixed(2));
50
+ total += totalAmount;
51
+ const unit = request.unitId
52
+ ? args.context.units.find((row) => row.id === request.unitId)
53
+ : null;
54
+ lineItems.push({
55
+ name: unit?.name ?? args.context.option?.name ?? "Traveler",
56
+ total: totalAmount,
57
+ quantity: request.quantity,
58
+ unitPrice: unitAmount,
59
+ });
60
+ }
61
+ }
62
+ if (lineItems.length === 0 && args.context.product?.sellAmountCents != null) {
63
+ const pax = Math.max(1, args.adults + args.children + args.infants);
64
+ const unitAmount = centsToAmount(args.context.product.sellAmountCents) ?? 0;
65
+ const totalAmount = Number((unitAmount * pax).toFixed(2));
66
+ total += totalAmount;
67
+ lineItems.push({
68
+ name: args.context.option?.name ?? "Base",
69
+ total: totalAmount,
70
+ quantity: pax,
71
+ unitPrice: unitAmount,
72
+ });
73
+ }
74
+ return {
75
+ currencyCode,
76
+ total: Number(total.toFixed(2)),
77
+ lineItems,
78
+ };
79
+ }
80
+ function buildResolvedLineItems(components, conversionRate) {
81
+ return components.map((component) => {
82
+ const total = convertedAmount(component.sellAmountCents, conversionRate) ?? 0;
83
+ const quantity = Math.max(1, component.quantity);
84
+ return {
85
+ name: component.title,
86
+ total,
87
+ quantity,
88
+ unitPrice: Number((total / quantity).toFixed(2)),
89
+ };
90
+ });
91
+ }
92
+ function buildRequestedUnitRows(args) {
93
+ return args.requestedUnits.map((request) => {
94
+ const unit = request.unitId ? args.context.units.find((row) => row.id === request.unitId) : null;
95
+ const unitRule = request.unitId
96
+ ? args.context.unitRules.find((row) => row.unitId === request.unitId)
97
+ : args.context.unitRules[0];
98
+ const tier = selectUnitTier(unitRule, args.context.tiers, request.quantity);
99
+ const component = args.components.find((row) => row.kind === "unit" && request.requestRef && row.requestRef === request.requestRef) ??
100
+ args.components.find((row) => row.kind === "unit" && request.unitId && row.unitId === request.unitId);
101
+ const quantity = Math.max(1, request.quantity);
102
+ const total = component != null
103
+ ? (convertedAmount(component.sellAmountCents, args.conversionRate) ?? 0)
104
+ : Number(((convertedAmount(tier?.sellAmountCents ?? unitRule?.sellAmountCents, args.conversionRate) ?? 0) * quantity).toFixed(2));
105
+ const unitAmount = Number((total / quantity).toFixed(2));
106
+ return {
107
+ unitId: request.unitId ?? null,
108
+ requestRef: request.requestRef ?? request.unitId ?? null,
109
+ name: unit?.name ?? args.context.option?.name ?? "Traveler",
110
+ unitType: unit?.unitType ?? null,
111
+ quantity,
112
+ pricingMode: component?.pricingMode ?? unitRule?.pricingMode ?? null,
113
+ unitPrice: unitAmount,
114
+ total,
115
+ currencyCode: args.currencyCode,
116
+ tierId: component?.tierId ?? tier?.id ?? null,
117
+ };
118
+ });
119
+ }
120
+ function buildRoomRows(args) {
121
+ return args.rooms.map((room) => {
122
+ const unit = args.context.units.find((row) => row.id === room.unitId);
123
+ const unitRule = args.context.unitRules.find((row) => row.unitId === room.unitId);
124
+ const pax = Math.max(1, room.occupancy * room.quantity);
125
+ const quantity = unitRule?.pricingMode === "per_person" ? pax : Math.max(1, room.quantity);
126
+ const tier = selectUnitTier(unitRule, args.context.tiers, pax);
127
+ const component = args.components.find((row) => row.kind === "unit" && row.requestRef === room.requestRef) ??
128
+ args.components.find((row) => row.kind === "unit" && row.unitId === room.unitId);
129
+ const total = component != null
130
+ ? (convertedAmount(component.sellAmountCents, args.conversionRate) ?? 0)
131
+ : Number(((convertedAmount(tier?.sellAmountCents ?? unitRule?.sellAmountCents, args.conversionRate) ?? 0) * quantity).toFixed(2));
132
+ const unitAmount = Number((total / quantity).toFixed(2));
133
+ return {
134
+ unitId: room.unitId,
135
+ name: unit?.name ?? room.unitId,
136
+ occupancy: room.occupancy,
137
+ quantity: room.quantity,
138
+ pax,
139
+ pricingMode: component?.pricingMode ?? unitRule?.pricingMode ?? null,
140
+ unitPrice: unitAmount,
141
+ total,
142
+ currencyCode: args.currencyCode,
143
+ tierId: component?.tierId ?? tier?.id ?? null,
144
+ };
145
+ });
146
+ }
147
+ async function buildExtraImpacts(args) {
148
+ const selectedQuantityByExtraId = new Map(args.extras.map((extra) => [extra.extraId, extra.quantity]));
149
+ const extras = await args.db
150
+ .select({
151
+ id: productExtrasRef.id,
152
+ name: productExtrasRef.name,
153
+ selectionType: productExtrasRef.selectionType,
154
+ pricingMode: productExtrasRef.pricingMode,
155
+ pricedPerPerson: productExtrasRef.pricedPerPerson,
156
+ defaultQuantity: productExtrasRef.defaultQuantity,
157
+ minQuantity: productExtrasRef.minQuantity,
158
+ })
159
+ .from(productExtrasRef)
160
+ .where(and(eq(productExtrasRef.productId, args.productId), eq(productExtrasRef.active, true)))
161
+ .orderBy(asc(productExtrasRef.sortOrder), asc(productExtrasRef.name));
162
+ const ruleByExtraId = new Map(args.context.extraRules
163
+ .filter((rule) => rule.productExtraId)
164
+ .map((rule) => [rule.productExtraId, rule]));
165
+ return extras.map((extra) => {
166
+ const rule = ruleByExtraId.get(extra.id);
167
+ const selectedQuantity = selectedQuantityByExtraId.get(extra.id);
168
+ const required = extra.selectionType === "required";
169
+ const selected = selectedQuantity != null || required;
170
+ const pricingMode = rule?.pricingMode ?? (extra.pricedPerPerson ? "per_person" : extra.pricingMode);
171
+ const unitAmount = convertedAmount(rule?.sellAmountCents, args.conversionRate) ?? 0;
172
+ const chargeable = pricingMode === "included" ||
173
+ pricingMode === "free" ||
174
+ pricingMode === "unavailable" ||
175
+ pricingMode === "on_request"
176
+ ? false
177
+ : selected;
178
+ const baseQuantity = selected
179
+ ? (selectedQuantity ?? extra.defaultQuantity ?? extra.minQuantity ?? 1)
180
+ : 0;
181
+ const quantity = chargeable && pricingMode === "per_person"
182
+ ? Math.max(1, args.paxTotal * Math.max(1, baseQuantity))
183
+ : Math.max(0, baseQuantity);
184
+ const total = chargeable ? Number((unitAmount * quantity).toFixed(2)) : 0;
185
+ return {
186
+ extraId: extra.id,
187
+ name: extra.name,
188
+ required,
189
+ selectable: extra.selectionType !== "unavailable",
190
+ selected,
191
+ pricingMode,
192
+ quantity,
193
+ unitPrice: unitAmount,
194
+ total,
195
+ currencyCode: args.currencyCode,
196
+ };
197
+ });
198
+ }
199
+ async function applyExtraLineItems(args) {
200
+ const impacts = await buildExtraImpacts(args);
201
+ const selectedImpacts = impacts.filter((extra) => extra.selected && extra.total > 0);
202
+ const lineItems = [
203
+ ...args.lineItems,
204
+ ...selectedImpacts.map((extra) => ({
205
+ name: extra.name,
206
+ total: extra.total,
207
+ quantity: Math.max(1, extra.quantity),
208
+ unitPrice: extra.unitPrice,
209
+ })),
210
+ ];
211
+ const total = selectedImpacts.reduce((sum, extra) => sum + extra.total, args.total);
212
+ return {
213
+ lineItems,
214
+ total: Number(total.toFixed(2)),
215
+ impacts,
216
+ };
217
+ }
218
+ export async function previewStorefrontDeparturePrice(db, departureId, input, offerResolvers) {
219
+ const [slot] = await listSlots(db, { slotId: departureId, limit: 1 });
220
+ if (!slot) {
221
+ return null;
222
+ }
223
+ const context = await resolvePricingContext(db, slot.productId, slot.optionId, slot.id);
224
+ const adults = Math.max(0, input.pax?.adults ?? 1);
225
+ const children = Math.max(0, input.pax?.children ?? 0);
226
+ const infants = Math.max(0, input.pax?.infants ?? 0);
227
+ const rooms = input.rooms.map((room, index) => ({
228
+ unitId: room.unitId,
229
+ requestRef: `${room.unitId}:${index}`,
230
+ occupancy: room.occupancy,
231
+ quantity: room.quantity,
232
+ }));
233
+ const extras = input.extras.map((extra) => ({
234
+ extraId: extra.extraId,
235
+ quantity: extra.quantity,
236
+ }));
237
+ const requestedUnits = rooms.length > 0
238
+ ? rooms.map((room) => ({
239
+ unitId: room.unitId,
240
+ requestRef: room.requestRef,
241
+ quantity: Math.max(1, room.occupancy * room.quantity),
242
+ }))
243
+ : buildTravelerRequestedUnits({
244
+ units: context.units,
245
+ adults,
246
+ children,
247
+ infants,
248
+ });
249
+ const resolved = await sellabilityService.resolve(db, {
250
+ productId: slot.productId,
251
+ optionId: slot.optionId ?? undefined,
252
+ slotId: departureId,
253
+ currencyCode: input.currencyCode ?? undefined,
254
+ requestedUnits,
255
+ limit: 25,
256
+ });
257
+ const candidate = resolved.data.find((row) => row.slot.id === departureId && (!slot.optionId || row.option.id === slot.optionId)) ?? resolved.data[0];
258
+ const conversionRate = candidate?.pricing.fx?.rateDecimal != null ? Number(candidate.pricing.fx.rateDecimal) : null;
259
+ const components = candidate ? candidate.pricing.components : [];
260
+ const seeded = candidate
261
+ ? {
262
+ currencyCode: candidate.pricing.currencyCode,
263
+ total: Number((candidate.pricing.sellAmountCents / 100).toFixed(2)),
264
+ lineItems: buildResolvedLineItems(components, conversionRate),
265
+ notes: candidate.sellability.onRequest ? "on_request" : null,
266
+ }
267
+ : {
268
+ ...computeFallbackLineItems({
269
+ context,
270
+ adults,
271
+ children,
272
+ infants,
273
+ rooms,
274
+ }),
275
+ notes: null,
276
+ };
277
+ const roomPaxTotal = rooms.reduce((sum, room) => sum + Math.max(1, room.occupancy * room.quantity), 0);
278
+ const travelerPaxTotal = Math.max(1, adults + children + infants);
279
+ const paxTotal = rooms.length > 0 ? Math.max(1, roomPaxTotal) : travelerPaxTotal;
280
+ const withExtras = await applyExtraLineItems({
281
+ db,
282
+ productId: slot.productId,
283
+ context,
284
+ paxTotal,
285
+ extras,
286
+ currencyCode: seeded.currencyCode,
287
+ conversionRate,
288
+ lineItems: seeded.lineItems,
289
+ total: seeded.total,
290
+ });
291
+ const unitRows = rooms.length > 0
292
+ ? []
293
+ : buildRequestedUnitRows({
294
+ context,
295
+ requestedUnits,
296
+ components,
297
+ currencyCode: seeded.currencyCode,
298
+ conversionRate,
299
+ });
300
+ const roomRows = buildRoomRows({
301
+ context,
302
+ rooms,
303
+ components,
304
+ currencyCode: seeded.currencyCode,
305
+ conversionRate,
306
+ });
307
+ const subtotal = withExtras.total;
308
+ const offers = await buildOfferPreview({
309
+ resolvers: offerResolvers,
310
+ productId: slot.productId,
311
+ departureId: slot.id,
312
+ basePriceCents: amountToCents(subtotal),
313
+ currencyCode: seeded.currencyCode,
314
+ paxTotal,
315
+ requestedOffers: input.offers,
316
+ offerCode: input.offerCode,
317
+ locale: input.locale,
318
+ market: input.market,
319
+ });
320
+ const total = offers.totalAfterDiscount;
321
+ const extrasTotal = withExtras.impacts.reduce((sum, extra) => sum + extra.total, 0);
322
+ const basePrice = Number((subtotal - extrasTotal).toFixed(2));
323
+ const slotResources = await getStorefrontSlotResourceAvailability(db, slot.id);
324
+ const resourceManifest = buildResourceManifest(slotResources);
325
+ return {
326
+ departureId: slot.id,
327
+ productId: slot.productId,
328
+ optionId: slot.optionId,
329
+ currencyCode: seeded.currencyCode,
330
+ basePrice,
331
+ taxAmount: 0,
332
+ total,
333
+ notes: seeded.notes,
334
+ lineItems: withExtras.lineItems,
335
+ allocation: {
336
+ slot: {
337
+ id: slot.id,
338
+ productId: slot.productId,
339
+ optionId: slot.optionId,
340
+ dateLocal: normalizeLocalDate(slot.dateLocal),
341
+ startAt: normalizeIso(slot.startsAt),
342
+ endAt: normalizeIso(slot.endsAt),
343
+ timezone: slot.timezone,
344
+ status: buildDepartureStatus(slot, context),
345
+ availabilityState: buildAvailabilityState({
346
+ status: buildDepartureStatus(slot, context),
347
+ remaining: slot.remainingPax ?? slot.remainingResources ?? null,
348
+ capacity: slot.unlimited ? null : (slot.initialPax ?? slot.remainingPax ?? null),
349
+ pastCutoff: slot.pastCutoff,
350
+ tooEarly: slot.tooEarly,
351
+ }),
352
+ capacity: slot.unlimited ? null : (slot.initialPax ?? slot.remainingPax ?? null),
353
+ remaining: slot.remainingPax ?? slot.remainingResources ?? null,
354
+ pastCutoff: slot.pastCutoff,
355
+ tooEarly: slot.tooEarly,
356
+ resourceManifest,
357
+ },
358
+ pax: {
359
+ adults,
360
+ children,
361
+ infants,
362
+ total: paxTotal,
363
+ },
364
+ requestedUnits: unitRows,
365
+ rooms: roomRows,
366
+ },
367
+ units: unitRows,
368
+ rooms: roomRows,
369
+ extras: withExtras.impacts,
370
+ offers,
371
+ totals: {
372
+ currencyCode: seeded.currencyCode,
373
+ base: basePrice,
374
+ extras: Number(extrasTotal.toFixed(2)),
375
+ subtotal,
376
+ discount: offers.discountTotal,
377
+ tax: 0,
378
+ total,
379
+ perPerson: Number((total / paxTotal).toFixed(2)),
380
+ perBooking: total,
381
+ },
382
+ };
383
+ }