@curless/hotel-storefront 0.18.3 → 0.19.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.
Files changed (56) hide show
  1. package/dist/__tests__/demo-hotels.test.js +21 -2
  2. package/dist/__tests__/demo-hotels.test.js.map +1 -1
  3. package/dist/__tests__/server.test.js +17 -3
  4. package/dist/__tests__/server.test.js.map +1 -1
  5. package/dist/__tests__/tools.test.js +282 -5
  6. package/dist/__tests__/tools.test.js.map +1 -1
  7. package/dist/backend.d.ts +12 -1
  8. package/dist/backend.d.ts.map +1 -1
  9. package/dist/backend.js +7 -1
  10. package/dist/backend.js.map +1 -1
  11. package/dist/card.generated.d.ts +1 -1
  12. package/dist/card.generated.js +2 -2
  13. package/dist/card.generated.js.map +1 -1
  14. package/dist/data.d.ts +2 -0
  15. package/dist/data.d.ts.map +1 -1
  16. package/dist/data.js +24 -7
  17. package/dist/data.js.map +1 -1
  18. package/dist/demo-hotels.d.ts +24 -2
  19. package/dist/demo-hotels.d.ts.map +1 -1
  20. package/dist/demo-hotels.js +313 -95
  21. package/dist/demo-hotels.js.map +1 -1
  22. package/dist/legal.d.ts +1 -1
  23. package/dist/legal.d.ts.map +1 -1
  24. package/dist/legal.js +31 -10
  25. package/dist/legal.js.map +1 -1
  26. package/dist/limits.d.ts +12 -0
  27. package/dist/limits.d.ts.map +1 -0
  28. package/dist/limits.js +47 -0
  29. package/dist/limits.js.map +1 -0
  30. package/dist/members.d.ts +9 -0
  31. package/dist/members.d.ts.map +1 -1
  32. package/dist/members.js +17 -0
  33. package/dist/members.js.map +1 -1
  34. package/dist/money.d.ts +6 -1
  35. package/dist/money.d.ts.map +1 -1
  36. package/dist/money.js +6 -1
  37. package/dist/money.js.map +1 -1
  38. package/dist/present.d.ts +2 -1
  39. package/dist/present.d.ts.map +1 -1
  40. package/dist/present.js +3 -2
  41. package/dist/present.js.map +1 -1
  42. package/dist/server.d.ts +8 -8
  43. package/dist/server.d.ts.map +1 -1
  44. package/dist/server.js +13 -4
  45. package/dist/server.js.map +1 -1
  46. package/dist/surface.d.ts +2 -2
  47. package/dist/surface.d.ts.map +1 -1
  48. package/dist/surface.js +2 -2
  49. package/dist/surface.js.map +1 -1
  50. package/dist/tools.d.ts +39 -13
  51. package/dist/tools.d.ts.map +1 -1
  52. package/dist/tools.js +376 -95
  53. package/dist/tools.js.map +1 -1
  54. package/dist/types.d.ts +12 -4
  55. package/dist/types.d.ts.map +1 -1
  56. package/package.json +1 -1
@@ -1,3 +1,4 @@
1
+ import { randomInt, randomUUID } from 'node:crypto';
1
2
  import { LASTING_REQUESTS, MAX_PAGE, REQUEST_KINDS, REQUEST_NOTE_MAX } from './backend.js';
2
3
  import { addDays, arrivalInstant, eachNight, isWeekendNight, nights as nightsBetween, stayProblem, todayAt, } from './dates.js';
3
4
  import { FACILITIES, hasFacility } from './facilities.js';
@@ -31,6 +32,25 @@ const HOLD_STOPS_BEFORE_ARRIVAL_MS = 2 * 3_600_000;
31
32
  const CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
32
33
  /** Only ever used for an empty trip, which has no hotel to take a currency from. */
33
34
  const DEFAULT_CURRENCY = 'HKD';
35
+ /** Conversations one booking can be opened from - its own, and a few that found it. */
36
+ const MAX_OWNERS = 10;
37
+ /**
38
+ * Bookings kept at once. Past it, the oldest that are over - cancelled, lapsed,
39
+ * or checked out - are forgotten first. A live hold is never dropped to make
40
+ * room: it is a room somebody was promised.
41
+ */
42
+ const MAX_BOOKINGS = 5_000;
43
+ /**
44
+ * Live holds one conversation may have at once. A demo's inventory is shared:
45
+ * one conversation asking over and over would take every room off every guest
46
+ * for the two hours the holds last.
47
+ */
48
+ export const MAX_HOLDS_PER_CONVERSATION = 10;
49
+ const publicBooking = (b) => {
50
+ const { owners, ...rest } = b;
51
+ void owners;
52
+ return { ...rest };
53
+ };
34
54
  /**
35
55
  * A trip cut into the stays a hotel would confirm one by one: the rooms at one
36
56
  * hotel for the same nights, and the add-ons bought for those nights.
@@ -71,12 +91,14 @@ export const splitByStay = (lines) => {
71
91
  export const createDemoHotels = (opts) => {
72
92
  const hotels = opts.data.hotels;
73
93
  const now = opts.now ?? (() => Date.now());
74
- const rng = opts.rng ?? Math.random;
94
+ // ⚠️ A CODE IS HALF OF WHAT OPENS A BOOKING, so it comes from the crypto
95
+ // generator: Math.random is predictable from its own output. Tests inject
96
+ // their own.
97
+ const rng = opts.rng ?? (() => randomInt(0, 2 ** 32) / 2 ** 32);
75
98
  const holdMs = opts.holdMs ?? HOLD_MS_DEFAULT;
76
99
  const bookings = new Map();
77
100
  /** Saved room preferences, by membership number - see `preferencesFor`. */
