@voyantjs/bookings 0.20.0 → 0.21.1

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 (43) hide show
  1. package/dist/index.d.ts +1 -1
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +1 -0
  4. package/dist/pii-redaction.d.ts +3 -6
  5. package/dist/pii-redaction.d.ts.map +1 -1
  6. package/dist/pii-redaction.js +5 -6
  7. package/dist/products-ref.d.ts +75 -0
  8. package/dist/products-ref.d.ts.map +1 -1
  9. package/dist/products-ref.js +6 -0
  10. package/dist/routes-groups.d.ts +8 -2
  11. package/dist/routes-groups.d.ts.map +1 -1
  12. package/dist/routes-public.d.ts +4 -4
  13. package/dist/routes.d.ts +83 -21
  14. package/dist/routes.d.ts.map +1 -1
  15. package/dist/routes.js +50 -0
  16. package/dist/schema-core.d.ts +53 -2
  17. package/dist/schema-core.d.ts.map +1 -1
  18. package/dist/schema-core.js +15 -0
  19. package/dist/schema-items.d.ts +1 -1
  20. package/dist/schema-operations.d.ts +2 -2
  21. package/dist/schema-shared.d.ts +2 -2
  22. package/dist/schema-shared.d.ts.map +1 -1
  23. package/dist/schema-shared.js +11 -0
  24. package/dist/service-public.d.ts +10 -10
  25. package/dist/service.d.ts +140 -27
  26. package/dist/service.d.ts.map +1 -1
  27. package/dist/service.js +313 -31
  28. package/dist/state-machine.d.ts +11 -2
  29. package/dist/state-machine.d.ts.map +1 -1
  30. package/dist/state-machine.js +9 -3
  31. package/dist/tasks/expire-stale-holds.d.ts +2 -1
  32. package/dist/tasks/expire-stale-holds.d.ts.map +1 -1
  33. package/dist/tasks/expire-stale-holds.js +2 -2
  34. package/dist/validation-public.d.ts +6 -3
  35. package/dist/validation-public.d.ts.map +1 -1
  36. package/dist/validation-public.js +1 -1
  37. package/dist/validation-shared.d.ts +2 -1
  38. package/dist/validation-shared.d.ts.map +1 -1
  39. package/dist/validation-shared.js +1 -0
  40. package/dist/validation.d.ts +69 -8
  41. package/dist/validation.d.ts.map +1 -1
  42. package/dist/validation.js +14 -2
  43. package/package.json +6 -7
package/dist/service.js CHANGED
@@ -1,7 +1,7 @@
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
3
  import { exchangeRatesRef } from "./markets-ref.js";
4
- import { bookingItemProductDetailsRef, bookingProductDetailsRef, optionUnitsRef, productDayServicesRef, productDaysRef, productItinerariesRef, productOptionsRef, productsRef, productTicketSettingsRef, } from "./products-ref.js";
4
+ import { bookingItemProductDetailsRef, bookingProductDetailsRef, optionUnitsRef, productDayServicesRef, productDaysRef, productItinerariesRef, productOptionsRef, productsRef, productTicketSettingsRef, suppliersRef, } from "./products-ref.js";
5
5
  import { bookingActivityLog, bookingAllocations, bookingDocuments, bookingFulfillments, bookingItems, bookingItemTravelers, bookingNotes, bookingRedemptionEvents, bookingStaffAssignments, bookingSupplierStatuses, bookings, bookingTravelers, } from "./schema.js";
6
6
  import { cleanupGroupOnBookingCancelled } from "./service-groups.js";
7
7
  import { canTransitionBooking, transitionBooking } from "./state-machine.js";
@@ -17,6 +17,26 @@ function pickTravelDetailFields(data) {
17
17
  isLeadTraveler: data.isLeadTraveler,
18
18
  };
19
19
  }
20
+ /** Stable string identifier for the event. */
21
+ export const AVAILABILITY_SLOT_CHANGED_EVENT = "availability.slot.changed";
22
+ /**
23
+ * Emit a batch of slot-change events through the runtime's EventBus.
24
+ * No-op when no event bus is wired (the common test path). Each emit is
25
+ * fire-and-forget per the EventBus contract — subscriber errors are
26
+ * logged, not rethrown — but we await to keep ordering deterministic in
27
+ * tests that drain the bus before assertions.
28
+ */
29
+ async function emitSlotChanges(runtime, changes) {
30
+ const eventBus = runtime.eventBus;
31
+ if (!eventBus || changes.length === 0)
32
+ return;
33
+ for (const change of changes) {
34
+ await eventBus.emit(AVAILABILITY_SLOT_CHANGED_EVENT, change, {
35
+ category: "domain",
36
+ source: "service",
37
+ });
38
+ }
39
+ }
20
40
  const travelerParticipantTypes = ["traveler", "occupant"];
