@voyant-travel/commerce 0.2.2 → 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.
- package/dist/checkout/acceptance-signature.d.ts +4 -0
- package/dist/checkout/acceptance-signature.d.ts.map +1 -0
- package/dist/checkout/acceptance-signature.js +95 -0
- package/dist/checkout/finalize.d.ts +42 -0
- package/dist/checkout/finalize.d.ts.map +1 -0
- package/dist/checkout/finalize.js +208 -0
- package/dist/checkout/index.d.ts +26 -0
- package/dist/checkout/index.d.ts.map +1 -0
- package/dist/checkout/index.js +24 -0
- package/dist/checkout/materialization-support.d.ts +105 -0
- package/dist/checkout/materialization-support.d.ts.map +1 -0
- package/dist/checkout/materialization-support.js +451 -0
- package/dist/checkout/materialization-support.test.d.ts +2 -0
- package/dist/checkout/materialization-support.test.d.ts.map +1 -0
- package/dist/checkout/materialization-support.test.js +196 -0
- package/dist/checkout/materialization-tax.d.ts +10 -0
- package/dist/checkout/materialization-tax.d.ts.map +1 -0
- package/dist/checkout/materialization-tax.js +113 -0
- package/dist/checkout/materialization-tax.test.d.ts +2 -0
- package/dist/checkout/materialization-tax.test.d.ts.map +1 -0
- package/dist/checkout/materialization-tax.test.js +69 -0
- package/dist/checkout/materialization.d.ts +99 -0
- package/dist/checkout/materialization.d.ts.map +1 -0
- package/dist/checkout/materialization.js +269 -0
- package/dist/checkout/options.d.ts +89 -0
- package/dist/checkout/options.d.ts.map +1 -0
- package/dist/checkout/options.js +21 -0
- package/dist/checkout/routes.d.ts +21 -0
- package/dist/checkout/routes.d.ts.map +1 -0
- package/dist/checkout/routes.js +59 -0
- package/dist/checkout/start-service.d.ts +75 -0
- package/dist/checkout/start-service.d.ts.map +1 -0
- package/dist/checkout/start-service.js +415 -0
- package/dist/checkout/start-service.test.d.ts +2 -0
- package/dist/checkout/start-service.test.d.ts.map +1 -0
- package/dist/checkout/start-service.test.js +57 -0
- package/dist/markets/routes.d.ts +1 -1
- package/dist/markets/service-core.d.ts +1 -1
- package/dist/pricing/routes-public.d.ts.map +1 -1
- package/dist/pricing/routes-public.js +12 -2
- package/dist/sellability/routes.d.ts +10 -10
- package/dist/sellability/service-records.d.ts +4 -4
- package/dist/sellability/service-snapshots.d.ts +2 -2
- package/dist/sellability/service.d.ts +10 -10
- package/package.json +28 -6
|
@@ -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 @@
|
|
|
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"}
|