@voyantjs/bookings 0.19.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/pii-redaction.d.ts +3 -6
- package/dist/pii-redaction.d.ts.map +1 -1
- package/dist/pii-redaction.js +5 -6
- package/dist/products-ref.d.ts +75 -0
- package/dist/products-ref.d.ts.map +1 -1
- package/dist/products-ref.js +6 -0
- package/dist/routes-groups.d.ts +8 -2
- package/dist/routes-groups.d.ts.map +1 -1
- package/dist/routes-public.d.ts +4 -4
- package/dist/routes.d.ts +83 -21
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +50 -0
- package/dist/schema-core.d.ts +53 -2
- package/dist/schema-core.d.ts.map +1 -1
- package/dist/schema-core.js +15 -0
- package/dist/schema-items.d.ts +1 -1
- package/dist/schema-operations.d.ts +2 -2
- package/dist/schema-shared.d.ts +2 -2
- package/dist/schema-shared.d.ts.map +1 -1
- package/dist/schema-shared.js +11 -0
- package/dist/service-public.d.ts +10 -10
- package/dist/service.d.ts +140 -27
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +313 -31
- package/dist/state-machine.d.ts +11 -2
- package/dist/state-machine.d.ts.map +1 -1
- package/dist/state-machine.js +9 -3
- package/dist/tasks/expire-stale-holds.d.ts +2 -1
- package/dist/tasks/expire-stale-holds.d.ts.map +1 -1
- package/dist/tasks/expire-stale-holds.js +2 -2
- package/dist/validation-public.d.ts +6 -3
- package/dist/validation-public.d.ts.map +1 -1
- package/dist/validation-public.js +1 -1
- package/dist/validation-shared.d.ts +2 -1
- package/dist/validation-shared.d.ts.map +1 -1
- package/dist/validation-shared.js +1 -0
- package/dist/validation.d.ts +69 -8
- package/dist/validation.d.ts.map +1 -1
- package/dist/validation.js +14 -2
- 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
|
-
|
|
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
|
|
270
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
}
|
package/dist/state-machine.d.ts
CHANGED
|
@@ -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
|
|
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"}
|
package/dist/state-machine.js
CHANGED
|
@@ -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;
|
|
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
|
}
|