@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.
@@ -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
+ }