@voyantjs/legal 0.119.0 → 0.119.2
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/contracts/routes.d.ts.map +1 -1
- package/dist/contracts/schema.d.ts.map +1 -1
- package/dist/contracts/schema.js +4 -0
- package/dist/contracts/service-auto-generate-types.d.ts +395 -0
- package/dist/contracts/service-auto-generate-types.d.ts.map +1 -0
- package/dist/contracts/service-auto-generate-types.js +1 -0
- package/dist/contracts/service-auto-generate-variables.d.ts +9 -0
- package/dist/contracts/service-auto-generate-variables.d.ts.map +1 -0
- package/dist/contracts/service-auto-generate-variables.js +683 -0
- package/dist/contracts/service-auto-generate.d.ts +2 -393
- package/dist/contracts/service-auto-generate.d.ts.map +1 -1
- package/dist/contracts/service-auto-generate.js +4 -674
- package/dist/contracts/service-contracts.d.ts.map +1 -1
- package/dist/contracts/service-contracts.js +3 -1
- package/dist/contracts/service-documents-browser.d.ts.map +1 -1
- package/dist/contracts/service-documents-browser.js +2 -1
- package/dist/contracts/service-documents.d.ts.map +1 -1
- package/dist/contracts/service-series.d.ts.map +1 -1
- package/dist/contracts/service-series.js +1 -0
- package/dist/contracts/service-shared.d.ts.map +1 -1
- package/dist/contracts/service-shared.js +5 -1
- package/dist/contracts/service.d.ts.map +1 -1
- package/dist/contracts/template-authoring.d.ts.map +1 -1
- package/dist/policies/service-core.d.ts.map +1 -1
- package/dist/policies/service-core.js +21 -6
- package/dist/policies/service.d.ts.map +1 -1
- package/package.json +8 -8
|
@@ -0,0 +1,683 @@
|
|
|
1
|
+
// agent-quality: file-size exception -- owner: legal; #1730 moved the default variable assembly out of service-auto-generate, with a follow-up split by traveler/item/settlement context still warranted.
|
|
2
|
+
import { ledgerSensitiveRead } from "@voyantjs/action-ledger";
|
|
3
|
+
import { BOOKING_PII_READ_CAPABILITY, bookingsService, } from "@voyantjs/bookings";
|
|
4
|
+
import { bookingPiiAccessLog } from "@voyantjs/bookings/schema";
|
|
5
|
+
import { bookingPaymentSchedules, invoices, payments } from "@voyantjs/finance/schema";
|
|
6
|
+
import { and, asc, desc, eq, ne, sql } from "drizzle-orm";
|
|
7
|
+
export async function resolveContractGenerationVariables(db, booking, event, options, runtime, template) {
|
|
8
|
+
const travelers = await bookingsService.listTravelers(db, event.bookingId);
|
|
9
|
+
const travelerTravelDetails = await resolveTravelerTravelDetails(db, booking.id, travelers, runtime.bookingPiiService, event.actorId, runtime.actionLedgerContext);
|
|
10
|
+
const leadTraveler = travelers.find((t) => travelerTravelDetails.get(t.id)?.isLeadTraveler) ??
|
|
11
|
+
travelers.find((t) => t.isPrimary) ??
|
|
12
|
+
travelers.find((t) => t.participantType === "traveler") ??
|
|
13
|
+
travelers[0] ??
|
|
14
|
+
null;
|
|
15
|
+
const leadTravelerDetails = leadTraveler ? travelerTravelDetails.get(leadTraveler.id) : null;
|
|
16
|
+
const bookingItemContext = await resolveBookingItemContext(db, booking.id);
|
|
17
|
+
const now = new Date();
|
|
18
|
+
const todayIso = now.toISOString().slice(0, 10);
|
|
19
|
+
const todayIsoDateTime = now.toISOString();
|
|
20
|
+
const todayTime = todayIsoDateTime.slice(11, 19);
|
|
21
|
+
const sellCurrency = booking.sellCurrency ?? "";
|
|
22
|
+
const totalCents = booking.sellAmountCents ?? 0;
|
|
23
|
+
const startDate = booking.startDate ?? "";
|
|
24
|
+
const endDate = booking.endDate ?? "";
|
|
25
|
+
const durationNights = computeNights(startDate, endDate);
|
|
26
|
+
const settlement = await resolveBookingSettlementVariables(db, booking.id, sellCurrency);
|
|
27
|
+
const amountDueCents = computeAmountDueCents(settlement, totalCents);
|
|
28
|
+
const isPaidInFull = amountDueCents <= 0 ||
|
|
29
|
+
(settlement.balanceDueCents == null &&
|
|
30
|
+
totalCents > 0 &&
|
|
31
|
+
settlement.paidAmountCents >= totalCents);
|
|
32
|
+
const paymentSchedule = await resolveBookingPaymentScheduleVariables(db, booking.id);
|
|
33
|
+
const roomsSummary = deriveRoomsSummary(bookingItemContext.rawItems, bookingItemContext.roomOptionUnitIds);
|
|
34
|
+
const mappedTravelers = travelers.map((t, i) => {
|
|
35
|
+
const fullName = [t.firstName, t.lastName].filter(Boolean).join(" ").trim();
|
|
36
|
+
const travelDetails = travelerTravelDetails.get(t.id);
|
|
37
|
+
return {
|
|
38
|
+
id: t.id,
|
|
39
|
+
index: i + 1,
|
|
40
|
+
band: t.participantType,
|
|
41
|
+
participantType: t.participantType,
|
|
42
|
+
isLead: leadTraveler?.id === t.id,
|
|
43
|
+
isPrimary: t.isPrimary,
|
|
44
|
+
firstName: t.firstName,
|
|
45
|
+
lastName: t.lastName,
|
|
46
|
+
fullName,
|
|
47
|
+
email: t.email ?? "",
|
|
48
|
+
phone: t.phone ?? "",
|
|
49
|
+
dateOfBirth: travelDetails?.dateOfBirth ?? "",
|
|
50
|
+
document: {
|
|
51
|
+
type: travelDetails?.documentType ?? "",
|
|
52
|
+
number: travelDetails?.documentNumber ?? "",
|
|
53
|
+
country: travelDetails?.documentIssuingCountry ?? "",
|
|
54
|
+
issuingAuthority: travelDetails?.documentIssuingAuthority ?? "",
|
|
55
|
+
issueDate: "",
|
|
56
|
+
expiryDate: travelDetails?.documentExpiry ?? "",
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
});
|
|
60
|
+
const customerFullName = [booking.contactFirstName, booking.contactLastName].filter(Boolean).join(" ").trim() ||
|
|
61
|
+
(leadTraveler ? `${leadTraveler.firstName} ${leadTraveler.lastName}`.trim() : "");
|
|
62
|
+
const defaults = {
|
|
63
|
+
today: todayIso,
|
|
64
|
+
currentDate: todayIso,
|
|
65
|
+
currentDateTime: todayIsoDateTime,
|
|
66
|
+
currentTime: todayTime,
|
|
67
|
+
contract: {
|
|
68
|
+
contractNumber: "",
|
|
69
|
+
number: "",
|
|
70
|
+
contractDate: todayIso,
|
|
71
|
+
date: todayIso,
|
|
72
|
+
issuedAt: todayIsoDateTime,
|
|
73
|
+
signedAt: "",
|
|
74
|
+
isManual: false,
|
|
75
|
+
series: options.seriesName ?? "",
|
|
76
|
+
channel: "",
|
|
77
|
+
source: "",
|
|
78
|
+
status: "draft",
|
|
79
|
+
},
|
|
80
|
+
booking: {
|
|
81
|
+
bookingId: booking.id,
|
|
82
|
+
bookingNumber: booking.bookingNumber,
|
|
83
|
+
number: booking.bookingNumber,
|
|
84
|
+
id: booking.id,
|
|
85
|
+
status: booking.status,
|
|
86
|
+
entityModule: bookingItemContext.entityModule,
|
|
87
|
+
entityId: bookingItemContext.entityId,
|
|
88
|
+
vertical: bookingItemContext.vertical,
|
|
89
|
+
productName: bookingItemContext.productTitle,
|
|
90
|
+
productSubtitle: bookingItemContext.productSubtitle,
|
|
91
|
+
destination: bookingItemContext.destination,
|
|
92
|
+
pax: booking.pax,
|
|
93
|
+
paxTotal: booking.pax ?? 0,
|
|
94
|
+
paxAdult: 0,
|
|
95
|
+
paxChild: 0,
|
|
96
|
+
paxInfant: 0,
|
|
97
|
+
paxBands: {},
|
|
98
|
+
travelDates: {
|
|
99
|
+
start: startDate,
|
|
100
|
+
end: endDate,
|
|
101
|
+
durationNights,
|
|
102
|
+
},
|
|
103
|
+
startDate: booking.startDate,
|
|
104
|
+
endDate: booking.endDate,
|
|
105
|
+
sellCurrency,
|
|
106
|
+
sellAmountCents: booking.sellAmountCents ?? null,
|
|
107
|
+
sellSubtotalCents: booking.sellAmountCents ?? 0,
|
|
108
|
+
sellTaxAmountCents: 0,
|
|
109
|
+
sellDiscountAmountCents: 0,
|
|
110
|
+
costCurrency: "",
|
|
111
|
+
costAmountCents: booking.costAmountCents ?? 0,
|
|
112
|
+
baseCurrency: booking.baseCurrency ?? "",
|
|
113
|
+
baseSellAmountCents: booking.baseSellAmountCents ?? 0,
|
|
114
|
+
marginPercent: booking.marginPercent ?? 0,
|
|
115
|
+
currency: sellCurrency,
|
|
116
|
+
totalAmountCents: booking.sellAmountCents ?? null,
|
|
117
|
+
subtotalAmountCents: booking.sellAmountCents ?? 0,
|
|
118
|
+
taxAmountCents: 0,
|
|
119
|
+
discountAmountCents: 0,
|
|
120
|
+
paidAmountCents: settlement.paidAmountCents,
|
|
121
|
+
amountDueCents,
|
|
122
|
+
balanceDueCents: amountDueCents,
|
|
123
|
+
isPaidInFull,
|
|
124
|
+
depositAmountCents: paymentSchedule.depositAmountCents,
|
|
125
|
+
depositDueDate: paymentSchedule.depositDueDate,
|
|
126
|
+
balanceAmountCents: paymentSchedule.balanceAmountCents,
|
|
127
|
+
balanceDueDate: paymentSchedule.balanceDueDate,
|
|
128
|
+
paymentPolicy: {
|
|
129
|
+
source: "operator_default",
|
|
130
|
+
},
|
|
131
|
+
roomsSummary,
|
|
132
|
+
source: {
|
|
133
|
+
kind: booking.sourceType ?? "",
|
|
134
|
+
type: booking.sourceType ?? "",
|
|
135
|
+
connectionId: "",
|
|
136
|
+
ref: booking.externalBookingRef ?? "",
|
|
137
|
+
externalRef: booking.externalBookingRef ?? "",
|
|
138
|
+
supplier: { id: "", name: "" },
|
|
139
|
+
},
|
|
140
|
+
internalNotes: booking.internalNotes ?? "",
|
|
141
|
+
customerNotes: "",
|
|
142
|
+
},
|
|
143
|
+
customer: {
|
|
144
|
+
type: "B2C",
|
|
145
|
+
firstName: booking.contactFirstName ?? "",
|
|
146
|
+
lastName: booking.contactLastName ?? "",
|
|
147
|
+
fullName: customerFullName,
|
|
148
|
+
email: booking.contactEmail ?? "",
|
|
149
|
+
phone: booking.contactPhone ?? "",
|
|
150
|
+
dateOfBirth: leadTravelerDetails?.dateOfBirth ?? "",
|
|
151
|
+
companyName: "",
|
|
152
|
+
vatId: "",
|
|
153
|
+
registrationNumber: "",
|
|
154
|
+
address: {
|
|
155
|
+
line1: booking.contactAddressLine1 ?? "",
|
|
156
|
+
line2: booking.contactAddressLine2 ?? "",
|
|
157
|
+
city: booking.contactCity ?? "",
|
|
158
|
+
region: booking.contactRegion ?? "",
|
|
159
|
+
postal: booking.contactPostalCode ?? "",
|
|
160
|
+
country: booking.contactCountry ?? "",
|
|
161
|
+
},
|
|
162
|
+
document: {
|
|
163
|
+
type: leadTravelerDetails?.documentType ?? "",
|
|
164
|
+
number: leadTravelerDetails?.documentNumber ?? "",
|
|
165
|
+
country: leadTravelerDetails?.documentIssuingCountry ?? "",
|
|
166
|
+
issuingAuthority: leadTravelerDetails?.documentIssuingAuthority ?? "",
|
|
167
|
+
issueDate: "",
|
|
168
|
+
expiryDate: leadTravelerDetails?.documentExpiry ?? "",
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
leadTraveler: leadTraveler
|
|
172
|
+
? {
|
|
173
|
+
id: leadTraveler.id,
|
|
174
|
+
firstName: leadTraveler.firstName,
|
|
175
|
+
lastName: leadTraveler.lastName,
|
|
176
|
+
fullName: [leadTraveler.firstName, leadTraveler.lastName]
|
|
177
|
+
.filter(Boolean)
|
|
178
|
+
.join(" ")
|
|
179
|
+
.trim(),
|
|
180
|
+
email: leadTraveler.email ?? "",
|
|
181
|
+
phone: leadTraveler.phone ?? "",
|
|
182
|
+
}
|
|
183
|
+
: null,
|
|
184
|
+
travelers: mappedTravelers,
|
|
185
|
+
passengers: mappedTravelers,
|
|
186
|
+
items: bookingItemContext.items,
|
|
187
|
+
addons: [],
|
|
188
|
+
product: {
|
|
189
|
+
title: bookingItemContext.productTitle,
|
|
190
|
+
subtitle: bookingItemContext.productSubtitle,
|
|
191
|
+
destination: bookingItemContext.destination,
|
|
192
|
+
module: bookingItemContext.entityModule,
|
|
193
|
+
id: bookingItemContext.entityId,
|
|
194
|
+
vertical: bookingItemContext.vertical,
|
|
195
|
+
heroImageUrl: "",
|
|
196
|
+
},
|
|
197
|
+
departureSlot: {
|
|
198
|
+
slotId: bookingItemContext.departureSlot.slotId,
|
|
199
|
+
startAt: bookingItemContext.departureSlot.startAt || startDate,
|
|
200
|
+
endAt: bookingItemContext.departureSlot.endAt || endDate,
|
|
201
|
+
durationDays: bookingItemContext.departureSlot.durationDays ?? durationNights,
|
|
202
|
+
departureCity: bookingItemContext.departureSlot.departureCity,
|
|
203
|
+
},
|
|
204
|
+
sailing: {
|
|
205
|
+
sailingId: "",
|
|
206
|
+
ship: "",
|
|
207
|
+
embarkationPort: "",
|
|
208
|
+
disembarkationPort: "",
|
|
209
|
+
airArrangement: "",
|
|
210
|
+
startDate: "",
|
|
211
|
+
endDate: "",
|
|
212
|
+
cabinCategoryId: "",
|
|
213
|
+
cabinNumberId: "",
|
|
214
|
+
},
|
|
215
|
+
stay: {
|
|
216
|
+
checkIn: "",
|
|
217
|
+
checkOut: "",
|
|
218
|
+
nights: durationNights,
|
|
219
|
+
rooms: [],
|
|
220
|
+
destination: "",
|
|
221
|
+
},
|
|
222
|
+
payment: {
|
|
223
|
+
intent: "",
|
|
224
|
+
method: settlement.latestCompleted?.methodLabel ?? "",
|
|
225
|
+
amountCents: totalCents,
|
|
226
|
+
currency: sellCurrency,
|
|
227
|
+
schedule: paymentSchedule.entries,
|
|
228
|
+
capturedAt: settlement.latestCompleted?.date ?? "",
|
|
229
|
+
createdAt: settlement.latestCompleted?.date ?? "",
|
|
230
|
+
latestCompleted: settlement.latestCompleted,
|
|
231
|
+
},
|
|
232
|
+
operator: {
|
|
233
|
+
name: "",
|
|
234
|
+
legalName: "",
|
|
235
|
+
vatId: "",
|
|
236
|
+
registrationNumber: "",
|
|
237
|
+
address: "",
|
|
238
|
+
phone: "",
|
|
239
|
+
email: "",
|
|
240
|
+
website: "",
|
|
241
|
+
iban: "",
|
|
242
|
+
bank: "",
|
|
243
|
+
license: "",
|
|
244
|
+
licenseAuthority: "",
|
|
245
|
+
signatoryName: "",
|
|
246
|
+
signatoryRole: "",
|
|
247
|
+
},
|
|
248
|
+
acceptance: {
|
|
249
|
+
ipAddress: "",
|
|
250
|
+
userAgent: "",
|
|
251
|
+
acceptedAt: "",
|
|
252
|
+
marketingConsent: false,
|
|
253
|
+
templateSlug: options.templateSlug,
|
|
254
|
+
templateId: template.id,
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
const variables = options.resolveVariables
|
|
258
|
+
? await options.resolveVariables({
|
|
259
|
+
db,
|
|
260
|
+
booking,
|
|
261
|
+
travelers,
|
|
262
|
+
defaults,
|
|
263
|
+
bindings: runtime.bindings ?? null,
|
|
264
|
+
})
|
|
265
|
+
: defaultVariablesToRecord(defaults);
|
|
266
|
+
return variables;
|
|
267
|
+
}
|
|
268
|
+
function defaultVariablesToRecord(defaults) {
|
|
269
|
+
return { ...defaults };
|
|
270
|
+
}
|
|
271
|
+
async function resolveTravelerTravelDetails(db, bookingId, travelers, pii, actorId, actionLedgerContext) {
|
|
272
|
+
const detailsByTraveler = new Map();
|
|
273
|
+
if (!pii || travelers.length === 0)
|
|
274
|
+
return detailsByTraveler;
|
|
275
|
+
await Promise.all(travelers.map(async (traveler) => {
|
|
276
|
+
const details = await ledgerSensitiveRead(db, {
|
|
277
|
+
context: actionLedgerContext ?? {
|
|
278
|
+
userId: actorId,
|
|
279
|
+
actor: actorId ? "staff" : "system",
|
|
280
|
+
callerType: actorId ? "staff" : "internal",
|
|
281
|
+
isInternalRequest: actorId == null,
|
|
282
|
+
},
|
|
283
|
+
actionName: "booking.pii.read",
|
|
284
|
+
actionVersion: "v1",
|
|
285
|
+
targetType: "booking_traveler",
|
|
286
|
+
targetId: traveler.id,
|
|
287
|
+
routeOrToolName: "legal.contracts.bookings.generate-document",
|
|
288
|
+
capabilityId: BOOKING_PII_READ_CAPABILITY.id,
|
|
289
|
+
capabilityVersion: BOOKING_PII_READ_CAPABILITY.version,
|
|
290
|
+
authorizationSource: "legal.contract.auto_generate",
|
|
291
|
+
reasonCode: "contract_variable_resolution",
|
|
292
|
+
disclosedFieldSet: ["dateOfBirth", "document"],
|
|
293
|
+
disclosureSummary: "Contract variable traveler identity snapshot",
|
|
294
|
+
decisionPolicy: "bookings-pii-scope-or-staff-v1",
|
|
295
|
+
fallbackPrincipalId: actorId ?? "legal_contract_auto_generate",
|
|
296
|
+
}, () => pii.getTravelerTravelDetails(db, traveler.id, actorId));
|
|
297
|
+
await logBookingPiiContractRead(db, {
|
|
298
|
+
bookingId,
|
|
299
|
+
travelerId: traveler.id,
|
|
300
|
+
actorId,
|
|
301
|
+
actionLedgerContext,
|
|
302
|
+
});
|
|
303
|
+
if (details) {
|
|
304
|
+
detailsByTraveler.set(traveler.id, details);
|
|
305
|
+
}
|
|
306
|
+
}));
|
|
307
|
+
return detailsByTraveler;
|
|
308
|
+
}
|
|
309
|
+
async function logBookingPiiContractRead(db, input) {
|
|
310
|
+
await db.insert(bookingPiiAccessLog).values({
|
|
311
|
+
bookingId: input.bookingId,
|
|
312
|
+
travelerId: input.travelerId,
|
|
313
|
+
actorId: input.actionLedgerContext?.userId ?? input.actorId,
|
|
314
|
+
actorType: input.actionLedgerContext?.actor ?? (input.actorId ? "staff" : "system"),
|
|
315
|
+
callerType: input.actionLedgerContext?.callerType ?? (input.actorId ? "staff" : "internal"),
|
|
316
|
+
action: "read",
|
|
317
|
+
outcome: "allowed",
|
|
318
|
+
reason: "contract_variable_resolution",
|
|
319
|
+
metadata: {
|
|
320
|
+
routeOrToolName: "legal.contracts.bookings.generate-document",
|
|
321
|
+
capabilityId: BOOKING_PII_READ_CAPABILITY.id,
|
|
322
|
+
capabilityVersion: BOOKING_PII_READ_CAPABILITY.version,
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
async function resolveBookingItemContext(db, bookingId) {
|
|
327
|
+
const items = await bookingsService.listItems(db, bookingId);
|
|
328
|
+
const primaryItem = items.find((item) => item.productNameSnapshot || item.productId || item.availabilitySlotId) ??
|
|
329
|
+
items[0] ??
|
|
330
|
+
null;
|
|
331
|
+
if (!primaryItem) {
|
|
332
|
+
return emptyBookingItemContext();
|
|
333
|
+
}
|
|
334
|
+
const metadata = recordValue(primaryItem.metadata);
|
|
335
|
+
const productId = primaryItem.productId ?? "";
|
|
336
|
+
const linkedProductTitle = productId && !primaryItem.productNameSnapshot
|
|
337
|
+
? await resolveLinkedProductTitle(db, productId)
|
|
338
|
+
: "";
|
|
339
|
+
const destination = firstString(pickString(metadata, [
|
|
340
|
+
"destination",
|
|
341
|
+
"destinationName",
|
|
342
|
+
"destination_name",
|
|
343
|
+
"productDestination",
|
|
344
|
+
"product_destination",
|
|
345
|
+
]), productId ? await resolveLinkedProductDestination(db, productId) : "");
|
|
346
|
+
const entityModule = firstString(pickString(metadata, ["entityModule", "entity_module", "module"]), productId ? "products" : "");
|
|
347
|
+
const entityId = firstString(pickString(metadata, ["entityId", "entity_id"]), productId);
|
|
348
|
+
const vertical = firstString(pickString(metadata, ["vertical", "entityVertical", "entity_vertical", "productType"]), entityModule);
|
|
349
|
+
const productTitle = firstString(primaryItem.productNameSnapshot, linkedProductTitle, primaryItem.title);
|
|
350
|
+
const productSubtitle = firstString(primaryItem.optionNameSnapshot, primaryItem.unitNameSnapshot, primaryItem.description);
|
|
351
|
+
const departureSlot = await resolveDepartureSlotContext(db, primaryItem);
|
|
352
|
+
const roomOptionUnitIds = await resolveRoomOptionUnitIds(db, items);
|
|
353
|
+
return {
|
|
354
|
+
entityModule,
|
|
355
|
+
entityId,
|
|
356
|
+
vertical,
|
|
357
|
+
productTitle,
|
|
358
|
+
productSubtitle,
|
|
359
|
+
destination,
|
|
360
|
+
departureSlot,
|
|
361
|
+
items: items.map(mapBookingItemToContractItem),
|
|
362
|
+
rawItems: items,
|
|
363
|
+
roomOptionUnitIds,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
function emptyBookingItemContext() {
|
|
367
|
+
return {
|
|
368
|
+
entityModule: "",
|
|
369
|
+
entityId: "",
|
|
370
|
+
vertical: "",
|
|
371
|
+
productTitle: "",
|
|
372
|
+
productSubtitle: "",
|
|
373
|
+
destination: "",
|
|
374
|
+
departureSlot: {
|
|
375
|
+
slotId: "",
|
|
376
|
+
startAt: "",
|
|
377
|
+
endAt: "",
|
|
378
|
+
durationDays: null,
|
|
379
|
+
departureCity: "",
|
|
380
|
+
},
|
|
381
|
+
items: [],
|
|
382
|
+
rawItems: [],
|
|
383
|
+
roomOptionUnitIds: new Set(),
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
async function resolveBookingPaymentScheduleVariables(db, bookingId) {
|
|
387
|
+
const rows = await db
|
|
388
|
+
.select()
|
|
389
|
+
.from(bookingPaymentSchedules)
|
|
390
|
+
.where(eq(bookingPaymentSchedules.bookingId, bookingId))
|
|
391
|
+
.orderBy(asc(bookingPaymentSchedules.dueDate), asc(bookingPaymentSchedules.createdAt));
|
|
392
|
+
const deposit = rows.find((row) => row.scheduleType === "deposit");
|
|
393
|
+
const balance = rows.find((row) => row.scheduleType === "balance");
|
|
394
|
+
return {
|
|
395
|
+
entries: rows.map((row, index) => ({
|
|
396
|
+
index: index + 1,
|
|
397
|
+
type: row.scheduleType,
|
|
398
|
+
amountCents: row.amountCents,
|
|
399
|
+
currency: row.currency,
|
|
400
|
+
dueDate: row.dueDate,
|
|
401
|
+
status: row.status,
|
|
402
|
+
})),
|
|
403
|
+
depositAmountCents: deposit?.amountCents ?? 0,
|
|
404
|
+
depositDueDate: deposit?.dueDate ?? "",
|
|
405
|
+
balanceAmountCents: balance?.amountCents ?? 0,
|
|
406
|
+
balanceDueDate: balance?.dueDate ?? "",
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
function deriveRoomsSummary(items, roomOptionUnitIds) {
|
|
410
|
+
const lines = items
|
|
411
|
+
.filter((item) => isAccommodationLikeItem(item, roomOptionUnitIds))
|
|
412
|
+
.map((item) => {
|
|
413
|
+
const label = firstString(item.unitNameSnapshot, item.optionNameSnapshot, item.title);
|
|
414
|
+
if (!label)
|
|
415
|
+
return "";
|
|
416
|
+
return `${item.quantity ?? 1}× ${label}`;
|
|
417
|
+
})
|
|
418
|
+
.filter(Boolean);
|
|
419
|
+
return lines.join(", ");
|
|
420
|
+
}
|
|
421
|
+
function isAccommodationLikeItem(item, roomOptionUnitIds) {
|
|
422
|
+
if (item.itemType === "accommodation")
|
|
423
|
+
return true;
|
|
424
|
+
if (item.optionUnitId && roomOptionUnitIds.has(item.optionUnitId))
|
|
425
|
+
return true;
|
|
426
|
+
const metadata = recordValue(item.metadata);
|
|
427
|
+
const unitType = pickString(metadata, [
|
|
428
|
+
"unitType",
|
|
429
|
+
"unit_type",
|
|
430
|
+
"optionUnitType",
|
|
431
|
+
"option_unit_type",
|
|
432
|
+
]).toLowerCase();
|
|
433
|
+
return unitType === "room" || unitType === "accommodation";
|
|
434
|
+
}
|
|
435
|
+
async function resolveRoomOptionUnitIds(db, items) {
|
|
436
|
+
const optionUnitIds = [
|
|
437
|
+
...new Set(items
|
|
438
|
+
.map((item) => item.optionUnitId)
|
|
439
|
+
.filter((id) => typeof id === "string" && id.trim().length > 0)),
|
|
440
|
+
];
|
|
441
|
+
if (optionUnitIds.length === 0)
|
|
442
|
+
return new Set();
|
|
443
|
+
try {
|
|
444
|
+
const result = await db.execute(sql `
|
|
445
|
+
SELECT id
|
|
446
|
+
FROM option_units
|
|
447
|
+
WHERE id IN (${sql.join(
|
|
448
|
+
// agent-quality: raw-sql reviewed -- owner: legal; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
|
|
449
|
+
optionUnitIds.map((id) => sql `${id}`), sql `, `)})
|
|
450
|
+
AND unit_type IN ('room', 'accommodation')
|
|
451
|
+
`);
|
|
452
|
+
return new Set(toRows(result).map((row) => row.id));
|
|
453
|
+
}
|
|
454
|
+
catch (error) {
|
|
455
|
+
if (isUndefinedTableError(error))
|
|
456
|
+
return new Set();
|
|
457
|
+
throw error;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
function mapBookingItemToContractItem(item, index) {
|
|
461
|
+
const quantity = item.quantity ?? 1;
|
|
462
|
+
const unitAmountCents = item.unitSellAmountCents ??
|
|
463
|
+
(item.totalSellAmountCents != null && quantity > 0
|
|
464
|
+
? Math.round(item.totalSellAmountCents / quantity)
|
|
465
|
+
: 0);
|
|
466
|
+
return {
|
|
467
|
+
index: index + 1,
|
|
468
|
+
kind: item.itemType,
|
|
469
|
+
description: firstString(item.productNameSnapshot, item.title, item.description),
|
|
470
|
+
quantity,
|
|
471
|
+
unitAmountCents,
|
|
472
|
+
totalAmountCents: item.totalSellAmountCents ?? unitAmountCents * quantity,
|
|
473
|
+
currency: item.sellCurrency,
|
|
474
|
+
taxIncluded: false,
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
async function resolveDepartureSlotContext(db, item) {
|
|
478
|
+
const slotId = item.availabilitySlotId ?? "";
|
|
479
|
+
const linkedSlot = slotId ? await resolveLinkedAvailabilitySlot(db, slotId) : null;
|
|
480
|
+
const startAt = normalizeDateTime(linkedSlot?.starts_at ?? item.startsAt);
|
|
481
|
+
const endAt = normalizeDateTime(linkedSlot?.ends_at ?? item.endsAt);
|
|
482
|
+
const durationDays = numberValue(linkedSlot?.days) ??
|
|
483
|
+
(startAt && endAt
|
|
484
|
+
? Math.max(1, computeNights(startAt.slice(0, 10), endAt.slice(0, 10)) + 1)
|
|
485
|
+
: null);
|
|
486
|
+
return {
|
|
487
|
+
slotId,
|
|
488
|
+
startAt,
|
|
489
|
+
endAt,
|
|
490
|
+
durationDays,
|
|
491
|
+
departureCity: extractDepartureCity(item.departureLabelSnapshot),
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
async function resolveLinkedProductTitle(db, productId) {
|
|
495
|
+
try {
|
|
496
|
+
const result = await db.execute(
|
|
497
|
+
// agent-quality: raw-sql reviewed -- owner: legal; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
|
|
498
|
+
sql `select name from products where id = ${productId} limit 1`);
|
|
499
|
+
return toRows(result)[0]?.name ?? "";
|
|
500
|
+
}
|
|
501
|
+
catch (error) {
|
|
502
|
+
if (isUndefinedTableError(error))
|
|
503
|
+
return "";
|
|
504
|
+
throw error;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
async function resolveLinkedProductDestination(db, productId) {
|
|
508
|
+
try {
|
|
509
|
+
const result = await db.execute(sql `
|
|
510
|
+
select coalesce(dt.name, d.slug) as destination
|
|
511
|
+
from product_destinations pd
|
|
512
|
+
inner join destinations d on d.id = pd.destination_id
|
|
513
|
+
left join destination_translations dt on dt.destination_id = d.id
|
|
514
|
+
where pd.product_id = ${productId}
|
|
515
|
+
order by
|
|
516
|
+
pd.sort_order asc,
|
|
517
|
+
case when dt.language_tag = 'en' then 0 else 1 end,
|
|
518
|
+
dt.language_tag asc nulls last,
|
|
519
|
+
d.slug asc
|
|
520
|
+
limit 1
|
|
521
|
+
`);
|
|
522
|
+
return toRows(result)[0]?.destination ?? "";
|
|
523
|
+
}
|
|
524
|
+
catch (error) {
|
|
525
|
+
if (isUndefinedTableError(error))
|
|
526
|
+
return "";
|
|
527
|
+
throw error;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
async function resolveLinkedAvailabilitySlot(db, slotId) {
|
|
531
|
+
try {
|
|
532
|
+
const result = await db.execute(sql `
|
|
533
|
+
select starts_at, ends_at, days
|
|
534
|
+
from availability_slots
|
|
535
|
+
where id = ${slotId}
|
|
536
|
+
limit 1
|
|
537
|
+
`);
|
|
538
|
+
return (toRows(result)[0] ?? null);
|
|
539
|
+
}
|
|
540
|
+
catch (error) {
|
|
541
|
+
if (isUndefinedTableError(error))
|
|
542
|
+
return null;
|
|
543
|
+
throw error;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
function recordValue(value) {
|
|
547
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
548
|
+
? value
|
|
549
|
+
: {};
|
|
550
|
+
}
|
|
551
|
+
function pickString(record, keys) {
|
|
552
|
+
for (const key of keys) {
|
|
553
|
+
const value = record[key];
|
|
554
|
+
if (typeof value === "string" && value.trim())
|
|
555
|
+
return value.trim();
|
|
556
|
+
}
|
|
557
|
+
return "";
|
|
558
|
+
}
|
|
559
|
+
function firstString(...values) {
|
|
560
|
+
for (const value of values) {
|
|
561
|
+
if (typeof value === "string" && value.trim())
|
|
562
|
+
return value.trim();
|
|
563
|
+
}
|
|
564
|
+
return "";
|
|
565
|
+
}
|
|
566
|
+
function normalizeDateTime(value) {
|
|
567
|
+
if (!value)
|
|
568
|
+
return "";
|
|
569
|
+
return value instanceof Date ? value.toISOString() : value;
|
|
570
|
+
}
|
|
571
|
+
function numberValue(value) {
|
|
572
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
573
|
+
}
|
|
574
|
+
function extractDepartureCity(label) {
|
|
575
|
+
const normalized = firstString(label);
|
|
576
|
+
if (!normalized)
|
|
577
|
+
return "";
|
|
578
|
+
const separator = normalized.includes("—") ? "—" : normalized.includes("-") ? "-" : "";
|
|
579
|
+
if (!separator)
|
|
580
|
+
return "";
|
|
581
|
+
const parts = normalized
|
|
582
|
+
.split(separator)
|
|
583
|
+
.map((part) => part.trim())
|
|
584
|
+
.filter(Boolean);
|
|
585
|
+
return parts.length > 1 ? (parts[parts.length - 1] ?? "") : "";
|
|
586
|
+
}
|
|
587
|
+
function toRows(result) {
|
|
588
|
+
if (Array.isArray(result))
|
|
589
|
+
return result;
|
|
590
|
+
if (result && typeof result === "object" && "rows" in result) {
|
|
591
|
+
const rows = result.rows;
|
|
592
|
+
return Array.isArray(rows) ? rows : [];
|
|
593
|
+
}
|
|
594
|
+
return [];
|
|
595
|
+
}
|
|
596
|
+
function isUndefinedTableError(error) {
|
|
597
|
+
return (typeof error === "object" &&
|
|
598
|
+
error !== null &&
|
|
599
|
+
"code" in error &&
|
|
600
|
+
error.code === "42P01");
|
|
601
|
+
}
|
|
602
|
+
async function resolveBookingSettlementVariables(db, bookingId, bookingCurrency) {
|
|
603
|
+
const invoiceRows = await db
|
|
604
|
+
.select({
|
|
605
|
+
currency: invoices.currency,
|
|
606
|
+
baseCurrency: invoices.baseCurrency,
|
|
607
|
+
balanceDueCents: invoices.balanceDueCents,
|
|
608
|
+
baseBalanceDueCents: invoices.baseBalanceDueCents,
|
|
609
|
+
})
|
|
610
|
+
.from(invoices)
|
|
611
|
+
.where(and(eq(invoices.bookingId, bookingId), ne(invoices.status, "void")));
|
|
612
|
+
const currency = bookingCurrency || invoiceRows[0]?.currency || "";
|
|
613
|
+
const completedPaymentRows = await db
|
|
614
|
+
.select({
|
|
615
|
+
amountCents: payments.amountCents,
|
|
616
|
+
currency: payments.currency,
|
|
617
|
+
baseCurrency: payments.baseCurrency,
|
|
618
|
+
baseAmountCents: payments.baseAmountCents,
|
|
619
|
+
paymentMethod: payments.paymentMethod,
|
|
620
|
+
paymentDate: payments.paymentDate,
|
|
621
|
+
})
|
|
622
|
+
.from(payments)
|
|
623
|
+
.innerJoin(invoices, eq(payments.invoiceId, invoices.id))
|
|
624
|
+
.where(and(eq(invoices.bookingId, bookingId), ne(invoices.status, "void"), eq(payments.status, "completed")))
|
|
625
|
+
.orderBy(desc(payments.paymentDate), desc(payments.createdAt));
|
|
626
|
+
const paidAmountCents = completedPaymentRows.reduce((sum, payment) => sum + amountInCurrency(payment, currency), 0);
|
|
627
|
+
const balanceDueCents = invoiceRows.length > 0
|
|
628
|
+
? invoiceRows.reduce((sum, invoice) => sum + invoiceBalanceInCurrency(invoice, currency), 0)
|
|
629
|
+
: null;
|
|
630
|
+
const latestPayment = completedPaymentRows[0];
|
|
631
|
+
return {
|
|
632
|
+
paidAmountCents,
|
|
633
|
+
balanceDueCents,
|
|
634
|
+
latestCompleted: latestPayment
|
|
635
|
+
? {
|
|
636
|
+
method: latestPayment.paymentMethod,
|
|
637
|
+
methodLabel: formatPaymentMethodLabel(latestPayment.paymentMethod),
|
|
638
|
+
date: latestPayment.paymentDate,
|
|
639
|
+
}
|
|
640
|
+
: undefined,
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
function computeAmountDueCents(settlement, totalCents) {
|
|
644
|
+
if (settlement.balanceDueCents != null)
|
|
645
|
+
return Math.max(0, settlement.balanceDueCents);
|
|
646
|
+
return Math.max(0, totalCents - settlement.paidAmountCents);
|
|
647
|
+
}
|
|
648
|
+
function amountInCurrency(payment, currency) {
|
|
649
|
+
if (!currency || payment.currency === currency)
|
|
650
|
+
return payment.amountCents;
|
|
651
|
+
if (payment.baseCurrency === currency)
|
|
652
|
+
return payment.baseAmountCents ?? 0;
|
|
653
|
+
return 0;
|
|
654
|
+
}
|
|
655
|
+
function invoiceBalanceInCurrency(invoice, currency) {
|
|
656
|
+
if (!currency || invoice.currency === currency)
|
|
657
|
+
return invoice.balanceDueCents;
|
|
658
|
+
if (invoice.baseCurrency === currency)
|
|
659
|
+
return invoice.baseBalanceDueCents ?? 0;
|
|
660
|
+
return 0;
|
|
661
|
+
}
|
|
662
|
+
function formatPaymentMethodLabel(method) {
|
|
663
|
+
return method
|
|
664
|
+
.split("_")
|
|
665
|
+
.filter(Boolean)
|
|
666
|
+
.map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`)
|
|
667
|
+
.join(" ");
|
|
668
|
+
}
|
|
669
|
+
function computeNights(startDate, endDate) {
|
|
670
|
+
if (!startDate || !endDate)
|
|
671
|
+
return 0;
|
|
672
|
+
try {
|
|
673
|
+
const start = new Date(startDate);
|
|
674
|
+
const end = new Date(endDate);
|
|
675
|
+
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime()))
|
|
676
|
+
return 0;
|
|
677
|
+
const ms = end.getTime() - start.getTime();
|
|
678
|
+
return Math.max(0, Math.round(ms / (1000 * 60 * 60 * 24)));
|
|
679
|
+
}
|
|
680
|
+
catch {
|
|
681
|
+
return 0;
|
|
682
|
+
}
|
|
683
|
+
}
|