21
41
  class BookingServiceError extends Error {
22
42
  code;
@@ -262,13 +282,93 @@ async function getConvertProductData(db, data) {
262
282
  })),
263
283
  };
264
284
  }
265
- function computeHoldExpiresAt(input) {
285
+ const DEFAULT_HOLD_MINUTES = 30;
286
+ function positiveHoldMinutes(value) {
287
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null;
288
+ }
289
+ function isUndefinedTableError(error) {
290
+ return (typeof error === "object" &&
291
+ error !== null &&
292
+ "code" in error &&
293
+ error.code === "42P01");
294
+ }
295
+ async function resolvePolicyHoldMinutes(db, items) {
296
+ const productIds = new Set();
297
+ const slotIds = new Set();
298
+ for (const item of items) {
299
+ if (item.productId)
300
+ productIds.add(item.productId);
301
+ const slotId = item.availabilitySlotId ?? item.slotId;
302
+ if (slotId)
303
+ slotIds.add(slotId);
304
+ }
305
+ if (slotIds.size > 0) {
306
+ const slotRows = await db
307
+ .select({ productId: availabilitySlotsRef.productId })
308
+ .from(availabilitySlotsRef)
309
+ .where(inArray(availabilitySlotsRef.id, [...slotIds]));
310
+ for (const slot of slotRows) {
311
+ if (slot.productId)
312
+ productIds.add(slot.productId);
313
+ }
314
+ }
315
+ if (productIds.size === 0) {
316
+ return DEFAULT_HOLD_MINUTES;
317
+ }
318
+ const productRows = await db
319
+ .select({
320
+ id: productsRef.id,
321
+ supplierId: productsRef.supplierId,
322
+ reservationTimeoutMinutes: productsRef.reservationTimeoutMinutes,
323
+ })
324
+ .from(productsRef)
325
+ .where(inArray(productsRef.id, [...productIds]))
326
+ .catch((error) => {
327
+ if (isUndefinedTableError(error))
328
+ return [];
329
+ throw error;
330
+ });
331
+ const supplierIds = [
332
+ ...new Set(productRows.map((product) => product.supplierId).filter(Boolean)),
333
+ ];
334
+ const supplierRows = supplierIds.length > 0
335
+ ? await db
336
+ .select({
337
+ id: suppliersRef.id,
338
+ reservationTimeoutMinutes: suppliersRef.reservationTimeoutMinutes,
339
+ })
340
+ .from(suppliersRef)
341
+ .where(inArray(suppliersRef.id, supplierIds))
342
+ .catch((error) => {
343
+ if (isUndefinedTableError(error))
344
+ return [];
345
+ throw error;
346
+ })
347
+ : [];
348
+ const supplierTimeouts = new Map(supplierRows.map((supplier) => [
349
+ supplier.id,
350
+ positiveHoldMinutes(supplier.reservationTimeoutMinutes),
351
+ ]));
352
+ const candidates = [];
353
+ for (const product of productRows) {
354
+ const productMinutes = positiveHoldMinutes(product.reservationTimeoutMinutes);
355
+ if (productMinutes !== null) {
356
+ candidates.push(productMinutes);
357
+ continue;
358
+ }
359
+ const supplierMinutes = product.supplierId ? supplierTimeouts.get(product.supplierId) : null;
360
+ if (supplierMinutes !== null && supplierMinutes !== undefined) {
361
+ candidates.push(supplierMinutes);
362
+ }
363
+ }
364
+ return candidates.length > 0 ? Math.min(...candidates) : DEFAULT_HOLD_MINUTES;
365
+ }
366
+ async function computeHoldExpiresAt(db, input, items = []) {
266
367
  if (input.holdExpiresAt) {
267
368
  return new Date(input.holdExpiresAt);
268
369
  }
269
- const now = Date.now();
270
- const minutes = input.holdMinutes ?? 30;
271
- return new Date(now + minutes * 60 * 1000);
370
+ const minutes = input.holdMinutes ?? (await resolvePolicyHoldMinutes(db, items));
371
+ return new Date(Date.now() + minutes * 60 * 1000);
272
372
  }
273
373
  /**
274
374
  * Walk a booking's items, convert each line into the booking's
@@ -374,7 +474,18 @@ async function lockAvailabilitySlot(db, slotId) {
374
474
  ends_at: toDateValueOrNull(row.ends_at),
375
475
  };
376
476
  }
377
- async function adjustSlotCapacity(db, slotId, delta) {
477
+ function buildSlotChange(slot, remainingPax, source) {
478
+ return {
479
+ slotId: slot.id,
480
+ productId: slot.product_id,
481
+ optionId: slot.option_id,
482
+ startsAt: slot.starts_at,
483
+ remainingPax: slot.unlimited ? null : remainingPax,
484
+ unlimited: slot.unlimited,
485
+ source,
486
+ };
487
+ }
488
+ async function adjustSlotCapacity(db, slotId, delta, source = "booking") {
378
489
  const locked = await lockAvailabilitySlot(db, slotId);
379
490
  if (!locked) {
380
491
  return { status: "slot_not_found" };
@@ -383,7 +494,12 @@ async function adjustSlotCapacity(db, slotId, delta) {
383
494
  return { status: "slot_unavailable", slot: locked };
384
495
  }
385
496
  if (locked.unlimited) {
386
- return { status: "ok", slot: locked, remainingPax: locked.remaining_pax };
497
+ return {
498
+ status: "ok",
499
+ slot: locked,
500
+ remainingPax: locked.remaining_pax,
501
+ slotChange: buildSlotChange(locked, locked.remaining_pax, source),
502
+ };
387
503
  }
388
504
  const currentRemaining = locked.remaining_pax ?? 0;
389
505
  const nextRemaining = currentRemaining + delta;
@@ -409,21 +525,28 @@ async function adjustSlotCapacity(db, slotId, delta) {
409
525
  updatedAt: new Date(),
410
526
  })
411
527
  .where(eq(availabilitySlotsRef.id, slotId));
412
- return { status: "ok", slot: locked, remainingPax: nextRemaining };
528
+ return {
529
+ status: "ok",
530
+ slot: locked,
531
+ remainingPax: nextRemaining,
532
+ slotChange: buildSlotChange(locked, nextRemaining, source),
533
+ };
413
534
  }
414
- async function releaseAllocationCapacity(db, allocation) {
535
+ async function releaseAllocationCapacity(db, allocation, source = "cancel") {
415
536
  if (!allocation.availabilitySlotId) {
416
- return;
537
+ return undefined;
417
538
  }
418
539
  if (allocation.status !== "held" && allocation.status !== "confirmed") {
419
- return;
540
+ return undefined;
420
541
  }
421
- await adjustSlotCapacity(db, allocation.availabilitySlotId, allocation.quantity);
542
+ const result = await adjustSlotCapacity(db, allocation.availabilitySlotId, allocation.quantity, source);
543
+ return result.status === "ok" ? result.slotChange : undefined;
422
544
  }
423
- async function reserveBookingFromTransactionSource(db, source, data, userId) {
545
+ async function reserveBookingFromTransactionSource(db, source, data, userId, runtime = {}) {
546
+ const slotChanges = [];
424
547
  try {
425
- return await db.transaction(async (tx) => {
426
- const holdExpiresAt = computeHoldExpiresAt(data);
548
+ const result = await db.transaction(async (tx) => {
549
+ const holdExpiresAt = await computeHoldExpiresAt(tx, data, source.items);
427
550
  const dateRange = deriveBookingDateRange(source.items);
428
551
  const pax = deriveBookingPax(source.participants, source.items);
429
552
  const [booking] = await tx
@@ -492,7 +615,7 @@ async function reserveBookingFromTransactionSource(db, source, data, userId) {
492
615
  const bookingItemMap = new Map();
493
616
  for (const item of source.items) {
494
617
  if (item.slotId) {
495
- const capacity = await adjustSlotCapacity(tx, item.slotId, -item.quantity);
618
+ const capacity = await adjustSlotCapacity(tx, item.slotId, -item.quantity, "booking");
496
619
  if (capacity.status === "slot_not_found") {
497
620
  throw new BookingServiceError("slot_not_found");
498
621
  }
@@ -509,6 +632,8 @@ async function reserveBookingFromTransactionSource(db, source, data, userId) {
509
632
  if (item.optionId && item.optionId !== slot.option_id) {
510
633
  throw new BookingServiceError("slot_option_mismatch");
511
634
  }
635
+ if (capacity.slotChange)
636
+ slotChanges.push(capacity.slotChange);
512
637
  }
513
638
  const [bookingItem] = await tx
514
639
  .insert(bookingItems)
@@ -680,6 +805,10 @@ async function reserveBookingFromTransactionSource(db, source, data, userId) {
680
805
  }
681
806
  return { status: "ok", booking };
682
807
  });
808
+ if (result.status === "ok") {
809
+ await emitSlotChanges(runtime, slotChanges);
810
+ }
811
+ return result;
683
812
  }
684
813
  catch (error) {
685
814
  if (error instanceof BookingServiceError) {
@@ -1033,8 +1162,37 @@ export const bookingsService = {
1033
1162
  .orderBy(desc(bookings.createdAt)),
1034
1163
  db.select({ count: sql `count(*)::int` }).from(bookings).where(where),
1035
1164
  ]);
1165
+ const bookingIds = rows.map((row) => row.id);
1166
+ const itemTimes = bookingIds.length > 0
1167
+ ? await db
1168
+ .select({
1169
+ bookingId: bookingItems.bookingId,
1170
+ startsAt: bookingItems.startsAt,
1171
+ endsAt: bookingItems.endsAt,
1172
+ })
1173
+ .from(bookingItems)
1174
+ .where(inArray(bookingItems.bookingId, bookingIds))
1175
+ : [];
1176
+ const ranges = new Map();
1177
+ for (const item of itemTimes) {
1178
+ const current = ranges.get(item.bookingId) ?? { startsAt: null, endsAt: null };
1179
+ if (item.startsAt && (!current.startsAt || item.startsAt < current.startsAt)) {
1180
+ current.startsAt = item.startsAt;
1181
+ }
1182
+ if (item.endsAt && (!current.endsAt || item.endsAt > current.endsAt)) {
1183
+ current.endsAt = item.endsAt;
1184
+ }
1185
+ ranges.set(item.bookingId, current);
1186
+ }
1036
1187
  return {
1037
- data: rows,
1188
+ data: rows.map((row) => {
1189
+ const range = ranges.get(row.id);
1190
+ return {
1191
+ ...row,
1192
+ startsAt: range?.startsAt?.toISOString() ?? null,
1193
+ endsAt: range?.endsAt?.toISOString() ?? null,
1194
+ };
1195
+ }),
1038
1196
  total: countResult[0]?.count ?? 0,
1039
1197
  limit: query.limit,
1040
1198
  offset: query.offset,
@@ -1207,7 +1365,7 @@ export const bookingsService = {
1207
1365
  .where(eq(bookingAllocations.bookingId, bookingId))
1208
1366
  .orderBy(asc(bookingAllocations.createdAt));
1209
1367
  },
1210
- async reserveBookingFromOffer(db, offerId, data, userId) {
1368
+ async reserveBookingFromOffer(db, offerId, data, userId, runtime = {}) {
1211
1369
  const [offer] = await db.select().from(offersRef).where(eq(offersRef.id, offerId)).limit(1);
1212
1370
  if (!offer) {
1213
1371
  return { status: "not_found" };
@@ -1282,9 +1440,9 @@ export const bookingsService = {
1282
1440
  participants: reservationParticipants,
1283
1441
  items,
1284
1442
  itemParticipants: reservationItemParticipants,
1285
- }, data, userId);
1443
+ }, data, userId, runtime);
1286
1444
  },
1287
- async reserveBookingFromOrder(db, orderId, data, userId) {
1445
+ async reserveBookingFromOrder(db, orderId, data, userId, runtime = {}) {
1288
1446
  const [order] = await db.select().from(ordersRef).where(eq(ordersRef.id, orderId)).limit(1);
1289
1447
  if (!order) {
1290
1448
  return { status: "not_found" };
@@ -1359,12 +1517,13 @@ export const bookingsService = {
1359
1517
  participants: reservationParticipants,
1360
1518
  items,
1361
1519
  itemParticipants: reservationItemParticipants,
1362
- }, data, userId);
1520
+ }, data, userId, runtime);
1363
1521
  },
1364
- async reserveBooking(db, data, userId) {
1522
+ async reserveBooking(db, data, userId, runtime = {}) {
1523
+ const slotChanges = [];
1365
1524
  try {
1366
- return await db.transaction(async (tx) => {
1367
- const holdExpiresAt = computeHoldExpiresAt(data);
1525
+ const result = await db.transaction(async (tx) => {
1526
+ const holdExpiresAt = await computeHoldExpiresAt(tx, data, data.items);
1368
1527
  const [booking] = await tx
1369
1528
  .insert(bookings)
1370
1529
  .values({
@@ -1403,7 +1562,7 @@ export const bookingsService = {
1403
1562
  throw new BookingServiceError("booking_create_failed");
1404
1563
  }
1405
1564
  for (const item of data.items) {
1406
- const capacity = await adjustSlotCapacity(tx, item.availabilitySlotId, -item.quantity);
1565
+ const capacity = await adjustSlotCapacity(tx, item.availabilitySlotId, -item.quantity, "booking");
1407
1566
  if (capacity.status === "slot_not_found") {
1408
1567
  throw new BookingServiceError("slot_not_found");
1409
1568
  }
@@ -1420,6 +1579,8 @@ export const bookingsService = {
1420
1579
  if (item.optionId && item.optionId !== slot.option_id) {
1421
1580
  throw new BookingServiceError("slot_option_mismatch");
1422
1581
  }
1582
+ if (capacity.slotChange)
1583
+ slotChanges.push(capacity.slotChange);
1423
1584
  const [bookingItem] = await tx
1424
1585
  .insert(bookingItems)
1425
1586
  .values({
@@ -1481,6 +1642,10 @@ export const bookingsService = {
1481
1642
  });
1482
1643
  return { status: "ok", booking };
1483
1644
  });
1645
+ if (result.status === "ok") {
1646
+ await emitSlotChanges(runtime, slotChanges);
1647
+ }
1648
+ return result;
1484
1649
  }
1485
1650
  catch (error) {
1486
1651
  if (error instanceof BookingServiceError) {
@@ -1575,7 +1740,12 @@ export const bookingsService = {
1575
1740
  if (!canTransitionBooking(booking.status, "confirmed")) {
1576
1741
  throw new BookingServiceError("invalid_transition");
1577
1742
  }
1578
- if (booking.status !== "on_hold") {
1743
+ // Accept both the staff-brokered "on_hold" and the customer
1744
+ // checkout flow's "awaiting_payment". Other statuses (draft,
1745
+ // already-confirmed, expired, cancelled) reject — the state
1746
+ // machine catches the rest, but we explicitly forbid the
1747
+ // states that would skip a step in the lifecycle.
1748
+ if (booking.status !== "on_hold" && booking.status !== "awaiting_payment") {
1579
1749
  throw new BookingServiceError("invalid_transition");
1580
1750
  }
1581
1751
  if (booking.hold_expires_at && booking.hold_expires_at < new Date()) {
@@ -1639,6 +1809,109 @@ export const bookingsService = {
1639
1809
  throw error;
1640
1810
  }
1641
1811
  },
1812
+ async recoverExpiredPaidBooking(db, id, data = {}, userId, runtime = {}) {
1813
+ const slotChanges = [];
1814
+ try {
1815
+ const result = await db.transaction(async (tx) => {
1816
+ const rows = await tx.execute(sql `SELECT id, booking_number, status
1817
+ FROM ${bookings}
1818
+ WHERE ${bookings.id} = ${id}
1819
+ FOR UPDATE`);
1820
+ const booking = rows[0];
1821
+ if (!booking) {
1822
+ throw new BookingServiceError("not_found");
1823
+ }
1824
+ if (booking.status !== "awaiting_payment" && booking.status !== "expired") {
1825
+ throw new BookingServiceError("invalid_transition");
1826
+ }
1827
+ const allocations = await tx
1828
+ .select()
1829
+ .from(bookingAllocations)
1830
+ .where(eq(bookingAllocations.bookingId, id));
1831
+ for (const allocation of allocations) {
1832
+ if (allocation.status === "confirmed") {
1833
+ continue;
1834
+ }
1835
+ if (allocation.status !== "held" && allocation.status !== "expired") {
1836
+ throw new BookingServiceError("invalid_transition");
1837
+ }
1838
+ if (!allocation.availabilitySlotId || allocation.status === "held") {
1839
+ continue;
1840
+ }
1841
+ const capacity = await adjustSlotCapacity(tx, allocation.availabilitySlotId, -allocation.quantity, "booking");
1842
+ if (capacity.status === "slot_not_found") {
1843
+ throw new BookingServiceError("slot_not_found");
1844
+ }
1845
+ if (capacity.status === "slot_unavailable") {
1846
+ throw new BookingServiceError("slot_unavailable");
1847
+ }
1848
+ if (capacity.status === "insufficient_capacity") {
1849
+ throw new BookingServiceError("insufficient_capacity");
1850
+ }
1851
+ if (capacity.slotChange)
1852
+ slotChanges.push(capacity.slotChange);
1853
+ }
1854
+ const now = new Date();
1855
+ await tx
1856
+ .update(bookingAllocations)
1857
+ .set({
1858
+ status: "confirmed",
1859
+ confirmedAt: now,
1860
+ releasedAt: null,
1861
+ updatedAt: now,
1862
+ })
1863
+ .where(and(eq(bookingAllocations.bookingId, id), inArray(bookingAllocations.status, ["held", "expired"])));
1864
+ await tx
1865
+ .update(bookingItems)
1866
+ .set({ status: "confirmed", updatedAt: now })
1867
+ .where(and(eq(bookingItems.bookingId, id), inArray(bookingItems.status, ["on_hold", "expired"])));
1868
+ const [row] = await tx
1869
+ .update(bookings)
1870
+ .set({
1871
+ status: "confirmed",
1872
+ confirmedAt: now,
1873
+ paidAt: now,
1874
+ expiredAt: null,
1875
+ holdExpiresAt: null,
1876
+ updatedAt: now,
1877
+ })
1878
+ .where(eq(bookings.id, id))
1879
+ .returning();
1880
+ await syncTransactionOnBookingConfirmed(tx, id);
1881
+ await autoIssueFulfillmentsForBooking(tx, id, userId);
1882
+ await tx.insert(bookingActivityLog).values({
1883
+ bookingId: id,
1884
+ actorId: userId ?? "system",
1885
+ activityType: "booking_confirmed",
1886
+ description: `Late payment recovered and booking ${booking.booking_number} confirmed`,
1887
+ metadata: { recoveredFromStatus: booking.status },
1888
+ });
1889
+ if (data.note) {
1890
+ await tx.insert(bookingNotes).values({
1891
+ bookingId: id,
1892
+ authorId: userId ?? "system",
1893
+ content: data.note,
1894
+ });
1895
+ }
1896
+ return { status: "ok", booking: row ?? null };
1897
+ });
1898
+ if (result.status === "ok" && result.booking) {
1899
+ await runtime.eventBus?.emit("booking.confirmed", {
1900
+ bookingId: result.booking.id,
1901
+ bookingNumber: result.booking.bookingNumber,
1902
+ actorId: userId ?? null,
1903
+ }, { category: "domain", source: "service" });
1904
+ await emitSlotChanges(runtime, slotChanges);
1905
+ }
1906
+ return result;
1907
+ }
1908
+ catch (error) {
1909
+ if (error instanceof BookingServiceError) {
1910
+ return { status: error.code };
1911
+ }
1912
+ throw error;
1913
+ }
1914
+ },
1642
1915
  async extendBookingHold(db, id, data, userId) {
1643
1916
  try {
1644
1917
  return await db.transaction(async (tx) => {
@@ -1650,13 +1923,13 @@ export const bookingsService = {
1650
1923
  if (!booking) {
1651
1924
  throw new BookingServiceError("not_found");
1652
1925
  }
1653
- if (booking.status !== "on_hold") {
1926
+ if (booking.status !== "on_hold" && booking.status !== "awaiting_payment") {
1654
1927
  throw new BookingServiceError("invalid_transition");
1655
1928
  }
1656
1929
  if (booking.hold_expires_at && booking.hold_expires_at < new Date()) {
1657
1930
  throw new BookingServiceError("hold_expired");
1658
1931
  }
1659
- const holdExpiresAt = computeHoldExpiresAt(data);
1932
+ const holdExpiresAt = await computeHoldExpiresAt(tx, data);
1660
1933
  await tx
1661
1934
  .update(bookingAllocations)
1662
1935
  .set({
@@ -1690,6 +1963,7 @@ export const bookingsService = {
1690
1963
  }
1691
1964
  },
1692
1965
  async expireBooking(db, id, data, userId, runtime = {}) {
1966
+ const slotChanges = [];
1693
1967
  try {
1694
1968
  const result = await db.transaction(async (tx) => {
1695
1969
  const rows = await tx.execute(sql `SELECT id, status, hold_expires_at
@@ -1712,7 +1986,9 @@ export const bookingsService = {
1712
1986
  .from(bookingAllocations)
1713
1987
  .where(eq(bookingAllocations.bookingId, id));
1714
1988
  for (const allocation of allocations) {
1715
- await releaseAllocationCapacity(tx, allocation);
1989
+ const change = await releaseAllocationCapacity(tx, allocation, "expire");
1990
+ if (change)
1991
+ slotChanges.push(change);
1716
1992
  }
1717
1993
  await tx
1718
1994
  .update(bookingAllocations)
@@ -1758,6 +2034,8 @@ export const bookingsService = {
1758
2034
  cause: runtime.cause ?? "route",
1759
2035
  actorId: userId ?? null,
1760
2036
  }, { category: "domain", source: "service" });
2037
+ await emitSlotChanges(runtime, slotChanges);
2038
+ await runtime.expirePaymentSessionsForBooking?.(db, result.booking.id);
1761
2039
  }
1762
2040
  return result;
1763
2041
  }
@@ -1773,7 +2051,7 @@ export const bookingsService = {
1773
2051
  const staleBookings = await db
1774
2052
  .select({ id: bookings.id })
1775
2053
  .from(bookings)
1776
- .where(and(eq(bookings.status, "on_hold"), sql `${bookings.holdExpiresAt} IS NOT NULL`, lte(bookings.holdExpiresAt, cutoff)))
2054
+ .where(and(inArray(bookings.status, ["on_hold", "awaiting_payment"]), sql `${bookings.holdExpiresAt} IS NOT NULL`, lte(bookings.holdExpiresAt, cutoff)))
1777
2055
  .orderBy(asc(bookings.holdExpiresAt), asc(bookings.createdAt));
1778
2056
  const expiredIds = [];
1779
2057
  for (const booking of staleBookings) {
@@ -1789,6 +2067,7 @@ export const bookingsService = {
1789
2067
  };
1790
2068
  },
1791
2069
  async cancelBooking(db, id, data, userId, runtime = {}) {
2070
+ const slotChanges = [];
1792
2071
  try {
1793
2072
  const result = await db.transaction(async (tx) => {
1794
2073
  const rows = await tx.execute(sql `SELECT id, status
@@ -1809,7 +2088,9 @@ export const bookingsService = {
1809
2088
  .from(bookingAllocations)
1810
2089
  .where(eq(bookingAllocations.bookingId, id));
1811
2090
  for (const allocation of allocations) {
1812
- await releaseAllocationCapacity(tx, allocation);
2091
+ const change = await releaseAllocationCapacity(tx, allocation, "cancel");
2092
+ if (change)
2093
+ slotChanges.push(change);
1813
2094
  }
1814
2095
  await tx
1815
2096
  .update(bookingAllocations)
@@ -1861,6 +2142,7 @@ export const bookingsService = {
1861
2142
  previousStatus: result.previousStatus,
1862
2143
  actorId: userId ?? null,
1863
2144
  }, { category: "domain", source: "service" });
2145
+ await emitSlotChanges(runtime, slotChanges);
1864
2146
  }
1865
2147
  return { status: result.status, booking: result.booking };
1866
2148
  }
@@ -1,8 +1,9 @@
1
1
  import type { bookings } from "./schema-core.js";
2
2
  export type BookingStatus = (typeof bookings.$inferSelect)["status"];
3
3
  export declare const BOOKING_TRANSITIONS: {
4
- readonly draft: readonly ["on_hold", "confirmed", "cancelled"];
5
- readonly on_hold: readonly ["confirmed", "expired", "cancelled"];
4
+ readonly draft: readonly ["on_hold", "awaiting_payment", "confirmed", "cancelled"];
5
+ readonly on_hold: readonly ["awaiting_payment", "confirmed", "expired", "cancelled"];
6
+ readonly awaiting_payment: readonly ["confirmed", "expired", "cancelled"];
6
7
  readonly confirmed: readonly ["in_progress", "cancelled"];
7
8
  readonly in_progress: readonly ["completed", "cancelled"];
8
9
  readonly completed: readonly [];
@@ -22,6 +23,14 @@ export interface BookingStatusPatch {
22
23
  expiredAt?: Date;
23
24
  cancelledAt?: Date;
24
25
  completedAt?: Date;
26
+ awaitingPaymentAt?: Date;
27
+ /**
28
+ * Stamped when the booking transitions to `confirmed` from
29
+ * `awaiting_payment` (i.e. the moment payment was received).
30
+ * Distinct from `confirmedAt` so reporting can split "free
31
+ * confirmations" from paid ones.
32
+ */
33
+ paidAt?: Date;
25
34
  }
