@voyant-travel/commerce 0.2.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/checkout/acceptance-signature.d.ts +4 -0
  2. package/dist/checkout/acceptance-signature.d.ts.map +1 -0
  3. package/dist/checkout/acceptance-signature.js +95 -0
  4. package/dist/checkout/finalize.d.ts +42 -0
  5. package/dist/checkout/finalize.d.ts.map +1 -0
  6. package/dist/checkout/finalize.js +208 -0
  7. package/dist/checkout/index.d.ts +26 -0
  8. package/dist/checkout/index.d.ts.map +1 -0
  9. package/dist/checkout/index.js +24 -0
  10. package/dist/checkout/materialization-support.d.ts +105 -0
  11. package/dist/checkout/materialization-support.d.ts.map +1 -0
  12. package/dist/checkout/materialization-support.js +451 -0
  13. package/dist/checkout/materialization-support.test.d.ts +2 -0
  14. package/dist/checkout/materialization-support.test.d.ts.map +1 -0
  15. package/dist/checkout/materialization-support.test.js +196 -0
  16. package/dist/checkout/materialization-tax.d.ts +10 -0
  17. package/dist/checkout/materialization-tax.d.ts.map +1 -0
  18. package/dist/checkout/materialization-tax.js +113 -0
  19. package/dist/checkout/materialization-tax.test.d.ts +2 -0
  20. package/dist/checkout/materialization-tax.test.d.ts.map +1 -0
  21. package/dist/checkout/materialization-tax.test.js +69 -0
  22. package/dist/checkout/materialization.d.ts +99 -0
  23. package/dist/checkout/materialization.d.ts.map +1 -0
  24. package/dist/checkout/materialization.js +269 -0
  25. package/dist/checkout/options.d.ts +89 -0
  26. package/dist/checkout/options.d.ts.map +1 -0
  27. package/dist/checkout/options.js +21 -0
  28. package/dist/checkout/routes.d.ts +21 -0
  29. package/dist/checkout/routes.d.ts.map +1 -0
  30. package/dist/checkout/routes.js +59 -0
  31. package/dist/checkout/start-service.d.ts +75 -0
  32. package/dist/checkout/start-service.d.ts.map +1 -0
  33. package/dist/checkout/start-service.js +415 -0
  34. package/dist/checkout/start-service.test.d.ts +2 -0
  35. package/dist/checkout/start-service.test.d.ts.map +1 -0
  36. package/dist/checkout/start-service.test.js +57 -0
  37. package/dist/markets/routes.d.ts +1 -1
  38. package/dist/markets/service-core.d.ts +1 -1
  39. package/dist/sellability/routes.d.ts +10 -10
  40. package/dist/sellability/service-records.d.ts +4 -4
  41. package/dist/sellability/service-snapshots.d.ts +2 -2
  42. package/dist/sellability/service.d.ts +10 -10
  43. package/package.json +27 -5