78
101
  const preferences = new Map();
79
- let seq = 0;
80
102
  const byId = (id) => {
81
103
  const wanted = id.trim().toLowerCase();
82
104
  return (hotels.find((h) => h.id.toLowerCase() === wanted) ??
@@ -185,6 +207,22 @@ export const createDemoHotels = (opts) => {
185
207
  b.status = 'expired';
186
208
  }
187
209
  };
210
+ /**
211
+ * Until when this room on these nights can be cancelled for nothing - or
212
+ * nothing, when it cannot.
213
+ *
214
+ * ⚠️ A WINDOW THAT HAS ALREADY CLOSED IS NO WINDOW. A stay two days out on a
215
+ * 48-hour rule was listed as "Free cancellation", compared as "Yes" and
216
+ * described as "free to cancel until" yesterday - and booked, it cost a
217
+ * night to cancel (2026-09-28 audit).
218
+ */
219
+ const freeUntilOf = (h, r, stay) => {
220
+ if (!r.refundable || r.freeCancelHours === undefined)
221
+ return undefined;
222
+ const at = arrivalInstant(stay.checkIn, h.checkInFrom ?? '15:00', offsetOf(h)) -
223
+ r.freeCancelHours * 3_600_000;
224
+ return at > now() ? new Date(at).toISOString() : undefined;
225
+ };
188
226
  const priceRoom = (h, r, stay, who,
189
227
  /** A booking whose own rooms do not count against it — see `heldOn`. */
190
228
  ignore) => {
@@ -199,10 +237,7 @@ export const createDemoHotels = (opts) => {
199
237
  : publicSubtotal;
200
238
  const taxes = wholeUnits(taxOn(roomSubtotal, h.taxRate));
201
239
  const publicTaxes = wholeUnits(taxOn(publicSubtotal, h.taxRate));
202
- const freeUntil = r.refundable && r.freeCancelHours !== undefined
203
- ? new Date(arrivalInstant(stay.checkIn, h.checkInFrom ?? '15:00', offsetOf(h)) -
204
- r.freeCancelHours * 3_600_000).toISOString()
205
- : undefined;
240
+ const freeUntil = freeUntilOf(h, r, stay);
206
241
  return {
207
242
  id: r.id,
208
243
  name: r.name,
@@ -312,13 +347,50 @@ export const createDemoHotels = (opts) => {
312
347
  if (!inUse(`SL-${out}${n}`))
313
348
  return `SL-${out}${n}`;
314
349
  };
315
- const mine = (b, who) => b.sessionId === who.sessionId ||
316
- (!!who.email && b.guest.email.toLowerCase() === who.email.toLowerCase());
317
- /** One booking by its id or the code a guest quotes, and only if it is theirs. */
350
+ /**
351
+ * One booking by its id or the code a guest quotes, and only if it is theirs:
352
+ * this conversation's, or found with its code and the email it was made with.
353
+ *
354
+ * ⚠️ WITH AN EMAIL, THE CODE AND ONLY THE CODE. The id was accepted too, and
355
+ * it was `bk_<the time>_<a counter>` - an email and a few dozen guesses
356
+ * opened somebody's booking (2026-09-28 audit). Ids are random now, and
357
+ * still not taken with an email: the code is what the guest was given.
358
+ *
359
+ * Found that way, the booking is this conversation's from then on - see
360
+ * `StoredBooking`.
361
+ */
318
362
  const findBooking = (ref, who) => {
319
363
  const wanted = ref.trim().toLowerCase();
320
- return [...bookings.values()].find((x) => (x.id.toLowerCase() === wanted || x.confirmationCode.toLowerCase() === wanted) &&
321
- mine(x, who));
364
+ if (!wanted)
365
+ return undefined;
366
+ const own = [...bookings.values()].find((x) => x.owners.has(who.sessionId) &&
367
+ (x.id.toLowerCase() === wanted || x.confirmationCode.toLowerCase() === wanted));
368
+ if (own || !who.email)
369
+ return own;
370
+ const email = who.email.trim().toLowerCase();
371
+ const there = [...bookings.values()].find((x) => x.confirmationCode.toLowerCase() === wanted && x.guest.email.trim().toLowerCase() === email);
372
+ if (there) {
373
+ there.owners.add(who.sessionId);
374
+ while (there.owners.size > MAX_OWNERS) {
375
+ const [, second] = [...there.owners];
376
+ // The conversation that made it stays; the oldest finder goes.
377
+ if (second === undefined)
378
+ break;
379
+ there.owners.delete(second);
380
+ }
381
+ }
382
+ return there;
383
+ };
384
+ /** Over and done with - nothing held, nothing left to change. */
385
+ const ended = (b) => (b.status !== 'requested' && b.status !== 'confirmed') || b.stay.checkOut < today(b.hotelId);
386
+ /** Room for one more, by forgetting the oldest booking that is over. */
387
+ const makeRoom = (incoming) => {
388
+ for (const b of [...bookings.values()]) {
389
+ if (bookings.size + incoming <= MAX_BOOKINGS)
390
+ return;
391
+ if (ended(b))
392
+ bookings.delete(b.id);
393
+ }
322
394
  };
323
395
  /** A room type by its id or the name the hotel prints. */
324
396
  const roomOf = (h, ref) => {
@@ -441,7 +513,8 @@ export const createDemoHotels = (opts) => {
441
513
  const units = extraUnits(rawExtra, stay, line.quantity);
442
514
  const total = multiplyMoney(extra.price, units);
443
515
  subtotal += total.minor;
444
- taxes += taxOn(total, h.taxRate).minor;
516
+ // Each add-on's tax rounded on its own, like each room's - see `taxOn`.
517
+ taxes += wholeUnits(taxOn(total, h.taxRate)).minor;
445
518
  lines.push({
446
519
  kind: 'extra',
447
520
  hotelId: h.id,
@@ -473,9 +546,8 @@ export const createDemoHotels = (opts) => {
473
546
  }
474
547
  : undefined;
475
548
  const member = who?.member;
476
- // Rounded once, here, to the whole unit - see `wholeUnits`. The rooms' own
477
- // tax already is; the add-ons' is added up first and rounded with it.
478
- const taxed = wholeUnits(money(taxes, cur)).minor;
549
+ // Every line's tax is already a whole unit - see `taxOn`.
550
+ const taxed = taxes;
479
551
  return {
480
552
  lines,
481
553
  subtotal: money(subtotal, cur),
@@ -528,6 +600,55 @@ export const createDemoHotels = (opts) => {
528
600
  };
529
601
  });
530
602
  };
603
+ /**
604
+ * The tax on one stored room line, exactly as it was charged: `priceRoom`
605
+ * rounds each room's tax to the whole unit, and a line is that room times
606
+ * how many.
607
+ */
608
+ const roomTaxOf = (l, taxRate) => {
609
+ const q = Math.max(1, l.quantity ?? 1);
610
+ return wholeUnits(taxOn(money(l.total.minor / q, l.total.currency), taxRate)).minor * q;
611
+ };
612
+ /**
613
+ * The booking with its add-ons re-priced and its ROOMS AS BOOKED.
614
+ *
615
+ * ⚠️ AN AIRPORT TRANSFER DOES NOT RE-PRICE THE ROOM. Every line used to be
616
+ * re-quoted for whoever was asking: a member's non-refundable room, with the
617
+ * member signed out, came back at the public rate, and "HK$2,879 more" was
618
+ * shown for a HK$1,413 transfer - the room's own non-refundable fee rising
619
+ * with it (2026-09-28 audit). A rate is what the booking was made on; only
620
+ * moving the nights or the room changes it.
621
+ */
622
+ const withRoomsKept = (was, fresh, taxRate) => {
623
+ const cur = was.total.currency;
624
+ const rooms = was.lines.filter((l) => l.kind === 'room');
625
+ const extras = fresh.lines.filter((l) => l.kind === 'extra');
626
+ const lines = [...rooms, ...extras];
627
+ const subtotal = lines.reduce((n, l) => n + l.total.minor, 0);
628
+ const taxes = rooms.reduce((n, l) => n + roomTaxOf(l, taxRate), 0) +
629
+ extras.reduce((n, l) => n + wholeUnits(taxOn(l.total, taxRate)).minor, 0);
630
+ const total = subtotal + taxes;
631
+ const { dropped, member, ...rest } = was;
632
+ void dropped;
633
+ return {
634
+ ...rest,
635
+ lines,
636
+ subtotal: money(subtotal, cur),
637
+ taxesAndFees: money(taxes, cur),
638
+ total: money(total, cur),
639
+ // Points are earned on what is paid, and scale with it.
640
+ ...(member
641
+ ? {
642
+ member: {
643
+ ...member,
644
+ points: was.total.minor
645
+ ? Math.round((member.points * total) / was.total.minor)
646
+ : member.points,
647
+ },
648
+ }
649
+ : {}),
650
+ };
651
+ };
531
652
  /**
532
653
  * A change, worked out whole: what the preview shows AND the quote the change
533
654
  * would store, from one computation.
@@ -579,6 +700,9 @@ export const createDemoHotels = (opts) => {
579
700
  change.checkOut !== undefined ||
580
701
  change.room !== undefined ||
581
702
  change.rooms !== undefined;
703
+ // An add-on asked for at the count it already has is no part of the change.
704
+ if (addOn && addOn.from === addOn.to && movesRooms)
705
+ addOn = undefined;
582
706
  // ⚠️ A NON-REFUNDABLE RATE CANNOT BE MOVED EITHER - ANY ROOM ON IT. The
583
707
  // hotel's own words are "cannot be cancelled or changed"; offering a change
584
708
  // would be this card contradicting the policy on the screen beside it.
@@ -589,6 +713,9 @@ export const createDemoHotels = (opts) => {
589
713
  : undefined;
590
714
  if (locked)
591
715
  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.`);
716
+ // "No rooms" is not a smaller booking. It became one room without a word.
717
+ if (change.rooms !== undefined && !(Math.floor(change.rooms) >= 1))
718
+ return no('A booking keeps at least one room. To give them all back, cancel it instead.');
592
719
  if (rooms.length > 1 && (change.room !== undefined || change.rooms !== undefined))
593
720
  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.`);
594
721
  const raw = h.rooms.find((r) => r.id === line.roomRef);
@@ -625,7 +752,7 @@ export const createDemoHotels = (opts) => {
625
752
  // on the nights that do not overlap.
626
753
  const one = rooms.length === 1;
627
754
  const left = priceRoom(h, wantedRoom, stay, who, b.id).roomsLeft;
628
- if (one && left < count)
755
+ if (movesRooms && one && left < count)
629
756
  return no(`${wantedRoom.name} has ${left} left for ${stay.checkIn} to ${stay.checkOut}; this change needs ${count}.`);
630
757
  const moved = linesOn(b, stay, one ? { roomId: wantedRoom.id, rooms: count } : undefined);
631
758
  // The add-on set to its new count - taken off at 0, put on when it was not.
@@ -637,12 +764,25 @@ export const createDemoHotels = (opts) => {
637
764
  : []),
638
765
  ]
639
766
  : moved;
640
- const quote = await quoteTrip({ lines }, who, b.id);
641
- const lost = quote.dropped?.[0];
642
- if (lost || !quote.lines.some((l) => l.kind === 'room'))
643
- return no(lost
644
- ? `${lost.title}: ${lost.why}`
645
- : 'The rooms on this booking could not be held for the new nights.');
767
+ const fresh = await quoteTrip({ lines }, who, b.id);
768
+ let quote = fresh;
769
+ if (movesRooms) {
770
+ const lost = fresh.dropped?.[0];
771
+ if (lost || !fresh.lines.some((l) => l.kind === 'room'))
772
+ return no(lost
773
+ ? `${lost.title}: ${lost.why}`
774
+ : 'The rooms on this booking could not be held for the new nights.');
775
+ }
776
+ else {
777
+ // Only the add-on has to price; the rooms stand as booked.
778
+ if (addOn &&
779
+ addOn.to > 0 &&
780
+ !fresh.lines.some((l) => l.kind === 'extra' && l.extraRef === addOn?.id)) {
781
+ const lost = fresh.dropped?.find((d) => d.title.includes(addOn?.id ?? '')) ?? fresh.dropped?.[0];
782
+ return no(lost ? `${lost.title}: ${lost.why}` : `${addOn.name} cannot be added to this booking.`);
783
+ }
784
+ quote = withRoomsKept(b.quote, fresh, h.taxRate);
785
+ }
646
786
  const becomesLocked = wantedRoom.id !== line.roomRef && !wantedRoom.refundable;
647
787
  return {
648
788
  preview: {
@@ -685,8 +825,11 @@ export const createDemoHotels = (opts) => {
685
825
  continue;
686
826
  const h = byId(line.hotelId);
687
827
  const taxRate = h?.taxRate ?? 0;
828
+ // ⚠️ THE TAX AS IT WAS CHARGED, whole units and all: a fee worked out
829
+ // afresh came to HK$13,200.66 on a HK$13,201 booking, and "HK$0.34 back"
830
+ // on a rate that returns nothing (2026-09-28 audit).
688
831
  if (!line.refundable) {
689
- const withTax = Math.round(line.total.minor * (1 + taxRate));
832
+ const withTax = line.total.minor + roomTaxOf(line, taxRate);
690
833
  fee += withTax;
691
834
  rules.push(`${line.title} is a non-refundable rate: the whole stay, ${formatMoney(money(withTax, currency))}, stands.`);
692
835
  continue;
@@ -697,9 +840,13 @@ export const createDemoHotels = (opts) => {
697
840
  rules.push(`${line.title} is free to cancel until ${line.freeCancellationUntil.slice(0, 16).replace('T', ' ')} UTC.`);
698
841
  continue;
699
842
  }
700
- const oneNight = Math.round(line.unit.minor * (line.quantity ?? 1) * (1 + taxRate));
843
+ // A night at the booking's own average nightly rate - a stay of a
844
+ // Thursday and a Friday is not charged as its dearer night - and said
845
+ // as that, so nobody reads it as a particular night's price.
846
+ const q = Math.max(1, line.quantity ?? 1);
847
+ const oneNight = (line.unit.minor + wholeUnits(taxOn(line.unit, taxRate)).minor) * q;
701
848
  fee += oneNight;
702
- rules.push(`${line.title}: the free window closed${line.freeCancellationUntil ? ` on ${line.freeCancellationUntil.slice(0, 10)}` : ''}, so one night plus tax, ${formatMoney(money(oneNight, currency))}, is charged.`);
849
+ rules.push(`${line.title}: the free window closed${line.freeCancellationUntil ? ` on ${line.freeCancellationUntil.slice(0, 10)}` : ''}, so one night at this booking's average nightly rate, plus tax, ${formatMoney(money(oneNight, currency))}, is charged.`);
703
850
  }
704
851
  if (b.quote.lines.some((l) => l.kind === 'extra')) {
705
852
  rules.push('Add-ons are dropped at no charge.');
@@ -711,6 +858,9 @@ export const createDemoHotels = (opts) => {
711
858
  today,
712
859
  bookingsForTest: () => [...bookings.values()],
713
860
  async searchHotels(q, who) {
861
+ // A lapsed hold stops holding here too: without it a list priced for the
862
+ // nights still said "No room" until some other call swept.
863
+ sweep();
714
864
  const terms = (q.query ?? '')
715
865
  .toLowerCase()
716
866
  .split(/\s+/)
@@ -747,12 +897,87 @@ export const createDemoHotels = (opts) => {
747
897
  // A room term is about a ROOM: a hotel passes when it has one, and on a
748
898
  // dated search that is the room that gets priced.
749
899
  const f = q.filters ?? {};
750
- const roomOk = (r, over) => (!over.freeCancellation || r.refundable) && (!over.breakfast || !!r.breakfast);
900
+ const stayAsked = q.stay;
901
+ const wantRooms = Math.max(1, Math.min(MAX_ROOMS_PER_LINE, Math.floor(q.rooms ?? 1)));
902
+ // A room term is about a ROOM, and on named nights about THOSE nights:
903
+ // free cancellation is a window that has not closed yet, not a rate that
904
+ // once had one.
905
+ const roomOk = (h, r, over) => (!over.freeCancellation || (stayAsked ? !!freeUntilOf(h, r, stayAsked) : r.refundable)) &&
906
+ (!over.breakfast || !!r.breakfast);
751
907
  const passes = (h, over) => (!over.brand || h.brand.toLowerCase() === over.brand.toLowerCase()) &&
752
908
  (over.facilities ?? []).every((k) => hasFacility(h.amenities, k)) &&
753
- (!(over.freeCancellation || over.breakfast) || h.rooms.some((r) => roomOk(r, over)));
909
+ (!(over.freeCancellation || over.breakfast) || h.rooms.some((r) => roomOk(h, r, over)));
754
910
  const matches = placed.filter((h) => passes(h, f));
755
- const count = (over) => placed.filter((h) => passes(h, over)).length;
911
+ // ⚠️ NAMED NIGHTS ARE PRICED, EVERY HOTEL (robin, 2026-09-28, with Trip.com
912
+ // and the group's own site as the reference: a place, then the dates,
913
+ // then hotels priced for THOSE dates). The cheapest room that takes the
914
+ // party, meets the room terms and has as many rooms left as were asked
915
+ // for, for the whole stay, taxes in - and the price order and the
916
+ // ceiling are about that room, not the list rate a Friday never costs.
917
+ //
918
+ // Per room terms, because a chip's count is asked with other terms than
919
+ // the list's own, and the cheapest room with breakfast is a different
920
+ // room at a different price.
921
+ const pricedRooms = new Map();
922
+ const best = new Map();
923
+ const bestFor = (h, over) => {
924
+ if (!stayAsked)
925
+ return null;
926
+ const k = `${h.id}|${!!over.freeCancellation}|${!!over.breakfast}`;
927
+ const known = best.get(k);
928
+ if (known !== undefined)
929
+ return known;
930
+ let found = null;
931
+ if (!stayProblem(stayAsked, today(h.id))) {
932
+ for (const raw of h.rooms) {
933
+ if (raw.sleeps < stayAsked.guests || !roomOk(h, raw, over))
934
+ continue;
935
+ const rk = `${h.id}|${raw.id}`;
936
+ const room = pricedRooms.get(rk) ?? priceRoom(h, raw, stayAsked, who);
937
+ pricedRooms.set(rk, room);
938
+ if (room.roomsLeft < wantRooms)
939
+ continue;
940
+ if (!found || room.stayTotal.minor < found.room.stayTotal.minor)
941
+ found = { room, raw };
942
+ }
943
+ }
944
+ best.set(k, found);
945
+ return found;
946
+ };
947
+ /**
948
+ * The number a ceiling is held against: a night of the cheapest room
949
+ * that fits on named nights, the list rate otherwise - and nothing at all
950
+ * for a hotel with no room for those nights, which is neither under a
951
+ * ceiling nor over it.
952
+ */
953
+ const nightlyOf = (h, over) => {
954
+ if (stayAsked) {
955
+ const b = bestFor(h, over);
956
+ return b ? b.room.nightly.minor / minorFactor(h.currency) : undefined;
957
+ }
958
+ return h.rooms.reduce((min, r) => (r.nightly < min ? r.nightly : min), Number.POSITIVE_INFINITY);
959
+ };
960
+ // ⚠️ THE CURRENCIES OF THE MATCHES, before any ceiling is applied. This
961
+ // is the set a price question would have to be answered across, and it is
962
+ // the whole reason the price knobs live behind this seam rather than in
963
+ // the tool: only here is it known that "Shangri-La in Asia" spans eleven
964
+ // currencies and "Shangri-La in Tokyo" spans one.
965
+ const currencies = [...new Set(matches.map((h) => h.currency))].sort();
966
+ const onePrice = currencies.length === 1;
967
+ const ceiling = onePrice && Number(q.maxNightly) > 0 ? Number(q.maxNightly) : undefined;
968
+ const under = (h, over) => {
969
+ if (ceiling === undefined)
970
+ return true;
971
+ const n = nightlyOf(h, over);
972
+ return n !== undefined && n <= ceiling;
973
+ };
974
+ // ⚠️ THE FILTERS, and what each would leave with the others applied - the
975
+ // count a chip shows before it is pressed (robin, 2026-09-28: 「筛选可以,
976
+ // 加上」). A brand is one at a time; facilities and room terms all apply.
977
+ // A chip counts what pressing it would LIST: under the ceiling too, and
978
+ // priced with its own room terms. It counted before the ceiling, and
979
+ // "Shangri-La 2" pressed gave one (2026-09-28 audit).
980
+ const count = (over) => placed.filter((h) => passes(h, over) && under(h, over)).length;
756
981
  const refine = {
757
982
  brands: [...new Set(placed.map((h) => h.brand))]
758
983
  .map((name) => ({ name, count: count({ ...f, brand: name }) }))
@@ -802,72 +1027,59 @@ export const createDemoHotels = (opts) => {
802
1027
  return size(b) - size(a) || a.cityId.localeCompare(b.cityId);
803
1028
  })
804
1029
  : matches;
805
- // ⚠️ THE CURRENCIES OF THE MATCHES, before any ceiling is applied. This
806
- // is the set a price question would have to be answered across, and it is
807
- // the whole reason the price knobs live behind this seam rather than in
808
- // the tool: only here is it known that "Shangri-La in Asia" spans eleven
809
- // currencies and "Shangri-La in Tokyo" spans one.
810
- const currencies = [...new Set(matches.map((h) => h.currency))].sort();
811
- const onePrice = currencies.length === 1;
812
- // ⚠️ NAMED NIGHTS ARE PRICED, EVERY HOTEL (robin, 2026-09-28, with Trip.com
813
- // and the group's own site as the reference: a place, then the dates,
814
- // then hotels priced for THOSE dates). The cheapest room that takes the
815
- // party and has as many rooms left as were asked for, for the whole
816
- // stay, taxes in - and the price order and the ceiling are about that
817
- // number, not the list rate a Friday never costs.
818
- const wantRooms = Math.max(1, Math.min(MAX_ROOMS_PER_LINE, Math.floor(q.rooms ?? 1)));
819
1030
  const forStay = new Map();
820
1031
  const noRooms = new Map();
821
- if (q.stay) {
822
- const stayAsked = q.stay;
1032
+ if (stayAsked) {
823
1033
  for (const h of matches) {
824
1034
  const problem = stayProblem(stayAsked, today(h.id));
825
- if (problem) {
826
- noRooms.set(h.id, problem);
827
- continue;
828
- }
829
- let best;
830
- for (const raw of h.rooms) {
831
- if (raw.sleeps < stayAsked.guests)
832
- continue;
833
- if (!roomOk(raw, f))
834
- continue;
835
- const room = priceRoom(h, raw, stayAsked, who);
836
- if (room.roomsLeft < wantRooms)
837
- continue;
838
- if (!best || room.stayTotal.minor < best.room.stayTotal.minor)
839
- best = { room, raw };
840
- }
841
- if (!best) {
842
- noRooms.set(h.id, 'No room here can be booked for those nights.');
1035
+ const b = problem ? null : bestFor(h, f);
1036
+ if (!b) {
1037
+ noRooms.set(h.id, problem ?? 'No room here can be booked for those nights.');
843
1038
  continue;
844
1039
  }
845
1040
  forStay.set(h.id, {
846
- total: multiplyMoney(best.room.stayTotal, wantRooms),
847
- nightly: best.room.nightly,
848
- room: best.raw.name,
1041
+ total: multiplyMoney(b.room.stayTotal, wantRooms),
1042
+ nightly: b.room.nightly,
1043
+ room: b.raw.name,
849
1044
  rooms: wantRooms,
850
- nights: best.room.nights,
851
- roomsLeft: best.room.roomsLeft,
852
- freeCancellation: !!best.room.freeCancellationUntil,
1045
+ nights: b.room.nights,
1046
+ roomsLeft: b.room.roomsLeft,
1047
+ freeCancellation: !!b.room.freeCancellationUntil,
853
1048
  });
854
1049
  }
855
1050
  }
856
- const cheapest = (h) => {
857
- if (q.stay) {
858
- const f = forStay.get(h.id);
859
- return f ? f.nightly.minor / minorFactor(h.currency) : Number.POSITIVE_INFINITY;
860
- }
861
- return h.rooms.reduce((min, r) => (r.nightly < min ? r.nightly : min), Number.POSITIVE_INFINITY);
862
- };
863
- const ceiling = onePrice && Number(q.maxNightly) > 0 ? Number(q.maxNightly) : undefined;
864
- const passed = ceiling === undefined ? ranked : ranked.filter((h) => cheapest(h) <= ceiling);
865
- const over = ceiling === undefined ? [] : ranked.filter((h) => cheapest(h) > ceiling);
1051
+ /**
1052
+ * What the price order reads: the stay's total, taxes in, when nights are
1053
+ * named - the number on the tile. It read the pre-tax average night, and
1054
+ * Pudong's ¥3,364 sorted above Kerry Pudong's ¥3,138: two taxes, one
1055
+ * currency (2026-09-28 audit). A hotel with no room has no price, and
1056
+ * goes last whichever way the list is sorted.
1057
+ */
1058
+ const priceKey = (h) => stayAsked ? forStay.get(h.id)?.total.minor : nightlyOf(h, f);
1059
+ const passed = ranked.filter((h) => under(h, f));
1060
+ // Over the ceiling means PRICED and over it. A hotel with no room for the
1061
+ // nights was counted here too - "2 more came in over the ceiling" about
1062
+ // two that were full.
1063
+ const over = ceiling === undefined
1064
+ ? []
1065
+ : ranked.filter((h) => {
1066
+ const n = nightlyOf(h, f);
1067
+ return n !== undefined && n > ceiling;
1068
+ });
866
1069
  // A price order across currencies is an exchange rate we picked. Dropped,
867
1070
  // not approximated; `currencies` tells the caller to say so.
868
1071
  const sort = q.sort ?? 'group';
1072
+ const byPrice = (a, b, dir) => {
1073
+ const x = priceKey(a);
1074
+ const y = priceKey(b);
1075
+ if (x === undefined)
1076
+ return y === undefined ? 0 : 1;
1077
+ if (y === undefined)
1078
+ return -1;
1079
+ return (x - y) * dir;
1080
+ };
869
1081
  const ordered = (sort === 'price-low' || sort === 'price-high') && onePrice
870
- ? [...passed].sort((a, b) => sort === 'price-low' ? cheapest(a) - cheapest(b) : cheapest(b) - cheapest(a))
1082
+ ? [...passed].sort((a, b) => byPrice(a, b, sort === 'price-low' ? 1 : -1))
871
1083
  : sort === 'rating'
872
1084
  ? // A hotel that publishes no rating is not a hotel with a bad one.
873
1085
  // They keep their order and follow the ones that do.
@@ -896,6 +1108,7 @@ export const createDemoHotels = (opts) => {
896
1108
  currencies,
897
1109
  refine,
898
1110
  ...(q.filters ? { beforeFilters: placed.length } : {}),
1111
+ ...(ceiling !== undefined ? { beforeCeiling: matches.length } : {}),
899
1112
  facets: {
900
1113
  countries: tally((h) => h.country ?? h.city),
901
1114
  cities: tally((h) => h.city).map((c) => ({
@@ -908,10 +1121,11 @@ export const createDemoHotels = (opts) => {
908
1121
  // and never mixed in with the passes.
909
1122
  ...(passed.length === 0 && over.length
910
1123
  ? {
1124
+ // Priced like the list: on named nights, what those nights cost.
911
1125
  nearest: [...over]
912
- .sort((a, b) => cheapest(a) - cheapest(b))
1126
+ .sort((a, b) => byPrice(a, b, 1))
913
1127
  .slice(0, 3)
914
- .map(toHotel),
1128
+ .map(priced),
915
1129
  }
916
1130
  : {}),
917
1131
  };
@@ -1051,6 +1265,10 @@ export const createDemoHotels = (opts) => {
1051
1265
  if (problems.length) {
1052
1266
  throw demoError('partly-unavailable', `Nothing was booked. ${problems.join(' ')} The trip is as it was - take that out or change it, then ask again.`);
1053
1267
  }
1268
+ const live = [...bookings.values()].filter((b) => b.owners.has(who.sessionId) && b.status === 'requested').length;
1269
+ if (live + groups.length > MAX_HOLDS_PER_CONVERSATION) {
1270
+ throw demoError('too-many-holds', `Nothing was booked. This conversation already holds ${live} booking${live === 1 ? '' : 's'}, and ${MAX_HOLDS_PER_CONVERSATION} at once is the most - cancel one that is not needed, then ask again.`);
1271
+ }
1054
1272
  const quotes = await Promise.all(groups.map((g) => quoteTrip({ lines: g }, who)));
1055
1273
  const t = now();
1056
1274
  const made = [];
@@ -1064,9 +1282,8 @@ export const createDemoHotels = (opts) => {
1064
1282
  }
1065
1283
  const arrival = arrivalInstant(quote.stay.checkIn, h.checkInFrom ?? '15:00', offsetOf(h));
1066
1284
  const holdUntil = new Date(Math.max(t + 3_600_000, Math.min(t + holdMs, arrival - HOLD_STOPS_BEFORE_ARRIVAL_MS))).toISOString();
1067
- seq += 1;
1068
1285
  made.push({
1069
- id: `bk_${Date.now().toString(36)}_${seq}`,
1286
+ id: `bk_${randomUUID()}`,
1070
1287
  confirmationCode: confirmationCode(new Set(made.map((m) => m.confirmationCode))),
1071
1288
  status: 'requested',
1072
1289
  createdAt: new Date(t).toISOString(),
@@ -1076,26 +1293,29 @@ export const createDemoHotels = (opts) => {
1076
1293
  stay: quote.stay,
1077
1294
  guest,
1078
1295
  quote,
1079
- sessionId: who.sessionId,
1296
+ owners: new Set([who.sessionId]),
1080
1297
  ...(cleanRequests(requests) ? { requests: cleanRequests(requests) } : {}),
1081
1298
  });
1082
1299
  }
1300
+ makeRoom(made.length);
1083
1301
  for (const booking of made)
1084
1302
  bookings.set(booking.id, booking);
1085
- return made.map((booking) => ({ ...booking }));
1303
+ return made.map(publicBooking);
1086
1304
  },
1087
1305
  async listBookings(who, limit = 10) {
1088
1306
  sweep();
1307
+ // A negative limit reached `slice(0, -2)` and returned all but two.
1308
+ const most = Math.max(1, Math.min(MAX_PAGE, Math.floor(Number(limit) > 0 ? limit : 10)));
1089
1309
  return [...bookings.values()]
1090
- .filter((b) => b.sessionId === who.sessionId)
1310
+ .filter((b) => b.owners.has(who.sessionId))
1091
1311
  .sort((a, b) => b.createdAt.localeCompare(a.createdAt))
1092
- .slice(0, limit)
1093
- .map((b) => ({ ...b }));
1312
+ .slice(0, most)
1313
+ .map(publicBooking);
1094
1314
  },
1095
1315
  async findBooking(ref, who) {
1096
1316
  sweep();
1097
1317
  const b = findBooking(ref, who);
1098
- return b ? { ...b } : null;
1318
+ return b ? publicBooking(b) : null;
1099
1319
  },
1100
1320
  /**
1101
1321
  * What changing the nights or the room count would do.
@@ -1129,7 +1349,7 @@ export const createDemoHotels = (opts) => {
1129
1349
  changedAt: new Date(now()).toISOString(),
1130
1350
  };
1131
1351
  bookings.set(b.id, updated);
1132
- return { ...updated };
1352
+ return publicBooking(updated);
1133
1353
  },
1134
1354
  async previewCancellation(bookingRef, who) {
1135
1355
  sweep();
@@ -1158,13 +1378,11 @@ export const createDemoHotels = (opts) => {
1158
1378
  },
1159
1379
  async cancelBooking(bookingRef, who, reason) {
1160
1380
  sweep();
1161
- const wanted = bookingRef.trim().toLowerCase();
1162
- const b = [...bookings.values()].find((x) => (x.id.toLowerCase() === wanted || x.confirmationCode.toLowerCase() === wanted) &&
1163
- mine(x, who));
1381
+ const b = findBooking(bookingRef, who);
1164
1382
  if (!b)
1165
1383
  return null;
1166
1384
  if (b.status !== 'requested' && b.status !== 'confirmed')
1167
- return { ...b };
1385
+ return publicBooking(b);
1168
1386
  const { fee } = feeFor(b, now());
1169
1387
  b.status = 'cancelled';
1170
1388
  b.cancelledAt = new Date(now()).toISOString();
@@ -1174,7 +1392,7 @@ export const createDemoHotels = (opts) => {
1174
1392
  ...b.guest,
1175
1393
  notes: [b.guest.notes, `Cancelled: ${reason}`].filter(Boolean).join(' | '),
1176
1394
  };
1177
- return { ...b };
1395
+ return publicBooking(b);
1178
1396
  },
1179
1397
  async policies(hotelId) {
1180
1398
  const h = byId(hotelId);