@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.
Files changed (51) hide show
  1. package/dist/index.d.ts +3 -1
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +2 -1
  4. package/dist/markets-ref.d.ts +151 -0
  5. package/dist/markets-ref.d.ts.map +1 -0
  6. package/dist/markets-ref.js +19 -0
  7. package/dist/pii-redaction.d.ts +89 -0
  8. package/dist/pii-redaction.d.ts.map +1 -0
  9. package/dist/pii-redaction.js +120 -0
  10. package/dist/pii.d.ts +1 -0
  11. package/dist/pii.d.ts.map +1 -1
  12. package/dist/pii.js +20 -1
  13. package/dist/routes-groups.d.ts +3 -2
  14. package/dist/routes-groups.d.ts.map +1 -1
  15. package/dist/routes-public.d.ts +11 -13
  16. package/dist/routes-public.d.ts.map +1 -1
  17. package/dist/routes-public.js +3 -3
  18. package/dist/routes.d.ts +232 -22
  19. package/dist/routes.d.ts.map +1 -1
  20. package/dist/routes.js +100 -24
  21. package/dist/schema/travel-details.d.ts +37 -0
  22. package/dist/schema/travel-details.d.ts.map +1 -1
  23. package/dist/schema/travel-details.js +6 -0
  24. package/dist/schema-core.d.ts +17 -17
  25. package/dist/schema-core.d.ts.map +1 -1
  26. package/dist/schema-core.js +8 -2
  27. package/dist/schema-items.d.ts.map +1 -1
  28. package/dist/schema-items.js +6 -1
  29. package/dist/schema-operations.d.ts +2 -2
  30. package/dist/schema-shared.d.ts +1 -1
  31. package/dist/schema-shared.d.ts.map +1 -1
  32. package/dist/schema-shared.js +3 -0
  33. package/dist/service-public.d.ts +0 -6
  34. package/dist/service-public.d.ts.map +1 -1
  35. package/dist/service-public.js +0 -4
  36. package/dist/service.d.ts +232 -56
  37. package/dist/service.d.ts.map +1 -1
  38. package/dist/service.js +469 -137
  39. package/dist/state-machine.d.ts +29 -0
  40. package/dist/state-machine.d.ts.map +1 -0
  41. package/dist/state-machine.js +39 -0
  42. package/dist/validation-public.d.ts +0 -6
  43. package/dist/validation-public.d.ts.map +1 -1
  44. package/dist/validation-public.js +0 -2
  45. package/dist/validation.d.ts +25 -16
  46. package/dist/validation.d.ts.map +1 -1
  47. package/dist/validation.js +17 -6
  48. package/dist/workflows/refund-booking.d.ts +87 -0
  49. package/dist/workflows/refund-booking.d.ts.map +1 -0
  50. package/dist/workflows/refund-booking.js +210 -0
  51. 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
- function toBookingStatusTimestamps(status) {
274
- const now = new Date();
275
- return {
276
- confirmedAt: status === "confirmed" ? now : undefined,
277
- expiredAt: status === "expired" ? now : undefined,
278
- cancelledAt: status === "cancelled" ? now : undefined,
279
- completedAt: status === "completed" ? now : undefined,
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
- status: "confirmed",
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
- status: "expired",
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 (!["draft", "on_hold", "confirmed", "in_progress"].includes(booking.status)) {
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
- status: "cancelled",
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
- async createItem(db, bookingId, data, userId) {
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({ id: bookings.id, sellCurrency: bookings.sellCurrency })
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 [row] = await db
1973
- .insert(bookingItems)
1974
- .values({
1975
- bookingId,
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
- .returning();
2000
- if (!row) {
2001
- return null;
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
- await db.insert(bookingActivityLog).values({
2004
- bookingId,
2005
- actorId: userId ?? "system",
2006
- activityType: "item_update",
2007
- description: `Booking item "${data.title}" added`,
2008
- metadata: { bookingItemId: row.id, itemType: data.itemType },
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
- const [row] = await db
2014
- .update(bookingItems)
2015
- .set({
2016
- ...data,
2017
- startsAt: data.startsAt === undefined ? undefined : toTimestamp(data.startsAt),
2018
- endsAt: data.endsAt === undefined ? undefined : toTimestamp(data.endsAt),
2019
- updatedAt: new Date(),
2020
- })
2021
- .where(eq(bookingItems.id, itemId))
2022
- .returning();
2023
- return row ?? null;
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
- const [row] = await db
2027
- .delete(bookingItems)
2028
- .where(eq(bookingItems.id, itemId))
2029
- .returning({ id: bookingItems.id });
2030
- return row ?? null;
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