26
35
  export declare function transitionBooking(from: BookingStatus, to: BookingStatus, opts?: {
27
36
  now?: Date;
@@ -1 +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"}
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;;;;;;;;;CASoC,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;IAClB,iBAAiB,CAAC,EAAE,IAAI,CAAA;IACxB;;;;;OAKG;IACH,MAAM,CAAC,EAAE,IAAI,CAAA;CACd;AAED,wBAAgB,iBAAiB,CAC/B,IAAI,EAAE,aAAa,EACnB,EAAE,EAAE,aAAa,EACjB,IAAI,GAAE;IAAE,GAAG,CAAC,EAAE,IAAI,CAAA;CAAO,GACxB,kBAAkB,CAkBpB"}
@@ -1,6 +1,7 @@
1
1
  export const BOOKING_TRANSITIONS = {
2
- draft: ["on_hold", "confirmed", "cancelled"],
3
- on_hold: ["confirmed", "expired", "cancelled"],
2
+ draft: ["on_hold", "awaiting_payment", "confirmed", "cancelled"],
3
+ on_hold: ["awaiting_payment", "confirmed", "expired", "cancelled"],
4
+ awaiting_payment: ["confirmed", "expired", "cancelled"],
4
5
  confirmed: ["in_progress", "cancelled"],
5
6
  in_progress: ["completed", "cancelled"],
6
7
  completed: [],
@@ -27,13 +28,18 @@ export function transitionBooking(from, to, opts = {}) {
27
28
  }
28
29
  const now = opts.now ?? new Date();
29
30
  const patch = { status: to };
30
- if (to === "confirmed")
31
+ if (to === "confirmed") {
31
32
  patch.confirmedAt = now;
33
+ if (from === "awaiting_payment")
34
+ patch.paidAt = now;
35
+ }
32
36
  if (to === "expired")
33
37
  patch.expiredAt = now;
34
38
  if (to === "cancelled")
35
39
  patch.cancelledAt = now;
36
40
  if (to === "completed")
37
41
  patch.completedAt = now;
42
+ if (to === "awaiting_payment")
43
+ patch.awaitingPaymentAt = now;
38
44
  return patch;
39
45
  }
@@ -1,4 +1,5 @@
1
1
  import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
2
+ import { type BookingServiceRuntime } from "../service.js";
2
3
  export interface ExpireStaleBookingHoldsInput {
3
4
  before?: string | null;
4
5
  note?: string | null;
@@ -8,5 +9,5 @@ export interface ExpireStaleBookingHoldsResult {
8
9
  count: number;
9
10
  cutoff: Date;
10
11
  }
11
- export declare function expireStaleBookingHolds(db: PostgresJsDatabase, input?: ExpireStaleBookingHoldsInput, userId?: string): Promise<ExpireStaleBookingHoldsResult>;
12
+ export declare function expireStaleBookingHolds(db: PostgresJsDatabase, input?: ExpireStaleBookingHoldsInput, userId?: string, runtime?: BookingServiceRuntime): Promise<ExpireStaleBookingHoldsResult>;
12
13
  //# sourceMappingURL=expire-stale-holds.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"expire-stale-holds.d.ts","sourceRoot":"","sources":["../../src/tasks/expire-stale-holds.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAA;AAIjE,MAAM,WAAW,4BAA4B;IAC3C,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACtB,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CACrB;AAED,MAAM,WAAW,6BAA6B;IAC5C,UAAU,EAAE,MAAM,EAAE,CAAA;IACpB,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,IAAI,CAAA;CACb;AAED,wBAAsB,uBAAuB,CAC3C,EAAE,EAAE,kBAAkB,EACtB,KAAK,GAAE,4BAAiC,EACxC,MAAM,SAAW,GAChB,OAAO,CAAC,6BAA6B,CAAC,CASxC"}
1
+ {"version":3,"file":"expire-stale-holds.d.ts","sourceRoot":"","sources":["../../src/tasks/expire-stale-holds.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAA;AAEjE,OAAO,EAAE,KAAK,qBAAqB,EAAmB,MAAM,eAAe,CAAA;AAE3E,MAAM,WAAW,4BAA4B;IAC3C,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACtB,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CACrB;AAED,MAAM,WAAW,6BAA6B;IAC5C,UAAU,EAAE,MAAM,EAAE,CAAA;IACpB,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,IAAI,CAAA;CACb;AAED,wBAAsB,uBAAuB,CAC3C,EAAE,EAAE,kBAAkB,EACtB,KAAK,GAAE,4BAAiC,EACxC,MAAM,SAAW,EACjB,OAAO,GAAE,qBAA0B,GAClC,OAAO,CAAC,6BAA6B,CAAC,CAUxC"}
@@ -1,7 +1,7 @@
1
1
  import { bookingsService } from "../service.js";
2
- export async function expireStaleBookingHolds(db, input = {}, userId = "system") {
2
+ export async function expireStaleBookingHolds(db, input = {}, userId = "system", runtime = {}) {
3
3
  return bookingsService.expireStaleBookings(db, {
4
4
  before: input.before ?? null,
5
5
  note: input.note ?? null,
6
- }, userId);
6
+ }, userId, runtime);
7
7
  }