@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.
@@ -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":"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"}
@@ -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
- /** Rooms this room type has already promised to someone. Not by date: see hotels.json's `_rates` note. */
105
- const heldCount = (hotelId, roomId) => {
106
- let held = 0;
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 === 'room' && line.hotelId === hotelId && line.roomRef === roomId)
112
- held += line.quantity ?? 1;
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 held;
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 - heldCount(h.id, r.id)),
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
- 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);
229
297
  let out = '';
230
- for (let i = 0; i < 6; i += 1)
231
- out += CODE_ALPHABET[Math.floor(rng() * CODE_ALPHABET.length)] ?? 'X';
232
- 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}`;
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
- 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) {
291
376
  dropped.push({
292
377
  title: `${h.name} - ${raw.name}`,
293
- why: room.roomsLeft === 0
294
- ? 'That room has gone since it went into the trip.'
295
- : `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}.`,
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
- const roomStay = trip.lines.find((l) => l.kind === 'room' && l.hotelId === h.id);
343
- 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];
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 [firstRoom] = quote.lines.filter((l) => l.kind === 'room');
627
- const first = firstRoom ? byId(firstRoom.hotelId) : undefined;
628
- const stay = quote.stay;
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
- 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)));
634
870
  const t = now();
635
- const holdUntil = new Date(Math.max(t + 3_600_000, Math.min(t + holdMs, arrival - HOLD_STOPS_BEFORE_ARRIVAL_MS))).toISOString();
636
- seq += 1;
637
- const booking = {
638
- id: `bk_${Date.now().toString(36)}_${seq}`,
639
- confirmationCode: confirmationCode(),
640
- status: 'requested',
641
- createdAt: new Date(t).toISOString(),
642
- holdUntil,
643
- hotelId: first.id,
644
- hotelName: first.name,
645
- stay,
646
- guest,
647
- quote,
648
- sessionId: who.sessionId,
649
- ...(cleanRequests(requests) ? { requests: cleanRequests(requests) } : {}),
650
- };
651
- bookings.set(booking.id, booking);
652
- 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 }));
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
- const h = byId(b.hotelId);
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
- const preview = await this.previewChange(bookingRef, change, who);
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
- const h = b ? byId(b.hotelId) : undefined;
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
- const room = change.room ? (roomOf(h, change.room) ?? undefined) : undefined;
751
- // Re-quote the whole booking, so add-on lines move with the nights they
752
- // were bought for rather than being silently dropped or double-counted.
753
- const lines = b.quote.lines.map((l) => l.kind === 'room'
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 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.',
797
963
  ].join(' ')
798
964
  : `This booking is ${b.status}; there is nothing left to cancel.`,
799
965
  freeUntil,