@voyantjs/bookings 0.9.0 → 0.10.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 (47) hide show
  1. package/dist/index.d.ts +2 -0
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +2 -0
  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 +16 -8
  19. package/dist/routes.d.ts.map +1 -1
  20. package/dist/routes.js +57 -9
  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/service-public.d.ts +0 -6
  30. package/dist/service-public.d.ts.map +1 -1
  31. package/dist/service-public.js +0 -4
  32. package/dist/service.d.ts +55 -46
  33. package/dist/service.d.ts.map +1 -1
  34. package/dist/service.js +288 -89
  35. package/dist/state-machine.d.ts +29 -0
  36. package/dist/state-machine.d.ts.map +1 -0
  37. package/dist/state-machine.js +39 -0
  38. package/dist/validation-public.d.ts +0 -6
  39. package/dist/validation-public.d.ts.map +1 -1
  40. package/dist/validation-public.js +0 -2
  41. package/dist/validation.d.ts +0 -4
  42. package/dist/validation.d.ts.map +1 -1
  43. package/dist/validation.js +0 -2
  44. package/dist/workflows/refund-booking.d.ts +87 -0
  45. package/dist/workflows/refund-booking.d.ts.map +1 -0
  46. package/dist/workflows/refund-booking.js +210 -0
  47. 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 { BookingTransitionError, 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
@@ -1499,17 +1568,24 @@ export const bookingsService = {
1499
1568
  if (data.status === "cancelled") {
1500
1569
  return bookingsService.cancelBooking(db, id, { note: data.note }, userId, runtime);
1501
1570
  }
1571
+ // `on_hold` is only reachable through `reserveBooking`, never via direct status PATCH.
1502
1572
  if (data.status === "on_hold") {
1503
1573
  return { status: "invalid_transition" };
1504
1574
  }
1505
- if (!isValidBookingTransition(current.status, data.status)) {
1506
- return { status: "invalid_transition" };
1575
+ let patch;
1576
+ try {
1577
+ patch = transitionBooking(current.status, data.status);
1578
+ }
1579
+ catch (error) {
1580
+ if (error instanceof BookingTransitionError) {
1581
+ return { status: "invalid_transition" };
1582
+ }
1583
+ throw error;
1507
1584
  }
1508
1585
  const [row] = await db
1509
1586
  .update(bookings)
1510
1587
  .set({
1511
- status: data.status,
1512
- ...toBookingStatusTimestamps(data.status),
1588
+ ...patch,
1513
1589
  updatedAt: new Date(),
1514
1590
  })
1515
1591
  .where(eq(bookings.id, id))
@@ -1544,12 +1620,16 @@ export const bookingsService = {
1544
1620
  if (!booking) {
1545
1621
  throw new BookingServiceError("not_found");
1546
1622
  }
1623
+ if (!canTransitionBooking(booking.status, "confirmed")) {
1624
+ throw new BookingServiceError("invalid_transition");
1625
+ }
1547
1626
  if (booking.status !== "on_hold") {
1548
1627
  throw new BookingServiceError("invalid_transition");
1549
1628
  }
1550
1629
  if (booking.hold_expires_at && booking.hold_expires_at < new Date()) {
1551
1630
  throw new BookingServiceError("hold_expired");
1552
1631
  }
1632
+ const patch = transitionBooking(booking.status, "confirmed");
1553
1633
  await tx
1554
1634
  .update(bookingAllocations)
1555
1635
  .set({
@@ -1565,9 +1645,8 @@ export const bookingsService = {
1565
1645
  const [row] = await tx
1566
1646
  .update(bookings)
1567
1647
  .set({
1568
- status: "confirmed",
1648
+ ...patch,
1569
1649
  holdExpiresAt: null,
1570
- confirmedAt: new Date(),
1571
1650
  updatedAt: new Date(),
1572
1651
  })
1573
1652
  .where(eq(bookings.id, id))
@@ -1669,9 +1748,13 @@ export const bookingsService = {
1669
1748
  if (!booking) {
1670
1749
  throw new BookingServiceError("not_found");
1671
1750
  }
1751
+ if (!canTransitionBooking(booking.status, "expired")) {
1752
+ throw new BookingServiceError("invalid_transition");
1753
+ }
1672
1754
  if (booking.status !== "on_hold") {
1673
1755
  throw new BookingServiceError("invalid_transition");
1674
1756
  }
1757
+ const patch = transitionBooking(booking.status, "expired");
1675
1758
  const allocations = await tx
1676
1759
  .select()
1677
1760
  .from(bookingAllocations)
@@ -1694,9 +1777,8 @@ export const bookingsService = {
1694
1777
  const [row] = await tx
1695
1778
  .update(bookings)
1696
1779
  .set({
1697
- status: "expired",
1780
+ ...patch,
1698
1781
  holdExpiresAt: null,
1699
- expiredAt: new Date(),
1700
1782
  updatedAt: new Date(),
1701
1783
  })
1702
1784
  .where(eq(bookings.id, id))
@@ -1765,9 +1847,10 @@ export const bookingsService = {
1765
1847
  if (!booking) {
1766
1848
  throw new BookingServiceError("not_found");
1767
1849
  }
1768
- if (!["draft", "on_hold", "confirmed", "in_progress"].includes(booking.status)) {
1850
+ if (!canTransitionBooking(booking.status, "cancelled")) {
1769
1851
  throw new BookingServiceError("invalid_transition");
1770
1852
  }
1853
+ const patch = transitionBooking(booking.status, "cancelled");
1771
1854
  const previousStatus = booking.status;
1772
1855
  const allocations = await tx
1773
1856
  .select()
@@ -1794,9 +1877,8 @@ export const bookingsService = {
1794
1877
  const [row] = await tx
1795
1878
  .update(bookings)
1796
1879
  .set({
1797
- status: "cancelled",
1880
+ ...patch,
1798
1881
  holdExpiresAt: null,
1799
- cancelledAt: new Date(),
1800
1882
  updatedAt: new Date(),
1801
1883
  })
1802
1884
  .where(eq(bookings.id, id))
@@ -1873,7 +1955,6 @@ export const bookingsService = {
1873
1955
  email: data.email ?? null,
1874
1956
  phone: data.phone ?? null,
1875
1957
  preferredLanguage: data.preferredLanguage ?? null,
1876
- accessibilityNeeds: data.accessibilityNeeds ?? null,
1877
1958
  specialRequests: data.specialRequests ?? null,
1878
1959
  isPrimary: data.isPrimary ?? false,
1879
1960
  notes: data.notes ?? null,
@@ -1928,7 +2009,6 @@ export const bookingsService = {
1928
2009
  email: data.email ?? null,
1929
2010
  phone: data.phone ?? null,
1930
2011
  preferredLanguage: data.preferredLanguage ?? null,
1931
- accessibilityNeeds: data.accessibilityNeeds ?? null,
1932
2012
  specialRequests: data.specialRequests ?? null,
1933
2013
  isPrimary: data.isPrimary ?? false,
1934
2014
  notes: data.notes ?? null,
@@ -1942,7 +2022,6 @@ export const bookingsService = {
1942
2022
  email: data.email ?? null,
1943
2023
  phone: data.phone ?? null,
1944
2024
  preferredLanguage: data.preferredLanguage ?? null,
1945
- accessibilityNeeds: data.accessibilityNeeds ?? null,
1946
2025
  specialRequests: data.specialRequests ?? null,
1947
2026
  travelerCategory: data.travelerCategory ?? null,
1948
2027
  isPrimary: data.isPrimary ?? undefined,
@@ -1960,74 +2039,194 @@ export const bookingsService = {
1960
2039
  .where(eq(bookingItems.bookingId, bookingId))
1961
2040
  .orderBy(asc(bookingItems.createdAt));
1962
2041
  },
1963
- async createItem(db, bookingId, data, userId) {
2042
+ /**
2043
+ * Re-derive `bookings.sellAmountCents` / `costAmountCents` from
2044
+ * `Σ(booking_items.total*AmountCents)`, plus — when the booking
2045
+ * declares a `baseCurrency` and `fxRateSetId` — re-derive
2046
+ * `baseSellAmountCents` / `baseCostAmountCents` by converting each
2047
+ * item's total via the FX rate set.
2048
+ *
2049
+ * Called automatically inside the item-mutation methods so callers
2050
+ * that go through `createItem` / `updateItem` / `deleteItem` never
2051
+ * have to remember to roll the parent. Public so external flows
2052
+ * (saga compensations, ad-hoc fix-ups) can also invoke it.
2053
+ *
2054
+ * Pass a tx-bound `db` to compose with an existing transaction; this
2055
+ * method does NOT wrap its own transaction.
2056
+ *
2057
+ * **FX rollup behaviour**:
2058
+ *
2059
+ * - Single-currency booking (every item's `sellCurrency === baseCurrency`,
2060
+ * or `baseCurrency === sellCurrency` on the parent): `base*Cents`
2061
+ * equal `sell*Cents` / `cost*Cents` directly. No FX lookup needed.
2062
+ * - Multi-currency booking with `fxRateSetId`: every item is
2063
+ * converted to `baseCurrency` via `exchange_rates`. If any item's
2064
+ * currency is missing from the rate set, the FX rollup short-circuits
2065
+ * with `fxStatus: "missing_rate"` and `base*Cents` are LEFT
2066
+ * UNCHANGED on the parent (caller chooses whether to abort).
2067
+ * - No `baseCurrency` configured: FX rollup is skipped entirely
2068
+ * (`fxStatus: "skipped"`), and `base*Cents` stay null.
2069
+ *
2070
+ * Returns `{ sellAmountCents, costAmountCents, baseSellAmountCents,
2071
+ * baseCostAmountCents, fxStatus, missingCurrency? }` or `null` for a
2072
+ * missing booking.
2073
+ */
2074
+ async recomputeBookingTotal(db, bookingId) {
1964
2075
  const [booking] = await db
1965
- .select({ id: bookings.id, sellCurrency: bookings.sellCurrency })
2076
+ .select({
2077
+ id: bookings.id,
2078
+ sellCurrency: bookings.sellCurrency,
2079
+ baseCurrency: bookings.baseCurrency,
2080
+ })
1966
2081
  .from(bookings)
1967
2082
  .where(eq(bookings.id, bookingId))
1968
2083
  .limit(1);
1969
2084
  if (!booking) {
1970
2085
  return null;
1971
2086
  }
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,
2087
+ const [totals] = await db
2088
+ .select({
2089
+ sellAmountCents: sql `coalesce(sum(${bookingItems.totalSellAmountCents}), 0)::int`,
2090
+ costAmountCents: sql `coalesce(sum(${bookingItems.totalCostAmountCents}), 0)::int`,
1998
2091
  })
1999
- .returning();
2000
- if (!row) {
2001
- return null;
2092
+ .from(bookingItems)
2093
+ .where(eq(bookingItems.bookingId, bookingId));
2094
+ const sellAmountCents = totals?.sellAmountCents ?? 0;
2095
+ const costAmountCents = totals?.costAmountCents ?? 0;
2096
+ // We need fxRateSetId from the bookings row plus per-item currency
2097
+ // for the FX rollup. Refetch with those columns.
2098
+ const [bookingForFx] = await db
2099
+ .select({
2100
+ baseCurrency: bookings.baseCurrency,
2101
+ sellCurrency: bookings.sellCurrency,
2102
+ })
2103
+ .from(bookings)
2104
+ .where(eq(bookings.id, bookingId))
2105
+ .limit(1);
2106
+ let fxStatus = "skipped";
2107
+ let baseSellAmountCents = null;
2108
+ let baseCostAmountCents = null;
2109
+ let missingCurrency = null;
2110
+ const baseCurrency = bookingForFx?.baseCurrency ?? null;
2111
+ if (baseCurrency) {
2112
+ const fxResult = await rollupBaseTotals(db, bookingId, baseCurrency);
2113
+ if (fxResult.status === "ok") {
2114
+ fxStatus = "ok";
2115
+ baseSellAmountCents = fxResult.baseSellAmountCents;
2116
+ baseCostAmountCents = fxResult.baseCostAmountCents;
2117
+ }
2118
+ else if (fxResult.status === "missing_rate") {
2119
+ fxStatus = "missing_rate";
2120
+ missingCurrency = fxResult.currency;
2121
+ }
2002
2122
  }
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 },
2123
+ const patch = {
2124
+ sellAmountCents,
2125
+ costAmountCents,
2126
+ updatedAt: new Date(),
2127
+ };
2128
+ if (fxStatus === "ok") {
2129
+ patch.baseSellAmountCents = baseSellAmountCents;
2130
+ patch.baseCostAmountCents = baseCostAmountCents;
2131
+ }
2132
+ await db.update(bookings).set(patch).where(eq(bookings.id, bookingId));
2133
+ return {
2134
+ sellAmountCents,
2135
+ costAmountCents,
2136
+ baseSellAmountCents,
2137
+ baseCostAmountCents,
2138
+ fxStatus,
2139
+ ...(missingCurrency ? { missingCurrency } : {}),
2140
+ };
2141
+ },
2142
+ async createItem(db, bookingId, data, userId) {
2143
+ return db.transaction(async (tx) => {
2144
+ const [booking] = await tx
2145
+ .select({ id: bookings.id, sellCurrency: bookings.sellCurrency })
2146
+ .from(bookings)
2147
+ .where(eq(bookings.id, bookingId))
2148
+ .limit(1);
2149
+ if (!booking) {
2150
+ return null;
2151
+ }
2152
+ const [row] = await tx
2153
+ .insert(bookingItems)
2154
+ .values({
2155
+ bookingId,
2156
+ title: data.title,
2157
+ description: data.description ?? null,
2158
+ itemType: data.itemType,
2159
+ status: data.status,
2160
+ serviceDate: data.serviceDate ?? null,
2161
+ startsAt: toTimestamp(data.startsAt),
2162
+ endsAt: toTimestamp(data.endsAt),
2163
+ quantity: data.quantity,
2164
+ sellCurrency: data.sellCurrency ?? booking.sellCurrency,
2165
+ unitSellAmountCents: data.unitSellAmountCents ?? null,
2166
+ totalSellAmountCents: data.totalSellAmountCents ?? null,
2167
+ costCurrency: data.costCurrency ?? null,
2168
+ unitCostAmountCents: data.unitCostAmountCents ?? null,
2169
+ totalCostAmountCents: data.totalCostAmountCents ?? null,
2170
+ notes: data.notes ?? null,
2171
+ productId: data.productId ?? null,
2172
+ optionId: data.optionId ?? null,
2173
+ optionUnitId: data.optionUnitId ?? null,
2174
+ pricingCategoryId: data.pricingCategoryId ?? null,
2175
+ sourceSnapshotId: data.sourceSnapshotId ?? null,
2176
+ sourceOfferId: data.sourceOfferId ?? null,
2177
+ metadata: data.metadata ?? null,
2178
+ })
2179
+ .returning();
2180
+ if (!row) {
2181
+ return null;
2182
+ }
2183
+ await tx.insert(bookingActivityLog).values({
2184
+ bookingId,
2185
+ actorId: userId ?? "system",
2186
+ activityType: "item_update",
2187
+ description: `Booking item "${data.title}" added`,
2188
+ metadata: { bookingItemId: row.id, itemType: data.itemType },
2189
+ });
2190
+ await bookingsService.recomputeBookingTotal(tx, bookingId);
2191
+ return row;
2009
2192
  });
2010
- return row;
2011
2193
  },
2012
2194
  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;
2195
+ return db.transaction(async (tx) => {
2196
+ const [row] = await tx
2197
+ .update(bookingItems)
2198
+ .set({
2199
+ ...data,
2200
+ startsAt: data.startsAt === undefined ? undefined : toTimestamp(data.startsAt),
2201
+ endsAt: data.endsAt === undefined ? undefined : toTimestamp(data.endsAt),
2202
+ updatedAt: new Date(),
2203
+ })
2204
+ .where(eq(bookingItems.id, itemId))
2205
+ .returning();
2206
+ if (!row)
2207
+ return null;
2208
+ await bookingsService.recomputeBookingTotal(tx, row.bookingId);
2209
+ return row;
2210
+ });
2024
2211
  },
2025
2212
  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;
2213
+ return db.transaction(async (tx) => {
2214
+ // Look up the parent booking BEFORE the delete so we can roll up
2215
+ // afterwards.
2216
+ const [item] = await tx
2217
+ .select({ bookingId: bookingItems.bookingId })
2218
+ .from(bookingItems)
2219
+ .where(eq(bookingItems.id, itemId))
2220
+ .limit(1);
2221
+ const [row] = await tx
2222
+ .delete(bookingItems)
2223
+ .where(eq(bookingItems.id, itemId))
2224
+ .returning({ id: bookingItems.id });
2225
+ if (item) {
2226
+ await bookingsService.recomputeBookingTotal(tx, item.bookingId);
2227
+ }
2228
+ return row ?? null;
2229
+ });
2031
2230
  },
2032
2231
  listItemParticipants(db, itemId) {
2033
2232
  return db
@@ -0,0 +1,29 @@
1
+ import type { bookings } from "./schema-core.js";
2
+ export type BookingStatus = (typeof bookings.$inferSelect)["status"];
3
+ export declare const BOOKING_TRANSITIONS: {
4
+ readonly draft: readonly ["on_hold", "confirmed", "cancelled"];
5
+ readonly on_hold: readonly ["confirmed", "expired", "cancelled"];
6
+ readonly confirmed: readonly ["in_progress", "cancelled"];
7
+ readonly in_progress: readonly ["completed", "cancelled"];
8
+ readonly completed: readonly [];
9
+ readonly expired: readonly [];
10
+ readonly cancelled: readonly [];
11
+ };
12
+ export declare class BookingTransitionError extends Error {
13
+ readonly from: BookingStatus;
14
+ readonly to: BookingStatus;
15
+ readonly code = "INVALID_BOOKING_TRANSITION";
16
+ constructor(from: BookingStatus, to: BookingStatus);
17
+ }
18
+ export declare function canTransitionBooking(from: BookingStatus, to: BookingStatus): boolean;
19
+ export interface BookingStatusPatch {
20
+ status: BookingStatus;
21
+ confirmedAt?: Date;
22
+ expiredAt?: Date;
23
+ cancelledAt?: Date;
24
+ completedAt?: Date;
25
+ }
26
+ export declare function transitionBooking(from: BookingStatus, to: BookingStatus, opts?: {
27
+ now?: Date;
28
+ }): BookingStatusPatch;
29
+ //# sourceMappingURL=state-machine.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"state-machine.d.ts","sourceRoot":"","sources":["../src/state-machine.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAA;AAEhD,MAAM,MAAM,aAAa,GAAG,CAAC,OAAO,QAAQ,CAAC,YAAY,CAAC,CAAC,QAAQ,CAAC,CAAA;AAEpE,eAAO,MAAM,mBAAmB;;;;;;;;CAQoC,CAAA;AAEpE,qBAAa,sBAAuB,SAAQ,KAAK;IAI7C,QAAQ,CAAC,IAAI,EAAE,aAAa;IAC5B,QAAQ,CAAC,EAAE,EAAE,aAAa;IAJ5B,QAAQ,CAAC,IAAI,gCAA+B;gBAGjC,IAAI,EAAE,aAAa,EACnB,EAAE,EAAE,aAAa;CAK7B;AAED,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,aAAa,EAAE,EAAE,EAAE,aAAa,GAAG,OAAO,CAEpF;AAED,MAAM,WAAW,kBAAkB;IACjC,MAAM,EAAE,aAAa,CAAA;IACrB,WAAW,CAAC,EAAE,IAAI,CAAA;IAClB,SAAS,CAAC,EAAE,IAAI,CAAA;IAChB,WAAW,CAAC,EAAE,IAAI,CAAA;IAClB,WAAW,CAAC,EAAE,IAAI,CAAA;CACnB;AAED,wBAAgB,iBAAiB,CAC/B,IAAI,EAAE,aAAa,EACnB,EAAE,EAAE,aAAa,EACjB,IAAI,GAAE;IAAE,GAAG,CAAC,EAAE,IAAI,CAAA;CAAO,GACxB,kBAAkB,CAcpB"}
@@ -0,0 +1,39 @@
1
+ export const BOOKING_TRANSITIONS = {
2
+ draft: ["on_hold", "confirmed", "cancelled"],
3
+ on_hold: ["confirmed", "expired", "cancelled"],
4
+ confirmed: ["in_progress", "cancelled"],
5
+ in_progress: ["completed", "cancelled"],
6
+ completed: [],
7
+ expired: [],
8
+ cancelled: [],
9
+ };
10
+ export class BookingTransitionError extends Error {
11
+ from;
12
+ to;
13
+ code = "INVALID_BOOKING_TRANSITION";
14
+ constructor(from, to) {
15
+ super(`Illegal booking status transition: ${from} → ${to}`);
16
+ this.from = from;
17
+ this.to = to;
18
+ this.name = "BookingTransitionError";
19
+ }
20
+ }
21
+ export function canTransitionBooking(from, to) {
22
+ return BOOKING_TRANSITIONS[from].includes(to);
23
+ }
24
+ export function transitionBooking(from, to, opts = {}) {
25
+ if (!canTransitionBooking(from, to)) {
26
+ throw new BookingTransitionError(from, to);
27
+ }
28
+ const now = opts.now ?? new Date();
29
+ const patch = { status: to };
30
+ if (to === "confirmed")
31
+ patch.confirmedAt = now;
32
+ if (to === "expired")
33
+ patch.expiredAt = now;
34
+ if (to === "cancelled")
35
+ patch.cancelledAt = now;
36
+ if (to === "completed")
37
+ patch.completedAt = now;
38
+ return patch;
39
+ }
@@ -17,7 +17,6 @@ export declare const publicBookingSessionTravelerInputSchema: z.ZodObject<{
17
17
  email: z.ZodOptional<z.ZodNullable<z.ZodString>>;
18
18
  phone: z.ZodOptional<z.ZodNullable<z.ZodString>>;
19
19
  preferredLanguage: z.ZodOptional<z.ZodNullable<z.ZodString>>;
20
- accessibilityNeeds: z.ZodOptional<z.ZodNullable<z.ZodString>>;
21
20
  specialRequests: z.ZodOptional<z.ZodNullable<z.ZodString>>;
22
21
  isPrimary: z.ZodDefault<z.ZodBoolean>;
23
22
  notes: z.ZodOptional<z.ZodNullable<z.ZodString>>;
@@ -127,7 +126,6 @@ export declare const publicCreateBookingSessionSchema: z.ZodObject<{
127
126
  email: z.ZodOptional<z.ZodNullable<z.ZodString>>;
128
127
  phone: z.ZodOptional<z.ZodNullable<z.ZodString>>;
129
128
  preferredLanguage: z.ZodOptional<z.ZodNullable<z.ZodString>>;
130
- accessibilityNeeds: z.ZodOptional<z.ZodNullable<z.ZodString>>;
131
129
  specialRequests: z.ZodOptional<z.ZodNullable<z.ZodString>>;
132
130
  isPrimary: z.ZodDefault<z.ZodBoolean>;
133
131
  notes: z.ZodOptional<z.ZodNullable<z.ZodString>>;
@@ -157,7 +155,6 @@ export declare const publicUpdateBookingSessionSchema: z.ZodObject<{
157
155
  email: z.ZodOptional<z.ZodNullable<z.ZodString>>;
158
156
  phone: z.ZodOptional<z.ZodNullable<z.ZodString>>;
159
157
  preferredLanguage: z.ZodOptional<z.ZodNullable<z.ZodString>>;
160
- accessibilityNeeds: z.ZodOptional<z.ZodNullable<z.ZodString>>;
161
158
  specialRequests: z.ZodOptional<z.ZodNullable<z.ZodString>>;
162
159
  isPrimary: z.ZodDefault<z.ZodBoolean>;
163
160
  notes: z.ZodOptional<z.ZodNullable<z.ZodString>>;
@@ -230,7 +227,6 @@ export declare const publicBookingSessionTravelerSchema: z.ZodObject<{
230
227
  email: z.ZodNullable<z.ZodString>;
231
228
  phone: z.ZodNullable<z.ZodString>;
232
229
  preferredLanguage: z.ZodNullable<z.ZodString>;
233
- accessibilityNeeds: z.ZodNullable<z.ZodString>;
234
230
  specialRequests: z.ZodNullable<z.ZodString>;
235
231
  isPrimary: z.ZodBoolean;
236
232
  notes: z.ZodNullable<z.ZodString>;
@@ -373,7 +369,6 @@ export declare const publicBookingSessionSchema: z.ZodObject<{
373
369
  email: z.ZodNullable<z.ZodString>;
374
370
  phone: z.ZodNullable<z.ZodString>;
375
371
  preferredLanguage: z.ZodNullable<z.ZodString>;
376
- accessibilityNeeds: z.ZodNullable<z.ZodString>;
377
372
  specialRequests: z.ZodNullable<z.ZodString>;
378
373
  isPrimary: z.ZodBoolean;
379
374
  notes: z.ZodNullable<z.ZodString>;
@@ -578,7 +573,6 @@ export declare const publicBookingSessionRepriceResultSchema: z.ZodObject<{
578
573
  email: z.ZodNullable<z.ZodString>;
579
574
  phone: z.ZodNullable<z.ZodString>;
580
575
  preferredLanguage: z.ZodNullable<z.ZodString>;
581
- accessibilityNeeds: z.ZodNullable<z.ZodString>;
582
576
  specialRequests: z.ZodNullable<z.ZodString>;
583
577
  isPrimary: z.ZodBoolean;
584
578
  notes: z.ZodNullable<z.ZodString>;
@@ -1 +1 @@
1
- {"version":3,"file":"validation-public.d.ts","sourceRoot":"","sources":["../src/validation-public.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"validation-public.d.ts","sourceRoot":"","sources":["../src/validation-public.ts"],"names":[],"mappings":"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"}
@@ -13,7 +13,6 @@ export const publicBookingSessionTravelerInputSchema = z.object({
13
13
  email: z.string().email().nullable().optional(),
14
14
  phone: z.string().max(50).nullable().optional(),
15
15
  preferredLanguage: z.string().max(35).nullable().optional(),
16
- accessibilityNeeds: z.string().nullable().optional(),
17
16
  specialRequests: z.string().nullable().optional(),
18
17
  isPrimary: z.boolean().default(false),
19
18
  notes: z.string().nullable().optional(),
@@ -130,7 +129,6 @@ export const publicBookingSessionTravelerSchema = z.object({
130
129
  email: z.string().nullable(),
131
130
  phone: z.string().nullable(),
132
131
  preferredLanguage: z.string().nullable(),
133
- accessibilityNeeds: z.string().nullable(),
134
132
  specialRequests: z.string().nullable(),
135
133
  isPrimary: z.boolean(),
136
134
  notes: z.string().nullable(),