@@ -0,0 +1,451 @@
1
+ // agent-quality: file-size exception -- owner: commerce; the checkout
2
+ // materialization-support helpers are one cohesive family (allocations,
3
+ // travelers, supplier/date/title resolution) extracted together to preserve
4
+ // behavior; splitting would scatter a single snapshot→booking bridge.
5
+ import { buildBookingRouteRuntime, createBookingPiiService } from "@voyant-travel/bookings";
6
+ import { and, eq } from "drizzle-orm";
7
+ export async function materializeBookingAllocations(db, booking, insertedItems, draftPayload, snapshot) {
8
+ const slotId = pickString(draftPayload.configure?.departureSlotId);
9
+ if (!slotId || insertedItems.length === 0)
10
+ return;
11
+ const { bookingAllocations } = await import("@voyant-travel/bookings/schema");
12
+ const existing = await db
13
+ .select({ id: bookingAllocations.id })
14
+ .from(bookingAllocations)
15
+ .where(eq(bookingAllocations.bookingId, booking.id))
16
+ .limit(1);
17
+ if (existing.length > 0)
18
+ return;
19
+ const productId = snapshot.entity_module === "products" ? snapshot.entity_id : null;
20
+ const status = booking.status === "confirmed" ||
21
+ booking.status === "in_progress" ||
22
+ booking.status === "completed"
23
+ ? "confirmed"
24
+ : "held";
25
+ const confirmedAt = status === "confirmed" ? new Date() : null;
26
+ await db.insert(bookingAllocations).values(insertedItems.map((item) => ({
27
+ bookingId: booking.id,
28
+ bookingItemId: item.id,
29
+ productId,
30
+ optionId: item.optionId ?? null,
31
+ optionUnitId: item.optionUnitId ?? null,
32
+ availabilitySlotId: slotId,
33
+ quantity: item.quantity ?? 1,
34
+ status,
35
+ confirmedAt,
36
+ })));
37
+ }
38
+ export function inferSnapshotTaxFacts(snapshot) {
39
+ const content = snapshot.frozen_payload?.content;
40
+ const accommodationCountries = extractAccommodationCountries(content);
41
+ return {
42
+ hasAccommodation: accommodationCountries.length > 0,
43
+ accommodationCountries,
44
+ };
45
+ }
46
+ function extractAccommodationCountries(value) {
47
+ const countries = new Set();
48
+ collectAccommodationCountries(value, countries, 0);
49
+ return [...countries];
50
+ }
51
+ function collectAccommodationCountries(value, countries, depth) {
52
+ if (depth > 6 || value == null)
53
+ return;
54
+ if (Array.isArray(value)) {
55
+ for (const item of value)
56
+ collectAccommodationCountries(item, countries, depth + 1);
57
+ return;
58
+ }
59
+ if (typeof value !== "object")
60
+ return;
61
+ const record = value;
62
+ const typeValue = pickString(record.type, record.kind, record.serviceType, record.service_type);
63
+ const looksLikeAccommodation = typeValue?.toLowerCase().includes("accommodation") ||
64
+ typeValue?.toLowerCase().includes("hotel") ||
65
+ typeValue?.toLowerCase().includes("lodging");
66
+ if (looksLikeAccommodation) {
67
+ const country = pickString(record.countryCode, record.country_code, record.country);
68
+ if (country && /^[a-z]{2}$/i.test(country))
69
+ countries.add(country.toUpperCase());
70
+ }
71
+ for (const child of Object.values(record)) {
72
+ collectAccommodationCountries(child, countries, depth + 1);
73
+ }
74
+ }
75
+ export async function materializeTravelerTravelDetails(db, insertedTravelers, draftTravelers, env) {
76
+ const runtime = buildBookingRouteRuntime(env);
77
+ const pii = createBookingPiiService({ kms: await runtime.getKmsProvider() });
78
+ for (const [index, traveler] of insertedTravelers.entries()) {
79
+ const draftTraveler = draftTravelers[index];
80
+ if (!draftTraveler)
81
+ continue;
82
+ const details = extractDraftTravelerTravelDetails(draftTraveler, index);
83
+ if (!hasTravelDetails(details))
84
+ continue;
85
+ await pii.upsertTravelerTravelDetails(db, traveler.id, details, "system");
86
+ }
87
+ }
88
+ function extractDraftTravelerTravelDetails(traveler, index) {
89
+ const documents = traveler.documents ?? {};
90
+ const documentType = pickIdentityDocumentType(traveler.documentType, documents.documentType, documents.document_type);
91
+ return {
92
+ nationality: pickString(traveler.nationality, documents.nationality, documents.country),
93
+ documentType,
94
+ documentNumber: pickString(traveler.documentNumber, traveler.passportNumber, documents.documentNumber, documents.passportNumber, documents.passport_number, documents.document_number, documents.passport),
95
+ documentExpiry: pickString(traveler.documentExpiry, traveler.passportExpiry, traveler.passportExpiresAt, documents.documentExpiry, documents.passportExpiry, documents.passport_expiry, documents.document_expiry, documents.passportExpiresAt),
96
+ dateOfBirth: pickString(traveler.dateOfBirth, documents.dateOfBirth, documents.date_of_birth, documents.dob),
97
+ dietaryRequirements: pickString(traveler.dietaryRequirements, documents.dietaryRequirements, documents.dietary),
98
+ accessibilityNeeds: pickString(traveler.accessibilityNeeds, documents.accessibilityNeeds, documents.accessibility),
99
+ isLeadTraveler: traveler.isLeadTraveler ?? traveler.isPrimary ?? index === 0,
100
+ };
101
+ }
102
+ function hasTravelDetails(input) {
103
+ return (Boolean(input.nationality) ||
104
+ Boolean(input.documentType) ||
105
+ Boolean(input.documentNumber) ||
106
+ Boolean(input.documentExpiry) ||
107
+ Boolean(input.dateOfBirth) ||
108
+ Boolean(input.dietaryRequirements) ||
109
+ Boolean(input.accessibilityNeeds) ||
110
+ input.isLeadTraveler);
111
+ }
112
+ /**
113
+ * Resolve supplier info for the booking from the catalog snapshot.
114
+ * Pulls from:
115
+ * 1. `catalog_sourced_entries.projection.supplierId` — supplier
116
+ * name/id captured at sync time (covers Bokun, demo adapter, etc.).
117
+ * 2. The frozen payload's `upstream_payload.supplierId` — fallback
118
+ * when the sourced-entries row is missing (legacy bookings).
119
+ * 3. `frozen_payload.reserve.orderId` — used as `supplierReference`
120
+ * so operators can match up against the upstream provider's
121
+ * booking reference.
122
+ *
123
+ * Returns null when no supplier can be resolved — the caller treats
124
+ * that as "skip auto-fill, leave blank for manual entry".
125
+ */
126
+ export async function resolveSupplierFromSnapshot(db, snapshot) {
127
+ let supplierName = null;
128
+ let serviceName = null;
129
+ let upstreamCostCents = null;
130
+ // Layer 1: sourced entry projection.
131
+ try {
132
+ const { catalogSourcedEntriesTable } = await import("@voyant-travel/catalog");
133
+ const [sourcedEntry] = await db
134
+ .select({ projection: catalogSourcedEntriesTable.projection })
135
+ .from(catalogSourcedEntriesTable)
136
+ .where(and(eq(catalogSourcedEntriesTable.entity_module, snapshot.entity_module), eq(catalogSourcedEntriesTable.entity_id, snapshot.entity_id)))
137
+ .limit(1);
138
+ if (sourcedEntry?.projection) {
139
+ const p = sourcedEntry.projection;
140
+ supplierName = pickString(p.supplierName, p.supplier_name, p.supplierId);
141
+ serviceName = pickString(p.name, p.title);
142
+ const cost = p.upstreamCostCents ?? p.netPriceCents ?? p.costCents;
143
+ if (typeof cost === "number" && Number.isFinite(cost))
144
+ upstreamCostCents = cost;
145
+ }
146
+ }
147
+ catch {
148
+ // continue
149
+ }
150
+ // Layer 2: frozen upstream payload.
151
+ if (!supplierName || !serviceName) {
152
+ const upstream = snapshot.frozen_payload?.quote
153
+ ?.upstream_payload;
154
+ if (upstream) {
155
+ supplierName = supplierName ?? pickString(upstream.supplierName, upstream.supplierId);
156
+ serviceName = serviceName ?? pickString(upstream.name, upstream.title);
157
+ }
158
+ }
159
+ // Layer 3: fallback labels.
160
+ if (!serviceName)
161
+ serviceName = `${snapshot.entity_module} booking`;
162
+ // Reserve.orderId is the upstream provider's reference for this
163
+ // booking — operators reconcile against it when the supplier
164
+ // sends a confirmation. Falls back to the snapshot's source_ref.
165
+ const reserve = snapshot.frozen_payload?.reserve;
166
+ const supplierReference = pickString(reserve?.orderId, reserve?.upstream_ref) ?? snapshot.source_ref;
167
+ // Compose the human label: "$serviceName" if no supplier name,
168
+ // "$supplierName · $serviceName" otherwise — gives operators the
169
+ // most useful one-line scan in the supplier statuses table.
170
+ const composedName = supplierName ? `${supplierName} · ${serviceName}` : serviceName;
171
+ return {
172
+ serviceName: composedName,
173
+ supplierReference,
174
+ supplierServiceId: null,
175
+ upstreamCostCents,
176
+ };
177
+ }
178
+ function pickString(...candidates) {
179
+ for (const c of candidates)
180
+ if (typeof c === "string" && c.length > 0)
181
+ return c;
182
+ return null;
183
+ }
184
+ function pickIdentityDocumentType(...candidates) {
185
+ const value = pickString(...candidates);
186
+ if (value === "passport" ||
187
+ value === "id_card" ||
188
+ value === "driver_license" ||
189
+ value === "visa" ||
190
+ value === "other") {
191
+ return value;
192
+ }
193
+ return null;
194
+ }
195
+ /**
196
+ * Resolve booking-level dates from the draft and frozen source data.
197
+ * `start_date`/`end_date` drive the admin booking header, while item
198
+ * dates drive the line table. A storefront product selection usually
199
+ * carries only `departureSlotId`, so we resolve that id against the
200
+ * quote/reserve/content payload before falling back to free-form dates.
201
+ */
202
+ export function extractBookingDates(snapshot, draftPayload) {
203
+ const range = draftPayload.configure?.dateRange;
204
+ if (range?.checkIn) {
205
+ return {
206
+ startDate: range.checkIn.slice(0, 10),
207
+ endDate: range.checkOut ? range.checkOut.slice(0, 10) : null,
208
+ };
209
+ }
210
+ const selectedDeparture = findSelectedDeparture(snapshot, draftPayload);
211
+ if (selectedDeparture?.startsRaw) {
212
+ return {
213
+ startDate: selectedDeparture.startsRaw.slice(0, 10),
214
+ endDate: selectedDeparture.endsRaw ? selectedDeparture.endsRaw.slice(0, 10) : null,
215
+ };
216
+ }
217
+ if (typeof draftPayload.configure?.departureDate === "string") {
218
+ return {
219
+ startDate: draftPayload.configure.departureDate.slice(0, 10),
220
+ endDate: null,
221
+ };
222
+ }
223
+ return { startDate: null, endDate: null };
224
+ }
225
+ /**
226
+ * Pull start/end dates for a booking-item from the most reliable
227
+ * source available. Order:
228
+ * 1. The selected `departureSlotId` resolved against reserve /
229
+ * quote / captured content payloads.
230
+ * 2. `frozen_payload.quote.upstream_payload.metadata.days[]` —
231
+ * Bokun-style itinerary captured at quote time, gives us per-day
232
+ * dates with full timezone fidelity.
233
+ * 3. Draft `configure.dateRange.checkIn`/`checkOut` — what the
234
+ * customer selected on the storefront before booking.
235
+ * 4. Draft `configure.departureDate` — single-day tour selection.
236
+ * 5. Booking row's own `start_date` / `end_date` columns — the
237
+ * caller already populated these from the same draft when
238
+ * writing the booking row, so this is a final safety net.
239
+ *
240
+ * Returns nulls when nothing resolves — the caller treats that as
241
+ * "no date data, leave NULL" rather than fabricating one.
242
+ */
243
+ export function extractItemDates(snapshot, draftPayload, booking) {
244
+ // Layer 1: concrete selected departure/sailing.
245
+ const selectedDeparture = findSelectedDeparture(snapshot, draftPayload);
246
+ if (selectedDeparture?.startsRaw) {
247
+ const startsAt = new Date(selectedDeparture.startsRaw);
248
+ const endsAt = selectedDeparture.endsRaw ? new Date(selectedDeparture.endsRaw) : null;
249
+ if (Number.isFinite(startsAt.getTime())) {
250
+ return {
251
+ serviceDate: selectedDeparture.startsRaw.slice(0, 10),
252
+ startsAt,
253
+ endsAt: endsAt && Number.isFinite(endsAt.getTime()) ? endsAt : null,
254
+ };
255
+ }
256
+ }
257
+ // Layer 2: upstream metadata.days[] — flat array of {date, ...} or
258
+ // {startAt/endAt} entries.
259
+ const days = snapshot.frozen_payload?.quote?.upstream_payload?.metadata;
260
+ const daysArray = (days?.days ?? days);
261
+ if (Array.isArray(daysArray) && daysArray.length > 0) {
262
+ const first = daysArray[0];
263
+ const last = daysArray[daysArray.length - 1];
264
+ const startsRaw = pickString(first?.startAt, first?.startsAt, first?.date);
265
+ const endsRaw = pickString(last?.endAt, last?.endsAt, last?.date);
266
+ if (startsRaw) {
267
+ const startsAt = new Date(startsRaw);
268
+ const endsAt = endsRaw ? new Date(endsRaw) : null;
269
+ if (Number.isFinite(startsAt.getTime())) {
270
+ return {
271
+ serviceDate: startsRaw.slice(0, 10),
272
+ startsAt,
273
+ endsAt: endsAt && Number.isFinite(endsAt.getTime()) ? endsAt : null,
274
+ };
275
+ }
276
+ }
277
+ }
278
+ // Layer 3: draft date range.
279
+ const range = draftPayload.configure?.dateRange;
280
+ if (range?.checkIn) {
281
+ const startsAt = new Date(range.checkIn);
282
+ const endsAt = range.checkOut ? new Date(range.checkOut) : null;
283
+ if (Number.isFinite(startsAt.getTime())) {
284
+ return {
285
+ serviceDate: range.checkIn.slice(0, 10),
286
+ startsAt,
287
+ endsAt: endsAt && Number.isFinite(endsAt.getTime()) ? endsAt : null,
288
+ };
289
+ }
290
+ }
291
+ // Layer 4: single-day tour.
292
+ if (typeof draftPayload.configure?.departureDate === "string") {
293
+ const startsAt = new Date(draftPayload.configure.departureDate);
294
+ if (Number.isFinite(startsAt.getTime())) {
295
+ return {
296
+ serviceDate: draftPayload.configure.departureDate.slice(0, 10),
297
+ startsAt,
298
+ endsAt: null,
299
+ };
300
+ }
301
+ }
302
+ // Layer 5: booking row dates (already populated from the draft).
303
+ if (booking.startDate) {
304
+ return {
305
+ serviceDate: booking.startDate,
306
+ startsAt: new Date(booking.startDate),
307
+ endsAt: booking.endDate ? new Date(booking.endDate) : null,
308
+ };
309
+ }
310
+ return { serviceDate: null, startsAt: null, endsAt: null };
311
+ }
312
+ function findSelectedDeparture(snapshot, draftPayload) {
313
+ const slotId = pickString(draftPayload.configure?.departureSlotId);
314
+ const frozen = snapshot.frozen_payload ?? {};
315
+ const reserve = frozen.reserve;
316
+ const quote = frozen.quote;
317
+ const quotePayload = quote?.upstream_payload;
318
+ const content = frozen.content;
319
+ const direct = departureDatesFromRecord(asRecord(reserve?.departure) ?? asRecord(quotePayload?.departure));
320
+ if (direct?.startsRaw && (!slotId || direct.id === slotId)) {
321
+ return direct;
322
+ }
323
+ if (!slotId)
324
+ return direct?.startsRaw ? direct : null;
325
+ const candidates = [
326
+ content?.departures,
327
+ content?.product?.departures,
328
+ quotePayload?.departures,
329
+ quotePayload?.metadata?.departures,
330
+ ];
331
+ for (const candidate of candidates) {
332
+ if (!Array.isArray(candidate))
333
+ continue;
334
+ for (const item of candidate) {
335
+ const row = asRecord(item);
336
+ if (!row || row.id !== slotId)
337
+ continue;
338
+ const dates = departureDatesFromRecord(row);
339
+ if (dates?.startsRaw)
340
+ return dates;
341
+ }
342
+ }
343
+ return null;
344
+ }
345
+ function departureDatesFromRecord(row) {
346
+ if (!row)
347
+ return null;
348
+ const startsRaw = pickString(row.starts_at, row.startsAt, row.start_at, row.startAt, row.start_date, row.startDate, row.date);
349
+ if (!startsRaw)
350
+ return null;
351
+ return {
352
+ id: pickString(row.id),
353
+ startsRaw,
354
+ endsRaw: pickString(row.ends_at, row.endsAt, row.end_at, row.endAt, row.end_date, row.endDate),
355
+ };
356
+ }
357
+ function asRecord(value) {
358
+ return value && typeof value === "object" && !Array.isArray(value)
359
+ ? value
360
+ : undefined;
361
+ }
362
+ /**
363
+ * Pull a description for the booking item from the upstream payload.
364
+ * Sourced products typically carry rich descriptions on the upstream
365
+ * entry; surfacing a short snippet on the item helps operators scan
366
+ * a multi-item booking without clicking into each line.
367
+ */
368
+ export function extractItemDescription(snapshot) {
369
+ const upstream = snapshot.frozen_payload?.quote
370
+ ?.upstream_payload;
371
+ const desc = pickString(upstream?.description, upstream?.summary, upstream?.short_description);
372
+ if (!desc)
373
+ return null;
374
+ // Cap at 600 chars — anything longer belongs in the catalog source
375
+ // sheet rather than on every booking-item row.
376
+ return desc.length > 600 ? `${desc.slice(0, 597)}…` : desc;
377
+ }
378
+ /**
379
+ * Look up the upstream cost (net rate the operator pays the supplier)
380
+ * for a sourced entity. Returns null when the adapter doesn't expose
381
+ * a net/gross split — caller falls back to sell-as-cost (zero-markup
382
+ * default).
383
+ */
384
+ export async function resolveUpstreamCostCents(db, snapshot) {
385
+ try {
386
+ const { catalogSourcedEntriesTable } = await import("@voyant-travel/catalog");
387
+ const [sourced] = await db
388
+ .select({ projection: catalogSourcedEntriesTable.projection })
389
+ .from(catalogSourcedEntriesTable)
390
+ .where(and(eq(catalogSourcedEntriesTable.entity_module, snapshot.entity_module), eq(catalogSourcedEntriesTable.entity_id, snapshot.entity_id)))
391
+ .limit(1);
392
+ if (sourced?.projection) {
393
+ const p = sourced.projection;
394
+ const cost = p.upstreamCostCents ?? p.netPriceCents ?? p.costCents;
395
+ if (typeof cost === "number" && Number.isFinite(cost))
396
+ return cost;
397
+ }
398
+ }
399
+ catch {
400
+ // ignore
401
+ }
402
+ return null;
403
+ }
404
+ /**
405
+ * Resolve a human title for the booking line item. Tries:
406
+ * 1. `catalog_sourced_entries.projection.name` — sourced products
407
+ * (demo, Bokun, …) all carry the upstream title there.
408
+ * 2. The injected `getOwnedProductName` — owned products from this
409
+ * deployment's products module (injected because inventory
410
+ * depends on commerce; a static import would cycle).
411
+ * 3. A generic "$module booking" fallback.
412
+ *
413
+ * Errors fall through quietly — a title is purely cosmetic, the
414
+ * booking-item row should always insert successfully.
415
+ */
416
+ export async function resolveLineItemTitle(db, snapshot, options) {
417
+ try {
418
+ const { catalogSourcedEntriesTable } = await import("@voyant-travel/catalog");
419
+ const [sourcedEntry] = await db
420
+ .select({ projection: catalogSourcedEntriesTable.projection })
421
+ .from(catalogSourcedEntriesTable)
422
+ .where(and(eq(catalogSourcedEntriesTable.entity_module, snapshot.entity_module), eq(catalogSourcedEntriesTable.entity_id, snapshot.entity_id)))
423
+ .limit(1);
424
+ if (sourcedEntry?.projection) {
425
+ const projection = sourcedEntry.projection;
426
+ const candidate = projection.name ?? projection.title;
427
+ if (typeof candidate === "string" && candidate.length > 0) {
428
+ return candidate;
429
+ }
430
+ }
431
+ }
432
+ catch {
433
+ // continue to owned-products fallback
434
+ }
435
+ if (snapshot.entity_module === "products") {
436
+ try {
437
+ const name = await options.getOwnedProductName(db, snapshot.entity_module, snapshot.entity_id);
438
+ if (name)
439
+ return name;
440
+ }
441
+ catch {
442
+ // continue to generic fallback
443
+ }
444
+ }
445
+ return `${snapshot.entity_module} booking`;
446
+ }
447
+ export function travelerBandToCategory(band) {
448
+ if (band === "child" || band === "infant" || band === "senior")
449
+ return band;
450
+ return "adult";
451
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=materialization-support.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"materialization-support.test.d.ts","sourceRoot":"","sources":["../../src/checkout/materialization-support.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,196 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { extractBookingDates, extractItemDates, extractItemDescription, inferSnapshotTaxFacts, resolveLineItemTitle, travelerBandToCategory, } from "./materialization-support.js";
3
+ function snapshot(overrides = {}) {
4
+ return {
5
+ id: "snap_1",
6
+ entity_module: "products",
7
+ entity_id: "prod_1",
8
+ source_kind: "demo",
9
+ source_provider: null,
10
+ source_ref: null,
11
+ frozen_payload: null,
12
+ pricing_base_amount: null,
13
+ pricing_taxes: null,
14
+ pricing_fees: null,
15
+ pricing_surcharges: null,
16
+ pricing_currency: "EUR",
17
+ ...overrides,
18
+ };
19
+ }
20
+ describe("travelerBandToCategory", () => {
21
+ it("maps known bands and defaults unknowns to adult", () => {
22
+ expect(travelerBandToCategory("child")).toBe("child");
23
+ expect(travelerBandToCategory("infant")).toBe("infant");
24
+ expect(travelerBandToCategory("senior")).toBe("senior");
25
+ expect(travelerBandToCategory("adult")).toBe("adult");
26
+ expect(travelerBandToCategory(undefined)).toBe("adult");
27
+ expect(travelerBandToCategory("teen")).toBe("adult");
28
+ });
29
+ });
30
+ describe("extractBookingDates", () => {
31
+ it("prefers the draft date range when present", () => {
32
+ const draft = {
33
+ configure: {
34
+ dateRange: { checkIn: "2026-07-01T00:00:00Z", checkOut: "2026-07-05T00:00:00Z" },
35
+ },
36
+ };
37
+ expect(extractBookingDates({ frozen_payload: null }, draft)).toEqual({
38
+ startDate: "2026-07-01",
39
+ endDate: "2026-07-05",
40
+ });
41
+ });
42
+ it("falls back to a selected departure resolved from the frozen payload", () => {
43
+ const draft = { configure: { departureSlotId: "dep_9" } };
44
+ const frozen = {
45
+ content: {
46
+ departures: [
47
+ { id: "dep_9", starts_at: "2026-08-10T09:00:00Z", ends_at: "2026-08-10T17:00:00Z" },
48
+ ],
49
+ },
50
+ };
51
+ expect(extractBookingDates({ frozen_payload: frozen }, draft)).toEqual({
52
+ startDate: "2026-08-10",
53
+ endDate: "2026-08-10",
54
+ });
55
+ });
56
+ it("falls back to a single departureDate", () => {
57
+ const draft = { configure: { departureDate: "2026-09-02T00:00:00Z" } };
58
+ expect(extractBookingDates({ frozen_payload: null }, draft)).toEqual({
59
+ startDate: "2026-09-02",
60
+ endDate: null,
61
+ });
62
+ });
63
+ it("returns nulls when nothing resolves", () => {
64
+ expect(extractBookingDates({ frozen_payload: null }, {})).toEqual({
65
+ startDate: null,
66
+ endDate: null,
67
+ });
68
+ });
69
+ });
70
+ describe("extractItemDates", () => {
71
+ const booking = { startDate: null, endDate: null };
72
+ it("resolves the selected departure first", () => {
73
+ const draft = { configure: { departureSlotId: "dep_1" } };
74
+ const snap = snapshot({
75
+ frozen_payload: {
76
+ reserve: {
77
+ departure: {
78
+ id: "dep_1",
79
+ startsAt: "2026-07-01T08:00:00Z",
80
+ endsAt: "2026-07-01T18:00:00Z",
81
+ },
82
+ },
83
+ },
84
+ });
85
+ const out = extractItemDates(snap, draft, booking);
86
+ expect(out.serviceDate).toBe("2026-07-01");
87
+ expect(out.startsAt?.toISOString()).toBe("2026-07-01T08:00:00.000Z");
88
+ expect(out.endsAt?.toISOString()).toBe("2026-07-01T18:00:00.000Z");
89
+ });
90
+ it("falls back to upstream metadata.days[]", () => {
91
+ const snap = snapshot({
92
+ frozen_payload: {
93
+ quote: {
94
+ upstream_payload: {
95
+ metadata: {
96
+ days: [{ date: "2026-06-01T00:00:00Z" }, { date: "2026-06-03T00:00:00Z" }],
97
+ },
98
+ },
99
+ },
100
+ },
101
+ });
102
+ const out = extractItemDates(snap, {}, booking);
103
+ expect(out.serviceDate).toBe("2026-06-01");
104
+ expect(out.endsAt?.toISOString()).toBe("2026-06-03T00:00:00.000Z");
105
+ });
106
+ it("falls back to booking row dates last", () => {
107
+ const out = extractItemDates(snapshot(), {}, {
108
+ startDate: "2026-05-05",
109
+ endDate: "2026-05-09",
110
+ });
111
+ expect(out.serviceDate).toBe("2026-05-05");
112
+ expect(out.startsAt?.toISOString().slice(0, 10)).toBe("2026-05-05");
113
+ });
114
+ it("returns nulls when nothing resolves", () => {
115
+ expect(extractItemDates(snapshot(), {}, booking)).toEqual({
116
+ serviceDate: null,
117
+ startsAt: null,
118
+ endsAt: null,
119
+ });
120
+ });
121
+ });
122
+ describe("inferSnapshotTaxFacts", () => {
123
+ it("detects accommodation countries in the frozen content", () => {
124
+ const snap = snapshot({
125
+ frozen_payload: {
126
+ content: {
127
+ items: [
128
+ { type: "accommodation", countryCode: "ro" },
129
+ { type: "transfer", country: "fr" },
130
+ ],
131
+ },
132
+ },
133
+ });
134
+ const facts = inferSnapshotTaxFacts(snap);
135
+ expect(facts.hasAccommodation).toBe(true);
136
+ expect(facts.accommodationCountries).toEqual(["RO"]);
137
+ });
138
+ it("returns no accommodation when none present", () => {
139
+ expect(inferSnapshotTaxFacts(snapshot())).toEqual({
140
+ hasAccommodation: false,
141
+ accommodationCountries: [],
142
+ });
143
+ });
144
+ });
145
+ describe("extractItemDescription", () => {
146
+ it("pulls + caps the upstream description", () => {
147
+ const long = "x".repeat(700);
148
+ const snap = snapshot({
149
+ frozen_payload: { quote: { upstream_payload: { description: long } } },
150
+ });
151
+ const out = extractItemDescription(snap);
152
+ expect(out).not.toBeNull();
153
+ expect(out?.length).toBe(598);
154
+ expect(out?.endsWith("…")).toBe(true);
155
+ });
156
+ it("returns null when no description", () => {
157
+ expect(extractItemDescription(snapshot())).toBeNull();
158
+ });
159
+ });
160
+ describe("resolveLineItemTitle", () => {
161
+ it("returns the sourced projection name when present", async () => {
162
+ const db = {
163
+ select: () => ({
164
+ from: () => ({
165
+ where: () => ({ limit: async () => [{ projection: { name: "Northern Lights Hunt" } }] }),
166
+ }),
167
+ }),
168
+ };
169
+ const title = await resolveLineItemTitle(db, snapshot(), {
170
+ getOwnedProductName: vi.fn(),
171
+ });
172
+ expect(title).toBe("Northern Lights Hunt");
173
+ });
174
+ it("falls back to the injected owned-product name", async () => {
175
+ const db = {
176
+ select: () => ({
177
+ from: () => ({ where: () => ({ limit: async () => [] }) }),
178
+ }),
179
+ };
180
+ const getOwnedProductName = vi.fn().mockResolvedValue("Owned Tour");
181
+ const title = await resolveLineItemTitle(db, snapshot(), { getOwnedProductName });
182
+ expect(title).toBe("Owned Tour");
183
+ expect(getOwnedProductName).toHaveBeenCalledWith(db, "products", "prod_1");
184
+ });
185
+ it("falls back to the generic module label", async () => {
186
+ const db = {
187
+ select: () => ({
188
+ from: () => ({ where: () => ({ limit: async () => [] }) }),
189
+ }),
190
+ };
191
+ const title = await resolveLineItemTitle(db, snapshot({ entity_module: "cruises" }), {
192
+ getOwnedProductName: vi.fn().mockResolvedValue(null),
193
+ });
194
+ expect(title).toBe("cruises booking");
195
+ });
196
+ });
@@ -0,0 +1,10 @@
1
+ import type { bookings } from "@voyant-travel/bookings/schema";
2
+ import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
3
+ import type { MaterializationSnapshot } from "./materialization.js";
4
+ import type { CheckoutModuleOptions } from "./options.js";
5
+ export declare function rebuildBookingItemTaxLines(db: PostgresJsDatabase, bookingId: string, options: Pick<CheckoutModuleOptions, "resolveBookingTaxSettings">): Promise<{
6
+ rebuilt: number;
7
+ itemsWithoutSnapshot: number;
8
+ }>;
9
+ export declare function materializeBookingItemTaxLine(db: PostgresJsDatabase, booking: typeof bookings.$inferSelect, bookingItemId: string, amountCents: number, snapshot: MaterializationSnapshot, options: Pick<CheckoutModuleOptions, "resolveBookingTaxSettings">): Promise<void>;
10
+ //# sourceMappingURL=materialization-tax.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"materialization-tax.d.ts","sourceRoot":"","sources":["../../src/checkout/materialization-tax.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gCAAgC,CAAA;AAO9D,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAA;AACjE,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,sBAAsB,CAAA;AAEnE,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,cAAc,CAAA;AAEzD,wBAAsB,0BAA0B,CAC9C,EAAE,EAAE,kBAAkB,EACtB,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,IAAI,CAAC,qBAAqB,EAAE,2BAA2B,CAAC,GAChE,OAAO,CAAC;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,oBAAoB,EAAE,MAAM,CAAA;CAAE,CAAC,CAqC5D;AA6CD,wBAAsB,6BAA6B,CACjD,EAAE,EAAE,kBAAkB,EACtB,OAAO,EAAE,OAAO,QAAQ,CAAC,YAAY,EACrC,aAAa,EAAE,MAAM,EACrB,WAAW,EAAE,MAAM,EACnB,QAAQ,EAAE,uBAAuB,EACjC,OAAO,EAAE,IAAI,CAAC,qBAAqB,EAAE,2BAA2B,CAAC,iBA+BlE"}