@voyantjs/bookings 0.9.0 → 0.11.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/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/markets-ref.d.ts +151 -0
- package/dist/markets-ref.d.ts.map +1 -0
- package/dist/markets-ref.js +19 -0
- package/dist/pii-redaction.d.ts +89 -0
- package/dist/pii-redaction.d.ts.map +1 -0
- package/dist/pii-redaction.js +120 -0
- package/dist/pii.d.ts +1 -0
- package/dist/pii.d.ts.map +1 -1
- package/dist/pii.js +20 -1
- package/dist/routes-groups.d.ts +3 -2
- package/dist/routes-groups.d.ts.map +1 -1
- package/dist/routes-public.d.ts +11 -13
- package/dist/routes-public.d.ts.map +1 -1
- package/dist/routes-public.js +3 -3
- package/dist/routes.d.ts +232 -22
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +100 -24
- package/dist/schema/travel-details.d.ts +37 -0
- package/dist/schema/travel-details.d.ts.map +1 -1
- package/dist/schema/travel-details.js +6 -0
- package/dist/schema-core.d.ts +17 -17
- package/dist/schema-core.d.ts.map +1 -1
- package/dist/schema-core.js +8 -2
- package/dist/schema-items.d.ts.map +1 -1
- package/dist/schema-items.js +6 -1
- package/dist/schema-operations.d.ts +2 -2
- package/dist/schema-shared.d.ts +1 -1
- package/dist/schema-shared.d.ts.map +1 -1
- package/dist/schema-shared.js +3 -0
- package/dist/service-public.d.ts +0 -6
- package/dist/service-public.d.ts.map +1 -1
- package/dist/service-public.js +0 -4
- package/dist/service.d.ts +232 -56
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +469 -137
- package/dist/state-machine.d.ts +29 -0
- package/dist/state-machine.d.ts.map +1 -0
- package/dist/state-machine.js +39 -0
- package/dist/validation-public.d.ts +0 -6
- package/dist/validation-public.d.ts.map +1 -1
- package/dist/validation-public.js +0 -2
- package/dist/validation.d.ts +25 -16
- package/dist/validation.d.ts.map +1 -1
- package/dist/validation.js +17 -6
- package/dist/workflows/refund-booking.d.ts +87 -0
- package/dist/workflows/refund-booking.d.ts.map +1 -0
- package/dist/workflows/refund-booking.js +210 -0
- package/package.json +7 -6
package/dist/service.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { and, asc, desc, eq, exists, ilike, inArray, lte, ne, or, sql } from "drizzle-orm";
|
|
2
2
|
import { availabilitySlotsRef } from "./availability-ref.js";
|
|
3
|
+
import { exchangeRatesRef } from "./markets-ref.js";
|
|
3
4
|
import { bookingItemProductDetailsRef, bookingProductDetailsRef, optionUnitsRef, productDayServicesRef, productDaysRef, productItinerariesRef, productOptionsRef, productsRef, productTicketSettingsRef, } from "./products-ref.js";
|
|
4
5
|
import { bookingActivityLog, bookingAllocations, bookingDocuments, bookingFulfillments, bookingItems, bookingItemTravelers, bookingNotes, bookingRedemptionEvents, bookingStaffAssignments, bookingSupplierStatuses, bookings, bookingTravelers, } from "./schema.js";
|
|
5
6
|
import { cleanupGroupOnBookingCancelled } from "./service-groups.js";
|
|
7
|
+
import { canTransitionBooking, transitionBooking } from "./state-machine.js";
|
|
6
8
|
import { bookingTransactionDetailsRef, offerItemParticipantsRef, offerItemsRef, offerParticipantsRef, offerStaffAssignmentsRef, offersRef, orderItemParticipantsRef, orderItemsRef, orderParticipantsRef, orderStaffAssignmentsRef, ordersRef, } from "./transactions-ref.js";
|
|
7
9
|
const travelerParticipantTypes = ["traveler", "occupant"];
|
|
8
10
|
class BookingServiceError extends Error {
|
|
@@ -35,7 +37,6 @@ function toTravelerResponse(participant) {
|
|
|
35
37
|
email: participant.email,
|
|
36
38
|
phone: participant.phone,
|
|
37
39
|
preferredLanguage: participant.preferredLanguage,
|
|
38
|
-
accessibilityNeeds: participant.accessibilityNeeds,
|
|
39
40
|
specialRequests: participant.specialRequests,
|
|
40
41
|
isPrimary: participant.isPrimary,
|
|
41
42
|
notes: participant.notes,
|
|
@@ -250,18 +251,6 @@ async function getConvertProductData(db, data) {
|
|
|
250
251
|
})),
|
|
251
252
|
};
|
|
252
253
|
}
|
|
253
|
-
const VALID_BOOKING_TRANSITIONS = {
|
|
254
|
-
draft: ["on_hold", "confirmed", "cancelled"],
|
|
255
|
-
on_hold: ["confirmed", "expired", "cancelled"],
|
|
256
|
-
confirmed: ["in_progress", "cancelled"],
|
|
257
|
-
in_progress: ["completed", "cancelled"],
|
|
258
|
-
completed: [],
|
|
259
|
-
expired: [],
|
|
260
|
-
cancelled: [],
|
|
261
|
-
};
|
|
262
|
-
function isValidBookingTransition(from, to) {
|
|
263
|
-
return VALID_BOOKING_TRANSITIONS[from].includes(to);
|
|
264
|
-
}
|
|
265
254
|
function computeHoldExpiresAt(input) {
|
|
266
255
|
if (input.holdExpiresAt) {
|
|
267
256
|
return new Date(input.holdExpiresAt);
|
|
@@ -270,14 +259,94 @@ function computeHoldExpiresAt(input) {
|
|
|
270
259
|
const minutes = input.holdMinutes ?? 30;
|
|
271
260
|
return new Date(now + minutes * 60 * 1000);
|
|
272
261
|
}
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
262
|
+
/**
|
|
263
|
+
* Walk a booking's items, convert each line into the booking's
|
|
264
|
+
* `baseCurrency` via the booking's `fxRateSetId`, sum.
|
|
265
|
+
*
|
|
266
|
+
* Returns:
|
|
267
|
+
* - `{ status: "ok", baseSellAmountCents, baseCostAmountCents }` when
|
|
268
|
+
* every item's currency was either already in `baseCurrency` or had
|
|
269
|
+
* a rate row in the rate set
|
|
270
|
+
* - `{ status: "missing_rate", currency }` when an item's
|
|
271
|
+
* `sellCurrency` had no rate in the rate set; caller treats as
|
|
272
|
+
* "leave base totals untouched, surface to ops"
|
|
273
|
+
* - `{ status: "skipped" }` when the booking has no `fxRateSetId`
|
|
274
|
+
* (multi-currency conversion isn't possible without one)
|
|
275
|
+
*
|
|
276
|
+
* Pure conversion math. Caller controls persistence.
|
|
277
|
+
*/
|
|
278
|
+
async function rollupBaseTotals(db, bookingId, baseCurrency) {
|
|
279
|
+
const [booking] = await db
|
|
280
|
+
.select({ fxRateSetId: bookings.fxRateSetId })
|
|
281
|
+
.from(bookings)
|
|
282
|
+
.where(eq(bookings.id, bookingId))
|
|
283
|
+
.limit(1);
|
|
284
|
+
if (!booking?.fxRateSetId) {
|
|
285
|
+
return { status: "skipped" };
|
|
286
|
+
}
|
|
287
|
+
// Cache for the closure — TypeScript can't narrow `booking` after
|
|
288
|
+
// the closure boundary, so capture the id in a local.
|
|
289
|
+
const fxRateSetId = booking.fxRateSetId;
|
|
290
|
+
const items = await db
|
|
291
|
+
.select({
|
|
292
|
+
sellCurrency: bookingItems.sellCurrency,
|
|
293
|
+
totalSellAmountCents: bookingItems.totalSellAmountCents,
|
|
294
|
+
costCurrency: bookingItems.costCurrency,
|
|
295
|
+
totalCostAmountCents: bookingItems.totalCostAmountCents,
|
|
296
|
+
})
|
|
297
|
+
.from(bookingItems)
|
|
298
|
+
.where(eq(bookingItems.bookingId, bookingId));
|
|
299
|
+
// Cache rates we look up to avoid N+1 within one booking.
|
|
300
|
+
const rateCache = new Map(); // key: `${from}->${to}`, value: decimal rate or null
|
|
301
|
+
async function rate(from, to) {
|
|
302
|
+
if (from === to)
|
|
303
|
+
return 1;
|
|
304
|
+
const key = `${from}->${to}`;
|
|
305
|
+
if (rateCache.has(key))
|
|
306
|
+
return rateCache.get(key) ?? null;
|
|
307
|
+
const [direct] = await db
|
|
308
|
+
.select({ rate: exchangeRatesRef.rateDecimal })
|
|
309
|
+
.from(exchangeRatesRef)
|
|
310
|
+
.where(and(eq(exchangeRatesRef.fxRateSetId, fxRateSetId), eq(exchangeRatesRef.baseCurrency, from), eq(exchangeRatesRef.quoteCurrency, to)))
|
|
311
|
+
.limit(1);
|
|
312
|
+
if (direct) {
|
|
313
|
+
const value = Number.parseFloat(direct.rate);
|
|
314
|
+
rateCache.set(key, value);
|
|
315
|
+
return value;
|
|
316
|
+
}
|
|
317
|
+
// Try the inverse
|
|
318
|
+
const [inverse] = await db
|
|
319
|
+
.select({ rate: exchangeRatesRef.inverseRateDecimal })
|
|
320
|
+
.from(exchangeRatesRef)
|
|
321
|
+
.where(and(eq(exchangeRatesRef.fxRateSetId, fxRateSetId), eq(exchangeRatesRef.baseCurrency, to), eq(exchangeRatesRef.quoteCurrency, from)))
|
|
322
|
+
.limit(1);
|
|
323
|
+
if (inverse?.rate) {
|
|
324
|
+
const value = Number.parseFloat(inverse.rate);
|
|
325
|
+
rateCache.set(key, value);
|
|
326
|
+
return value;
|
|
327
|
+
}
|
|
328
|
+
rateCache.set(key, null);
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
let baseSellAmountCents = 0;
|
|
332
|
+
let baseCostAmountCents = 0;
|
|
333
|
+
for (const item of items) {
|
|
334
|
+
if (item.totalSellAmountCents !== null) {
|
|
335
|
+
const r = await rate(item.sellCurrency, baseCurrency);
|
|
336
|
+
if (r === null) {
|
|
337
|
+
return { status: "missing_rate", currency: item.sellCurrency };
|
|
338
|
+
}
|
|
339
|
+
baseSellAmountCents += Math.round(item.totalSellAmountCents * r);
|
|
340
|
+
}
|
|
341
|
+
if (item.totalCostAmountCents !== null && item.costCurrency) {
|
|
342
|
+
const r = await rate(item.costCurrency, baseCurrency);
|
|
343
|
+
if (r === null) {
|
|
344
|
+
return { status: "missing_rate", currency: item.costCurrency };
|
|
345
|
+
}
|
|
346
|
+
baseCostAmountCents += Math.round(item.totalCostAmountCents * r);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return { status: "ok", baseSellAmountCents, baseCostAmountCents };
|
|
281
350
|
}
|
|
282
351
|
async function lockAvailabilitySlot(db, slotId) {
|
|
283
352
|
const rows = await db.execute(sql `SELECT id, product_id, option_id, date_local, starts_at, ends_at, timezone, status, unlimited, remaining_pax
|
|
@@ -1481,58 +1550,6 @@ export const bookingsService = {
|
|
|
1481
1550
|
.returning({ id: bookings.id });
|
|
1482
1551
|
return row ?? null;
|
|
1483
1552
|
},
|
|
1484
|
-
async updateBookingStatus(db, id, data, userId, runtime = {}) {
|
|
1485
|
-
const [current] = await db
|
|
1486
|
-
.select({ id: bookings.id, status: bookings.status })
|
|
1487
|
-
.from(bookings)
|
|
1488
|
-
.where(eq(bookings.id, id))
|
|
1489
|
-
.limit(1);
|
|
1490
|
-
if (!current) {
|
|
1491
|
-
return { status: "not_found" };
|
|
1492
|
-
}
|
|
1493
|
-
if (current.status === "on_hold" && data.status === "confirmed") {
|
|
1494
|
-
return bookingsService.confirmBooking(db, id, { note: data.note }, userId, runtime);
|
|
1495
|
-
}
|
|
1496
|
-
if (current.status === "on_hold" && data.status === "expired") {
|
|
1497
|
-
return bookingsService.expireBooking(db, id, { note: data.note }, userId, runtime);
|
|
1498
|
-
}
|
|
1499
|
-
if (data.status === "cancelled") {
|
|
1500
|
-
return bookingsService.cancelBooking(db, id, { note: data.note }, userId, runtime);
|
|
1501
|
-
}
|
|
1502
|
-
if (data.status === "on_hold") {
|
|
1503
|
-
return { status: "invalid_transition" };
|
|
1504
|
-
}
|
|
1505
|
-
if (!isValidBookingTransition(current.status, data.status)) {
|
|
1506
|
-
return { status: "invalid_transition" };
|
|
1507
|
-
}
|
|
1508
|
-
const [row] = await db
|
|
1509
|
-
.update(bookings)
|
|
1510
|
-
.set({
|
|
1511
|
-
status: data.status,
|
|
1512
|
-
...toBookingStatusTimestamps(data.status),
|
|
1513
|
-
updatedAt: new Date(),
|
|
1514
|
-
})
|
|
1515
|
-
.where(eq(bookings.id, id))
|
|
1516
|
-
.returning();
|
|
1517
|
-
await db.insert(bookingActivityLog).values({
|
|
1518
|
-
bookingId: id,
|
|
1519
|
-
actorId: userId ?? "system",
|
|
1520
|
-
activityType: "status_change",
|
|
1521
|
-
description: `Status changed from ${current.status} to ${data.status}`,
|
|
1522
|
-
metadata: { oldStatus: current.status, newStatus: data.status },
|
|
1523
|
-
});
|
|
1524
|
-
if (data.note) {
|
|
1525
|
-
await db.insert(bookingNotes).values({
|
|
1526
|
-
bookingId: id,
|
|
1527
|
-
authorId: userId ?? "system",
|
|
1528
|
-
content: data.note,
|
|
1529
|
-
});
|
|
1530
|
-
}
|
|
1531
|
-
if (data.status === "confirmed") {
|
|
1532
|
-
await autoIssueFulfillmentsForBooking(db, id, userId);
|
|
1533
|
-
}
|
|
1534
|
-
return { status: "ok", booking: row ?? null };
|
|
1535
|
-
},
|
|
1536
1553
|
async confirmBooking(db, id, data, userId, runtime = {}) {
|
|
1537
1554
|
try {
|
|
1538
1555
|
const result = await db.transaction(async (tx) => {
|
|
@@ -1544,12 +1561,16 @@ export const bookingsService = {
|
|
|
1544
1561
|
if (!booking) {
|
|
1545
1562
|
throw new BookingServiceError("not_found");
|
|
1546
1563
|
}
|
|
1564
|
+
if (!canTransitionBooking(booking.status, "confirmed")) {
|
|
1565
|
+
throw new BookingServiceError("invalid_transition");
|
|
1566
|
+
}
|
|
1547
1567
|
if (booking.status !== "on_hold") {
|
|
1548
1568
|
throw new BookingServiceError("invalid_transition");
|
|
1549
1569
|
}
|
|
1550
1570
|
if (booking.hold_expires_at && booking.hold_expires_at < new Date()) {
|
|
1551
1571
|
throw new BookingServiceError("hold_expired");
|
|
1552
1572
|
}
|
|
1573
|
+
const patch = transitionBooking(booking.status, "confirmed");
|
|
1553
1574
|
await tx
|
|
1554
1575
|
.update(bookingAllocations)
|
|
1555
1576
|
.set({
|
|
@@ -1565,9 +1586,8 @@ export const bookingsService = {
|
|
|
1565
1586
|
const [row] = await tx
|
|
1566
1587
|
.update(bookings)
|
|
1567
1588
|
.set({
|
|
1568
|
-
|
|
1589
|
+
...patch,
|
|
1569
1590
|
holdExpiresAt: null,
|
|
1570
|
-
confirmedAt: new Date(),
|
|
1571
1591
|
updatedAt: new Date(),
|
|
1572
1592
|
})
|
|
1573
1593
|
.where(eq(bookings.id, id))
|
|
@@ -1669,9 +1689,13 @@ export const bookingsService = {
|
|
|
1669
1689
|
if (!booking) {
|
|
1670
1690
|
throw new BookingServiceError("not_found");
|
|
1671
1691
|
}
|
|
1692
|
+
if (!canTransitionBooking(booking.status, "expired")) {
|
|
1693
|
+
throw new BookingServiceError("invalid_transition");
|
|
1694
|
+
}
|
|
1672
1695
|
if (booking.status !== "on_hold") {
|
|
1673
1696
|
throw new BookingServiceError("invalid_transition");
|
|
1674
1697
|
}
|
|
1698
|
+
const patch = transitionBooking(booking.status, "expired");
|
|
1675
1699
|
const allocations = await tx
|
|
1676
1700
|
.select()
|
|
1677
1701
|
.from(bookingAllocations)
|
|
@@ -1694,9 +1718,8 @@ export const bookingsService = {
|
|
|
1694
1718
|
const [row] = await tx
|
|
1695
1719
|
.update(bookings)
|
|
1696
1720
|
.set({
|
|
1697
|
-
|
|
1721
|
+
...patch,
|
|
1698
1722
|
holdExpiresAt: null,
|
|
1699
|
-
expiredAt: new Date(),
|
|
1700
1723
|
updatedAt: new Date(),
|
|
1701
1724
|
})
|
|
1702
1725
|
.where(eq(bookings.id, id))
|
|
@@ -1765,9 +1788,10 @@ export const bookingsService = {
|
|
|
1765
1788
|
if (!booking) {
|
|
1766
1789
|
throw new BookingServiceError("not_found");
|
|
1767
1790
|
}
|
|
1768
|
-
if (!
|
|
1791
|
+
if (!canTransitionBooking(booking.status, "cancelled")) {
|
|
1769
1792
|
throw new BookingServiceError("invalid_transition");
|
|
1770
1793
|
}
|
|
1794
|
+
const patch = transitionBooking(booking.status, "cancelled");
|
|
1771
1795
|
const previousStatus = booking.status;
|
|
1772
1796
|
const allocations = await tx
|
|
1773
1797
|
.select()
|
|
@@ -1794,9 +1818,8 @@ export const bookingsService = {
|
|
|
1794
1818
|
const [row] = await tx
|
|
1795
1819
|
.update(bookings)
|
|
1796
1820
|
.set({
|
|
1797
|
-
|
|
1821
|
+
...patch,
|
|
1798
1822
|
holdExpiresAt: null,
|
|
1799
|
-
cancelledAt: new Date(),
|
|
1800
1823
|
updatedAt: new Date(),
|
|
1801
1824
|
})
|
|
1802
1825
|
.where(eq(bookings.id, id))
|
|
@@ -1837,6 +1860,198 @@ export const bookingsService = {
|
|
|
1837
1860
|
throw error;
|
|
1838
1861
|
}
|
|
1839
1862
|
},
|
|
1863
|
+
async startBooking(db, id, data, userId, runtime = {}) {
|
|
1864
|
+
try {
|
|
1865
|
+
const result = await db.transaction(async (tx) => {
|
|
1866
|
+
const rows = await tx.execute(sql `SELECT id, booking_number, status
|
|
1867
|
+
FROM ${bookings}
|
|
1868
|
+
WHERE ${bookings.id} = ${id}
|
|
1869
|
+
FOR UPDATE`);
|
|
1870
|
+
const booking = rows[0];
|
|
1871
|
+
if (!booking) {
|
|
1872
|
+
throw new BookingServiceError("not_found");
|
|
1873
|
+
}
|
|
1874
|
+
if (!canTransitionBooking(booking.status, "in_progress")) {
|
|
1875
|
+
throw new BookingServiceError("invalid_transition");
|
|
1876
|
+
}
|
|
1877
|
+
const patch = transitionBooking(booking.status, "in_progress");
|
|
1878
|
+
const [row] = await tx
|
|
1879
|
+
.update(bookings)
|
|
1880
|
+
.set({
|
|
1881
|
+
...patch,
|
|
1882
|
+
updatedAt: new Date(),
|
|
1883
|
+
})
|
|
1884
|
+
.where(eq(bookings.id, id))
|
|
1885
|
+
.returning();
|
|
1886
|
+
await tx.insert(bookingActivityLog).values({
|
|
1887
|
+
bookingId: id,
|
|
1888
|
+
actorId: userId ?? "system",
|
|
1889
|
+
activityType: "booking_started",
|
|
1890
|
+
description: `Booking ${booking.booking_number} started`,
|
|
1891
|
+
});
|
|
1892
|
+
if (data.note) {
|
|
1893
|
+
await tx.insert(bookingNotes).values({
|
|
1894
|
+
bookingId: id,
|
|
1895
|
+
authorId: userId ?? "system",
|
|
1896
|
+
content: data.note,
|
|
1897
|
+
});
|
|
1898
|
+
}
|
|
1899
|
+
return { status: "ok", booking: row ?? null };
|
|
1900
|
+
});
|
|
1901
|
+
if (result.status === "ok" && result.booking) {
|
|
1902
|
+
await runtime.eventBus?.emit("booking.started", {
|
|
1903
|
+
bookingId: result.booking.id,
|
|
1904
|
+
bookingNumber: result.booking.bookingNumber,
|
|
1905
|
+
actorId: userId ?? null,
|
|
1906
|
+
}, { category: "domain", source: "service" });
|
|
1907
|
+
}
|
|
1908
|
+
return result;
|
|
1909
|
+
}
|
|
1910
|
+
catch (error) {
|
|
1911
|
+
if (error instanceof BookingServiceError) {
|
|
1912
|
+
return { status: error.code };
|
|
1913
|
+
}
|
|
1914
|
+
throw error;
|
|
1915
|
+
}
|
|
1916
|
+
},
|
|
1917
|
+
async completeBooking(db, id, data, userId, runtime = {}) {
|
|
1918
|
+
try {
|
|
1919
|
+
const result = await db.transaction(async (tx) => {
|
|
1920
|
+
const rows = await tx.execute(sql `SELECT id, booking_number, status
|
|
1921
|
+
FROM ${bookings}
|
|
1922
|
+
WHERE ${bookings.id} = ${id}
|
|
1923
|
+
FOR UPDATE`);
|
|
1924
|
+
const booking = rows[0];
|
|
1925
|
+
if (!booking) {
|
|
1926
|
+
throw new BookingServiceError("not_found");
|
|
1927
|
+
}
|
|
1928
|
+
if (!canTransitionBooking(booking.status, "completed")) {
|
|
1929
|
+
throw new BookingServiceError("invalid_transition");
|
|
1930
|
+
}
|
|
1931
|
+
const patch = transitionBooking(booking.status, "completed");
|
|
1932
|
+
await tx
|
|
1933
|
+
.update(bookingAllocations)
|
|
1934
|
+
.set({ status: "fulfilled", updatedAt: new Date() })
|
|
1935
|
+
.where(and(eq(bookingAllocations.bookingId, id), eq(bookingAllocations.status, "confirmed")));
|
|
1936
|
+
await tx
|
|
1937
|
+
.update(bookingItems)
|
|
1938
|
+
.set({ status: "fulfilled", updatedAt: new Date() })
|
|
1939
|
+
.where(and(eq(bookingItems.bookingId, id), eq(bookingItems.status, "confirmed")));
|
|
1940
|
+
const [row] = await tx
|
|
1941
|
+
.update(bookings)
|
|
1942
|
+
.set({
|
|
1943
|
+
...patch,
|
|
1944
|
+
updatedAt: new Date(),
|
|
1945
|
+
})
|
|
1946
|
+
.where(eq(bookings.id, id))
|
|
1947
|
+
.returning();
|
|
1948
|
+
await tx.insert(bookingActivityLog).values({
|
|
1949
|
+
bookingId: id,
|
|
1950
|
+
actorId: userId ?? "system",
|
|
1951
|
+
activityType: "booking_completed",
|
|
1952
|
+
description: `Booking ${booking.booking_number} completed`,
|
|
1953
|
+
});
|
|
1954
|
+
if (data.note) {
|
|
1955
|
+
await tx.insert(bookingNotes).values({
|
|
1956
|
+
bookingId: id,
|
|
1957
|
+
authorId: userId ?? "system",
|
|
1958
|
+
content: data.note,
|
|
1959
|
+
});
|
|
1960
|
+
}
|
|
1961
|
+
return { status: "ok", booking: row ?? null };
|
|
1962
|
+
});
|
|
1963
|
+
if (result.status === "ok" && result.booking) {
|
|
1964
|
+
await runtime.eventBus?.emit("booking.completed", {
|
|
1965
|
+
bookingId: result.booking.id,
|
|
1966
|
+
bookingNumber: result.booking.bookingNumber,
|
|
1967
|
+
actorId: userId ?? null,
|
|
1968
|
+
}, { category: "domain", source: "service" });
|
|
1969
|
+
}
|
|
1970
|
+
return result;
|
|
1971
|
+
}
|
|
1972
|
+
catch (error) {
|
|
1973
|
+
if (error instanceof BookingServiceError) {
|
|
1974
|
+
return { status: error.code };
|
|
1975
|
+
}
|
|
1976
|
+
throw error;
|
|
1977
|
+
}
|
|
1978
|
+
},
|
|
1979
|
+
/**
|
|
1980
|
+
* Admin-only force: bypasses the transition graph. Updates the booking row
|
|
1981
|
+
* only — does NOT cascade to items, allocations, or fulfillments. If the
|
|
1982
|
+
* operator needs cascaded behavior (e.g. release allocations), they should
|
|
1983
|
+
* call the verb-specific method instead. The override is for data
|
|
1984
|
+
* correction; misuse leaves child state inconsistent with the parent.
|
|
1985
|
+
*/
|
|
1986
|
+
async overrideBookingStatus(db, id, data, userId, runtime = {}) {
|
|
1987
|
+
try {
|
|
1988
|
+
const result = await db.transaction(async (tx) => {
|
|
1989
|
+
const rows = await tx.execute(sql `SELECT id, booking_number, status
|
|
1990
|
+
FROM ${bookings}
|
|
1991
|
+
WHERE ${bookings.id} = ${id}
|
|
1992
|
+
FOR UPDATE`);
|
|
1993
|
+
const booking = rows[0];
|
|
1994
|
+
if (!booking) {
|
|
1995
|
+
throw new BookingServiceError("not_found");
|
|
1996
|
+
}
|
|
1997
|
+
const now = new Date();
|
|
1998
|
+
const updates = {
|
|
1999
|
+
status: data.status,
|
|
2000
|
+
updatedAt: now,
|
|
2001
|
+
};
|
|
2002
|
+
if (data.status === "confirmed")
|
|
2003
|
+
updates.confirmedAt = now;
|
|
2004
|
+
if (data.status === "expired")
|
|
2005
|
+
updates.expiredAt = now;
|
|
2006
|
+
if (data.status === "cancelled")
|
|
2007
|
+
updates.cancelledAt = now;
|
|
2008
|
+
if (data.status === "completed")
|
|
2009
|
+
updates.completedAt = now;
|
|
2010
|
+
const [row] = await tx.update(bookings).set(updates).where(eq(bookings.id, id)).returning();
|
|
2011
|
+
await tx.insert(bookingActivityLog).values({
|
|
2012
|
+
bookingId: id,
|
|
2013
|
+
actorId: userId ?? "system",
|
|
2014
|
+
activityType: "status_overridden",
|
|
2015
|
+
description: `Booking status overridden from ${booking.status} to ${data.status}`,
|
|
2016
|
+
metadata: {
|
|
2017
|
+
oldStatus: booking.status,
|
|
2018
|
+
newStatus: data.status,
|
|
2019
|
+
reason: data.reason,
|
|
2020
|
+
},
|
|
2021
|
+
});
|
|
2022
|
+
if (data.note) {
|
|
2023
|
+
await tx.insert(bookingNotes).values({
|
|
2024
|
+
bookingId: id,
|
|
2025
|
+
authorId: userId ?? "system",
|
|
2026
|
+
content: data.note,
|
|
2027
|
+
});
|
|
2028
|
+
}
|
|
2029
|
+
return {
|
|
2030
|
+
status: "ok",
|
|
2031
|
+
booking: row ?? null,
|
|
2032
|
+
fromStatus: booking.status,
|
|
2033
|
+
toStatus: data.status,
|
|
2034
|
+
};
|
|
2035
|
+
});
|
|
2036
|
+
if (result.status === "ok" && result.booking) {
|
|
2037
|
+
await runtime.eventBus?.emit("booking.status_overridden", {
|
|
2038
|
+
bookingId: result.booking.id,
|
|
2039
|
+
bookingNumber: result.booking.bookingNumber,
|
|
2040
|
+
fromStatus: result.fromStatus,
|
|
2041
|
+
toStatus: result.toStatus,
|
|
2042
|
+
reason: data.reason,
|
|
2043
|
+
actorId: userId ?? null,
|
|
2044
|
+
}, { category: "domain", source: "service" });
|
|
2045
|
+
}
|
|
2046
|
+
return { status: result.status, booking: result.booking };
|
|
2047
|
+
}
|
|
2048
|
+
catch (error) {
|
|
2049
|
+
if (error instanceof BookingServiceError) {
|
|
2050
|
+
return { status: error.code };
|
|
2051
|
+
}
|
|
2052
|
+
throw error;
|
|
2053
|
+
}
|
|
2054
|
+
},
|
|
1840
2055
|
listTravelerRecords(db, bookingId) {
|
|
1841
2056
|
return db
|
|
1842
2057
|
.select()
|
|
@@ -1873,7 +2088,6 @@ export const bookingsService = {
|
|
|
1873
2088
|
email: data.email ?? null,
|
|
1874
2089
|
phone: data.phone ?? null,
|
|
1875
2090
|
preferredLanguage: data.preferredLanguage ?? null,
|
|
1876
|
-
accessibilityNeeds: data.accessibilityNeeds ?? null,
|
|
1877
2091
|
specialRequests: data.specialRequests ?? null,
|
|
1878
2092
|
isPrimary: data.isPrimary ?? false,
|
|
1879
2093
|
notes: data.notes ?? null,
|
|
@@ -1928,7 +2142,6 @@ export const bookingsService = {
|
|
|
1928
2142
|
email: data.email ?? null,
|
|
1929
2143
|
phone: data.phone ?? null,
|
|
1930
2144
|
preferredLanguage: data.preferredLanguage ?? null,
|
|
1931
|
-
accessibilityNeeds: data.accessibilityNeeds ?? null,
|
|
1932
2145
|
specialRequests: data.specialRequests ?? null,
|
|
1933
2146
|
isPrimary: data.isPrimary ?? false,
|
|
1934
2147
|
notes: data.notes ?? null,
|
|
@@ -1942,7 +2155,6 @@ export const bookingsService = {
|
|
|
1942
2155
|
email: data.email ?? null,
|
|
1943
2156
|
phone: data.phone ?? null,
|
|
1944
2157
|
preferredLanguage: data.preferredLanguage ?? null,
|
|
1945
|
-
accessibilityNeeds: data.accessibilityNeeds ?? null,
|
|
1946
2158
|
specialRequests: data.specialRequests ?? null,
|
|
1947
2159
|
travelerCategory: data.travelerCategory ?? null,
|
|
1948
2160
|
isPrimary: data.isPrimary ?? undefined,
|
|
@@ -1960,74 +2172,194 @@ export const bookingsService = {
|
|
|
1960
2172
|
.where(eq(bookingItems.bookingId, bookingId))
|
|
1961
2173
|
.orderBy(asc(bookingItems.createdAt));
|
|
1962
2174
|
},
|
|
1963
|
-
|
|
2175
|
+
/**
|
|
2176
|
+
* Re-derive `bookings.sellAmountCents` / `costAmountCents` from
|
|
2177
|
+
* `Σ(booking_items.total*AmountCents)`, plus — when the booking
|
|
2178
|
+
* declares a `baseCurrency` and `fxRateSetId` — re-derive
|
|
2179
|
+
* `baseSellAmountCents` / `baseCostAmountCents` by converting each
|
|
2180
|
+
* item's total via the FX rate set.
|
|
2181
|
+
*
|
|
2182
|
+
* Called automatically inside the item-mutation methods so callers
|
|
2183
|
+
* that go through `createItem` / `updateItem` / `deleteItem` never
|
|
2184
|
+
* have to remember to roll the parent. Public so external flows
|
|
2185
|
+
* (saga compensations, ad-hoc fix-ups) can also invoke it.
|
|
2186
|
+
*
|
|
2187
|
+
* Pass a tx-bound `db` to compose with an existing transaction; this
|
|
2188
|
+
* method does NOT wrap its own transaction.
|
|
2189
|
+
*
|
|
2190
|
+
* **FX rollup behaviour**:
|
|
2191
|
+
*
|
|
2192
|
+
* - Single-currency booking (every item's `sellCurrency === baseCurrency`,
|
|
2193
|
+
* or `baseCurrency === sellCurrency` on the parent): `base*Cents`
|
|
2194
|
+
* equal `sell*Cents` / `cost*Cents` directly. No FX lookup needed.
|
|
2195
|
+
* - Multi-currency booking with `fxRateSetId`: every item is
|
|
2196
|
+
* converted to `baseCurrency` via `exchange_rates`. If any item's
|
|
2197
|
+
* currency is missing from the rate set, the FX rollup short-circuits
|
|
2198
|
+
* with `fxStatus: "missing_rate"` and `base*Cents` are LEFT
|
|
2199
|
+
* UNCHANGED on the parent (caller chooses whether to abort).
|
|
2200
|
+
* - No `baseCurrency` configured: FX rollup is skipped entirely
|
|
2201
|
+
* (`fxStatus: "skipped"`), and `base*Cents` stay null.
|
|
2202
|
+
*
|
|
2203
|
+
* Returns `{ sellAmountCents, costAmountCents, baseSellAmountCents,
|
|
2204
|
+
* baseCostAmountCents, fxStatus, missingCurrency? }` or `null` for a
|
|
2205
|
+
* missing booking.
|
|
2206
|
+
*/
|
|
2207
|
+
async recomputeBookingTotal(db, bookingId) {
|
|
1964
2208
|
const [booking] = await db
|
|
1965
|
-
.select({
|
|
2209
|
+
.select({
|
|
2210
|
+
id: bookings.id,
|
|
2211
|
+
sellCurrency: bookings.sellCurrency,
|
|
2212
|
+
baseCurrency: bookings.baseCurrency,
|
|
2213
|
+
})
|
|
1966
2214
|
.from(bookings)
|
|
1967
2215
|
.where(eq(bookings.id, bookingId))
|
|
1968
2216
|
.limit(1);
|
|
1969
2217
|
if (!booking) {
|
|
1970
2218
|
return null;
|
|
1971
2219
|
}
|
|
1972
|
-
const [
|
|
1973
|
-
.
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
title: data.title,
|
|
1977
|
-
description: data.description ?? null,
|
|
1978
|
-
itemType: data.itemType,
|
|
1979
|
-
status: data.status,
|
|
1980
|
-
serviceDate: data.serviceDate ?? null,
|
|
1981
|
-
startsAt: toTimestamp(data.startsAt),
|
|
1982
|
-
endsAt: toTimestamp(data.endsAt),
|
|
1983
|
-
quantity: data.quantity,
|
|
1984
|
-
sellCurrency: data.sellCurrency ?? booking.sellCurrency,
|
|
1985
|
-
unitSellAmountCents: data.unitSellAmountCents ?? null,
|
|
1986
|
-
totalSellAmountCents: data.totalSellAmountCents ?? null,
|
|
1987
|
-
costCurrency: data.costCurrency ?? null,
|
|
1988
|
-
unitCostAmountCents: data.unitCostAmountCents ?? null,
|
|
1989
|
-
totalCostAmountCents: data.totalCostAmountCents ?? null,
|
|
1990
|
-
notes: data.notes ?? null,
|
|
1991
|
-
productId: data.productId ?? null,
|
|
1992
|
-
optionId: data.optionId ?? null,
|
|
1993
|
-
optionUnitId: data.optionUnitId ?? null,
|
|
1994
|
-
pricingCategoryId: data.pricingCategoryId ?? null,
|
|
1995
|
-
sourceSnapshotId: data.sourceSnapshotId ?? null,
|
|
1996
|
-
sourceOfferId: data.sourceOfferId ?? null,
|
|
1997
|
-
metadata: data.metadata ?? null,
|
|
2220
|
+
const [totals] = await db
|
|
2221
|
+
.select({
|
|
2222
|
+
sellAmountCents: sql `coalesce(sum(${bookingItems.totalSellAmountCents}), 0)::int`,
|
|
2223
|
+
costAmountCents: sql `coalesce(sum(${bookingItems.totalCostAmountCents}), 0)::int`,
|
|
1998
2224
|
})
|
|
1999
|
-
.
|
|
2000
|
-
|
|
2001
|
-
|
|
2225
|
+
.from(bookingItems)
|
|
2226
|
+
.where(eq(bookingItems.bookingId, bookingId));
|
|
2227
|
+
const sellAmountCents = totals?.sellAmountCents ?? 0;
|
|
2228
|
+
const costAmountCents = totals?.costAmountCents ?? 0;
|
|
2229
|
+
// We need fxRateSetId from the bookings row plus per-item currency
|
|
2230
|
+
// for the FX rollup. Refetch with those columns.
|
|
2231
|
+
const [bookingForFx] = await db
|
|
2232
|
+
.select({
|
|
2233
|
+
baseCurrency: bookings.baseCurrency,
|
|
2234
|
+
sellCurrency: bookings.sellCurrency,
|
|
2235
|
+
})
|
|
2236
|
+
.from(bookings)
|
|
2237
|
+
.where(eq(bookings.id, bookingId))
|
|
2238
|
+
.limit(1);
|
|
2239
|
+
let fxStatus = "skipped";
|
|
2240
|
+
let baseSellAmountCents = null;
|
|
2241
|
+
let baseCostAmountCents = null;
|
|
2242
|
+
let missingCurrency = null;
|
|
2243
|
+
const baseCurrency = bookingForFx?.baseCurrency ?? null;
|
|
2244
|
+
if (baseCurrency) {
|
|
2245
|
+
const fxResult = await rollupBaseTotals(db, bookingId, baseCurrency);
|
|
2246
|
+
if (fxResult.status === "ok") {
|
|
2247
|
+
fxStatus = "ok";
|
|
2248
|
+
baseSellAmountCents = fxResult.baseSellAmountCents;
|
|
2249
|
+
baseCostAmountCents = fxResult.baseCostAmountCents;
|
|
2250
|
+
}
|
|
2251
|
+
else if (fxResult.status === "missing_rate") {
|
|
2252
|
+
fxStatus = "missing_rate";
|
|
2253
|
+
missingCurrency = fxResult.currency;
|
|
2254
|
+
}
|
|
2002
2255
|
}
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2256
|
+
const patch = {
|
|
2257
|
+
sellAmountCents,
|
|
2258
|
+
costAmountCents,
|
|
2259
|
+
updatedAt: new Date(),
|
|
2260
|
+
};
|
|
2261
|
+
if (fxStatus === "ok") {
|
|
2262
|
+
patch.baseSellAmountCents = baseSellAmountCents;
|
|
2263
|
+
patch.baseCostAmountCents = baseCostAmountCents;
|
|
2264
|
+
}
|
|
2265
|
+
await db.update(bookings).set(patch).where(eq(bookings.id, bookingId));
|
|
2266
|
+
return {
|
|
2267
|
+
sellAmountCents,
|
|
2268
|
+
costAmountCents,
|
|
2269
|
+
baseSellAmountCents,
|
|
2270
|
+
baseCostAmountCents,
|
|
2271
|
+
fxStatus,
|
|
2272
|
+
...(missingCurrency ? { missingCurrency } : {}),
|
|
2273
|
+
};
|
|
2274
|
+
},
|
|
2275
|
+
async createItem(db, bookingId, data, userId) {
|
|
2276
|
+
return db.transaction(async (tx) => {
|
|
2277
|
+
const [booking] = await tx
|
|
2278
|
+
.select({ id: bookings.id, sellCurrency: bookings.sellCurrency })
|
|
2279
|
+
.from(bookings)
|
|
2280
|
+
.where(eq(bookings.id, bookingId))
|
|
2281
|
+
.limit(1);
|
|
2282
|
+
if (!booking) {
|
|
2283
|
+
return null;
|
|
2284
|
+
}
|
|
2285
|
+
const [row] = await tx
|
|
2286
|
+
.insert(bookingItems)
|
|
2287
|
+
.values({
|
|
2288
|
+
bookingId,
|
|
2289
|
+
title: data.title,
|
|
2290
|
+
description: data.description ?? null,
|
|
2291
|
+
itemType: data.itemType,
|
|
2292
|
+
status: data.status,
|
|
2293
|
+
serviceDate: data.serviceDate ?? null,
|
|
2294
|
+
startsAt: toTimestamp(data.startsAt),
|
|
2295
|
+
endsAt: toTimestamp(data.endsAt),
|
|
2296
|
+
quantity: data.quantity,
|
|
2297
|
+
sellCurrency: data.sellCurrency ?? booking.sellCurrency,
|
|
2298
|
+
unitSellAmountCents: data.unitSellAmountCents ?? null,
|
|
2299
|
+
totalSellAmountCents: data.totalSellAmountCents ?? null,
|
|
2300
|
+
costCurrency: data.costCurrency ?? null,
|
|
2301
|
+
unitCostAmountCents: data.unitCostAmountCents ?? null,
|
|
2302
|
+
totalCostAmountCents: data.totalCostAmountCents ?? null,
|
|
2303
|
+
notes: data.notes ?? null,
|
|
2304
|
+
productId: data.productId ?? null,
|
|
2305
|
+
optionId: data.optionId ?? null,
|
|
2306
|
+
optionUnitId: data.optionUnitId ?? null,
|
|
2307
|
+
pricingCategoryId: data.pricingCategoryId ?? null,
|
|
2308
|
+
sourceSnapshotId: data.sourceSnapshotId ?? null,
|
|
2309
|
+
sourceOfferId: data.sourceOfferId ?? null,
|
|
2310
|
+
metadata: data.metadata ?? null,
|
|
2311
|
+
})
|
|
2312
|
+
.returning();
|
|
2313
|
+
if (!row) {
|
|
2314
|
+
return null;
|
|
2315
|
+
}
|
|
2316
|
+
await tx.insert(bookingActivityLog).values({
|
|
2317
|
+
bookingId,
|
|
2318
|
+
actorId: userId ?? "system",
|
|
2319
|
+
activityType: "item_update",
|
|
2320
|
+
description: `Booking item "${data.title}" added`,
|
|
2321
|
+
metadata: { bookingItemId: row.id, itemType: data.itemType },
|
|
2322
|
+
});
|
|
2323
|
+
await bookingsService.recomputeBookingTotal(tx, bookingId);
|
|
2324
|
+
return row;
|
|
2009
2325
|
});
|
|
2010
|
-
return row;
|
|
2011
2326
|
},
|
|
2012
2327
|
async updateItem(db, itemId, data) {
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2328
|
+
return db.transaction(async (tx) => {
|
|
2329
|
+
const [row] = await tx
|
|
2330
|
+
.update(bookingItems)
|
|
2331
|
+
.set({
|
|
2332
|
+
...data,
|
|
2333
|
+
startsAt: data.startsAt === undefined ? undefined : toTimestamp(data.startsAt),
|
|
2334
|
+
endsAt: data.endsAt === undefined ? undefined : toTimestamp(data.endsAt),
|
|
2335
|
+
updatedAt: new Date(),
|
|
2336
|
+
})
|
|
2337
|
+
.where(eq(bookingItems.id, itemId))
|
|
2338
|
+
.returning();
|
|
2339
|
+
if (!row)
|
|
2340
|
+
return null;
|
|
2341
|
+
await bookingsService.recomputeBookingTotal(tx, row.bookingId);
|
|
2342
|
+
return row;
|
|
2343
|
+
});
|
|
2024
2344
|
},
|
|
2025
2345
|
async deleteItem(db, itemId) {
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
.
|
|
2029
|
-
|
|
2030
|
-
|
|
2346
|
+
return db.transaction(async (tx) => {
|
|
2347
|
+
// Look up the parent booking BEFORE the delete so we can roll up
|
|
2348
|
+
// afterwards.
|
|
2349
|
+
const [item] = await tx
|
|
2350
|
+
.select({ bookingId: bookingItems.bookingId })
|
|
2351
|
+
.from(bookingItems)
|
|
2352
|
+
.where(eq(bookingItems.id, itemId))
|
|
2353
|
+
.limit(1);
|
|
2354
|
+
const [row] = await tx
|
|
2355
|
+
.delete(bookingItems)
|
|
2356
|
+
.where(eq(bookingItems.id, itemId))
|
|
2357
|
+
.returning({ id: bookingItems.id });
|
|
2358
|
+
if (item) {
|
|
2359
|
+
await bookingsService.recomputeBookingTotal(tx, item.bookingId);
|
|
2360
|
+
}
|
|
2361
|
+
return row ?? null;
|
|
2362
|
+
});
|
|
2031
2363
|
},
|
|
2032
2364
|
listItemParticipants(db, itemId) {
|
|
2033
2365
|
return db
|