@curless/hotel-storefront 0.11.1 → 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 +242 -8
- 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 +301 -135
- 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 +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +24 -2
- 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());
|
|
@@ -101,18 +138,41 @@ export const createDemoHotels = (opts) => {
|
|
|
101
138
|
const h = hotelId ? byId(hotelId) : undefined;
|
|
102
139
|
return todayAt(now(), h ? offsetOf(h) : DEFAULT_OFFSET);
|
|
103
140
|
};
|
|
104
|
-
/**
|
|
105
|
-
|
|
106
|
-
|
|
141
|
+
/**
|
|
142
|
+
* Rooms of this type already promised on `stay`'s nights — the most held on
|
|
143
|
+
* any ONE of them, because a stay needs a room every night it covers.
|
|
144
|
+
*
|
|
145
|
+
* ⚠️ BY NIGHT (robin, 2026-09-28: 「改成按晚算吧」). It used to count every
|
|
146
|
+
* hold against every date: one November booking took the room off December
|
|
147
|
+
* as well, and three eval runs emptied the Island Shangri-La's two deluxe
|
|
148
|
+
* types for EVERY date — so a guest asking about any night was told the room
|
|
149
|
+
* type did not exist. The stock itself still does not move by date (the
|
|
150
|
+
* data's `_rates` note); a hold takes a room only on the nights it holds.
|
|
151
|
+
*
|
|
152
|
+
* `ignore` leaves one booking out: a booking being moved does not stand in
|
|
153
|
+
* its own way on the nights it already holds.
|
|
154
|
+
*/
|
|
155
|
+
const heldOn = (hotelId, roomId, stay, ignore) => {
|
|
156
|
+
const nights = new Map(eachNight(stay.checkIn, stay.checkOut).map((n) => [n, 0]));
|
|
157
|
+
if (nights.size === 0)
|
|
158
|
+
return 0;
|
|
107
159
|
for (const b of bookings.values()) {
|
|
160
|
+
if (b.id === ignore)
|
|
161
|
+
continue;
|
|
108
162
|
if (b.status !== 'requested' && b.status !== 'confirmed')
|
|
109
163
|
continue;
|
|
110
164
|
for (const line of b.quote.lines) {
|
|
111
|
-
if (line.kind
|
|
112
|
-
|
|
165
|
+
if (line.kind !== 'room' || line.hotelId !== hotelId || line.roomRef !== roomId)
|
|
166
|
+
continue;
|
|
167
|
+
const held = line.stay ?? b.stay;
|
|
168
|
+
for (const n of eachNight(held.checkIn, held.checkOut)) {
|
|
169
|
+
const count = nights.get(n);
|
|
170
|
+
if (count !== undefined)
|
|
171
|
+
nights.set(n, count + (line.quantity ?? 1));
|
|
172
|
+
}
|
|
113
173
|
}
|
|
114
174
|
}
|
|
115
|
-
return
|
|
175
|
+
return Math.max(...nights.values());
|
|
116
176
|
};
|
|
117
177
|
/** Requests that were never taken up stop holding the room. */
|
|
118
178
|
const sweep = () => {
|
|
@@ -122,7 +182,9 @@ export const createDemoHotels = (opts) => {
|
|
|
122
182
|
b.status = 'expired';
|
|
123
183
|
}
|
|
124
184
|
};
|
|
125
|
-
const priceRoom = (h, r, stay, who
|
|
185
|
+
const priceRoom = (h, r, stay, who,
|
|
186
|
+
/** A booking whose own rooms do not count against it — see `heldOn`. */
|
|
187
|
+
ignore) => {
|
|
126
188
|
const perNight = eachNight(stay.checkIn, stay.checkOut).map((day) => money(toMinor(r.nightly, h.currency) * (isWeekendNight(day) ? 1 + h.weekendUplift : 1), h.currency));
|
|
127
189
|
const publicSubtotal = perNight.reduce((a, b) => addMoney(a, b), money(0, h.currency));
|
|
128
190
|
// ⚠️ THE DISCOUNT COMES OFF THE ROOM, NOT OFF THE TAX. Tax is charged on
|
|
@@ -166,7 +228,7 @@ export const createDemoHotels = (opts) => {
|
|
|
166
228
|
nights: perNight.length,
|
|
167
229
|
refundable: r.refundable,
|
|
168
230
|
freeCancellationUntil: freeUntil,
|
|
169
|
-
roomsLeft: Math.max(0, r.roomsLeft -
|
|
231
|
+
roomsLeft: Math.max(0, r.roomsLeft - heldOn(h.id, r.id, stay, ignore)),
|
|
170
232
|
};
|
|
171
233
|
};
|
|
172
234
|
const toHotel = (h) => {
|
|
@@ -225,11 +287,26 @@ export const createDemoHotels = (opts) => {
|
|
|
225
287
|
return undefined;
|
|
226
288
|
return { kinds, ...(arrivingAt ? { arrivingAt } : {}), ...(note ? { note } : {}) };
|
|
227
289
|
};
|
|
228
|
-
|
|
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);
|
|
229
297
|
let out = '';
|
|
230
|
-
for (let
|
|
231
|
-
out
|
|
232
|
-
|
|
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}`;
|
|
233
310
|
};
|
|
234
311
|
const mine = (b, who) => b.sessionId === who.sessionId ||
|
|
235
312
|
(!!who.email && b.guest.email.toLowerCase() === who.email.toLowerCase());
|
|
@@ -246,7 +323,7 @@ export const createDemoHotels = (opts) => {
|
|
|
246
323
|
h.rooms.find((r) => r.name.toLowerCase() === wanted) ??
|
|
247
324
|
h.rooms.find((r) => r.name.toLowerCase().includes(wanted) && wanted.length >= 4));
|
|
248
325
|
};
|
|
249
|
-
const quoteTrip = async (trip, who) => {
|
|
326
|
+
const quoteTrip = async (trip, who, ignore) => {
|
|
250
327
|
sweep();
|
|
251
328
|
const lines = [];
|
|
252
329
|
const dropped = [];
|
|
@@ -256,6 +333,8 @@ export const createDemoHotels = (opts) => {
|
|
|
256
333
|
// What the tier took off, added up as we go - so the card can say what
|
|
257
334
|
// membership was worth on THIS trip rather than quoting a percentage.
|
|
258
335
|
let saved = 0;
|
|
336
|
+
// Rooms this same trip has already taken, by hotel, room type and night.
|
|
337
|
+
const taken = new Map();
|
|
259
338
|
for (const line of trip.lines) {
|
|
260
339
|
const h = byId(line.hotelId);
|
|
261
340
|
if (!h) {
|
|
@@ -285,17 +364,27 @@ export const createDemoHotels = (opts) => {
|
|
|
285
364
|
});
|
|
286
365
|
continue;
|
|
287
366
|
}
|
|
288
|
-
const room = priceRoom(h, raw, line.stay, who);
|
|
367
|
+
const room = priceRoom(h, raw, line.stay, who, ignore);
|
|
289
368
|
const wanted = Math.max(1, Math.floor(line.rooms ?? 1));
|
|
290
|
-
|
|
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) {
|
|
291
376
|
dropped.push({
|
|
292
377
|
title: `${h.name} - ${raw.name}`,
|
|
293
|
-
why:
|
|
294
|
-
?
|
|
295
|
-
|
|
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}.`,
|
|
296
383
|
});
|
|
297
384
|
continue;
|
|
298
385
|
}
|
|
386
|
+
for (const k of held)
|
|
387
|
+
taken.set(k, (taken.get(k) ?? 0) + wanted);
|
|
299
388
|
if (raw.sleeps < line.stay.guests) {
|
|
300
389
|
dropped.push({
|
|
301
390
|
title: `${h.name} - ${raw.name}`,
|
|
@@ -339,8 +428,11 @@ export const createDemoHotels = (opts) => {
|
|
|
339
428
|
});
|
|
340
429
|
continue;
|
|
341
430
|
}
|
|
342
|
-
|
|
343
|
-
|
|
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];
|
|
344
436
|
const extra = toExtra(h, rawExtra);
|
|
345
437
|
const units = extraUnits(rawExtra, stay, line.quantity);
|
|
346
438
|
const total = multiplyMoney(extra.price, units);
|
|
@@ -354,6 +446,7 @@ export const createDemoHotels = (opts) => {
|
|
|
354
446
|
title: extra.name,
|
|
355
447
|
detail: extra.description,
|
|
356
448
|
quantity: units,
|
|
449
|
+
ordered: Math.max(1, line.quantity),
|
|
357
450
|
unit: extra.price,
|
|
358
451
|
total,
|
|
359
452
|
day: line.day ?? (stay && rawExtra.when === 'departure' ? stay.checkOut : stay?.checkIn),
|
|
@@ -398,6 +491,141 @@ export const createDemoHotels = (opts) => {
|
|
|
398
491
|
: {}),
|
|
399
492
|
};
|
|
400
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
|
+
};
|
|
401
629
|
/** What cancelling this booking costs, line by line, in the hotel's own terms. */
|
|
402
630
|
const feeFor = (b, at) => {
|
|
403
631
|
const currency = b.quote.total.currency;
|
|
@@ -612,7 +840,6 @@ export const createDemoHotels = (opts) => {
|
|
|
612
840
|
return memberFor(number, surname);
|
|
613
841
|
},
|
|
614
842
|
async requestBooking(trip, guest, who, requests) {
|
|
615
|
-
const quote = await quoteTrip(trip, who);
|
|
616
843
|
// ⚠️ A ROOM HELD FOR NOBODY. The tool's schema says name and email are
|
|
617
844
|
// required, but a schema is what the model is TOLD, not what it must do -
|
|
618
845
|
// `request_booking {}` came back with a confirmation code and
|
|
@@ -623,33 +850,54 @@ export const createDemoHotels = (opts) => {
|
|
|
623
850
|
if (!guest.name.trim() || !guest.email.includes('@')) {
|
|
624
851
|
throw demoError('no-guest', "A booking needs the guest's name and an email to send it to.");
|
|
625
852
|
}
|
|
626
|
-
const
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
if (!firstRoom || !first || !stay) {
|
|
630
|
-
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 ??
|
|
631
856
|
'There are no room nights in this trip, so there is nothing to book.');
|
|
632
857
|
}
|
|
633
|
-
|
|
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)));
|
|
634
870
|
const t = now();
|
|
635
|
-
const
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
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 }));
|
|
653
901
|
},
|
|
654
902
|
async listBookings(who, limit = 10) {
|
|
655
903
|
sweep();
|
|
@@ -673,99 +921,17 @@ export const createDemoHotels = (opts) => {
|
|
|
673
921
|
const b = findBooking(bookingRef, who);
|
|
674
922
|
if (!b)
|
|
675
923
|
return null;
|
|
676
|
-
|
|
677
|
-
const line = b.quote.lines.find((l) => l.kind === 'room');
|
|
678
|
-
const from = {
|
|
679
|
-
stay: b.stay,
|
|
680
|
-
rooms: line?.quantity ?? 1,
|
|
681
|
-
roomName: line?.title ?? 'the room',
|
|
682
|
-
total: b.quote.total,
|
|
683
|
-
};
|
|
684
|
-
const base = {
|
|
685
|
-
bookingId: b.id,
|
|
686
|
-
confirmationCode: b.confirmationCode,
|
|
687
|
-
hotelName: b.hotelName,
|
|
688
|
-
from,
|
|
689
|
-
};
|
|
690
|
-
const no = (rule) => ({ ...base, changeable: false, rule });
|
|
691
|
-
if (b.status !== 'requested' && b.status !== 'confirmed')
|
|
692
|
-
return no(`This booking is ${b.status}; there is nothing left to change.`);
|
|
693
|
-
if (!h || !line?.roomRef)
|
|
694
|
-
return no('This booking has no room line to change.');
|
|
695
|
-
const raw = h.rooms.find((r) => r.id === line.roomRef);
|
|
696
|
-
// ⚠️ A NON-REFUNDABLE RATE CANNOT BE MOVED EITHER. The hotel's own words
|
|
697
|
-
// are "cannot be cancelled or changed"; offering a change would be this
|
|
698
|
-
// card contradicting the policy on the screen beside it.
|
|
699
|
-
if (raw && !raw.refundable)
|
|
700
|
-
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.`);
|
|
701
|
-
const wantedRoom = change.room ? (roomOf(h, change.room) ?? raw) : raw;
|
|
702
|
-
if (!wantedRoom)
|
|
703
|
-
return no('No room type here answers to that.');
|
|
704
|
-
const stay = {
|
|
705
|
-
checkIn: change.checkIn ?? b.stay.checkIn,
|
|
706
|
-
checkOut: change.checkOut ?? b.stay.checkOut,
|
|
707
|
-
guests: b.stay.guests,
|
|
708
|
-
};
|
|
709
|
-
const rooms = Math.max(1, Math.min(MAX_ROOMS_PER_LINE, Math.floor(change.rooms ?? from.rooms)));
|
|
710
|
-
const problem = stayProblem(stay, today(h.id));
|
|
711
|
-
if (problem)
|
|
712
|
-
return no(problem);
|
|
713
|
-
if (stay.checkIn === b.stay.checkIn &&
|
|
714
|
-
stay.checkOut === b.stay.checkOut &&
|
|
715
|
-
rooms === from.rooms &&
|
|
716
|
-
wantedRoom.id === line.roomRef)
|
|
717
|
-
return no('That is what this booking already holds.');
|
|
718
|
-
// What is left for the NEW nights, counting this booking's own rooms as
|
|
719
|
-
// returned: a guest moving from two rooms to three must not be refused by
|
|
720
|
-
// the two they are giving back.
|
|
721
|
-
const priced = priceRoom(h, wantedRoom, stay, who);
|
|
722
|
-
const freed = wantedRoom.id === line.roomRef ? from.rooms : 0;
|
|
723
|
-
const left = priced.roomsLeft + freed;
|
|
724
|
-
if (left < rooms)
|
|
725
|
-
return no(`${wantedRoom.name} has ${left} left for ${stay.checkIn} to ${stay.checkOut}; this change needs ${rooms}.`);
|
|
726
|
-
const total = multiplyMoney(priced.stayTotal, rooms);
|
|
727
|
-
return {
|
|
728
|
-
...base,
|
|
729
|
-
to: { stay, rooms, roomName: wantedRoom.name, total },
|
|
730
|
-
difference: money(total.minor - from.total.minor, total.currency),
|
|
731
|
-
changeable: true,
|
|
732
|
-
roomsLeft: left,
|
|
733
|
-
rule: [
|
|
734
|
-
`The new nights are priced at today's rates for those nights, not at the rate this booking was made on.`,
|
|
735
|
-
'Nothing has been charged, so a difference is what the desk asks for - or gives back - at check-in.',
|
|
736
|
-
].join(' '),
|
|
737
|
-
};
|
|
924
|
+
return (await planChange(b, change, who)).preview;
|
|
738
925
|
},
|
|
739
926
|
async changeBooking(bookingRef, change, who) {
|
|
740
|
-
|
|
741
|
-
if (!preview)
|
|
742
|
-
return null;
|
|
743
|
-
if (!preview.changeable || !preview.to)
|
|
744
|
-
throw demoError('unavailable', preview.rule);
|
|
927
|
+
sweep();
|
|
745
928
|
const b = findBooking(bookingRef, who);
|
|
746
|
-
|
|
747
|
-
const line = b?.quote.lines.find((l) => l.kind === 'room');
|
|
748
|
-
if (!b || !h || !line?.roomRef)
|
|
929
|
+
if (!b)
|
|
749
930
|
return null;
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
? {
|
|
755
|
-
kind: 'room',
|
|
756
|
-
hotelId: b.hotelId,
|
|
757
|
-
roomId: room?.id ?? String(l.roomRef),
|
|
758
|
-
rooms: preview.to?.rooms ?? l.quantity ?? 1,
|
|
759
|
-
stay: preview.to?.stay ?? b.stay,
|
|
760
|
-
}
|
|
761
|
-
: {
|
|
762
|
-
kind: 'extra',
|
|
763
|
-
hotelId: b.hotelId,
|
|
764
|
-
extraId: String(l.extraRef ?? ''),
|
|
765
|
-
quantity: l.quantity ?? 1,
|
|
766
|
-
stay: preview.to?.stay ?? b.stay,
|
|
767
|
-
});
|
|
768
|
-
const quote = await quoteTrip({ lines }, who);
|
|
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);
|
|
769
935
|
const updated = {
|
|
770
936
|
...b,
|
|
771
937
|
stay: preview.to.stay,
|
|
@@ -793,7 +959,7 @@ export const createDemoHotels = (opts) => {
|
|
|
793
959
|
rule: cancellable
|
|
794
960
|
? [
|
|
795
961
|
...rules,
|
|
796
|
-
'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.',
|
|
797
963
|
].join(' ')
|
|
798
964
|
: `This booking is ${b.status}; there is nothing left to cancel.`,
|
|
799
965
|
freeUntil,
|