@curless/hotel-storefront 0.12.0 → 0.13.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/__tests__/card-contract.test.js +25 -0
- package/dist/__tests__/card-contract.test.js.map +1 -1
- package/dist/__tests__/demo-hotels.test.js +169 -10
- package/dist/__tests__/demo-hotels.test.js.map +1 -1
- package/dist/__tests__/present.test.js +74 -5
- package/dist/__tests__/present.test.js.map +1 -1
- package/dist/__tests__/tools.test.js +61 -0
- package/dist/__tests__/tools.test.js.map +1 -1
- package/dist/backend.d.ts +9 -1
- package/dist/backend.d.ts.map +1 -1
- package/dist/card.generated.d.ts +1 -1
- package/dist/card.generated.js +2 -2
- package/dist/card.generated.js.map +1 -1
- package/dist/demo-hotels.d.ts +16 -2
- package/dist/demo-hotels.d.ts.map +1 -1
- package/dist/demo-hotels.js +266 -133
- package/dist/demo-hotels.js.map +1 -1
- package/dist/present.d.ts +18 -2
- package/dist/present.d.ts.map +1 -1
- package/dist/present.js +115 -54
- package/dist/present.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +23 -1
- package/dist/server.js.map +1 -1
- package/dist/surface.d.ts.map +1 -1
- package/dist/surface.js +13 -2
- package/dist/surface.js.map +1 -1
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +78 -19
- package/dist/tools.js.map +1 -1
- package/dist/types.d.ts +9 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"card.generated.js","sourceRoot":"","sources":["../src/card.generated.ts"],"names":[],"mappings":"AAAA,6FAA6F;AAC7F,wEAAwE;AACxE,gFAAgF;AAChF,4DAA4D;AAC5D,MAAM,CAAC,MAAM,eAAe,GAAW,MAAM,CAAC,IAAI,CAChD,
|
|
1
|
+
{"version":3,"file":"card.generated.js","sourceRoot":"","sources":["../src/card.generated.ts"],"names":[],"mappings":"AAAA,6FAA6F;AAC7F,wEAAwE;AACxE,gFAAgF;AAChF,4DAA4D;AAC5D,MAAM,CAAC,MAAM,eAAe,GAAW,MAAM,CAAC,IAAI,CAChD,s7uVAAs7uV,EACt7uV,QAAQ,CACT,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;AAEnB,2FAA2F;AAC3F,MAAM,CAAC,MAAM,qBAAqB,GAChC,kEAAkE,CAAC"}
|
package/dist/demo-hotels.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { HotelBackend } from './backend.js';
|
|
2
|
-
import type { Booking, ExtraKind, HotelPolicyTopic } from './types.js';
|
|
2
|
+
import type { Booking, ExtraKind, HotelPolicyTopic, TripLine } from './types.js';
|
|
3
3
|
/**
|
|
4
4
|
* The hotels, out of a JSON file, priced by us.
|
|
5
5
|
*
|
|
@@ -112,7 +112,7 @@ export type RawHotelData = {
|
|
|
112
112
|
membership?: RawMembership;
|
|
113
113
|
};
|
|
114
114
|
export type DemoError = Error & {
|
|
115
|
-
code: 'not-found' | 'bad-stay' | 'unavailable' | 'mixed-currency' | 'empty-trip' | 'no-guest';
|
|
115
|
+
code: 'not-found' | 'bad-stay' | 'unavailable' | 'mixed-currency' | 'empty-trip' | 'no-guest' | 'partly-unavailable';
|
|
116
116
|
};
|
|
117
117
|
export declare const demoError: (code: DemoError["code"], message: string) => DemoError;
|
|
118
118
|
export type DemoHotelsOptions = {
|
|
@@ -126,6 +126,20 @@ export type DemoHotelsOptions = {
|
|
|
126
126
|
type StoredBooking = Booking & {
|
|
127
127
|
sessionId: string;
|
|
128
128
|
};
|
|
129
|
+
/**
|
|
130
|
+
* A trip cut into the stays a hotel would confirm one by one: the rooms at one
|
|
131
|
+
* hotel for the same nights, and the add-ons bought for those nights.
|
|
132
|
+
*
|
|
133
|
+
* An add-on goes with the stay at its hotel whose nights hold its day, else
|
|
134
|
+
* the first stay there. One at a hotel the trip holds no room at is an orphan -
|
|
135
|
+
* it would otherwise ride along on another hotel's booking.
|
|
136
|
+
*/
|
|
137
|
+
export declare const splitByStay: (lines: TripLine[]) => {
|
|
138
|
+
groups: TripLine[][];
|
|
139
|
+
orphans: Extract<TripLine, {
|
|
140
|
+
kind: "extra";
|
|
141
|
+
}>[];
|
|
142
|
+
};
|
|
129
143
|
export declare const createDemoHotels: (opts: DemoHotelsOptions) => HotelBackend & {
|
|
130
144
|
/** For the tools: the same clock the prices were computed against. */
|
|
131
145
|
today(hotelId?: string): string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"demo-hotels.d.ts","sourceRoot":"","sources":["../src/demo-hotels.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAoBjD,OAAO,KAAK,EACV,OAAO,
|
|
1
|
+
{"version":3,"file":"demo-hotels.d.ts","sourceRoot":"","sources":["../src/demo-hotels.ts"],"names":[],"mappings":"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"}
|
package/dist/demo-hotels.js
CHANGED
|
@@ -30,6 +30,43 @@ const HOLD_STOPS_BEFORE_ARRIVAL_MS = 2 * 3_600_000;
|
|
|
30
30
|
const CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
|
31
31
|
/** Only ever used for an empty trip, which has no hotel to take a currency from. */
|
|
32
32
|
const DEFAULT_CURRENCY = 'HKD';
|
|
33
|
+
/**
|
|
34
|
+
* A trip cut into the stays a hotel would confirm one by one: the rooms at one
|
|
35
|
+
* hotel for the same nights, and the add-ons bought for those nights.
|
|
36
|
+
*
|
|
37
|
+
* An add-on goes with the stay at its hotel whose nights hold its day, else
|
|
38
|
+
* the first stay there. One at a hotel the trip holds no room at is an orphan -
|
|
39
|
+
* it would otherwise ride along on another hotel's booking.
|
|
40
|
+
*/
|
|
41
|
+
export const splitByStay = (lines) => {
|
|
42
|
+
const stays = [];
|
|
43
|
+
for (const l of lines) {
|
|
44
|
+
if (l.kind !== 'room')
|
|
45
|
+
continue;
|
|
46
|
+
const same = stays.find((g) => g.hotelId === l.hotelId &&
|
|
47
|
+
g.stay.checkIn === l.stay.checkIn &&
|
|
48
|
+
g.stay.checkOut === l.stay.checkOut);
|
|
49
|
+
if (same)
|
|
50
|
+
same.lines.push(l);
|
|
51
|
+
else
|
|
52
|
+
stays.push({ hotelId: l.hotelId, stay: l.stay, lines: [l] });
|
|
53
|
+
}
|
|
54
|
+
const orphans = [];
|
|
55
|
+
for (const l of lines) {
|
|
56
|
+
if (l.kind !== 'extra')
|
|
57
|
+
continue;
|
|
58
|
+
const here = stays.filter((g) => g.hotelId === l.hotelId);
|
|
59
|
+
const day = l.day;
|
|
60
|
+
const home = (day ? here.find((g) => g.stay.checkIn <= day && day <= g.stay.checkOut) : undefined) ??
|
|
61
|
+
here[0];
|
|
62
|
+
if (home)
|
|
63
|
+
home.lines.push(l);
|
|
64
|
+
else
|
|
65
|
+
orphans.push(l);
|
|
66
|
+
}
|
|
67
|
+
stays.sort((a, b) => a.stay.checkIn.localeCompare(b.stay.checkIn));
|
|
68
|
+
return { groups: stays.map((g) => g.lines), orphans };
|
|
69
|
+
};
|
|
33
70
|
export const createDemoHotels = (opts) => {
|
|
34
71
|
const hotels = opts.data.hotels;
|
|
35
72
|
const now = opts.now ?? (() => Date.now());
|
|
@@ -250,11 +287,26 @@ export const createDemoHotels = (opts) => {
|
|
|
250
287
|
return undefined;
|
|
251
288
|
return { kinds, ...(arrivingAt ? { arrivingAt } : {}), ...(note ? { note } : {}) };
|
|
252
289
|
};
|
|
253
|
-
|
|
290
|
+
/**
|
|
291
|
+
* A code nobody else holds. ⚠️ UNIQUE, NOT JUST RANDOM: a code is how a
|
|
292
|
+
* booking is found, and a trip of two stays asks for two at once - two
|
|
293
|
+
* bookings under one code would be the second one cancelled by the first's.
|
|
294
|
+
*/
|
|
295
|
+
const confirmationCode = (alsoTaken) => {
|
|
296
|
+
const inUse = (code) => alsoTaken.has(code) || [...bookings.values()].some((b) => b.confirmationCode === code);
|
|
254
297
|
let out = '';
|
|
255
|
-
for (let
|
|
256
|
-
out
|
|
257
|
-
|
|
298
|
+
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
299
|
+
out = '';
|
|
300
|
+
for (let i = 0; i < 6; i += 1)
|
|
301
|
+
out += CODE_ALPHABET[Math.floor(rng() * CODE_ALPHABET.length)] ?? 'X';
|
|
302
|
+
if (!inUse(`SL-${out}`))
|
|
303
|
+
return `SL-${out}`;
|
|
304
|
+
}
|
|
305
|
+
// A generator that keeps repeating itself (a test's fixed one) still has
|
|
306
|
+
// to give each booking its own.
|
|
307
|
+
for (let n = 1;; n += 1)
|
|
308
|
+
if (!inUse(`SL-${out}${n}`))
|
|
309
|
+
return `SL-${out}${n}`;
|
|
258
310
|
};
|
|
259
311
|
const mine = (b, who) => b.sessionId === who.sessionId ||
|
|
260
312
|
(!!who.email && b.guest.email.toLowerCase() === who.email.toLowerCase());
|
|
@@ -281,6 +333,8 @@ export const createDemoHotels = (opts) => {
|
|
|
281
333
|
// What the tier took off, added up as we go - so the card can say what
|
|
282
334
|
// membership was worth on THIS trip rather than quoting a percentage.
|
|
283
335
|
let saved = 0;
|
|
336
|
+
// Rooms this same trip has already taken, by hotel, room type and night.
|
|
337
|
+
const taken = new Map();
|
|
284
338
|
for (const line of trip.lines) {
|
|
285
339
|
const h = byId(line.hotelId);
|
|
286
340
|
if (!h) {
|
|
@@ -312,15 +366,25 @@ export const createDemoHotels = (opts) => {
|
|
|
312
366
|
}
|
|
313
367
|
const room = priceRoom(h, raw, line.stay, who, ignore);
|
|
314
368
|
const wanted = Math.max(1, Math.floor(line.rooms ?? 1));
|
|
315
|
-
|
|
369
|
+
// ⚠️ AND THIS TRIP'S OWN EARLIER LINES. `priceRoom` counts every other
|
|
370
|
+
// booking; two lines of one trip for the same room on overlapping
|
|
371
|
+
// nights it does not, and the one suite a hotel has was held twice for
|
|
372
|
+
// the night they shared (2026-09-28 audit).
|
|
373
|
+
const held = eachNight(line.stay.checkIn, line.stay.checkOut).map((n) => `${h.id}|${raw.id}|${n}`);
|
|
374
|
+
const left = Math.max(0, room.roomsLeft - Math.max(0, ...held.map((k) => taken.get(k) ?? 0)));
|
|
375
|
+
if (left < wanted) {
|
|
316
376
|
dropped.push({
|
|
317
377
|
title: `${h.name} - ${raw.name}`,
|
|
318
|
-
why:
|
|
319
|
-
?
|
|
320
|
-
|
|
378
|
+
why: left === 0
|
|
379
|
+
? room.roomsLeft === 0
|
|
380
|
+
? 'That room has gone since it went into the trip.'
|
|
381
|
+
: 'This trip already holds the last of that room on one of those nights.'
|
|
382
|
+
: `Only ${left} of that room is left and the trip asks for ${wanted}.`,
|
|
321
383
|
});
|
|
322
384
|
continue;
|
|
323
385
|
}
|
|
386
|
+
for (const k of held)
|
|
387
|
+
taken.set(k, (taken.get(k) ?? 0) + wanted);
|
|
324
388
|
if (raw.sleeps < line.stay.guests) {
|
|
325
389
|
dropped.push({
|
|
326
390
|
title: `${h.name} - ${raw.name}`,
|
|
@@ -364,8 +428,11 @@ export const createDemoHotels = (opts) => {
|
|
|
364
428
|
});
|
|
365
429
|
continue;
|
|
366
430
|
}
|
|
367
|
-
|
|
368
|
-
|
|
431
|
+
// The stay it belongs to: the room at this hotel whose nights hold its
|
|
432
|
+
// day, and the first one here when it names no day.
|
|
433
|
+
const here = trip.lines.flatMap((l) => l.kind === 'room' && l.hotelId === h.id ? [l.stay] : []);
|
|
434
|
+
const day = line.day;
|
|
435
|
+
const stay = (day ? here.find((s) => s.checkIn <= day && day <= s.checkOut) : undefined) ?? here[0];
|
|
369
436
|
const extra = toExtra(h, rawExtra);
|
|
370
437
|
const units = extraUnits(rawExtra, stay, line.quantity);
|
|
371
438
|
const total = multiplyMoney(extra.price, units);
|
|
@@ -379,6 +446,7 @@ export const createDemoHotels = (opts) => {
|
|
|
379
446
|
title: extra.name,
|
|
380
447
|
detail: extra.description,
|
|
381
448
|
quantity: units,
|
|
449
|
+
ordered: Math.max(1, line.quantity),
|
|
382
450
|
unit: extra.price,
|
|
383
451
|
total,
|
|
384
452
|
day: line.day ?? (stay && rawExtra.when === 'departure' ? stay.checkOut : stay?.checkIn),
|
|
@@ -423,6 +491,141 @@ export const createDemoHotels = (opts) => {
|
|
|
423
491
|
: {}),
|
|
424
492
|
};
|
|
425
493
|
};
|
|
494
|
+
/**
|
|
495
|
+
* The booking's lines on new nights, each room keeping its own type and
|
|
496
|
+
* count unless `single` says otherwise.
|
|
497
|
+
*
|
|
498
|
+
* ⚠️ EACH LINE ITS OWN. This wrote every line with the first line's room and
|
|
499
|
+
* count, so a booking of a Deluxe and a Suite came back as two Deluxes.
|
|
500
|
+
*/
|
|
501
|
+
const linesOn = (b, stay, single) => {
|
|
502
|
+
const shift = nightsBetween(b.stay.checkIn, stay.checkIn);
|
|
503
|
+
return b.quote.lines.map((l) => {
|
|
504
|
+
if (l.kind === 'room')
|
|
505
|
+
return {
|
|
506
|
+
kind: 'room',
|
|
507
|
+
hotelId: l.hotelId,
|
|
508
|
+
roomId: single?.roomId ?? String(l.roomRef),
|
|
509
|
+
rooms: single?.rooms ?? l.quantity ?? 1,
|
|
510
|
+
stay: { ...stay, guests: l.stay?.guests ?? stay.guests },
|
|
511
|
+
};
|
|
512
|
+
// An add-on keeps its place in the stay: a dinner on the second night is
|
|
513
|
+
// still on the second night after the stay moves.
|
|
514
|
+
const day = l.day ? addDays(l.day, shift) : undefined;
|
|
515
|
+
return {
|
|
516
|
+
kind: 'extra',
|
|
517
|
+
hotelId: l.hotelId,
|
|
518
|
+
extraId: String(l.extraRef ?? ''),
|
|
519
|
+
quantity: l.ordered ?? l.quantity ?? 1,
|
|
520
|
+
...(day && stay.checkIn <= day && day <= stay.checkOut ? { day } : {}),
|
|
521
|
+
};
|
|
522
|
+
});
|
|
523
|
+
};
|
|
524
|
+
/**
|
|
525
|
+
* A change, worked out whole: what the preview shows AND the quote the change
|
|
526
|
+
* would store, from one computation.
|
|
527
|
+
*
|
|
528
|
+
* ⚠️ ONE NUMBER, NOT TWO. The preview used to price only the first room and
|
|
529
|
+
* compare it with the whole booking - add-ons and all - so "HK$13,141.90
|
|
530
|
+
* more" was confirmed and HK$14,554.40 more was stored. The guest agrees to
|
|
531
|
+
* a figure; the figure has to be the one that is kept.
|
|
532
|
+
*/
|
|
533
|
+
const planChange = async (b, change, who) => {
|
|
534
|
+
const h = byId(b.hotelId);
|
|
535
|
+
const rooms = b.quote.lines.filter((l) => l.kind === 'room' && l.roomRef);
|
|
536
|
+
const [line] = rooms;
|
|
537
|
+
const from = {
|
|
538
|
+
stay: b.stay,
|
|
539
|
+
rooms: rooms.reduce((n, l) => n + (l.quantity ?? 1), 0) || 1,
|
|
540
|
+
roomName: rooms.map((l) => l.title).join(' + ') || 'the room',
|
|
541
|
+
total: b.quote.total,
|
|
542
|
+
};
|
|
543
|
+
const base = {
|
|
544
|
+
bookingId: b.id,
|
|
545
|
+
confirmationCode: b.confirmationCode,
|
|
546
|
+
hotelName: b.hotelName,
|
|
547
|
+
from,
|
|
548
|
+
};
|
|
549
|
+
const no = (rule) => ({ preview: { ...base, changeable: false, rule } });
|
|
550
|
+
if (b.status !== 'requested' && b.status !== 'confirmed')
|
|
551
|
+
return no(`This booking is ${b.status}; there is nothing left to change.`);
|
|
552
|
+
if (!h || !line?.roomRef)
|
|
553
|
+
return no('This booking has no room line to change.');
|
|
554
|
+
// ⚠️ A NON-REFUNDABLE RATE CANNOT BE MOVED EITHER - ANY ROOM ON IT. The
|
|
555
|
+
// hotel's own words are "cannot be cancelled or changed"; offering a change
|
|
556
|
+
// would be this card contradicting the policy on the screen beside it.
|
|
557
|
+
const locked = rooms.find((l) => h.rooms.find((r) => r.id === l.roomRef)?.refundable === false);
|
|
558
|
+
if (locked)
|
|
559
|
+
return no(`${locked.title} is a non-refundable rate: it cannot be cancelled or changed. Book the new nights as their own stay - this one stands.`);
|
|
560
|
+
if (rooms.length > 1 && (change.room !== undefined || change.rooms !== undefined))
|
|
561
|
+
return no(`This booking holds ${rooms.length} room types (${from.roomName}). A change of room type or of how many works on a booking of one; the dates can still move, or cancel it and book again.`);
|
|
562
|
+
const raw = h.rooms.find((r) => r.id === line.roomRef);
|
|
563
|
+
let wantedRoom = raw;
|
|
564
|
+
if (change.room) {
|
|
565
|
+
// ⚠️ A NAME THAT MATCHES NOTHING IS NOT "THE SAME ROOM". It fell back to
|
|
566
|
+
// the current room, and the guest who asked for a suite was shown a
|
|
567
|
+
// preview of the room they already had.
|
|
568
|
+
wantedRoom = roomOf(h, change.room);
|
|
569
|
+
if (!wantedRoom)
|
|
570
|
+
return no(`No room type at ${h.name} answers to "${change.room}".`);
|
|
571
|
+
}
|
|
572
|
+
if (!wantedRoom)
|
|
573
|
+
return no('That room type is no longer listed.');
|
|
574
|
+
const stay = {
|
|
575
|
+
checkIn: change.checkIn ?? b.stay.checkIn,
|
|
576
|
+
checkOut: change.checkOut ?? b.stay.checkOut,
|
|
577
|
+
guests: b.stay.guests,
|
|
578
|
+
};
|
|
579
|
+
const count = Math.max(1, Math.min(MAX_ROOMS_PER_LINE, Math.floor(change.rooms ?? line.quantity ?? 1)));
|
|
580
|
+
const problem = stayProblem(stay, today(h.id));
|
|
581
|
+
if (problem)
|
|
582
|
+
return no(problem);
|
|
583
|
+
const sameNights = stay.checkIn === b.stay.checkIn && stay.checkOut === b.stay.checkOut;
|
|
584
|
+
if (sameNights &&
|
|
585
|
+
(rooms.length > 1 || (count === (line.quantity ?? 1) && wantedRoom.id === line.roomRef)))
|
|
586
|
+
return no('That is what this booking already holds.');
|
|
587
|
+
// What is left for the NEW nights with this booking left out of the count:
|
|
588
|
+
// a guest moving from two rooms to three must not be refused by the two
|
|
589
|
+
// they are giving back. ⚠️ LEFT OUT, NOT ADDED BACK. By night, this
|
|
590
|
+
// booking holds rooms only on the nights it already covers; adding its
|
|
591
|
+
// rooms back across the whole new stay would count rooms it never held
|
|
592
|
+
// on the nights that do not overlap.
|
|
593
|
+
const one = rooms.length === 1;
|
|
594
|
+
const left = priceRoom(h, wantedRoom, stay, who, b.id).roomsLeft;
|
|
595
|
+
if (one && left < count)
|
|
596
|
+
return no(`${wantedRoom.name} has ${left} left for ${stay.checkIn} to ${stay.checkOut}; this change needs ${count}.`);
|
|
597
|
+
const quote = await quoteTrip({ lines: linesOn(b, stay, one ? { roomId: wantedRoom.id, rooms: count } : undefined) }, who, b.id);
|
|
598
|
+
const lost = quote.dropped?.[0];
|
|
599
|
+
if (lost || !quote.lines.some((l) => l.kind === 'room'))
|
|
600
|
+
return no(lost
|
|
601
|
+
? `${lost.title}: ${lost.why}`
|
|
602
|
+
: 'The rooms on this booking could not be held for the new nights.');
|
|
603
|
+
const becomesLocked = wantedRoom.id !== line.roomRef && !wantedRoom.refundable;
|
|
604
|
+
return {
|
|
605
|
+
preview: {
|
|
606
|
+
...base,
|
|
607
|
+
to: {
|
|
608
|
+
stay,
|
|
609
|
+
rooms: one ? count : from.rooms,
|
|
610
|
+
roomName: one ? wantedRoom.name : from.roomName,
|
|
611
|
+
total: quote.total,
|
|
612
|
+
},
|
|
613
|
+
difference: money(quote.total.minor - from.total.minor, quote.total.currency),
|
|
614
|
+
changeable: true,
|
|
615
|
+
roomsLeft: left,
|
|
616
|
+
rule: [
|
|
617
|
+
`The new nights are priced at today's rates for those nights, not at the rate this booking was made on.`,
|
|
618
|
+
'Nothing has been charged, so a difference is what the desk asks for - or gives back - at check-in.',
|
|
619
|
+
becomesLocked
|
|
620
|
+
? `${wantedRoom.name} is a non-refundable rate: once the booking is on it, it cannot be cancelled or changed.`
|
|
621
|
+
: '',
|
|
622
|
+
]
|
|
623
|
+
.filter(Boolean)
|
|
624
|
+
.join(' '),
|
|
625
|
+
},
|
|
626
|
+
quote,
|
|
627
|
+
};
|
|
628
|
+
};
|
|
426
629
|
/** What cancelling this booking costs, line by line, in the hotel's own terms. */
|
|
427
630
|
const feeFor = (b, at) => {
|
|
428
631
|
const currency = b.quote.total.currency;
|
|
@@ -637,7 +840,6 @@ export const createDemoHotels = (opts) => {
|
|
|
637
840
|
return memberFor(number, surname);
|
|
638
841
|
},
|
|
639
842
|
async requestBooking(trip, guest, who, requests) {
|
|
640
|
-
const quote = await quoteTrip(trip, who);
|
|
641
843
|
// ⚠️ A ROOM HELD FOR NOBODY. The tool's schema says name and email are
|
|
642
844
|
// required, but a schema is what the model is TOLD, not what it must do -
|
|
643
845
|
// `request_booking {}` came back with a confirmation code and
|
|
@@ -648,33 +850,54 @@ export const createDemoHotels = (opts) => {
|
|
|
648
850
|
if (!guest.name.trim() || !guest.email.includes('@')) {
|
|
649
851
|
throw demoError('no-guest', "A booking needs the guest's name and an email to send it to.");
|
|
650
852
|
}
|
|
651
|
-
const
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
if (!firstRoom || !first || !stay) {
|
|
655
|
-
throw demoError('empty-trip', quote.dropped?.[0]?.why ??
|
|
853
|
+
const whole = await quoteTrip(trip, who);
|
|
854
|
+
if (!whole.lines.some((l) => l.kind === 'room')) {
|
|
855
|
+
throw demoError('empty-trip', whole.dropped?.[0]?.why ??
|
|
656
856
|
'There are no room nights in this trip, so there is nothing to book.');
|
|
657
857
|
}
|
|
658
|
-
|
|
858
|
+
// ⚠️ ALL OF IT OR NONE OF IT - see the seam. This used to book whatever
|
|
859
|
+
// still priced and clear the trip, so a Beijing suite that went while
|
|
860
|
+
// the guest was typing their name was simply not there any more.
|
|
861
|
+
const { groups, orphans } = splitByStay(trip.lines);
|
|
862
|
+
const problems = [
|
|
863
|
+
...(whole.dropped ?? []).map((d) => `${d.title}: ${d.why}`),
|
|
864
|
+
...orphans.map((l) => `${byId(l.hotelId)?.name ?? l.hotelId} - ${l.extraId}: an add-on here needs a room night at the same hotel in this trip.`),
|
|
865
|
+
];
|
|
866
|
+
if (problems.length) {
|
|
867
|
+
throw demoError('partly-unavailable', `Nothing was booked. ${problems.join(' ')} The trip is as it was - take that out or change it, then ask again.`);
|
|
868
|
+
}
|
|
869
|
+
const quotes = await Promise.all(groups.map((g) => quoteTrip({ lines: g }, who)));
|
|
659
870
|
const t = now();
|
|
660
|
-
const
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
871
|
+
const made = [];
|
|
872
|
+
for (const quote of quotes) {
|
|
873
|
+
const [firstRoom] = quote.lines.filter((l) => l.kind === 'room');
|
|
874
|
+
const h = firstRoom ? byId(firstRoom.hotelId) : undefined;
|
|
875
|
+
if (!firstRoom || !h || !quote.stay || quote.dropped?.length) {
|
|
876
|
+
// Every line priced a moment ago as part of the whole trip; a stay
|
|
877
|
+
// that does not now is a bug, and still books nothing.
|
|
878
|
+
throw demoError('partly-unavailable', 'Nothing was booked: the trip changed while it was being held.');
|
|
879
|
+
}
|
|
880
|
+
const arrival = arrivalInstant(quote.stay.checkIn, h.checkInFrom ?? '15:00', offsetOf(h));
|
|
881
|
+
const holdUntil = new Date(Math.max(t + 3_600_000, Math.min(t + holdMs, arrival - HOLD_STOPS_BEFORE_ARRIVAL_MS))).toISOString();
|
|
882
|
+
seq += 1;
|
|
883
|
+
made.push({
|
|
884
|
+
id: `bk_${Date.now().toString(36)}_${seq}`,
|
|
885
|
+
confirmationCode: confirmationCode(new Set(made.map((m) => m.confirmationCode))),
|
|
886
|
+
status: 'requested',
|
|
887
|
+
createdAt: new Date(t).toISOString(),
|
|
888
|
+
holdUntil,
|
|
889
|
+
hotelId: h.id,
|
|
890
|
+
hotelName: h.name,
|
|
891
|
+
stay: quote.stay,
|
|
892
|
+
guest,
|
|
893
|
+
quote,
|
|
894
|
+
sessionId: who.sessionId,
|
|
895
|
+
...(cleanRequests(requests) ? { requests: cleanRequests(requests) } : {}),
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
for (const booking of made)
|
|
899
|
+
bookings.set(booking.id, booking);
|
|
900
|
+
return made.map((booking) => ({ ...booking }));
|
|
678
901
|
},
|
|
679
902
|
async listBookings(who, limit = 10) {
|
|
680
903
|
sweep();
|
|
@@ -698,107 +921,17 @@ export const createDemoHotels = (opts) => {
|
|
|
698
921
|
const b = findBooking(bookingRef, who);
|
|
699
922
|
if (!b)
|
|
700
923
|
return null;
|
|
701
|
-
|
|
702
|
-
const line = b.quote.lines.find((l) => l.kind === 'room');
|
|
703
|
-
const from = {
|
|
704
|
-
stay: b.stay,
|
|
705
|
-
rooms: line?.quantity ?? 1,
|
|
706
|
-
roomName: line?.title ?? 'the room',
|
|
707
|
-
total: b.quote.total,
|
|
708
|
-
};
|
|
709
|
-
const base = {
|
|
710
|
-
bookingId: b.id,
|
|
711
|
-
confirmationCode: b.confirmationCode,
|
|
712
|
-
hotelName: b.hotelName,
|
|
713
|
-
from,
|
|
714
|
-
};
|
|
715
|
-
const no = (rule) => ({ ...base, changeable: false, rule });
|
|
716
|
-
if (b.status !== 'requested' && b.status !== 'confirmed')
|
|
717
|
-
return no(`This booking is ${b.status}; there is nothing left to change.`);
|
|
718
|
-
if (!h || !line?.roomRef)
|
|
719
|
-
return no('This booking has no room line to change.');
|
|
720
|
-
const raw = h.rooms.find((r) => r.id === line.roomRef);
|
|
721
|
-
// ⚠️ A NON-REFUNDABLE RATE CANNOT BE MOVED EITHER. The hotel's own words
|
|
722
|
-
// are "cannot be cancelled or changed"; offering a change would be this
|
|
723
|
-
// card contradicting the policy on the screen beside it.
|
|
724
|
-
if (raw && !raw.refundable)
|
|
725
|
-
return no(`${line.title} is a non-refundable rate: it cannot be cancelled or changed. Book the new nights as their own stay - this one stands.`);
|
|
726
|
-
const wantedRoom = change.room ? (roomOf(h, change.room) ?? raw) : raw;
|
|
727
|
-
if (!wantedRoom)
|
|
728
|
-
return no('No room type here answers to that.');
|
|
729
|
-
const stay = {
|
|
730
|
-
checkIn: change.checkIn ?? b.stay.checkIn,
|
|
731
|
-
checkOut: change.checkOut ?? b.stay.checkOut,
|
|
732
|
-
guests: b.stay.guests,
|
|
733
|
-
};
|
|
734
|
-
const rooms = Math.max(1, Math.min(MAX_ROOMS_PER_LINE, Math.floor(change.rooms ?? from.rooms)));
|
|
735
|
-
const problem = stayProblem(stay, today(h.id));
|
|
736
|
-
if (problem)
|
|
737
|
-
return no(problem);
|
|
738
|
-
if (stay.checkIn === b.stay.checkIn &&
|
|
739
|
-
stay.checkOut === b.stay.checkOut &&
|
|
740
|
-
rooms === from.rooms &&
|
|
741
|
-
wantedRoom.id === line.roomRef)
|
|
742
|
-
return no('That is what this booking already holds.');
|
|
743
|
-
// What is left for the NEW nights with this booking left out of the count:
|
|
744
|
-
// a guest moving from two rooms to three must not be refused by the two
|
|
745
|
-
// they are giving back. ⚠️ LEFT OUT, NOT ADDED BACK. By night, this
|
|
746
|
-
// booking holds rooms only on the nights it already covers; adding its
|
|
747
|
-
// rooms back across the whole new stay would count rooms it never held
|
|
748
|
-
// on the nights that do not overlap.
|
|
749
|
-
const priced = priceRoom(h, wantedRoom, stay, who, b.id);
|
|
750
|
-
const left = priced.roomsLeft;
|
|
751
|
-
if (left < rooms)
|
|
752
|
-
return no(`${wantedRoom.name} has ${left} left for ${stay.checkIn} to ${stay.checkOut}; this change needs ${rooms}.`);
|
|
753
|
-
const total = multiplyMoney(priced.stayTotal, rooms);
|
|
754
|
-
return {
|
|
755
|
-
...base,
|
|
756
|
-
to: { stay, rooms, roomName: wantedRoom.name, total },
|
|
757
|
-
difference: money(total.minor - from.total.minor, total.currency),
|
|
758
|
-
changeable: true,
|
|
759
|
-
roomsLeft: left,
|
|
760
|
-
rule: [
|
|
761
|
-
`The new nights are priced at today's rates for those nights, not at the rate this booking was made on.`,
|
|
762
|
-
'Nothing has been charged, so a difference is what the desk asks for - or gives back - at check-in.',
|
|
763
|
-
].join(' '),
|
|
764
|
-
};
|
|
924
|
+
return (await planChange(b, change, who)).preview;
|
|
765
925
|
},
|
|
766
926
|
async changeBooking(bookingRef, change, who) {
|
|
767
|
-
|
|
768
|
-
if (!preview)
|
|
769
|
-
return null;
|
|
770
|
-
if (!preview.changeable || !preview.to)
|
|
771
|
-
throw demoError('unavailable', preview.rule);
|
|
927
|
+
sweep();
|
|
772
928
|
const b = findBooking(bookingRef, who);
|
|
773
|
-
|
|
774
|
-
const line = b?.quote.lines.find((l) => l.kind === 'room');
|
|
775
|
-
if (!b || !h || !line?.roomRef)
|
|
929
|
+
if (!b)
|
|
776
930
|
return null;
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
? {
|
|
782
|
-
kind: 'room',
|
|
783
|
-
hotelId: b.hotelId,
|
|
784
|
-
roomId: room?.id ?? String(l.roomRef),
|
|
785
|
-
rooms: preview.to?.rooms ?? l.quantity ?? 1,
|
|
786
|
-
stay: preview.to?.stay ?? b.stay,
|
|
787
|
-
}
|
|
788
|
-
: {
|
|
789
|
-
kind: 'extra',
|
|
790
|
-
hotelId: b.hotelId,
|
|
791
|
-
extraId: String(l.extraRef ?? ''),
|
|
792
|
-
quantity: l.quantity ?? 1,
|
|
793
|
-
stay: preview.to?.stay ?? b.stay,
|
|
794
|
-
});
|
|
795
|
-
// Re-quoted with this booking left out of the count — the same rule the
|
|
796
|
-
// preview used. Counting it against itself here dropped the room line
|
|
797
|
-
// whenever the stock was tight, and saved a booking with no room in it.
|
|
798
|
-
const quote = await quoteTrip({ lines }, who, b.id);
|
|
799
|
-
if (!quote.lines.some((l) => l.kind === 'room')) {
|
|
800
|
-
throw demoError('unavailable', quote.dropped?.[0]?.why ?? 'The room could not be held for the new nights.');
|
|
801
|
-
}
|
|
931
|
+
// The very quote the preview showed is the one stored - see `planChange`.
|
|
932
|
+
const { preview, quote } = await planChange(b, change, who);
|
|
933
|
+
if (!preview.changeable || !preview.to || !quote)
|
|
934
|
+
throw demoError('unavailable', preview.rule);
|
|
802
935
|
const updated = {
|
|
803
936
|
...b,
|
|
804
937
|
stay: preview.to.stay,
|
|
@@ -826,7 +959,7 @@ export const createDemoHotels = (opts) => {
|
|
|
826
959
|
rule: cancellable
|
|
827
960
|
? [
|
|
828
961
|
...rules,
|
|
829
|
-
'Nothing has been charged for this booking, so
|
|
962
|
+
'Nothing has been charged for this booking, so any fee is what the hotel would put on the card at check-in time, not a refund we would keep.',
|
|
830
963
|
].join(' ')
|
|
831
964
|
: `This booking is ${b.status}; there is nothing left to cancel.`,
|
|
832
965
|
freeUntil,
|