@curless/hotel-storefront 0.26.2 → 0.27.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.
Files changed (90) hide show
  1. package/dist/__tests__/audit-p0.test.d.ts +2 -0
  2. package/dist/__tests__/audit-p0.test.d.ts.map +1 -0
  3. package/dist/__tests__/audit-p0.test.js +274 -0
  4. package/dist/__tests__/audit-p0.test.js.map +1 -0
  5. package/dist/__tests__/card-contract.test.js +15 -0
  6. package/dist/__tests__/card-contract.test.js.map +1 -1
  7. package/dist/__tests__/demo-hotels.test.js +2 -1
  8. package/dist/__tests__/demo-hotels.test.js.map +1 -1
  9. package/dist/__tests__/huazhu-audit.test.js +19 -0
  10. package/dist/__tests__/huazhu-audit.test.js.map +1 -1
  11. package/dist/__tests__/huazhu-backend.test.js +510 -14
  12. package/dist/__tests__/huazhu-backend.test.js.map +1 -1
  13. package/dist/__tests__/huazhu-sandbox.test.js +1 -1
  14. package/dist/__tests__/huazhu-sandbox.test.js.map +1 -1
  15. package/dist/__tests__/server.test.js +1 -1
  16. package/dist/__tests__/server.test.js.map +1 -1
  17. package/dist/__tests__/tools.test.js +13 -7
  18. package/dist/__tests__/tools.test.js.map +1 -1
  19. package/dist/backend.d.ts +30 -0
  20. package/dist/backend.d.ts.map +1 -1
  21. package/dist/backend.js +37 -0
  22. package/dist/backend.js.map +1 -1
  23. package/dist/card.generated.d.ts +1 -1
  24. package/dist/card.generated.js +2 -2
  25. package/dist/card.generated.js.map +1 -1
  26. package/dist/demo-hotels.d.ts +14 -0
  27. package/dist/demo-hotels.d.ts.map +1 -1
  28. package/dist/demo-hotels.js +100 -12
  29. package/dist/demo-hotels.js.map +1 -1
  30. package/dist/huazhu/backend.d.ts +11 -2
  31. package/dist/huazhu/backend.d.ts.map +1 -1
  32. package/dist/huazhu/backend.js +524 -271
  33. package/dist/huazhu/backend.js.map +1 -1
  34. package/dist/huazhu/bookings.d.ts +119 -9
  35. package/dist/huazhu/bookings.d.ts.map +1 -1
  36. package/dist/huazhu/bookings.js +355 -66
  37. package/dist/huazhu/bookings.js.map +1 -1
  38. package/dist/huazhu/catalog.d.ts.map +1 -1
  39. package/dist/huazhu/catalog.js +11 -1
  40. package/dist/huazhu/catalog.js.map +1 -1
  41. package/dist/huazhu/check.d.ts.map +1 -1
  42. package/dist/huazhu/check.js +19 -4
  43. package/dist/huazhu/check.js.map +1 -1
  44. package/dist/huazhu/client.d.ts +26 -0
  45. package/dist/huazhu/client.d.ts.map +1 -1
  46. package/dist/huazhu/client.js +77 -2
  47. package/dist/huazhu/client.js.map +1 -1
  48. package/dist/huazhu/fake.d.ts +12 -2
  49. package/dist/huazhu/fake.d.ts.map +1 -1
  50. package/dist/huazhu/fake.js +17 -4
  51. package/dist/huazhu/fake.js.map +1 -1
  52. package/dist/huazhu/origin.d.ts +29 -0
  53. package/dist/huazhu/origin.d.ts.map +1 -0
  54. package/dist/huazhu/origin.js +69 -0
  55. package/dist/huazhu/origin.js.map +1 -0
  56. package/dist/huazhu/sandbox.d.ts.map +1 -1
  57. package/dist/huazhu/sandbox.js +5 -1
  58. package/dist/huazhu/sandbox.js.map +1 -1
  59. package/dist/huazhu/sellable.d.ts +6 -1
  60. package/dist/huazhu/sellable.d.ts.map +1 -1
  61. package/dist/huazhu/sellable.js +14 -3
  62. package/dist/huazhu/sellable.js.map +1 -1
  63. package/dist/huazhu/wire.d.ts +28 -4
  64. package/dist/huazhu/wire.d.ts.map +1 -1
  65. package/dist/huazhu/wire.js +42 -3
  66. package/dist/huazhu/wire.js.map +1 -1
  67. package/dist/index.js +13 -5
  68. package/dist/index.js.map +1 -1
  69. package/dist/limits.d.ts +10 -0
  70. package/dist/limits.d.ts.map +1 -1
  71. package/dist/limits.js +25 -2
  72. package/dist/limits.js.map +1 -1
  73. package/dist/server.d.ts +25 -8
  74. package/dist/server.d.ts.map +1 -1
  75. package/dist/server.js +114 -9
  76. package/dist/server.js.map +1 -1
  77. package/dist/sessions.d.ts +31 -4
  78. package/dist/sessions.d.ts.map +1 -1
  79. package/dist/sessions.js +66 -15
  80. package/dist/sessions.js.map +1 -1
  81. package/dist/state.d.ts +16 -0
  82. package/dist/state.d.ts.map +1 -1
  83. package/dist/state.js +33 -3
  84. package/dist/state.js.map +1 -1
  85. package/dist/tools.d.ts.map +1 -1
  86. package/dist/tools.js +63 -18
  87. package/dist/tools.js.map +1 -1
  88. package/dist/types.d.ts +16 -1
  89. package/dist/types.d.ts.map +1 -1
  90. package/package.json +9 -11
@@ -1,16 +1,17 @@
1
1
  import { randomBytes, randomUUID } from 'node:crypto';
2
- import { DEFAULT_PAGE, MAX_PAGE, REQUEST_KINDS, REQUEST_NOTE_MAX, } from '../backend.js';
2
+ import { DEFAULT_PAGE, MAX_PAGE, REQUEST_KINDS, REQUEST_NOTE_MAX, guestTooLong, partyNeeds, } from '../backend.js';
3
3
  import { addDays, atHotelTime, nights as nightsBetween, stayProblem, todayAt } from '../dates.js';
4
4
  import { byWords, splitByStay } from '../demo-hotels.js';
5
5
  import { FACILITIES, hasFacility } from '../facilities.js';
6
6
  import { BOOKING_LIMITS, DAY_MS, HOUR_MS, WindowCounter } from '../limits.js';
7
7
  import { formatMoney, minorFactor, money, multiplyMoney, toMinor } from '../money.js';
8
8
  import { MAX_ROOMS_PER_LINE } from '../trip.js';
9
- import { statusOf } from './bookings.js';
9
+ import { IN_REQUEST_AT_MS, StoreWriteError, nextCheckAt, statusOf, } from './bookings.js';
10
10
  import { describePolicy, feeFor, freeUntilOf, nightAuditOf, parseCancelPolicy, penaltyAt, refundableFrom, } from './cancel-policy.js';
