@curless/hotel-storefront 0.12.0 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,0nkVAA0nkV,EAC1nkV,QAAQ,CACT,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;AAEnB,2FAA2F;AAC3F,MAAM,CAAC,MAAM,qBAAqB,GAChC,kEAAkE,CAAC"}
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"}
@@ -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":"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"}
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,EAKP,SAAS,EAKT,gBAAgB,EAWhB,QAAQ,EACT,MAAM,YAAY,CAAC;AAEpB;;;;;;;;;;;;;;;GAeG;AAEH,MAAM,MAAM,OAAO,GAAG;IACpB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,OAAO,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,4DAA4D;IAC5D,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,OAAO,CAAC;IACpB,2FAA2F;IAC3F,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B,CAAC;AAEF,MAAM,MAAM,QAAQ,GAAG;IACrB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,SAAS,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,GAAG,QAAQ,GAAG,OAAO,CAAC;IACjC,IAAI,CAAC,EAAE,SAAS,GAAG,WAAW,GAAG,KAAK,CAAC;CACxC,CAAC;AAEF,MAAM,MAAM,QAAQ,GAAG;IACrB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,mFAAmF;IACnF,OAAO,EAAE,MAAM,CAAC;IAChB,qEAAqE;IACrE,aAAa,EAAE,MAAM,CAAC;IACtB,gEAAgE;IAChE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;IAC5D,KAAK,EAAE,OAAO,EAAE,CAAC;IACjB,MAAM,CAAC,EAAE,QAAQ,EAAE,CAAC;IACpB,QAAQ,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,gBAAgB,EAAE,MAAM,CAAC,CAAC,CAAC;CACtD,CAAC;AAEF,MAAM,MAAM,OAAO,GAAG;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,aAAa,EAAE,MAAM,CAAA;CAAE,CAAC;AAE5F;;;;;;;;;;GAUG;AACH,MAAM,MAAM,OAAO,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE,CAAC;AACtE,MAAM,MAAM,SAAS,GAAG;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;CAChB,CAAC;AACF,MAAM,MAAM,aAAa,GAAG;IAC1B,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,OAAO,EAAE,CAAC;IACjB,OAAO,EAAE,SAAS,EAAE,CAAC;IACrB,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB,CAAC;AAEF,MAAM,MAAM,YAAY,GAAG;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,QAAQ,EAAE,CAAC;IAAC,UAAU,CAAC,EAAE,aAAa,CAAA;CAAE,CAAC;AAE7F,MAAM,MAAM,SAAS,GAAG,KAAK,GAAG;IAC9B,IAAI,EACA,WAAW,GACX,UAAU,GACV,aAAa,GACb,gBAAgB,GAChB,YAAY,GACZ,UAAU,GACV,oBAAoB,CAAC;CAC1B,CAAC;AAEF,eAAO,MAAM,SAAS,GAAI,MAAM,SAAS,CAAC,MAAM,CAAC,EAAE,SAAS,MAAM,KAAG,SACxB,CAAC;AAgC9C,MAAM,MAAM,iBAAiB,GAAG;IAC9B,IAAI,EAAE,YAAY,CAAC;IACnB,2FAA2F;IAC3F,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,2DAA2D;IAC3D,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IACnB,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;CACpB,CAAC;AAEF,KAAK,aAAa,GAAG,OAAO,GAAG;IAAE,SAAS,EAAE,MAAM,CAAA;CAAE,CAAC;AAErD;;;;;;;GAOG;AACH,eAAO,MAAM,WAAW,GACtB,OAAO,QAAQ,EAAE,KAChB;IAAE,MAAM,EAAE,QAAQ,EAAE,EAAE,CAAC;IAAC,OAAO,EAAE,OAAO,CAAC,QAAQ,EAAE;QAAE,IAAI,EAAE,OAAO,CAAA;KAAE,CAAC,EAAE,CAAA;CA0BzE,CAAC;AAEF,eAAO,MAAM,gBAAgB,GAC3B,MAAM,iBAAiB,KACtB,YAAY,GAAG;IAChB,sEAAsE;IACtE,KAAK,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IAChC,eAAe,IAAI,aAAa,EAAE,CAAC;CAmhCpC,CAAC"}
@@ -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
- const confirmationCode = () => {
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 i = 0; i < 6; i += 1)
256
- out += CODE_ALPHABET[Math.floor(rng() * CODE_ALPHABET.length)] ?? 'X';
257
- return `SL-${out}`;
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
- if (room.roomsLeft < wanted) {
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: room.roomsLeft === 0
319
- ? 'That room has gone since it went into the trip.'
320
- : `Only ${room.roomsLeft} of that room is left and the trip asks for ${wanted}.`,
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
- const roomStay = trip.lines.find((l) => l.kind === 'room' && l.hotelId === h.id);
368
- const stay = roomStay && roomStay.kind === 'room' ? roomStay.stay : undefined;
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 [firstRoom] = quote.lines.filter((l) => l.kind === 'room');
652
- const first = firstRoom ? byId(firstRoom.hotelId) : undefined;
653
- const stay = quote.stay;
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
- const arrival = arrivalInstant(stay.checkIn, first.checkInFrom ?? '15:00', offsetOf(first));
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 holdUntil = new Date(Math.max(t + 3_600_000, Math.min(t + holdMs, arrival - HOLD_STOPS_BEFORE_ARRIVAL_MS))).toISOString();
661
- seq += 1;
662
- const booking = {
663
- id: `bk_${Date.now().toString(36)}_${seq}`,
664
- confirmationCode: confirmationCode(),
665
- status: 'requested',
666
- createdAt: new Date(t).toISOString(),
667
- holdUntil,
668
- hotelId: first.id,
669
- hotelName: first.name,
670
- stay,
671
- guest,
672
- quote,
673
- sessionId: who.sessionId,
674
- ...(cleanRequests(requests) ? { requests: cleanRequests(requests) } : {}),
675
- };
676
- bookings.set(booking.id, booking);
677
- return { ...booking };
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
- const h = byId(b.hotelId);
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
- const preview = await this.previewChange(bookingRef, change, who);
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
- const h = b ? byId(b.hotelId) : undefined;
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
- const room = change.room ? (roomOf(h, change.room) ?? undefined) : undefined;
778
- // Re-quote the whole booking, so add-on lines move with the nights they
779
- // were bought for rather than being silently dropped or double-counted.
780
- const lines = b.quote.lines.map((l) => l.kind === 'room'
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 a fee above is what the hotel would put on the card at check-in time, not a refund we would keep.',
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,