11
11
  import { haystackOf, inPlace, roomPhoto, toHotel } from './catalog.js';
12
12
  import { HuazhuRefusal, HuazhuUnreachable } from './client.js';
13
13
  import { ariRanges, bookableNow, clockAt, offeredAtAll, sell, } from './sellable.js';
14
+ import { BOOK_EXISTS, BOOK_REFUSALS, CODES, } from './wire.js';
14
15
  export const HUAZHU_CAPABILITIES = {
15
16
  members: false,
16
17
  extras: false,
@@ -29,6 +30,26 @@ export const isPhone = (raw) => {
29
30
  const p = (raw ?? '').trim();
30
31
  return /^\+?[\d\s()-]+$/.test(p) && p.replace(/\D/g, '').length >= 6;
31
32
  };
33
+ /**
34
+ * The guest's code for a booking: the connector's own, random, and the same
35
+ * from the moment it is asked for to the day it is forgotten.
36
+ *
37
+ * ⚠️ NEVER HUAZHU'S ORDER NUMBER. Huazhu numbers its orders in sequence
38
+ * (R + hotel + counter, on the sandbox 44 seconds apart: R10011014,
39
+ * R10021015): a code next to someone's gives their booking away to anyone
40
+ * who knows their email, and the count of everyone's (2026-10-05 audit, P1-6).
41
+ * 32 letters and digits that cannot be misread, eight of them: 2^40.
42
+ */
43
+ const CODE_LETTERS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
44
+ export const newPublicCode = (taken) => {
45
+ for (;;) {
46
+ const code = `HZ-${[...randomBytes(8)].map((b) => CODE_LETTERS[b % 32]).join('')}`;
47
+ if (!taken(code))
48
+ return code;
49
+ }
50
+ };
51
+ /** The same trip asked for again this soon, by the same conversation and email, is answered with the first. */
52
+ const SAME_TRIP_MS = 10 * 60_000;
32
53
  /**
33
54
  * The most hotels priced for one search. A city can hold hundreds of Huazhu
34
55
  * hotels, and every one priced is two calls to Huazhu; past this, the page is
@@ -156,8 +177,53 @@ export const createHuazhuHotels = (opts) => {
156
177
  const limits = { ...BOOKING_LIMITS, ...(opts.limits ?? {}) };
157
178
  const requestsByEmail = new WindowCounter(DAY_MS, now);
158
179
  const requestsByIp = new WindowCounter(HOUR_MS, now);
159
- /** When the deployment's daily ceiling last turned a booking away - a person is asked for for a day after. */
160
- let ceilingHitAt;
180
+ /**
181
+ * Bookings a request has passed the limits for and not yet written down.
182
+ *
183
+ * ⚠️ TAKEN THE MOMENT THE CHECK PASSES. The rows are written after prebook,
184
+ * seconds later - and five requests at once all passed a limit of three
185
+ * before any was written (2026-10-05 audit, P1-5).
186
+ */
187
+ const slots = new Set();
188
+ const slotsWhere = (pred) => [...slots].filter(pred).reduce((a, x) => a + x.n, 0);
189
+ /** Requests in flight, by conversation and by email - see `single`. */
190
+ const flights = new Map();
191
+ /** Set by `drain`: a stopping process starts no booking. */
192
+ let closing = false;
193
+ /**
194
+ * One booking request at a time per conversation and per email.
195
+ *
196
+ * ⚠️ TWO AT ONCE BOOKED TWICE. The trip is emptied only when a request
197
+ * succeeds, and every request has its own distributorResId: a guest who
198
+ * pressed twice, or a host that retried, had two real bookings (2026-10-05
199
+ * audit, P1-2). The second waits for the first, then finds its trip
200
+ * already booked.
201
+ */
202
+ const single = async (keys, run) => {
203
+ for (;;) {
204
+ const busy = keys.flatMap((k) => {
205
+ const p = flights.get(k);
206
+ return p ? [p] : [];
207
+ });
208
+ if (busy.length === 0)
209
+ break;
210
+ await Promise.allSettled(busy);
211
+ }
212
+ // Nothing awaited between the check above and these lines: no other
213
+ // request can slip in.
214
+ const p = run();
215
+ const done = p.then(() => undefined, () => undefined);
216
+ for (const k of keys)
217
+ flights.set(k, done);
218
+ try {
219
+ return await p;
220
+ }
221
+ finally {
222
+ for (const k of keys)
223
+ if (flights.get(k) === done)
224
+ flights.delete(k);
225
+ }
226
+ };
161
227
  // ------------------------------------------------------------- lookups ----
162
228
  /**
163
229
  * Every hotel, as the connector shows it - worked out again only when the
@@ -342,7 +408,7 @@ export const createHuazhuHotels = (opts) => {
342
408
  const only = (k) => kinds.size === 1 && kinds.has(k);
343
409
  if (only('party')) {
344
410
  const largest = Math.max(...fails.map((f) => Number(/\d+/.exec(f.why)?.[0] ?? 0)));
345
- return `No room here sleeps ${stay.guests}; the largest sleeps ${largest}, so two rooms would work.`;
411
+ return partyNeeds(stay.guests, largest);
346
412
  }
347
413
  if (only('prepaid'))
348
414
  return 'Every rate here for those nights is paid in advance, and this connector books rates paid at the hotel.';
@@ -505,24 +571,31 @@ export const createHuazhuHotels = (opts) => {
505
571
  ...(b.cancellationFee ? { cancellationFee: b.cancellationFee } : {}),
506
572
  ...(b.requests ? { requests: b.requests } : {}),
507
573
  });
574
+ /**
575
+ * The codes a guest may have been given for a booking: ours, and the
576
+ * distributorResId a booking still waiting on Huazhu showed before 0.27.
577
+ * ⚠️ NEVER Huazhu's order number - see `newPublicCode`. A booking made
578
+ * before 0.27 showed it as its code, and is opened by it only in the
579
+ * conversation that made it.
580
+ */
581
+ const codesOf = (b) => [b.confirmationCode !== b.resId ? b.confirmationCode : undefined, b.distributorResId]
582
+ .filter((x) => !!x)
583
+ .map((x) => x.toLowerCase());
508
584
  const findOwn = (ref, who) => {
509
585
  const wanted = ref.trim().toLowerCase();
510
586
  if (!wanted)
511
587
  return undefined;
512
- const matches = (b) => [b.id, b.confirmationCode, b.resId, b.distributorResId]
513
- .filter(Boolean)
514
- .some((x) => String(x).toLowerCase() === wanted);
515
- const own = store.all().find((b) => b.owners.includes(who.sessionId) && matches(b));
588
+ const own = store
589
+ .all()
590
+ .find((b) => b.owners.includes(who.sessionId) &&
591
+ [b.id.toLowerCase(), b.confirmationCode.toLowerCase(), ...codesOf(b)].includes(wanted));
516
592
  if (own || !who.email)
517
593
  return own;
518
594
  // ⚠️ A CODE AND ITS EMAIL, never an id and never an email alone - see the seam.
519
595
  const email = who.email.trim().toLowerCase();
520
596
  const there = store
521
597
  .all()
522
- .find((b) => [b.confirmationCode, b.resId]
523
- .filter(Boolean)
524
- .some((x) => String(x).toLowerCase() === wanted) &&
525
- b.guest.email.trim().toLowerCase() === email);
598
+ .find((b) => codesOf(b).includes(wanted) && b.guest.email.trim().toLowerCase() === email);
526
599
  if (there) {
527
600
  const owners = [...new Set([...there.owners, who.sessionId])];
528
601
  // The conversation that made it stays; the oldest finder goes.
@@ -557,6 +630,10 @@ export const createHuazhuHotels = (opts) => {
557
630
  return b;
558
631
  }
559
632
  };
633
+ /** Why a booking cannot be cancelled yet: Huazhu has not said whether it exists. */
634
+ const waitingWords = (b) => b.status === 'unconfirmed'
635
+ ? `${opts.sandbox ? 'The simulated Huazhu' : 'Huazhu'} has not said whether this booking was made, and a person here is confirming it. It cannot be cancelled here until then - ask again later.`
636
+ : `${opts.sandbox ? 'The simulated Huazhu' : 'Huazhu'} has not confirmed this booking yet - that takes up to eight minutes. It can be cancelled once it is confirmed.`;
560
637
  const feeNow = (b) => {
561
638
  const policy = policyOf(b.cancelCode);
562
639
  const amounts = {
@@ -582,18 +659,380 @@ export const createHuazhuHotels = (opts) => {
582
659
  freeUntil: freeUntilOf(policy, b.stay.checkIn, b.tz, now()),
583
660
  };
584
661
  };
585
- /** Chase one unanswered booking call for a little while, inside the guest's request. */
586
- const settle = async (row) => {
662
+ /**
663
+ * Chase one unanswered booking call inside the guest's request - the first
664
+ * of its checks (`IN_REQUEST_AT_MS`), on the reconciler's own schedule; the
665
+ * rest are the reconciler's, spread over the eight minutes.
666
+ */
667
+ const settle = async (id) => {
587
668
  const until = now() + (opts.settleWithinMs ?? 25_000);
588
- let current = row;
589
- while (current.pendingSince !== undefined && now() < until) {
590
- await sleep(5_000);
591
- await reconciler.check(current);
592
- current = store.get(row.id) ?? current;
669
+ let row = store.get(id);
670
+ while (row?.pendingSince !== undefined && (row.checks ?? 0) < IN_REQUEST_AT_MS.length) {
671
+ const due = nextCheckAt(row);
672
+ if (due > until)
673
+ break;
674
+ if (due > now())
675
+ await sleep(due - now());
676
+ await reconciler.check(row);
677
+ row = store.get(id);
593
678
  }
594
- return current;
679
+ return row;
595
680
  };
596
681
  const distributorResId = () => `CUR${now().toString(36).toUpperCase()}${randomBytes(6).toString('hex').toUpperCase()}`;
682
+ /**
683
+ * The conversation a booking is asked for in: the trip's owner when the
684
+ * card asks on a connection of its own, else the caller - what "this
685
+ * conversation's bookings" counts and what the same trip asked twice is
686
+ * matched by.
687
+ */
688
+ const conversationOf = (who) => who.tripOwner ?? who.sessionId;
689
+ /** A name for a booking in what the guest is told. */
690
+ const named = (b) => `${b.hotelName} (${b.confirmationCode})`;
691
+ const isLive = (b) => (b.status === 'confirmed' || b.status === 'requested' || b.status === 'unconfirmed') &&
692
+ !b.rollback &&
693
+ b.stay.checkOut >= today();
694
+ /** The booking itself, one request at a time per conversation and email - see `single`. */
695
+ const book = async (lines, tripKey, email, guest, contact, who, requests) => {
696
+ // ⚠️ THE SAME TRIP ASKED FOR AGAIN IS ANSWERED WITH THE FIRST. A second
697
+ // press, or a host retrying a call whose answer it lost, waited for the
698
+ // first request (`single`) and finds it here - rather than booking every
699
+ // stay a second time.
700
+ const earlier = store
701
+ .all()
702
+ .filter((b) => b.tripKey === tripKey &&
703
+ b.owners.includes(conversationOf(who)) &&
704
+ b.guest.email.trim().toLowerCase() === email &&
705
+ now() - Date.parse(b.createdAt) < SAME_TRIP_MS &&
706
+ isLive(b));
707
+ const ref = earlier[0]?.tripRef;
708
+ if (ref &&
709
+ earlier.length === lines.length &&
710
+ earlier.every((b) => b.tripRef === ref) &&
711
+ (store.intent(ref)?.done ?? true)) {
712
+ return earlier.map(publicBooking);
713
+ }
714
+ // ⚠️ THE LIMITS - see `BookingLimits`. Each is said as what it is, and
715
+ // none suggests a way round itself (another email, another chat).
716
+ const plural = (n, one) => `${n} ${one}${n === 1 ? '' : 's'}`;
717
+ const liveHere = store.all().filter((b) => b.owners.includes(conversationOf(who)) && isLive(b)).length +
718
+ slotsWhere((x) => x.sessionId === conversationOf(who));
719
+ if (liveHere + lines.length > limits.perConversationLive) {
720
+ throw new Error(`Nothing was booked. This conversation already has ${plural(liveHere, 'booking')} still to come, and ${limits.perConversationLive} at once is the most - cancel one that is not needed, then ask again.`);
721
+ }
722
+ const liveForEmail = store.all().filter((b) => b.guest.email.trim().toLowerCase() === email && isLive(b)).length +
723
+ slotsWhere((x) => x.email === email);
724
+ if (liveForEmail + lines.length > limits.perEmailLive) {
725
+ throw new Error(`Nothing was booked. ${guest.email.trim()} already has ${plural(liveForEmail, 'booking')} still to come through this connector, and ${limits.perEmailLive} at once is the most - cancel one that is not needed, then ask again.`);
726
+ }
727
+ if (requestsByEmail.count(email) >= limits.perEmailPerDay) {
728
+ throw new Error(`Nothing was booked. ${guest.email.trim()} has asked for ${plural(limits.perEmailPerDay, 'booking')} in the last 24 hours, which is the most here - try again later.`);
729
+ }
730
+ if (who.ip && requestsByIp.count(who.ip) >= limits.perIpPerHour) {
731
+ throw new Error('Nothing was booked. Too many bookings have been asked for from this network in the last hour - try again later.');
732
+ }
733
+ const madeToday = store
734
+ .all()
735
+ .filter((b) => now() - Date.parse(b.createdAt) < DAY_MS &&
736
+ (b.status === 'confirmed' ||
737
+ b.status === 'requested' ||
738
+ b.status === 'unconfirmed' ||
739
+ b.status === 'cancelled')).length + slotsWhere(() => true);
740
+ if (madeToday + lines.length > limits.perDeploymentPerDay) {
741
+ // ⚠️ A CEILING HIT IS SOMETHING A PERSON SHOULD KNOW: either business
742
+ // is booming or somebody is filling hotels with names. Logged, and on
743
+ // /healthz/detail as needsAPerson for a day - across a restart.
744
+ const last = store.ceilingHitAt;
745
+ if (last === undefined || now() - last > HOUR_MS) {
746
+ console.error(`[huazhu bookings] ⚠️ NEEDS A PERSON: ${madeToday} bookings in the last 24 hours reached the ceiling of ${limits.perDeploymentPerDay}; new bookings are refused until the count falls`);
747
+ }
748
+ store.ceilingHitAt = now();
749
+ throw new Error(`Nothing was booked: bookings through this connector are paused for now. Try again later, or book with ${opts.brand} directly.`);
750
+ }
751
+ // The network counted once it has passed every limit - a request turned
752
+ // away is not one more against the guest, or a guest at a limit could
753
+ // never leave it. The email is counted when a booking is asked of Huazhu
754
+ // (`bookHeld`): a price that moved is not one of the guest's five.
755
+ if (who.ip)
756
+ requestsByIp.add(who.ip);
757
+ // ⚠️ AND ITS PLACES TAKEN NOW, before anything is awaited - see `slots`.
758
+ const slot = { sessionId: conversationOf(who), email, n: lines.length };
759
+ slots.add(slot);
760
+ try {
761
+ return await bookHeld(lines, tripKey, email, guest, contact, who, requests, slot);
762
+ }
763
+ finally {
764
+ slots.delete(slot);
765
+ }
766
+ };
767
+ const bookHeld = async (lines, tripKey, email, guest, contact, who, requests, slot) => {
768
+ // Priced afresh, not from the cache: this is the price that will be sent.
769
+ const priced = await quoteLines({ lines }, true);
770
+ if (priced.dropped?.length || priced.sold.size !== lines.length) {
771
+ throw new Error(`Nothing was booked. ${(priced.dropped ?? []).map((d) => `${d.title}: ${d.why}`).join(' ')} The trip is as it was - take that out or change it, then ask again.`);
772
+ }
773
+ const clean = cleanRequests(requests);
774
+ const comment = commentOf(clean, guest.notes);
775
+ const plans = [];
776
+ for (const [i, line] of lines.entries()) {
777
+ const sold = priced.sold.get(i);
778
+ const qline = priced.lines[i];
779
+ if (line.kind !== 'room' || !sold || !qline)
780
+ throw new Error('Nothing was booked: the trip changed while it was being priced.');
781
+ plans.push({ i, line, sold, qline });
782
+ }
783
+ for (const plan of plans) {
784
+ const s = plan.sold.res;
785
+ const title = `${plan.qline.hotelName} - ${plan.qline.title}`;
786
+ try {
787
+ const out = await client.prebook({
788
+ hotelId: plan.line.hotelId,
789
+ checkinType: 'DAY',
790
+ paymentType: opts.paymentType,
791
+ reservations: [
792
+ {
793
+ roomTypeCode: plan.sold.product.roomTypeCode,
794
+ rateCode: plan.sold.product.rateCode,
795
+ checkin: plan.line.stay.checkIn,
796
+ checkout: plan.line.stay.checkOut,
797
+ amountAfterTax: s.after,
798
+ ...(s.before ? { amountBeforeTax: s.before } : {}),
799
+ currencyCode: s.currency,
800
+ roomCount: plan.qline.quantity ?? 1,
801
+ adultCount: plan.line.stay.guests,
802
+ },
803
+ ],
804
+ });
805
+ plan.token = out?.preBookToken;
806
+ }
807
+ catch (err) {
808
+ if (err instanceof HuazhuRefusal)
809
+ throw new Error(`Nothing was booked. ${refusalWords(err, title, s.currency)}`);
810
+ throw new Error(`Nothing was booked: Huazhu could not be reached to check ${title} (${err.message}). Asking again is safe.`);
811
+ }
812
+ }
813
+ requestsByEmail.add(email);
814
+ const tripRef = randomUUID();
815
+ const made = [];
816
+ /**
817
+ * Undo what this trip booked, when a later stay of it failed - and say
818
+ * what actually became of each, not what was meant to (2026-10-05 audit,
819
+ * e7: "flagged for a person" of a stay nobody had flagged).
820
+ */
821
+ const takeBackAll = async () => {
822
+ const waiting = [];
823
+ const charged = [];
824
+ const flagged = [];
825
+ const retrying = [];
826
+ for (const b of made) {
827
+ const row = store.update(b.id, { rollback: true });
828
+ if (!row || row.status === 'failed' || row.status === 'cancelled')
829
+ continue;
830
+ if (row.pendingSince !== undefined || row.status !== 'confirmed') {
831
+ // The reconciler cancels it if it turns up.
832
+ waiting.push(named(row));
833
+ continue;
834
+ }
835
+ await reconciler.takeBack(row);
836
+ const after = store.get(b.id) ?? row;
837
+ if (after.status === 'cancelled') {
838
+ if (after.cancellationFee && after.cancellationFee.minor > 0)
839
+ charged.push(`${named(after)}: ${formatMoney(after.cancellationFee)}`);
840
+ }
841
+ else if (after.attention && !after.resolvedAt)
842
+ flagged.push(named(after));
843
+ else
844
+ retrying.push(named(after));
845
+ }
846
+ const and = (xs) => xs.join(' and ');
847
+ return [
848
+ charged.length
849
+ ? ` Cancelling again cost a fee under the rate's terms - ${charged.join('; ')}. A person here has been told and will follow it up.`
850
+ : '',
851
+ flagged.length
852
+ ? ` ${and(flagged)} could not be cancelled automatically and has been flagged for a person to cancel with the hotel.`
853
+ : '',
854
+ retrying.length
855
+ ? ` ${and(retrying)} could not be cancelled yet: it is tried again automatically, and a person is told if that keeps failing.`
856
+ : '',
857
+ waiting.length
858
+ ? ` Whether ${and(waiting)} was booked is not known yet; if it turns up in the next few minutes it is cancelled automatically.`
859
+ : '',
860
+ ].join('');
861
+ };
862
+ const notWritten = async (title) => {
863
+ const back = await takeBackAll();
864
+ throw new Error(`Nothing was booked: ${title} could not be written down here before asking Huazhu, so it was not asked for. A person has been told.${back}`);
865
+ };
866
+ // ⚠️ THE REQUEST, WRITTEN DOWN BEFORE ITS FIRST STAY IS BOOKED. A
867
+ // process that dies half way through a trip leaves this open, and the
868
+ // next one takes back what it booked (`Reconciler.recover`).
869
+ try {
870
+ store.open({
871
+ tripRef,
872
+ key: tripKey,
873
+ sessionId: conversationOf(who),
874
+ email,
875
+ stays: plans.length,
876
+ startedAt: now(),
877
+ });
878
+ }
879
+ catch (err) {
880
+ if (err instanceof StoreWriteError)
881
+ return notWritten('the trip');
882
+ throw err;
883
+ }
884
+ try {
885
+ for (const plan of plans) {
886
+ const s = plan.sold.res;
887
+ const rooms = plan.qline.quantity ?? 1;
888
+ const qTotal = plan.qline.total.minor;
889
+ const nightlyMinor = s.after.map((x) => toMinor(x, s.currency));
890
+ const allIn = nightlyMinor.reduce((a, b) => a + b, 0) * rooms;
891
+ const cur = s.currency;
892
+ const title = `${plan.qline.hotelName} - ${plan.qline.title}`;
893
+ const id = `hz_${randomUUID()}`;
894
+ const dres = distributorResId();
895
+ const row = {
896
+ id,
897
+ confirmationCode: newPublicCode((c) => store.all().some((b) => b.confirmationCode === c)),
898
+ status: 'requested',
899
+ createdAt: new Date(now()).toISOString(),
900
+ hotelId: plan.line.hotelId,
901
+ hotelName: plan.qline.hotelName,
902
+ stay: plan.line.stay,
903
+ guest,
904
+ quote: {
905
+ lines: [plan.qline],
906
+ subtotal: money(qTotal, cur),
907
+ taxesAndFees: money(allIn - qTotal, cur),
908
+ total: money(allIn, cur),
909
+ stay: plan.line.stay,
910
+ },
911
+ ...(clean ? { requests: clean } : {}),
912
+ // The caller, and the conversation whose trip it was - see `Identity.tripOwner`.
913
+ owners: [...new Set([who.sessionId, conversationOf(who)])],
914
+ distributorResId: dres,
915
+ tripRef,
916
+ tripKey,
917
+ paymentType: opts.paymentType,
918
+ guaranteeCode: s.guaranteeCode,
919
+ cancelCode: s.cancelCode,
920
+ nightlyMinor,
921
+ rooms,
922
+ tz: opts.tz,
923
+ roomTypeCode: plan.sold.product.roomTypeCode,
924
+ rateCode: plan.sold.product.rateCode,
925
+ // ⚠️ SET BEFORE THE CALL: a process that dies mid-call leaves a
926
+ // record the reconciler follows up on restart.
927
+ pendingSince: now(),
928
+ };
929
+ // ⚠️ NOT WRITTEN DOWN, NOT SENT. A booking Huazhu made that nobody
930
+ // here knows of is one nobody can find, follow up or cancel - and a
931
+ // write that failed used to skip taking back the trip's earlier stays
932
+ // too (2026-10-05 audit, P1-8).
933
+ try {
934
+ store.put(row);
935
+ }
936
+ catch (err) {
937
+ if (err instanceof StoreWriteError)
938
+ return notWritten(title);
939
+ throw err;
940
+ }
941
+ made.push(row);
942
+ slot.n -= 1;
943
+ const reservation = {
944
+ roomTypeCode: plan.sold.product.roomTypeCode,
945
+ rateCode: plan.sold.product.rateCode,
946
+ checkin: plan.line.stay.checkIn,
947
+ checkout: plan.line.stay.checkOut,
948
+ amountAfterTax: s.after,
949
+ ...(s.before ? { amountBeforeTax: s.before } : {}),
950
+ ...(s.rack ? { rackRate: s.rack } : {}),
951
+ currencyCode: cur,
952
+ roomCount: rooms,
953
+ adultCount: plan.line.stay.guests,
954
+ guaranteePolicyCode: s.guaranteeCode,
955
+ ...(clean?.arrivingAt ? { estimatedArrivalTime: clean.arrivingAt } : {}),
956
+ resGuest: [contact],
957
+ };
958
+ const request = {
959
+ hotelId: plan.line.hotelId,
960
+ checkinType: 'DAY',
961
+ contactPerson: contact,
962
+ distributorResId: dres,
963
+ paymentType: opts.paymentType,
964
+ reservations: [reservation],
965
+ ...(comment ? { guestComment: comment } : {}),
966
+ ...(plan.token ? { preBookToken: plan.token } : {}),
967
+ };
968
+ // ⚠️ ONLY HUAZHU'S OWN "NO" MEANS "NOT BOOKED" - a code
969
+ // /reservation/book documents as one (`BOOK_REFUSALS`). Anything else
970
+ // - no answer, an answer that did not parse, a code nobody
971
+ // documented, a "yes" without an order number - means it may exist,
972
+ // and is followed up by the consistency query, never by sending the
973
+ // booking again.
974
+ let refusal;
975
+ let answer;
976
+ for (let attempt = 0; attempt < 2; attempt += 1) {
977
+ try {
978
+ const got = await client.book(request);
979
+ if (got && typeof got.resId === 'string' && got.resId)
980
+ answer = got;
981
+ }
982
+ catch (err) {
983
+ if (err instanceof HuazhuRefusal && err.code === CODES.systemError && attempt === 0) {
984
+ // The documentation's own advice: retry a system error, same id.
985
+ await sleep(1_000);
986
+ continue;
987
+ }
988
+ if (err instanceof HuazhuRefusal &&
989
+ BOOK_REFUSALS.has(err.code) &&
990
+ !BOOK_EXISTS.has(err.code))
991
+ refusal = err;
992
+ }
993
+ break;
994
+ }
995
+ if (answer) {
996
+ // Outside the try: a failure to WRITE this down is not Huazhu
997
+ // refusing it, and must not be recorded as one.
998
+ store.update(id, {
999
+ resId: answer.resId,
1000
+ ...(answer.resCode ? { resCode: answer.resCode } : {}),
1001
+ status: 'confirmed',
1002
+ hotelStatus: 'HOTEL_RESERVATION',
1003
+ pendingSince: undefined,
1004
+ });
1005
+ continue;
1006
+ }
1007
+ if (refusal) {
1008
+ // Never made at Huazhu: nothing to follow up, nothing to show.
1009
+ store.update(id, { pendingSince: undefined, status: 'failed' });
1010
+ made.pop();
1011
+ const before = made.length;
1012
+ const back = await takeBackAll();
1013
+ throw new Error(`Nothing was booked. ${refusalWords(refusal, title, cur)}${before && !back ? ' The stays booked before it in this trip were cancelled again.' : ''}${back}`);
1014
+ }
1015
+ // Unknown: the eight minutes run from the moment that was known.
1016
+ store.update(id, { pendingSince: now() });
1017
+ const settled = await settle(id);
1018
+ if (settled?.status === 'confirmed' && settled.pendingSince === undefined)
1019
+ continue;
1020
+ if (plans.length > 1 || settled?.status === 'failed') {
1021
+ const back = await takeBackAll();
1022
+ if (settled?.status === 'failed') {
1023
+ throw new Error(`Nothing was booked: Huazhu did not confirm ${plan.qline.hotelName}.${back}`);
1024
+ }
1025
+ throw new Error(`Nothing was booked: Huazhu did not answer in time for ${plan.qline.hotelName}, so the trip was stopped.${back}`);
1026
+ }
1027
+ // One stay, outcome still unknown: it is the guest's booking, marked
1028
+ // as waiting, and the reconciler settles it within eight minutes.
1029
+ }
1030
+ }
1031
+ finally {
1032
+ store.close(tripRef);
1033
+ }
1034
+ return made.map((b) => publicBooking(store.get(b.id) ?? b));
1035
+ };
597
1036
  return {
598
1037
  brand: opts.brand,
599
1038
  capabilities: HUAZHU_CAPABILITIES,
@@ -616,17 +1055,49 @@ export const createHuazhuHotels = (opts) => {
616
1055
  },
617
1056
  health() {
618
1057
  const rows = store.all();
1058
+ const open = store.unresolved();
1059
+ const ceilingHitAt = store.ceilingHitAt;
1060
+ const ceiling = ceilingHitAt !== undefined && now() - ceilingHitAt < DAY_MS;
619
1061
  return {
620
1062
  catalog: catalog.state(),
621
1063
  bookings: rows.length,
622
1064
  pending: rows.filter((r) => r.pendingSince !== undefined).length,
623
- needsAPerson: rows.filter((r) => r.attention).length +
624
- (ceilingHitAt !== undefined && now() - ceilingHitAt < DAY_MS ? 1 : 0),
625
- ...(ceilingHitAt !== undefined && now() - ceilingHitAt < DAY_MS
626
- ? { ceilingHitAt: new Date(ceilingHitAt).toISOString() }
1065
+ // ⚠️ ONLY WHAT IS STILL OPEN. A booking a person has dealt with is
1066
+ // marked resolved, and stops counting (2026-10-05 audit, P1-9).
1067
+ needsAPerson: open.length +
1068
+ (ceiling ? 1 : 0) +
1069
+ (store.writeFailing ? 1 : 0) +
1070
+ (client.misconfigured ? 1 : 0),
1071
+ ...(open.length
1072
+ ? {
1073
+ attention: open.slice(0, 20).map((r) => ({
1074
+ id: r.id,
1075
+ since: r.attentionAt,
1076
+ why: r.attention,
1077
+ })),
1078
+ }
627
1079
  : {}),
1080
+ ...(ceiling ? { ceilingHitAt: new Date(ceilingHitAt).toISOString() } : {}),
1081
+ ...(store.writeFailing ? { writeFailing: store.writeFailing } : {}),
1082
+ ...(client.misconfigured ? { misconfigured: client.misconfigured } : {}),
628
1083
  };
629
1084
  },
1085
+ /** A person dealt with a booking that needed one - see `BookingStore.resolve`. */
1086
+ resolve(id, resolution) {
1087
+ const row = store.get(id);
1088
+ if (!row?.attention)
1089
+ return undefined;
1090
+ return store.resolve(id, resolution);
1091
+ },
1092
+ /**
1093
+ * Stop taking bookings, and wait for the ones in flight - a deploy's
1094
+ * SIGTERM. A process stopped in the middle of a trip leaves its first
1095
+ * stays booked and the rest not (2026-10-05 audit, P1-2).
1096
+ */
1097
+ async drain() {
1098
+ closing = true;
1099
+ await Promise.allSettled([...new Set(flights.values())]);
1100
+ },
630
1101
  async searchHotels(q) {
631
1102
  const terms = (q.query ?? '')
632
1103
  .toLowerCase()
@@ -962,9 +1433,18 @@ export const createHuazhuHotels = (opts) => {
962
1433
  },
963
1434
  quote,
964
1435
  async requestBooking(trip, guest, who, requests) {
1436
+ if (closing) {
1437
+ throw new Error('Nothing was booked: this connector is restarting. Ask again in a minute - the trip is kept.');
1438
+ }
965
1439
  if (!guest.name.trim() || !guest.email.includes('@')) {
966
- throw new Error("A booking needs the guest's name and an email to send it to.");
1440
+ // Nothing is emailed from here (2026-10-05 audit): the email is how the
1441
+ // booking is found again, with its code.
1442
+ throw new Error("A booking needs the guest's name and email - the email is how the booking is found again, with its code.");
967
1443
  }
1444
+ // Bounded at the seam too - see GUEST_LIMITS.
1445
+ const tooLong = guestTooLong(guest);
1446
+ if (tooLong.length)
1447
+ throw new Error(`Nothing was booked: a booking takes ${tooLong.join(', ')}.`);
968
1448
  const contact = contactOf(guest);
969
1449
  if (typeof contact === 'string')
970
1450
  throw new Error(contact);
@@ -979,247 +1459,16 @@ export const createHuazhuHotels = (opts) => {
979
1459
  }
980
1460
  const { groups } = splitByStay(trip.lines);
981
1461
  const lines = groups.flat();
982
- // ⚠️ THE LIMITS - see `BookingLimits`. Each is said as what it is, and
983
- // none suggests a way round itself (another email, another chat).
984
- const isLive = (b) => (b.status === 'confirmed' || b.status === 'requested') && b.stay.checkOut >= today();
985
1462
  const email = guest.email.trim().toLowerCase();
986
- const plural = (n, one) => `${n} ${one}${n === 1 ? '' : 's'}`;
987
- const liveHere = store
988
- .all()
989
- .filter((b) => b.owners.includes(who.sessionId) && isLive(b)).length;
990
- if (liveHere + lines.length > limits.perConversationLive) {
991
- throw new Error(`Nothing was booked. This conversation already has ${plural(liveHere, 'booking')} still to come, and ${limits.perConversationLive} at once is the most - cancel one that is not needed, then ask again.`);
992
- }
993
- const liveForEmail = store
994
- .all()
995
- .filter((b) => b.guest.email.trim().toLowerCase() === email && isLive(b)).length;
996
- if (liveForEmail + lines.length > limits.perEmailLive) {
997
- throw new Error(`Nothing was booked. ${guest.email.trim()} already has ${plural(liveForEmail, 'booking')} still to come through this connector, and ${limits.perEmailLive} at once is the most - cancel one that is not needed, then ask again.`);
998
- }
999
- if (requestsByEmail.count(email) >= limits.perEmailPerDay) {
1000
- throw new Error(`Nothing was booked. ${guest.email.trim()} has asked for ${plural(limits.perEmailPerDay, 'booking')} in the last 24 hours, which is the most here - try again later.`);
1001
- }
1002
- if (who.ip && requestsByIp.count(who.ip) >= limits.perIpPerHour) {
1003
- throw new Error('Nothing was booked. Too many bookings have been asked for from this network in the last hour - try again later.');
1004
- }
1005
- const madeToday = store
1006
- .all()
1007
- .filter((b) => now() - Date.parse(b.createdAt) < DAY_MS &&
1008
- (b.status === 'confirmed' || b.status === 'requested' || b.status === 'cancelled')).length;
1009
- if (madeToday + lines.length > limits.perDeploymentPerDay) {
1010
- // ⚠️ A CEILING HIT IS SOMETHING A PERSON SHOULD KNOW: either business
1011
- // is booming or somebody is filling hotels with names. Logged, and on
1012
- // /healthz/detail as needsAPerson for a day.
1013
- if (ceilingHitAt === undefined || now() - ceilingHitAt > HOUR_MS) {
1014
- console.error(`[huazhu bookings] ⚠️ NEEDS A PERSON: ${madeToday} bookings in the last 24 hours reached the ceiling of ${limits.perDeploymentPerDay}; new bookings are refused until the count falls`);
1015
- }
1016
- ceilingHitAt = now();
1017
- throw new Error(`Nothing was booked: bookings through this connector are paused for now. Try again later, or book with ${opts.brand} directly.`);
1018
- }
1019
- // Counted once it has passed every limit - a request turned away is not
1020
- // one more against the guest, or a guest at a limit could never leave it.
1021
- requestsByEmail.add(email);
1022
- if (who.ip)
1023
- requestsByIp.add(who.ip);
1024
- // Priced afresh, not from the cache: this is the price that will be sent.
1025
- const priced = await quoteLines({ lines }, true);
1026
- if (priced.dropped?.length || priced.sold.size !== lines.length) {
1027
- throw new Error(`Nothing was booked. ${(priced.dropped ?? []).map((d) => `${d.title}: ${d.why}`).join(' ')} The trip is as it was - take that out or change it, then ask again.`);
1028
- }
1029
- const clean = cleanRequests(requests);
1030
- const comment = commentOf(clean, guest.notes);
1031
- const plans = [];
1032
- for (const [i, line] of lines.entries()) {
1033
- const sold = priced.sold.get(i);
1034
- const qline = priced.lines[i];
1035
- if (line.kind !== 'room' || !sold || !qline)
1036
- throw new Error('Nothing was booked: the trip changed while it was being priced.');
1037
- plans.push({ i, line, sold, qline });
1038
- }
1039
- for (const plan of plans) {
1040
- const s = plan.sold.res;
1041
- const title = `${plan.qline.hotelName} - ${plan.qline.title}`;
1042
- try {
1043
- const out = await client.prebook({
1044
- hotelId: plan.line.hotelId,
1045
- checkinType: 'DAY',
1046
- paymentType: opts.paymentType,
1047
- reservations: [
1048
- {
1049
- roomTypeCode: plan.sold.product.roomTypeCode,
1050
- rateCode: plan.sold.product.rateCode,
1051
- checkin: plan.line.stay.checkIn,
1052
- checkout: plan.line.stay.checkOut,
1053
- amountAfterTax: s.after,
1054
- ...(s.before ? { amountBeforeTax: s.before } : {}),
1055
- currencyCode: s.currency,
1056
- roomCount: plan.qline.quantity ?? 1,
1057
- adultCount: plan.line.stay.guests,
1058
- },
1059
- ],
1060
- });
1061
- plan.token = out.preBookToken;
1062
- }
1063
- catch (err) {
1064
- if (err instanceof HuazhuRefusal)
1065
- throw new Error(`Nothing was booked. ${refusalWords(err, title, s.currency)}`);
1066
- throw new Error(`Nothing was booked: Huazhu could not be reached to check ${title} (${err.message}). Asking again is safe.`);
1067
- }
1068
- }
1069
- const tripRef = randomUUID();
1070
- const made = [];
1071
- /** Undo what this trip booked, when a later stay of it failed. */
1072
- const takeBackAll = async () => {
1073
- const stuck = [];
1074
- for (const b of made) {
1075
- store.update(b.id, { rollback: true });
1076
- const row = store.get(b.id);
1077
- if (!row)
1078
- continue;
1079
- if (row.pendingSince !== undefined)
1080
- continue; // the reconciler cancels it if it turns up
1081
- await reconciler.takeBack(row);
1082
- if (store.get(b.id)?.status !== 'cancelled')
1083
- stuck.push(`${row.hotelName} (${row.confirmationCode})`);
1084
- }
1085
- return stuck.length
1086
- ? ` ${stuck.join(' and ')} could not be cancelled automatically and has been flagged for a person to cancel with the hotel.`
1087
- : '';
1088
- };
1089
- for (const plan of plans) {
1090
- const s = plan.sold.res;
1091
- const rooms = plan.qline.quantity ?? 1;
1092
- const qTotal = plan.qline.total.minor;
1093
- const nightlyMinor = s.after.map((x) => toMinor(x, s.currency));
1094
- const allIn = nightlyMinor.reduce((a, b) => a + b, 0) * rooms;
1095
- const cur = s.currency;
1096
- const id = `hz_${randomUUID()}`;
1097
- const dres = distributorResId();
1098
- const row = {
1099
- id,
1100
- confirmationCode: dres,
1101
- status: 'requested',
1102
- createdAt: new Date(now()).toISOString(),
1103
- hotelId: plan.line.hotelId,
1104
- hotelName: plan.qline.hotelName,
1105
- stay: plan.line.stay,
1106
- guest,
1107
- quote: {
1108
- lines: [plan.qline],
1109
- subtotal: money(qTotal, cur),
1110
- taxesAndFees: money(allIn - qTotal, cur),
1111
- total: money(allIn, cur),
1112
- stay: plan.line.stay,
1113
- },
1114
- ...(clean ? { requests: clean } : {}),
1115
- owners: [who.sessionId],
1116
- distributorResId: dres,
1117
- tripRef,
1118
- paymentType: opts.paymentType,
1119
- guaranteeCode: s.guaranteeCode,
1120
- cancelCode: s.cancelCode,
1121
- nightlyMinor,
1122
- rooms,
1123
- tz: opts.tz,
1124
- roomTypeCode: plan.sold.product.roomTypeCode,
1125
- rateCode: plan.sold.product.rateCode,
1126
- // ⚠️ SET BEFORE THE CALL: a process that dies mid-call leaves a
1127
- // record the reconciler follows up on restart.
1128
- pendingSince: now(),
1129
- };
1130
- store.put(row);
1131
- made.push(row);
1132
- const reservation = {
1133
- roomTypeCode: plan.sold.product.roomTypeCode,
1134
- rateCode: plan.sold.product.rateCode,
1135
- checkin: plan.line.stay.checkIn,
1136
- checkout: plan.line.stay.checkOut,
1137
- amountAfterTax: s.after,
1138
- ...(s.before ? { amountBeforeTax: s.before } : {}),
1139
- ...(s.rack ? { rackRate: s.rack } : {}),
1140
- currencyCode: cur,
1141
- roomCount: rooms,
1142
- adultCount: plan.line.stay.guests,
1143
- guaranteePolicyCode: s.guaranteeCode,
1144
- ...(clean?.arrivingAt ? { estimatedArrivalTime: clean.arrivingAt } : {}),
1145
- resGuest: [contact],
1146
- };
1147
- const request = {
1148
- hotelId: plan.line.hotelId,
1149
- checkinType: 'DAY',
1150
- contactPerson: contact,
1151
- distributorResId: dres,
1152
- paymentType: opts.paymentType,
1153
- reservations: [reservation],
1154
- ...(comment ? { guestComment: comment } : {}),
1155
- ...(plan.token ? { preBookToken: plan.token } : {}),
1156
- };
1157
- // ⚠️ ONLY A REFUSAL FROM HUAZHU MEANS "NOT BOOKED". Anything else -
1158
- // no answer, an answer that did not parse, our own code failing after
1159
- // the call - means it may exist, and is followed up by the consistency
1160
- // query, never written off.
1161
- let outcome = 'unknown';
1162
- let refusal;
1163
- let answer;
1164
- for (let attempt = 0; attempt < 2 && !answer && !refusal; attempt += 1) {
1165
- try {
1166
- answer = await client.book(request);
1167
- }
1168
- catch (err) {
1169
- if (!(err instanceof HuazhuRefusal))
1170
- break;
1171
- // "Already exists" for OUR id: an earlier try got through.
1172
- if (err.code === 5218 || err.code === 5221 || err.code === 5231)
1173
- break;
1174
- if (err.code === 2001 && attempt === 0) {
1175
- // The documentation's own advice: retry a system error, same id.
1176
- await sleep(1_000);
1177
- continue;
1178
- }
1179
- refusal = err;
1180
- }
1181
- }
1182
- if (answer) {
1183
- // Outside the try: a failure to WRITE this down is not Huazhu
1184
- // refusing it, and must not be recorded as one.
1185
- store.update(id, {
1186
- resId: answer.resId,
1187
- ...(answer.resCode ? { resCode: answer.resCode } : {}),
1188
- confirmationCode: answer.resId,
1189
- status: 'confirmed',
1190
- hotelStatus: 'HOTEL_RESERVATION',
1191
- pendingSince: undefined,
1192
- });
1193
- outcome = 'booked';
1194
- }
1195
- else if (refusal) {
1196
- outcome = 'refused';
1197
- }
1198
- if (outcome === 'refused') {
1199
- // Never made at Huazhu: nothing to follow up, nothing to show.
1200
- store.update(id, { pendingSince: undefined, status: 'failed' });
1201
- made.pop();
1202
- const before = made.length;
1203
- const back = await takeBackAll();
1204
- const title = `${plan.qline.hotelName} - ${plan.qline.title}`;
1205
- throw new Error(`Nothing was booked. ${refusal ? refusalWords(refusal, title, cur) : `Huazhu did not accept ${title}.`}${before && !back ? ' The stays booked before it in this trip were cancelled again.' : ''}${back}`);
1206
- }
1207
- if (outcome === 'unknown') {
1208
- const settled = await settle(store.get(id) ?? row);
1209
- if (settled.pendingSince !== undefined || settled.status !== 'confirmed') {
1210
- if (plans.length > 1 || settled.status === 'failed') {
1211
- const back = await takeBackAll();
1212
- if (settled.status === 'failed') {
1213
- throw new Error(`Nothing was booked: Huazhu did not confirm ${plan.qline.hotelName}.${back}`);
1214
- }
1215
- throw new Error(`Nothing was booked: Huazhu did not answer in time for ${plan.qline.hotelName}, so the trip was stopped. If that booking turns up in the next few minutes it is cancelled automatically.${back}`);
1216
- }
1217
- // One stay, outcome still unknown: it is the guest's booking, marked
1218
- // as waiting, and the reconciler settles it within eight minutes.
1219
- }
1220
- }
1221
- }
1222
- return made.map((b) => publicBooking(store.get(b.id) ?? b));
1463
+ const tripKey = lines
1464
+ .map((l) => l.kind === 'room'
1465
+ ? `${l.hotelId}|${l.roomId}|${l.stay.checkIn}|${l.stay.checkOut}|${l.stay.guests}|${l.rooms ?? 1}`
1466
+ : '')
1467
+ .sort()
1468
+ .join(';');
1469
+ // The caller and, from the card, the conversation whose trip it is.
1470
+ const keys = [...new Set([who.sessionId, who.tripOwner ?? who.sessionId])].map((k) => `s:${k}`);
1471
+ return single([...keys, `e:${email}`], () => book(lines, tripKey, email, guest, contact, who, requests));
1223
1472
  },
1224
1473
  async listBookings(who, limit = 10) {
1225
1474
  const most = Math.max(1, Math.min(MAX_PAGE, Math.floor(Number(limit) > 0 ? limit : 10)));
@@ -1266,13 +1515,13 @@ export const createHuazhuHotels = (opts) => {
1266
1515
  const cur = b.quote.total.currency;
1267
1516
  const zero = money(0, cur);
1268
1517
  const past = now() >= nightAuditOf(b.stay.checkIn, b.tz);
1269
- if (b.pendingSince !== undefined) {
1518
+ if (b.pendingSince !== undefined || b.status === 'unconfirmed') {
1270
1519
  return {
1271
1520
  bookingId: b.id,
1272
1521
  confirmationCode: b.confirmationCode,
1273
1522
  fee: zero,
1274
1523
  refund: zero,
1275
- rule: 'Huazhu has not confirmed this booking yet - that takes up to eight minutes. It can be cancelled once it is confirmed.',
1524
+ rule: waitingWords(b),
1276
1525
  cancellable: false,
1277
1526
  };
1278
1527
  }
@@ -1297,7 +1546,9 @@ export const createHuazhuHotels = (opts) => {
1297
1546
  refund: zero,
1298
1547
  rule: forbidden
1299
1548
  ? `This rate cannot be cancelled now. ${rule}`
1300
- : `${rule} Nothing was paid through this connector - the booking is paid at the hotel - so any fee is charged under the hotel's terms, and Huazhu works out the exact amount when it cancels.`,
1549
+ : opts.sandbox
1550
+ ? `${rule} This is a test booking in the simulated Huazhu: the fee is worked out as a real booking's would be, and nothing is charged to anyone.`
1551
+ : `${rule} Nothing was paid through this connector - the booking is paid at the hotel - so any fee is charged under the hotel's terms, and Huazhu works out the exact amount when it cancels.`,
1301
1552
  ...(freeUntil ? { freeUntil: new Date(freeUntil).toISOString() } : {}),
1302
1553
  cancellable: !forbidden,
1303
1554
  };
@@ -1307,9 +1558,11 @@ export const createHuazhuHotels = (opts) => {
1307
1558
  if (!found)
1308
1559
  return null;
1309
1560
  const b = await refresh(found);
1310
- if (b.pendingSince !== undefined || !b.resId) {
1311
- throw new Error('Huazhu has not confirmed this booking yet - that takes up to eight minutes. It can be cancelled once it is confirmed.');
1561
+ if (b.pendingSince !== undefined || b.status === 'unconfirmed') {
1562
+ throw new Error(waitingWords(b));
1312
1563
  }
1564
+ if (!b.resId)
1565
+ return publicBooking(b);
1313
1566
  if (b.status !== 'confirmed')
1314
1567
  return publicBooking(b);
1315
1568
  const cur = b.quote.total.currency;