@curless/hotel-storefront 0.27.28 → 0.29.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 (93) hide show
  1. package/dist/backend.d.ts +19 -0
  2. package/dist/backend.d.ts.map +1 -1
  3. package/dist/backend.js.map +1 -1
  4. package/dist/card.generated.d.ts +1 -1
  5. package/dist/card.generated.js +2 -2
  6. package/dist/card.generated.js.map +1 -1
  7. package/dist/config.d.ts +45 -0
  8. package/dist/config.d.ts.map +1 -1
  9. package/dist/config.js.map +1 -1
  10. package/dist/demo-hotels.d.ts.map +1 -1
  11. package/dist/demo-hotels.js +7 -1
  12. package/dist/demo-hotels.js.map +1 -1
  13. package/dist/hourly.d.ts +31 -0
  14. package/dist/hourly.d.ts.map +1 -0
  15. package/dist/hourly.js +61 -0
  16. package/dist/hourly.js.map +1 -0
  17. package/dist/huazhu/backend.d.ts +13 -1
  18. package/dist/huazhu/backend.d.ts.map +1 -1
  19. package/dist/huazhu/backend.js +712 -39
  20. package/dist/huazhu/backend.js.map +1 -1
  21. package/dist/huazhu/bookings.d.ts +37 -1
  22. package/dist/huazhu/bookings.d.ts.map +1 -1
  23. package/dist/huazhu/bookings.js.map +1 -1
  24. package/dist/huazhu/check.js +2 -2
  25. package/dist/huazhu/check.js.map +1 -1
  26. package/dist/huazhu/client.d.ts +3 -3
  27. package/dist/huazhu/client.d.ts.map +1 -1
  28. package/dist/huazhu/client.js +4 -4
  29. package/dist/huazhu/client.js.map +1 -1
  30. package/dist/huazhu/english.d.ts.map +1 -1
  31. package/dist/huazhu/english.js +15 -0
  32. package/dist/huazhu/english.js.map +1 -1
  33. package/dist/huazhu/fake.d.ts +3 -1
  34. package/dist/huazhu/fake.d.ts.map +1 -1
  35. package/dist/huazhu/fake.js +69 -7
  36. package/dist/huazhu/fake.js.map +1 -1
  37. package/dist/huazhu/labels.d.ts +9 -0
  38. package/dist/huazhu/labels.d.ts.map +1 -0
  39. package/dist/huazhu/labels.js +28 -0
  40. package/dist/huazhu/labels.js.map +1 -0
  41. package/dist/huazhu/make.d.ts.map +1 -1
  42. package/dist/huazhu/make.js +26 -0
  43. package/dist/huazhu/make.js.map +1 -1
  44. package/dist/huazhu/sandbox-photos.d.ts +7 -0
  45. package/dist/huazhu/sandbox-photos.d.ts.map +1 -1
  46. package/dist/huazhu/sandbox-photos.js +7 -0
  47. package/dist/huazhu/sandbox-photos.js.map +1 -1
  48. package/dist/huazhu/sandbox.d.ts +2 -0
  49. package/dist/huazhu/sandbox.d.ts.map +1 -1
  50. package/dist/huazhu/sandbox.js +125 -6
  51. package/dist/huazhu/sandbox.js.map +1 -1
  52. package/dist/huazhu/sellable.d.ts +46 -3
  53. package/dist/huazhu/sellable.d.ts.map +1 -1
  54. package/dist/huazhu/sellable.js +105 -12
  55. package/dist/huazhu/sellable.js.map +1 -1
  56. package/dist/huazhu/wire.d.ts +11 -0
  57. package/dist/huazhu/wire.d.ts.map +1 -1
  58. package/dist/huazhu/wire.js.map +1 -1
  59. package/dist/legal.d.ts.map +1 -1
  60. package/dist/legal.js +19 -8
  61. package/dist/legal.js.map +1 -1
  62. package/dist/payments/checkout.d.ts +71 -0
  63. package/dist/payments/checkout.d.ts.map +1 -0
  64. package/dist/payments/checkout.js +133 -0
  65. package/dist/payments/checkout.js.map +1 -0
  66. package/dist/payments/fake-gateway.d.ts +43 -0
  67. package/dist/payments/fake-gateway.d.ts.map +1 -0
  68. package/dist/payments/fake-gateway.js +135 -0
  69. package/dist/payments/fake-gateway.js.map +1 -0
  70. package/dist/present.d.ts.map +1 -1
  71. package/dist/present.js +8 -4
  72. package/dist/present.js.map +1 -1
  73. package/dist/server.d.ts +4 -2
  74. package/dist/server.d.ts.map +1 -1
  75. package/dist/server.js +19 -4
  76. package/dist/server.js.map +1 -1
  77. package/dist/supplier.d.ts +2 -0
  78. package/dist/supplier.d.ts.map +1 -1
  79. package/dist/supplier.js.map +1 -1
  80. package/dist/surface.d.ts.map +1 -1
  81. package/dist/surface.js +7 -2
  82. package/dist/surface.js.map +1 -1
  83. package/dist/tools.d.ts +15 -1
  84. package/dist/tools.d.ts.map +1 -1
  85. package/dist/tools.js +298 -30
  86. package/dist/tools.js.map +1 -1
  87. package/dist/trip.d.ts +6 -0
  88. package/dist/trip.d.ts.map +1 -1
  89. package/dist/trip.js +17 -3
  90. package/dist/trip.js.map +1 -1
  91. package/dist/types.d.ts +109 -1
  92. package/dist/types.d.ts.map +1 -1
  93. package/package.json +1 -1
@@ -3,6 +3,7 @@ import { DEFAULT_PAGE, MAX_PAGE, REQUEST_KINDS, REQUEST_NOTE_MAX, guestTooLong,
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
+ import { endOf, startProblem } from '../hourly.js';
6
7
  import { BOOKING_LIMITS, DAY_MS, HOUR_MS, WindowCounter } from '../limits.js';
7
8
  import { formatMoney, minorFactor, money, multiplyMoney, toMinor } from '../money.js';
8
9
  import { MAX_ROOMS_PER_LINE, quoteLineKey } from '../trip.js';
@@ -10,8 +11,9 @@ import { IN_REQUEST_AT_MS, StoreWriteError, nextCheckAt, statusOf, } from './boo
10
11
  import { describePolicy, feeFor, freeUntilOf, nightAuditOf, parseCancelPolicy, penaltyAt, refundableFrom, } from './cancel-policy.js';
11
12
  import { haystackOf, inPlace, roomPhoto, toHotel } from './catalog.js';
12
13
  import { HuazhuRefusal, HuazhuUnreachable } from './client.js';
13
- import { bedNameEn, offerNameEn, roomNameEn } from './english.js';
14
- import { ariRanges, bookableNow, clockAt, offeredAtAll, sell, } from './sellable.js';
14
+ import { bedNameEn, offerNameEn, rateNameEn, roomNameEn } from './english.js';
15
+ import { rateLabels } from './labels.js';
16
+ import { ariRanges, bookableNow, clockAt, hourlyWindow, inStayWindow, offeredAtAll, sell, } from './sellable.js';
15
17
  import { BOOK_EXISTS, BOOK_REFUSALS, CODES, } from './wire.js';
16
18
  export const HUAZHU_CAPABILITIES = {
17
19
  members: false,
@@ -84,6 +86,21 @@ const WINDOW = {
84
86
  '3': 'No window',
85
87
  '4': 'Window onto an inner courtyard',
86
88
  };
89
+ /** The same, as Huazhu's own dictionary says it - for a connector that speaks Chinese. */
90
+ const WINDOW_ZH = {
91
+ '1': '有窗',
92
+ '2': '部分有窗',
93
+ '3': '无窗',
94
+ '4': '内窗(朝天井)',
95
+ };
96
+ /** A rate's meal in Chinese, by its code; room only is 不含早. */
97
+ const MEAL_ZH = {
98
+ BB: '含早',
99
+ BF: '含自助早',
100
+ HB: '含半餐',
101
+ FB: '含全餐',
102
+ AI: '全包',
103
+ };
87
104
  const pool = async (items, n, fn) => {
88
105
  const out = new Array(items.length);
89
106
  let next = 0;
@@ -266,12 +283,43 @@ export const englishFits = (hotels, ref) => {
266
283
  * "Sun 11 Oct, 00:00" for a stay arriving on Saturday the 10th - read as a
267
284
  * day more than there is (2026-10-05 audit, P3). Said as the 10th, 24:00.
268
285
  */
269
- export const deadlineAt = (instant, tz) => {
286
+ export const deadlineAt = (instant, tz, language = 'en') => {
287
+ if (language === 'zh')
288
+ return deadlineZh(instant, tz);
270
289
  const shown = atHotelTime(instant, tz);
271
290
  if (!/, 00:00\b/.test(shown))
272
291
  return shown;
273
292
  return atHotelTime(instant - 60_000, tz).replace(/, 23:59\b/, ', 24:00');
274
293
  };
294
+ /**
295
+ * The same deadline in Chinese - "10月25日 周日 18:00", midnight still the
296
+ * day before's 24:00. A connector that speaks Chinese had its card say
297
+ * "Sun 25 Oct, 18:00" beside Chinese words (香格里拉, 2026-10-12).
298
+ */
299
+ const deadlineZh = (instant, tz) => {
300
+ const parts = (at) => {
301
+ const p = Object.fromEntries(new Intl.DateTimeFormat('en-GB', {
302
+ timeZone: tz,
303
+ month: 'numeric',
304
+ day: 'numeric',
305
+ weekday: 'short',
306
+ hour: '2-digit',
307
+ minute: '2-digit',
308
+ hourCycle: 'h23',
309
+ })
310
+ .formatToParts(at)
311
+ .map((x) => [x.type, x.value]));
312
+ const week = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].indexOf(String(p.weekday));
313
+ return {
314
+ day: `${Number(p.month)}月${Number(p.day)}日 周${'日一二三四五六'[week] ?? ''}`,
315
+ time: `${p.hour}:${p.minute}`,
316
+ };
317
+ };
318
+ const now = parts(instant);
319
+ if (now.time !== '00:00')
320
+ return `${now.day} ${now.time}`;
321
+ return `${parts(instant - 60_000).day} 24:00`;
322
+ };
275
323
  /**
276
324
  * How a fresh price differs from what the guest was shown - or nothing, when
277
325
  * it does not. See `ShownQuote`.
@@ -300,6 +348,15 @@ export const movedSince = (shown, q) => {
300
348
  };
301
349
  export const createHuazhuHotels = (opts) => {
302
350
  const { client, catalog, store, reconciler } = opts;
351
+ // ⚠️ NO MONEY TAKEN FOR A REAL HUAZHU YET: who takes it, the licence, a
352
+ // partial refund, mixed trips, invoices and VCC are robin's to decide
353
+ // (总线程, 2026-10-12). Prepaid runs against the simulated Huazhu only.
354
+ if (opts.prepaid && !opts.sandbox)
355
+ throw new Error('prepaid rates are sold against the simulated Huazhu only, until robin decides who takes the money (docs/huazhu-products-plan.md)');
356
+ const checkout = opts.prepaid?.checkout;
357
+ /** Unpaid this long, the page is closed and the booking lapses - inside the card page's own ~31 minutes. */
358
+ const payWithin = opts.prepaid?.payWithinMs ?? 30 * 60_000;
359
+ const prepaidType = checkout ? 'OPN' : undefined;
303
360
  const now = opts.now ?? (() => Date.now());
304
361
  const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
305
362
  const ariTtl = opts.ariTtlMs ?? 60_000;
@@ -312,6 +369,16 @@ export const createHuazhuHotels = (opts) => {
312
369
  /** A room as offered - type and rate - in the connector's language. */
313
370
  const offerOf = (p) => (en ? offerNameEn(p.roomTypeName, p.rateCodeName) : undefined) ??
314
371
  [p.roomTypeName, p.rateCodeName].filter(Boolean).join(' · ');
372
+ /** A rate's meal as the trip line says it, in the connector's language. */
373
+ const mealWords = (r) => en
374
+ ? r.meal
375
+ ? `${r.meal}${r.mealCount > 1 ? ` × ${r.mealCount}` : ''}`
376
+ : 'Room only'
377
+ : r.meal
378
+ ? `${MEAL_ZH[r.mealType] ?? '含餐'}${r.mealCount > 1 ? ` × ${r.mealCount}` : ''}`
379
+ : '不含早';
380
+ /** A rate's own name in the connector's language, or Huazhu's words as they are. */
381
+ const rateNameOf = (p) => (en ? rateNameEn(p.rateCodeName) : undefined) ?? p.rateCodeName ?? p.rateCode;
315
382
  const today = () => todayAt(now(), opts.tz);
316
383
  const limits = { ...BOOKING_LIMITS, ...(opts.limits ?? {}) };
317
384
  const requestsByEmail = new WindowCounter(DAY_MS, now);
@@ -494,7 +561,9 @@ export const createHuazhuHotels = (opts) => {
494
561
  };
495
562
  /** Every sellable-or-not product of one hotel for one stay. */
496
563
  const priceStay = async (h, stay, rooms, fresh = false) => {
497
- const { value: products, sellDays } = await productsWithDays(h.hotelId);
564
+ const { value: all, sellDays } = await productsWithDays(h.hotelId);
565
+ // Nights only: rooms by the hour are priced for today apart - `priceHourly`.
566
+ const products = all.filter((p) => p.checkinType === 'DAY');
498
567
  if (products.length === 0)
499
568
  return [];
500
569
  const roomTypeOf = (p) => (h.hotelRoomTypeList ?? []).find((r) => r.roomTypeCode === p.roomTypeCode);
@@ -523,6 +592,7 @@ export const createHuazhuHotels = (opts) => {
523
592
  today: today(),
524
593
  clock: clockAt(now(), opts.tz),
525
594
  paymentType: opts.paymentType,
595
+ ...(prepaidType ? { prepaidType } : {}),
526
596
  allowTags,
527
597
  };
528
598
  return products
@@ -537,6 +607,85 @@ export const createHuazhuHotels = (opts) => {
537
607
  })
538
608
  .filter((x) => !(x.roomType && x.roomType.status === 0));
539
609
  };
610
+ const hourlyAriCache = new Map();
611
+ /**
612
+ * Today's prices and rooms for a hotel's rooms by the hour. Huazhu answers
613
+ * these for today only ("时租房 … 只能查 startDate 为当天的 ARI"), asked apart
614
+ * from the nights with `checkinType: HOUR`.
615
+ */
616
+ const hourlyAriFor = async (hotelId, date, fresh = false) => {
617
+ const key = `${hotelId}|${date}`;
618
+ const hit = hourlyAriCache.get(key);
619
+ if (!fresh && hit && now() - hit.at < ariTtl)
620
+ return hit.value;
621
+ const [offers, inventory] = await Promise.all([
622
+ client.offers(hotelId, date, date, undefined, 'HOUR').catch((err) => {
623
+ if (err instanceof HuazhuRefusal && [4206, 2008].includes(err.code))
624
+ return { dailyOffers: [] };
625
+ throw err;
626
+ }),
627
+ client.inventory(hotelId, date, date, undefined, 'HOUR').catch((err) => {
628
+ if (err instanceof HuazhuRefusal && [4306, 2008].includes(err.code))
629
+ return {
630
+ dailyInventories: [],
631
+ };
632
+ throw err;
633
+ }),
634
+ ]);
635
+ const out = new Map();
636
+ for (const offer of offers.dailyOffers ?? []) {
637
+ const row = (inventory.dailyInventories ?? []).find((i) => roomIdOf(i) === roomIdOf(offer));
638
+ out.set(roomIdOf(offer), { start: date, offer, inventory: row?.inventories ?? [] });
639
+ }
640
+ hourlyAriCache.set(key, { at: now(), value: out });
641
+ if (hourlyAriCache.size > 500)
642
+ hourlyAriCache.delete(hourlyAriCache.keys().next().value);
643
+ return out;
644
+ };
645
+ /**
646
+ * A hotel's rooms by the hour, for today on the hotel's own date and clock:
647
+ * each sellable one with the starts it takes, or why not.
648
+ *
649
+ * ⚠️ TODAY IS THE HOTEL'S, NOT THE SERVER'S (总线程, 2026-10-12): at 01:00 in
650
+ * Singapore it is still yesterday nowhere that matters, and a start that has
651
+ * already gone by on the hotel's clock is not offered.
652
+ */
653
+ const priceHourly = async (h, guests, rooms, fresh = false) => {
654
+ const products = (await productsOf(h.hotelId)).filter((p) => p.checkinType === 'HOUR');
655
+ if (products.length === 0)
656
+ return [];
657
+ const date = today();
658
+ const clock = clockAt(now(), opts.tz);
659
+ const live = products.filter((p) => bookableNow(p.bookWindow, date, clock) && inStayWindow(p.stayWindow, date));
660
+ if (live.length === 0)
661
+ return [];
662
+ const ari = await hourlyAriFor(h.hotelId, date, fresh);
663
+ // One day, priced once: `sell` reads it as a single night from today.
664
+ const context = {
665
+ checkIn: date,
666
+ checkOut: addDays(date, 1),
667
+ adults: guests,
668
+ rooms,
669
+ today: date,
670
+ clock,
671
+ paymentType: opts.paymentType,
672
+ ...(prepaidType ? { prepaidType } : {}),
673
+ allowTags,
674
+ };
675
+ return live
676
+ .map((product) => {
677
+ const roomType = (h.hotelRoomTypeList ?? []).find((r) => r.roomTypeCode === product.roomTypeCode);
678
+ const w = ari.get(roomIdOf(product));
679
+ const hourly = hourlyWindow(product, date, clock);
680
+ let res = w
681
+ ? sell(product, w, roomType, context)
682
+ : { ok: false, kind: 'no-price', why: 'no price for today' };
683
+ if (res.ok && !hourly)
684
+ res = { ok: false, kind: 'closed', why: 'no start time is left today' };
685
+ return { product, roomType, res, ...(hourly ? { hourly } : {}) };
686
+ })
687
+ .filter((x) => !(x.roomType && x.roomType.status === 0));
688
+ };
540
689
  const policyOf = (code) => parseCancelPolicy(code);
541
690
  const toRoom = (h, p, stay) => {
542
691
  const s = p.res;
@@ -553,23 +702,30 @@ export const createHuazhuHotels = (opts) => {
553
702
  .filter(Boolean)
554
703
  .join(' '))
555
704
  .filter(Boolean);
705
+ const hourly = p.hourly;
706
+ const maxChildren = Number(p.product.occupancy?.maxChild ?? rt?.maxChild);
556
707
  return {
557
708
  id: roomIdOf(p.product),
558
709
  name: offerOf(p.product),
559
710
  sleeps: s.sleeps,
560
711
  ...(rt?.hotelArea ? { sizeSqm: rt.hotelArea } : {}),
561
712
  ...(beds.length ? { beds: beds.join(' / ') } : {}),
562
- ...(rt?.isHasWindow && WINDOW[rt.isHasWindow] ? { view: WINDOW[rt.isHasWindow] } : {}),
713
+ ...(rt?.isHasWindow && (en ? WINDOW : WINDOW_ZH)[rt.isHasWindow]
714
+ ? { view: (en ? WINDOW : WINDOW_ZH)[rt.isHasWindow] }
715
+ : {}),
563
716
  breakfast: !!s.meal,
564
717
  ...(roomPhoto(h, p.product.roomTypeCode)
565
718
  ? { imageUrl: roomPhoto(h, p.product.roomTypeCode) }
566
719
  : {}),
567
720
  // A whole yuan, as a hotel quotes a nightly rate - not ¥343.50 (2026-10-05
568
- // audit, P3). The stay total stays exact.
569
- nightly: money(Math.round(pre / n / minorFactor(cur)) * minorFactor(cur), cur),
721
+ // audit, P3). The stay total stays exact. A room by the hour is the
722
+ // price of its hours, and no nights.
723
+ nightly: hourly
724
+ ? money(total, cur)
725
+ : money(Math.round(pre / n / minorFactor(cur)) * minorFactor(cur), cur),
570
726
  stayTotal: money(total, cur),
571
727
  taxesAndFees: money(total - pre, cur),
572
- nights: n,
728
+ nights: hourly ? 0 : n,
573
729
  refundable: policy ? refundableFrom(policy, stay.checkIn, opts.tz, now()) : false,
574
730
  ...(freeUntil
575
731
  ? {
@@ -577,12 +733,41 @@ export const createHuazhuHotels = (opts) => {
577
733
  // ⚠️ ON THE HOTEL'S CLOCK. Without it the guest read "10:00 (UTC)"
578
734
  // for what a Shanghai desk calls 18:00 - found by the Huazhu evals
579
735
  // (2026-10-05).
580
- freeCancellationLocal: deadlineAt(freeUntil, opts.tz),
736
+ freeCancellationLocal: deadlineAt(freeUntil, opts.tz, en ? 'en' : 'zh'),
581
737
  }
582
738
  : {}),
583
739
  roomsLeft: s.roomsLeft,
740
+ roomType: { id: p.product.roomTypeCode, name: roomTypeOf(p.product) },
741
+ rate: { id: p.product.rateCode, name: rateNameOf(p.product) },
742
+ labels: rateLabels(p.product, { type: s.mealType, count: s.mealCount }),
743
+ // ⚠️ PREPAID ONLY WHERE THIS DEPLOYMENT TAKES PAYMENT - a sandbox's test
744
+ // mode (`opts.prepaid`); everywhere else `sell` leaves prepaid rates out
745
+ // and every room is paid at the hotel (总线程, 2026-10-12).
746
+ payment: s.prepaid ? 'prepaid' : 'at-hotel',
747
+ ...(hourly ? { hourly } : {}),
748
+ ...(maxChildren > 0 && maxChildren < 99 ? { maxChildren } : {}),
584
749
  };
585
750
  };
751
+ /** The most a room here sleeps, from rates refused for the party ("sleeps 2"). */
752
+ const largestOf = (fails) => Math.max(...fails.map((f) => Number(/\d+/.exec(f.why)?.[0] ?? 0)));
753
+ /**
754
+ * Why no room by the hour can be sold here today.
755
+ *
756
+ * ⚠️ A PARTY TOO BIG IS SAID AS partyNeeds SAYS IT, as for nights: the card
757
+ * reads "the largest sleeps N" from that sentence to offer more rooms with
758
+ * fewer in each (香格里拉, 2026-10-12). Three guests at a hotel whose hourly
759
+ * rooms sleep two were told none was left.
760
+ */
761
+ const hourlyNoRoomsWhy = (priced, guests) => {
762
+ if (priced.length === 0)
763
+ return 'This hotel sells no rooms by the hour today.';
764
+ const fails = priced.map((p) => p.res).filter((r) => !r.ok);
765
+ if (fails.length > 0 && fails.every((f) => f.kind === 'party'))
766
+ return partyNeeds(guests, largestOf(fails));
767
+ if (fails.some((f) => f.kind === 'prepaid'))
768
+ return "This hotel's rooms by the hour are paid in advance, and this connector books rates paid at the hotel.";
769
+ return 'No room by the hour is left here today.';
770
+ };
586
771
  /** Why nothing here can be sold for these nights - the most useful reason first. */
587
772
  const noRoomsWhy = (priced, stay, rooms) => {
588
773
  if (priced.length === 0)
@@ -590,10 +775,8 @@ export const createHuazhuHotels = (opts) => {
590
775
  const fails = priced.map((p) => p.res).filter((r) => !r.ok);
591
776
  const kinds = new Set(fails.map((f) => f.kind));
592
777
  const only = (k) => kinds.size === 1 && kinds.has(k);
593
- if (only('party')) {
594
- const largest = Math.max(...fails.map((f) => Number(/\d+/.exec(f.why)?.[0] ?? 0)));
595
- return partyNeeds(stay.guests, largest);
596
- }
778
+ if (only('party'))
779
+ return partyNeeds(stay.guests, largestOf(fails));
597
780
  if (only('prepaid'))
598
781
  return 'Every rate here for those nights is paid in advance, and this connector books rates paid at the hotel.';
599
782
  if (only('sold-out'))
@@ -636,7 +819,13 @@ export const createHuazhuHotels = (opts) => {
636
819
  });
637
820
  continue;
638
821
  }
639
- const problem = stayProblem(line.stay, today());
822
+ const hourly = line.hourly;
823
+ // A room by the hour is today, on the hotel's date, in and out the same day.
824
+ const problem = hourly
825
+ ? line.stay.checkIn !== today() || line.stay.checkOut !== line.stay.checkIn
826
+ ? `Rooms by the hour are sold for today (${today()}) only, in and out the same day.`
827
+ : undefined
828
+ : stayProblem(line.stay, today());
640
829
  if (problem) {
641
830
  dropped.push({ title: hotelName, why: problem });
642
831
  continue;
@@ -644,7 +833,9 @@ export const createHuazhuHotels = (opts) => {
644
833
  const wanted = Math.max(1, Math.floor(line.rooms ?? 1));
645
834
  let priced;
646
835
  try {
647
- priced = await priceStay(h, line.stay, wanted, fresh);
836
+ priced = hourly
837
+ ? await priceHourly(h, line.stay.guests, wanted, fresh)
838
+ : await priceStay(h, line.stay, wanted, fresh);
648
839
  }
649
840
  catch (err) {
650
841
  dropped.push({
@@ -657,7 +848,7 @@ export const createHuazhuHotels = (opts) => {
657
848
  if (!p) {
658
849
  dropped.push({
659
850
  title: `${hotelName} - ${line.roomId}`,
660
- why: 'That rate is no longer sold.',
851
+ why: hourly ? 'That room by the hour is not sold today.' : 'That rate is no longer sold.',
661
852
  });
662
853
  continue;
663
854
  }
@@ -665,7 +856,20 @@ export const createHuazhuHotels = (opts) => {
665
856
  dropped.push({ title: `${hotelName} - ${roomTypeOf(p.product)}`, why: `${p.res.why}.` });
666
857
  continue;
667
858
  }
668
- const held = `${h.hotelId}|${line.roomId}|${line.stay.checkIn}|${line.stay.checkOut}`;
859
+ if (hourly) {
860
+ // ⚠️ CHECKED AGAIN AT EVERY PRICING: the starts a room takes shrink as
861
+ // the day goes on, and a start that was fine at noon is gone by four.
862
+ const bad = !p.hourly
863
+ ? 'No start time is left today.'
864
+ : hourly.hours !== p.hourly.hours
865
+ ? `This room is ${p.hourly.hours} hours, not ${hourly.hours}.`
866
+ : startProblem(p.hourly, hourly.start);
867
+ if (bad) {
868
+ dropped.push({ title: `${hotelName} - ${offerOf(p.product)}`, why: bad });
869
+ continue;
870
+ }
871
+ }
872
+ const held = `${h.hotelId}|${line.roomId}|${line.stay.checkIn}|${line.stay.checkOut}${hourly ? `|${hourly.start}` : ''}`;
669
873
  const already = taken.get(held) ?? 0;
670
874
  if (p.res.roomsLeft - already < wanted) {
671
875
  dropped.push({
@@ -700,19 +904,23 @@ export const createHuazhuHotels = (opts) => {
700
904
  detail: [
701
905
  room.beds,
702
906
  room.view,
703
- p.res.meal
704
- ? `${p.res.meal}${p.res.mealCount > 1 ? ` × ${p.res.mealCount}` : ''}`
705
- : 'Room only',
907
+ mealWords(p.res),
908
+ hourly ? `${hourly.start}–${endOf(hourly.start, hourly.hours)}` : undefined,
706
909
  ]
707
910
  .filter(Boolean)
708
911
  .join(' · '),
709
912
  stay: line.stay,
710
913
  nights: room.nights,
914
+ ...(hourly ? { hourly } : {}),
915
+ ...(room.payment === 'prepaid' ? { payment: 'prepaid' } : {}),
711
916
  quantity: wanted,
712
917
  unit: room.nightly,
713
918
  total: money(preTax, room.stayTotal.currency),
714
919
  refundable: room.refundable,
715
920
  freeCancellationUntil: room.freeCancellationUntil,
921
+ ...(room.freeCancellationLocal
922
+ ? { freeCancellationLocal: room.freeCancellationLocal }
923
+ : {}),
716
924
  day: line.stay.checkIn,
717
925
  });
718
926
  }
@@ -748,6 +956,33 @@ export const createHuazhuHotels = (opts) => {
748
956
  return q;
749
957
  };
750
958
  // ------------------------------------------------------------ bookings ----
959
+ /** The money's side of a booking paid in advance, as the guest is shown it. */
960
+ const paymentOf = (b) => {
961
+ const p = b.prepaid;
962
+ const refund = p.refund;
963
+ const status = refund
964
+ ? refund.status === 'refunded'
965
+ ? 'refunded'
966
+ : 'refunding'
967
+ : p.lapsedAt
968
+ ? 'lapsed'
969
+ : p.paidAt
970
+ ? 'paid'
971
+ : 'awaiting';
972
+ return {
973
+ method: p.method,
974
+ amount: b.quote.total,
975
+ status,
976
+ ...(status === 'awaiting' && p.url ? { url: p.url } : {}),
977
+ ...(status === 'awaiting'
978
+ ? {
979
+ payBy: new Date(p.payBy).toISOString(),
980
+ payByLocal: deadlineAt(p.payBy, b.tz, en ? 'en' : 'zh'),
981
+ }
982
+ : {}),
983
+ ...(refund ? { refundReason: refund.reason } : {}),
984
+ };
985
+ };
751
986
  const publicBooking = (b) => ({
752
987
  id: b.id,
753
988
  confirmationCode: b.confirmationCode,
@@ -756,11 +991,13 @@ export const createHuazhuHotels = (opts) => {
756
991
  hotelId: b.hotelId,
757
992
  hotelName: b.hotelName,
758
993
  stay: b.stay,
994
+ ...(b.hourly ? { hourly: b.hourly } : {}),
759
995
  guest: b.guest,
760
996
  quote: b.quote,
761
997
  ...(b.cancelledAt ? { cancelledAt: b.cancelledAt } : {}),
762
998
  ...(b.cancellationFee ? { cancellationFee: b.cancellationFee } : {}),
763
999
  ...(b.requests ? { requests: b.requests } : {}),
1000
+ ...(b.prepaid ? { payment: paymentOf(b) } : {}),
764
1001
  });
765
1002
  /**
766
1003
  * The codes a guest may have been given for a booking: ours, and the
@@ -797,7 +1034,15 @@ export const createHuazhuHotels = (opts) => {
797
1034
  return there;
798
1035
  };
799
1036
  /** Huazhu's own word on a booking, at most once a minute each. */
800
- const refresh = async (b) => {
1037
+ const refresh = async (found) => {
1038
+ // A page open for payment, or money going back, is looked at as it is
1039
+ // asked about - the guest who just paid sees it booked without waiting
1040
+ // for the next pass.
1041
+ let b = found;
1042
+ if (b.prepaid && moneyPending(b)) {
1043
+ await settlePrepaid(b);
1044
+ b = store.get(b.id) ?? b;
1045
+ }
801
1046
  if (!b.resId || b.pendingSince !== undefined)
802
1047
  return b;
803
1048
  if (b.refreshedAt && now() - b.refreshedAt < 60_000)
@@ -881,7 +1126,11 @@ export const createHuazhuHotels = (opts) => {
881
1126
  const conversationOf = (who) => who.tripOwner ?? who.sessionId;
882
1127
  /** A name for a booking in what the guest is told. */
883
1128
  const named = (b) => `${b.hotelName} (${b.confirmationCode})`;
884
- const isLive = (b) => (b.status === 'confirmed' || b.status === 'requested' || b.status === 'unconfirmed') &&
1129
+ const isLive = (b) => (b.status === 'confirmed' ||
1130
+ b.status === 'requested' ||
1131
+ b.status === 'unconfirmed' ||
1132
+ // A page open for payment holds its place in the limits until it closes.
1133
+ b.status === 'awaiting-payment') &&
885
1134
  !b.rollback &&
886
1135
  b.stay.checkOut >= today();
887
1136
  /** The booking itself, one request at a time per conversation and email - see `single`. */
@@ -961,6 +1210,211 @@ export const createHuazhuHotels = (opts) => {
961
1210
  slots.delete(slot);
962
1211
  }
963
1212
  };
1213
+ /**
1214
+ * A stay's dates as Huazhu takes them: check-in and check-out, and for a room
1215
+ * by the hour the same day with its hours ("时租入离同一天").
1216
+ */
1217
+ const stayDatesOf = (line) => line.hourly
1218
+ ? {
1219
+ checkin: line.stay.checkIn,
1220
+ checkout: line.stay.checkIn,
1221
+ hourlyRoom: {
1222
+ hourlyRateStartTime: line.hourly.start,
1223
+ hourlyRateEndTime: endOf(line.hourly.start, line.hourly.hours),
1224
+ },
1225
+ }
1226
+ : { checkin: line.stay.checkIn, checkout: line.stay.checkOut };
1227
+ /**
1228
+ * Books a stay the guest has paid for - the request made from what was
1229
+ * prebooked and written down, at the price Huazhu's prebook vouched for.
1230
+ * Huazhu's "no" refunds the money in full; no answer leaves it to the
1231
+ * reconciler, whose write-off or "nobody could confirm it" refunds it too
1232
+ * (`settlePrepaid`).
1233
+ */
1234
+ const bookPaid = async (id) => {
1235
+ const row = store.get(id);
1236
+ const p = row?.prepaid;
1237
+ if (!row || !p?.paidAt || row.status !== 'awaiting-payment')
1238
+ return;
1239
+ const contact = contactOf(row.guest);
1240
+ if (typeof contact === 'string') {
1241
+ store.update(id, { status: 'failed' });
1242
+ await refundOf(id, "the guest's name could not be sent to Huazhu as written");
1243
+ return;
1244
+ }
1245
+ const comment = commentOf(row.requests, row.guest.notes);
1246
+ const request = {
1247
+ hotelId: row.hotelId,
1248
+ checkinType: row.hourly ? 'HOUR' : 'DAY',
1249
+ contactPerson: contact,
1250
+ distributorResId: row.distributorResId,
1251
+ paymentType: row.paymentType,
1252
+ reservations: [
1253
+ {
1254
+ roomTypeCode: row.roomTypeCode,
1255
+ rateCode: row.rateCode,
1256
+ ...stayDatesOf(row),
1257
+ amountAfterTax: p.after,
1258
+ ...(p.before ? { amountBeforeTax: p.before } : {}),
1259
+ ...(p.rack ? { rackRate: p.rack } : {}),
1260
+ currencyCode: p.currency,
1261
+ roomCount: row.rooms,
1262
+ adultCount: row.stay.guests,
1263
+ guaranteePolicyCode: row.guaranteeCode,
1264
+ ...(row.requests?.arrivingAt ? { estimatedArrivalTime: row.requests.arrivingAt } : {}),
1265
+ resGuest: [contact],
1266
+ },
1267
+ ],
1268
+ ...(comment ? { guestComment: comment } : {}),
1269
+ ...(p.preBookToken ? { preBookToken: p.preBookToken } : {}),
1270
+ };
1271
+ // ⚠️ SET BEFORE THE CALL, as every booking is: a process that dies in it
1272
+ // leaves a row the reconciler follows up.
1273
+ store.update(id, { status: 'requested', pendingSince: now() });
1274
+ let refusal;
1275
+ let answer;
1276
+ for (let attempt = 0; attempt < 2; attempt += 1) {
1277
+ try {
1278
+ const got = await client.book(request);
1279
+ if (got && typeof got.resId === 'string' && got.resId)
1280
+ answer = got;
1281
+ }
1282
+ catch (err) {
1283
+ if (err instanceof HuazhuRefusal && err.code === CODES.systemError && attempt === 0) {
1284
+ await sleep(1_000);
1285
+ continue;
1286
+ }
1287
+ if (err instanceof HuazhuRefusal &&
1288
+ BOOK_REFUSALS.has(err.code) &&
1289
+ !BOOK_EXISTS.has(err.code))
1290
+ refusal = err;
1291
+ }
1292
+ break;
1293
+ }
1294
+ if (answer) {
1295
+ store.update(id, {
1296
+ resId: answer.resId,
1297
+ ...(answer.resCode ? { resCode: answer.resCode } : {}),
1298
+ status: 'confirmed',
1299
+ hotelStatus: 'HOTEL_RESERVATION',
1300
+ pendingSince: undefined,
1301
+ });
1302
+ return;
1303
+ }
1304
+ if (refusal) {
1305
+ store.update(id, { status: 'failed', pendingSince: undefined });
1306
+ // A price that moved since the prebook is one of these (5209): the
1307
+ // guest paid for a price Huazhu no longer gives, and gets it all back.
1308
+ await refundOf(id, `Huazhu did not take the booking: ${refusal.huazhuMessage} (${refusal.code})`);
1309
+ }
1310
+ // No answer: the reconciler's eight minutes decide it.
1311
+ };
1312
+ /**
1313
+ * Gives a guest's money back, in full - the only refund the gateway makes.
1314
+ * Written down with the gateway's checkout session (its payment intent)
1315
+ * and, once open, its refund request: the two ids the money is found by.
1316
+ */
1317
+ const refundOf = async (id, reason) => {
1318
+ const row = store.get(id);
1319
+ const p = row?.prepaid;
1320
+ if (!row || !p?.sessionId || !p.paidAt || !checkout || p.refund?.requestId)
1321
+ return;
1322
+ const tries = (p.refund?.tries ?? 0) + 1;
1323
+ const at = p.refund?.at ?? now();
1324
+ try {
1325
+ const r = await checkout.refund(p.sessionId, reason);
1326
+ store.update(id, {
1327
+ prepaid: { ...p, refund: { reason, at, requestId: r.requestId, status: r.status, tries } },
1328
+ });
1329
+ console.error(`[huazhu prepaid ${id}] refund ${r.requestId} (${r.status}) of session ${p.sessionId}: ${reason}`);
1330
+ }
1331
+ catch (err) {
1332
+ store.update(id, { prepaid: { ...p, refund: { reason, at, tries } } });
1333
+ if (tries >= 8 && !row.attention) {
1334
+ const why = `the guest paid (gateway session ${p.sessionId}) and the refund could not be opened: ${err.message}`;
1335
+ store.update(id, { attention: why, attentionAt: new Date(now()).toISOString() });
1336
+ console.error(`[huazhu prepaid ${id}] ⚠️ NEEDS A PERSON: ${why}`);
1337
+ }
1338
+ }
1339
+ };
1340
+ /** Whether a booking paid in advance still has money work to do. */
1341
+ const moneyPending = (b) => {
1342
+ const p = b.prepaid;
1343
+ if (!p)
1344
+ return false;
1345
+ if (b.status === 'awaiting-payment')
1346
+ return true;
1347
+ if (!p.paidAt)
1348
+ return false;
1349
+ if (!p.refund?.requestId)
1350
+ return !!p.refund || b.status === 'failed' || b.status === 'unconfirmed';
1351
+ return !['refunded', 'failed', 'rejected'].includes(p.refund.status ?? '');
1352
+ };
1353
+ /**
1354
+ * One step for a booking paid in advance: a page paid → booked; a page
1355
+ * unpaid past its time → closed (so it cannot be paid later) and lapsed; a
1356
+ * paid booking Huazhu did not take, or nobody could confirm → refunded in
1357
+ * full; a refund under way → followed to its end.
1358
+ */
1359
+ const settlePrepaid = async (found) => {
1360
+ const row = store.get(found.id) ?? found;
1361
+ const p = row.prepaid;
1362
+ if (!p || !checkout)
1363
+ return;
1364
+ if (row.status === 'awaiting-payment') {
1365
+ if (!p.sessionId) {
1366
+ if (now() >= p.payBy)
1367
+ store.update(row.id, { status: 'expired', prepaid: { ...p, lapsedAt: now() } });
1368
+ return;
1369
+ }
1370
+ let state;
1371
+ try {
1372
+ state = await checkout.state(p.sessionId);
1373
+ if (state === 'open' && now() >= p.payBy)
1374
+ state = await checkout.close(p.sessionId);
1375
+ }
1376
+ catch {
1377
+ return; // asked again on the next pass
1378
+ }
1379
+ if (state === 'paid') {
1380
+ store.update(row.id, { prepaid: { ...p, paidAt: now(), url: undefined } });
1381
+ await bookPaid(row.id);
1382
+ }
1383
+ else if (state === 'closed') {
1384
+ store.update(row.id, {
1385
+ status: 'expired',
1386
+ prepaid: { ...p, lapsedAt: now(), url: undefined },
1387
+ });
1388
+ }
1389
+ return;
1390
+ }
1391
+ if (!p.paidAt)
1392
+ return;
1393
+ if (!p.refund?.requestId &&
1394
+ (p.refund || row.status === 'failed' || row.status === 'unconfirmed')) {
1395
+ await refundOf(row.id, p.refund?.reason ??
1396
+ (row.status === 'failed'
1397
+ ? 'Huazhu did not take the booking'
1398
+ : 'nobody could confirm the booking with Huazhu - if it turns up there, a person cancels it'));
1399
+ return;
1400
+ }
1401
+ const refund = p.refund;
1402
+ if (refund?.requestId && !['refunded', 'failed', 'rejected'].includes(refund.status ?? '')) {
1403
+ try {
1404
+ const status = await checkout.refundState(refund.requestId);
1405
+ if (status !== refund.status)
1406
+ store.update(row.id, { prepaid: { ...p, refund: { ...refund, status } } });
1407
+ if ((status === 'failed' || status === 'rejected') && !row.attention) {
1408
+ const why = `refund ${refund.requestId} of gateway session ${p.sessionId} came back ${status}: the guest's money is not back`;
1409
+ store.update(row.id, { attention: why, attentionAt: new Date(now()).toISOString() });
1410
+ console.error(`[huazhu prepaid ${row.id}] ⚠️ NEEDS A PERSON: ${why}`);
1411
+ }
1412
+ }
1413
+ catch {
1414
+ // asked again on the next pass
1415
+ }
1416
+ }
1417
+ };
964
1418
  const bookHeld = async (lines, shown, tripKey, email, guest, contact, who, requests, slot) => {
965
1419
  // Priced afresh, not from the cache: this is the price that will be sent.
966
1420
  const priced = await quoteLines({ lines }, true);
@@ -982,20 +1436,24 @@ export const createHuazhuHotels = (opts) => {
982
1436
  throw new Error('Nothing was booked: the trip changed while it was being priced.');
983
1437
  plans.push({ i, line, sold, qline });
984
1438
  }
1439
+ // ⚠️ A STAY PAID IN ADVANCE IS BOOKED ON ITS OWN (v1): its money is taken
1440
+ // before Huazhu is asked, and a trip half paid and half not - or two paid
1441
+ // stays, one refused - has no whole refund to give (总线程, 2026-10-12).
1442
+ if (plans.length > 1 && plans.some((p) => p.sold.res.prepaid))
1443
+ throw new Error('Nothing was booked: a stay paid in advance is booked on its own here. Take the other stays out of the trip, book this one, then book the rest.');
985
1444
  for (const plan of plans) {
986
1445
  const s = plan.sold.res;
987
1446
  const title = `${plan.qline.hotelName} - ${plan.qline.title}`;
988
1447
  try {
989
1448
  const out = await client.prebook({
990
1449
  hotelId: plan.line.hotelId,
991
- checkinType: 'DAY',
992
- paymentType: opts.paymentType,
1450
+ checkinType: plan.line.hourly ? 'HOUR' : 'DAY',
1451
+ paymentType: plan.sold.res.paymentType,
993
1452
  reservations: [
994
1453
  {
995
1454
  roomTypeCode: plan.sold.product.roomTypeCode,
996
1455
  rateCode: plan.sold.product.rateCode,
997
- checkin: plan.line.stay.checkIn,
998
- checkout: plan.line.stay.checkOut,
1456
+ ...stayDatesOf(plan.line),
999
1457
  amountAfterTax: s.after,
1000
1458
  ...(s.before ? { amountBeforeTax: s.before } : {}),
1001
1459
  currencyCode: s.currency,
@@ -1015,6 +1473,92 @@ export const createHuazhuHotels = (opts) => {
1015
1473
  requestsByEmail.add(email);
1016
1474
  requestsByConversation.add(conversationOf(who));
1017
1475
  const tripRef = randomUUID();
1476
+ // ⚠️ PAID FIRST, BOOKED AFTER. A stay paid in advance is not asked of
1477
+ // Huazhu until the guest has paid on the gateway's card page - then
1478
+ // `settlePrepaid` books it, and gives the money back whole if it cannot.
1479
+ const prepaidPlan = plans.find((p) => p.sold.res.prepaid);
1480
+ if (prepaidPlan) {
1481
+ const plan = prepaidPlan;
1482
+ const s = plan.sold.res;
1483
+ if (!checkout)
1484
+ throw new Error('Nothing was booked: this deployment takes no payment.');
1485
+ const rooms = plan.qline.quantity ?? 1;
1486
+ const qTotal = plan.qline.total.minor;
1487
+ const nightlyMinor = s.after.map((x) => toMinor(x, s.currency));
1488
+ const allIn = nightlyMinor.reduce((a, b) => a + b, 0) * rooms;
1489
+ const cur = s.currency;
1490
+ const id = `hz_${randomUUID()}`;
1491
+ const dres = distributorResId();
1492
+ const record = {
1493
+ method: checkout.method,
1494
+ payBy: now() + payWithin,
1495
+ ...(plan.token ? { preBookToken: plan.token } : {}),
1496
+ after: s.after,
1497
+ ...(s.before ? { before: s.before } : {}),
1498
+ ...(s.rack ? { rack: s.rack } : {}),
1499
+ currency: cur,
1500
+ };
1501
+ const row = {
1502
+ id,
1503
+ confirmationCode: newPublicCode((c) => store.all().some((b) => b.confirmationCode === c)),
1504
+ status: 'awaiting-payment',
1505
+ createdAt: new Date(now()).toISOString(),
1506
+ hotelId: plan.line.hotelId,
1507
+ hotelName: plan.qline.hotelName,
1508
+ stay: plan.line.stay,
1509
+ ...(plan.line.hourly ? { hourly: plan.line.hourly } : {}),
1510
+ guest,
1511
+ quote: {
1512
+ lines: [plan.qline],
1513
+ subtotal: money(qTotal, cur),
1514
+ taxesAndFees: money(allIn - qTotal, cur),
1515
+ ...(s.before ? {} : { taxesIncluded: true }),
1516
+ total: money(allIn, cur),
1517
+ stay: plan.line.stay,
1518
+ },
1519
+ ...(clean ? { requests: clean } : {}),
1520
+ owners: [...new Set([who.sessionId, conversationOf(who)])],
1521
+ distributorResId: dres,
1522
+ tripRef,
1523
+ tripKey,
1524
+ paymentType: s.paymentType,
1525
+ guaranteeCode: s.guaranteeCode,
1526
+ cancelCode: s.cancelCode,
1527
+ nightlyMinor,
1528
+ rooms,
1529
+ tz: opts.tz,
1530
+ roomTypeCode: plan.sold.product.roomTypeCode,
1531
+ rateCode: plan.sold.product.rateCode,
1532
+ prepaid: record,
1533
+ };
1534
+ try {
1535
+ store.put(row);
1536
+ }
1537
+ catch (err) {
1538
+ if (err instanceof StoreWriteError)
1539
+ throw new Error('Nothing was booked or charged: the booking could not be written down here, so no payment was asked for. A person has been told.');
1540
+ throw err;
1541
+ }
1542
+ slot.n -= 1;
1543
+ let opened;
1544
+ try {
1545
+ opened = await checkout.open({
1546
+ ref: dres,
1547
+ title: `${plan.qline.hotelName} · ${plan.qline.title}`.slice(0, 200),
1548
+ amountMinor: allIn,
1549
+ currency: cur,
1550
+ });
1551
+ }
1552
+ catch (err) {
1553
+ store.update(id, { status: 'failed' });
1554
+ throw new Error(`Nothing was booked or charged: the payment page could not be opened (${err.message}). Asking again is safe.`);
1555
+ }
1556
+ return [
1557
+ publicBooking(store.update(id, {
1558
+ prepaid: { ...record, sessionId: opened.sessionId, url: opened.url },
1559
+ }) ?? row),
1560
+ ];
1561
+ }
1018
1562
  const made = [];
1019
1563
  /**
1020
1564
  * Undo what this trip booked, when a later stay of it failed - and say
@@ -1103,6 +1647,7 @@ export const createHuazhuHotels = (opts) => {
1103
1647
  hotelId: plan.line.hotelId,
1104
1648
  hotelName: plan.qline.hotelName,
1105
1649
  stay: plan.line.stay,
1650
+ ...(plan.line.hourly ? { hourly: plan.line.hourly } : {}),
1106
1651
  guest,
1107
1652
  quote: {
1108
1653
  lines: [plan.qline],
@@ -1118,7 +1663,7 @@ export const createHuazhuHotels = (opts) => {
1118
1663
  distributorResId: dres,
1119
1664
  tripRef,
1120
1665
  tripKey,
1121
- paymentType: opts.paymentType,
1666
+ paymentType: s.paymentType,
1122
1667
  guaranteeCode: s.guaranteeCode,
1123
1668
  cancelCode: s.cancelCode,
1124
1669
  nightlyMinor,
@@ -1147,8 +1692,7 @@ export const createHuazhuHotels = (opts) => {
1147
1692
  const reservation = {
1148
1693
  roomTypeCode: plan.sold.product.roomTypeCode,
1149
1694
  rateCode: plan.sold.product.rateCode,
1150
- checkin: plan.line.stay.checkIn,
1151
- checkout: plan.line.stay.checkOut,
1695
+ ...stayDatesOf(plan.line),
1152
1696
  amountAfterTax: s.after,
1153
1697
  ...(s.before ? { amountBeforeTax: s.before } : {}),
1154
1698
  ...(s.rack ? { rackRate: s.rack } : {}),
@@ -1161,10 +1705,10 @@ export const createHuazhuHotels = (opts) => {
1161
1705
  };
1162
1706
  const request = {
1163
1707
  hotelId: plan.line.hotelId,
1164
- checkinType: 'DAY',
1708
+ checkinType: plan.line.hourly ? 'HOUR' : 'DAY',
1165
1709
  contactPerson: contact,
1166
1710
  distributorResId: dres,
1167
- paymentType: opts.paymentType,
1711
+ paymentType: s.paymentType,
1168
1712
  reservations: [reservation],
1169
1713
  ...(comment ? { guestComment: comment } : {}),
1170
1714
  ...(plan.token ? { preBookToken: plan.token } : {}),
@@ -1239,7 +1783,21 @@ export const createHuazhuHotels = (opts) => {
1239
1783
  };
1240
1784
  return {
1241
1785
  brand: opts.brand,
1242
- capabilities: HUAZHU_CAPABILITIES,
1786
+ capabilities: {
1787
+ ...HUAZHU_CAPABILITIES,
1788
+ ...(opts.hourly ? { hourly: true } : {}),
1789
+ ...(checkout ? { prepaid: true } : {}),
1790
+ },
1791
+ /**
1792
+ * One pass over the bookings paid in advance - every 10 seconds while the
1793
+ * deployment takes payment (make.ts): pages paid or past their time, and
1794
+ * money going back.
1795
+ */
1796
+ async prepaidTick() {
1797
+ for (const row of store.all())
1798
+ if (moneyPending(row))
1799
+ await settlePrepaid(row);
1800
+ },
1243
1801
  imageDomains() {
1244
1802
  const hosts = new Set();
1245
1803
  for (const h of catalog.all()) {
@@ -1332,10 +1890,17 @@ export const createHuazhuHotels = (opts) => {
1332
1890
  return terms.every((t) => hay.includes(t));
1333
1891
  });
1334
1892
  const f = q.filters ?? {};
1335
- const stay = q.stay;
1893
+ // Rooms by the hour are today's, on the hotel's date: the nights asked
1894
+ // for, if any, do not apply to them.
1895
+ const hourlyOnly = !!f.hourly;
1896
+ const stay = hourlyOnly
1897
+ ? { checkIn: today(), checkOut: today(), guests: q.stay?.guests ?? 2 }
1898
+ : q.stay;
1336
1899
  const wantRooms = Math.max(1, Math.min(MAX_ROOMS_PER_LINE, Math.floor(q.rooms ?? 1)));
1337
1900
  const unapplied = [];
1338
1901
  const ignored = {};
1902
+ if (hourlyOnly && q.stay)
1903
+ unapplied.push('Rooms by the hour are sold for today only, so the nights asked for were not used.');
1339
1904
  const roomTerms = !!(f.freeCancellation || f.breakfast);
1340
1905
  // A brand by the name shown or by Huazhu's own: "Hanting" and "汉庭" are one brand.
1341
1906
  const passesStatic = (h, over, raw) => (!over.brand ||
@@ -1357,6 +1922,8 @@ export const createHuazhuHotels = (opts) => {
1357
1922
  if (roomTerms)
1358
1923
  ignored.roomTerms = true;
1359
1924
  }
1925
+ if (hourlyOnly && !priceEverything)
1926
+ unapplied.push(`${matches.length} hotels matched - too many to ask each for rooms by the hour at once, so only this page was asked. A district, a brand or a landmark narrows it.`);
1360
1927
  if (stay && !priceEverything && (wantsPriceKnob || roomTerms)) {
1361
1928
  unapplied.push(`${matches.length} hotels matched - too many to price at once, so ${[
1362
1929
  wantsPriceKnob ? 'the price order and ceiling' : '',
@@ -1365,7 +1932,7 @@ export const createHuazhuHotels = (opts) => {
1365
1932
  .filter(Boolean)
1366
1933
  .join(' and ')} were not applied. A district, a brand or a landmark narrows it.`);
1367
1934
  }
1368
- if (!stay && (wantsPriceKnob || roomTerms)) {
1935
+ if (!stay && !hourlyOnly && (wantsPriceKnob || roomTerms)) {
1369
1936
  unapplied.push('Prices, free cancellation and breakfast belong to a room on given nights, and Huazhu publishes no price without them - with check-in and check-out they apply.');
1370
1937
  }
1371
1938
  const best = new Map();
@@ -1375,6 +1942,19 @@ export const createHuazhuHotels = (opts) => {
1375
1942
  const priceHotel = async (raw) => {
1376
1943
  if (!stay || pricedRooms.has(raw.hotelId))
1377
1944
  return;
1945
+ if (hourlyOnly) {
1946
+ try {
1947
+ const priced = await priceHourly(raw, stay.guests, wantRooms);
1948
+ pricedRooms.set(raw.hotelId, priced);
1949
+ if (!priced.some((p) => p.res.ok))
1950
+ why.set(raw.hotelId, hourlyNoRoomsWhy(priced, stay.guests));
1951
+ }
1952
+ catch {
1953
+ pricedRooms.set(raw.hotelId, []);
1954
+ why.set(raw.hotelId, 'Rooms by the hour could not be read from Huazhu just now; open the hotel for them.');
1955
+ }
1956
+ return;
1957
+ }
1378
1958
  const problem = stayProblem(stay, today());
1379
1959
  if (problem) {
1380
1960
  pricedRooms.set(raw.hotelId, []);
@@ -1417,8 +1997,16 @@ export const createHuazhuHotels = (opts) => {
1417
1997
  if (priceEverything)
1418
1998
  await pool(matches.map((m) => m.raw), PRICE_CONCURRENCY, priceHotel);
1419
1999
  const canRoomFilter = priceEverything;
1420
- if (canRoomFilter && roomTerms)
1421
- matches = matches.filter(({ raw }) => bestFor(raw, f) !== null);
2000
+ // ⚠️ A HOTEL WHOSE ROOMS BY THE HOUR ARE ONLY TOO SMALL STILL SELLS
2001
+ // THEM: it stays, with partyNeeds as its reason, so the card can offer
2002
+ // more rooms with fewer in each (香格里拉, 2026-10-12) - three guests
2003
+ // were shown no hotel at all, as if none sold rooms by the hour.
2004
+ const tooSmallOnly = (raw) => {
2005
+ const priced = pricedRooms.get(raw.hotelId) ?? [];
2006
+ return priced.length > 0 && priced.every((p) => !p.res.ok && p.res.kind === 'party');
2007
+ };
2008
+ if (canRoomFilter && (roomTerms || hourlyOnly))
2009
+ matches = matches.filter(({ raw }) => bestFor(raw, f) !== null || (hourlyOnly && !roomTerms && tooSmallOnly(raw)));
1422
2010
  const currencies = [...new Set(matches.map((m) => m.hotel.currency))].sort();
1423
2011
  const onePrice = currencies.length === 1;
1424
2012
  const ceiling = priceEverything && onePrice && Number(q.maxNightly) > 0 ? Number(q.maxNightly) : undefined;
@@ -1492,6 +2080,8 @@ export const createHuazhuHotels = (opts) => {
1492
2080
  nights: b.room.nights,
1493
2081
  roomsLeft: b.room.roomsLeft,
1494
2082
  freeCancellation: !!b.room.freeCancellationUntil,
2083
+ ...(b.room.hourly ? { hours: b.room.hourly.hours } : {}),
2084
+ ...(b.room.labels?.length ? { labels: b.room.labels } : {}),
1495
2085
  },
1496
2086
  };
1497
2087
  };
@@ -1593,6 +2183,35 @@ export const createHuazhuHotels = (opts) => {
1593
2183
  ...(rooms.length === 0 ? { noRooms: noRoomsWhy(priced, stay, 1) } : {}),
1594
2184
  };
1595
2185
  },
2186
+ async hourlyRooms(id, guests) {
2187
+ const raw = findHotel(id);
2188
+ if (!raw)
2189
+ return null;
2190
+ const hotel = toHotel(raw, view, now());
2191
+ const date = today();
2192
+ const stay = { checkIn: date, checkOut: date, guests };
2193
+ let priced;
2194
+ try {
2195
+ priced = await priceHourly(raw, guests, 1);
2196
+ }
2197
+ catch (err) {
2198
+ return {
2199
+ hotel,
2200
+ stay,
2201
+ noRooms: `Huazhu could not be asked for this hotel's rooms by the hour just now (${err instanceof HuazhuRefusal ? err.huazhuMessage : 'no answer'}). Try again in a moment.`,
2202
+ };
2203
+ }
2204
+ const rooms = priced
2205
+ .filter((p) => p.res.ok)
2206
+ .map((p) => toRoom(raw, p, stay))
2207
+ .sort((a, b) => a.stayTotal.minor - b.stayTotal.minor);
2208
+ return {
2209
+ hotel,
2210
+ stay,
2211
+ rooms,
2212
+ ...(rooms.length === 0 ? { noRooms: hourlyNoRoomsWhy(priced, guests) } : {}),
2213
+ };
2214
+ },
1596
2215
  async datesAround(id, stay, o) {
1597
2216
  const raw = findHotel(id);
1598
2217
  if (!raw)
@@ -1631,6 +2250,7 @@ export const createHuazhuHotels = (opts) => {
1631
2250
  today: from,
1632
2251
  clock: clockAt(now(), opts.tz),
1633
2252
  paymentType: opts.paymentType,
2253
+ ...(prepaidType ? { prepaidType } : {}),
1634
2254
  allowTags,
1635
2255
  };
1636
2256
  let cheapest;
@@ -1710,7 +2330,13 @@ export const createHuazhuHotels = (opts) => {
1710
2330
  const mine = store
1711
2331
  .all()
1712
2332
  .filter((b) => b.owners.includes(who.sessionId) &&
1713
- !(b.status === 'failed' && !b.resId && b.pendingSince === undefined && !b.attention))
2333
+ // A refused booking is nothing to show - unless the guest paid for
2334
+ // it: their money going back is.
2335
+ !(b.status === 'failed' &&
2336
+ !b.resId &&
2337
+ b.pendingSince === undefined &&
2338
+ !b.attention &&
2339
+ !b.prepaid?.paidAt))
1714
2340
  .sort((a, b) => b.createdAt.localeCompare(a.createdAt))
1715
2341
  .slice(0, most);
1716
2342
  const fresh = await pool(mine, 3, (b) => refresh(b));
@@ -1750,6 +2376,16 @@ export const createHuazhuHotels = (opts) => {
1750
2376
  const cur = b.quote.total.currency;
1751
2377
  const zero = money(0, cur);
1752
2378
  const past = now() >= nightAuditOf(b.stay.checkIn, b.tz);
2379
+ if (b.status === 'awaiting-payment') {
2380
+ return {
2381
+ bookingId: b.id,
2382
+ confirmationCode: b.confirmationCode,
2383
+ fee: zero,
2384
+ refund: zero,
2385
+ rule: 'Not paid yet, and nothing is booked with Huazhu: cancelling closes the payment page, and nothing is charged.',
2386
+ cancellable: true,
2387
+ };
2388
+ }
1753
2389
  if (b.pendingSince !== undefined || b.status === 'unconfirmed') {
1754
2390
  return {
1755
2391
  bookingId: b.id,
@@ -1773,6 +2409,24 @@ export const createHuazhuHotels = (opts) => {
1773
2409
  };
1774
2410
  }
1775
2411
  const { fee, forbidden, rule, freeUntil } = feeNow(b);
2412
+ if (b.prepaid?.paidAt) {
2413
+ // Paid here in advance: free means all of it back, anything else -
2414
+ // the whole stay, on these rates - means none.
2415
+ const back = !forbidden && fee === 0;
2416
+ return {
2417
+ bookingId: b.id,
2418
+ confirmationCode: b.confirmationCode,
2419
+ fee: money(forbidden ? 0 : fee, cur),
2420
+ refund: back ? b.quote.total : zero,
2421
+ rule: forbidden
2422
+ ? `This rate cannot be cancelled now. ${rule} The ${formatMoney(b.quote.total)} paid stays paid.`
2423
+ : back
2424
+ ? `${rule} Cancelling now gives back all ${formatMoney(b.quote.total)} paid, to the card it was paid with.${opts.sandbox ? ' This is a test booking in the simulated Huazhu, paid in the gateway’s test mode: no real money moves.' : ''}`
2425
+ : `${rule} Cancelling now gives nothing back: the fee is the whole stay.`,
2426
+ ...(freeUntil ? { freeUntil: new Date(freeUntil).toISOString() } : {}),
2427
+ cancellable: !forbidden,
2428
+ };
2429
+ }
1776
2430
  return {
1777
2431
  bookingId: b.id,
1778
2432
  confirmationCode: b.confirmationCode,
@@ -1793,6 +2447,19 @@ export const createHuazhuHotels = (opts) => {
1793
2447
  if (!found)
1794
2448
  return null;
1795
2449
  const b = await refresh(found);
2450
+ // Not paid yet: the page is closed, so it cannot be paid after this.
2451
+ if (b.status === 'awaiting-payment' && b.prepaid?.sessionId && checkout) {
2452
+ const state = await checkout.close(b.prepaid.sessionId).catch(() => undefined);
2453
+ if (state === 'closed') {
2454
+ return publicBooking(store.update(b.id, {
2455
+ status: 'expired',
2456
+ prepaid: { ...b.prepaid, lapsedAt: now(), url: undefined },
2457
+ }) ?? b);
2458
+ }
2459
+ // Paid in the last second: it is booked now, and cancelled as one.
2460
+ await settlePrepaid(b);
2461
+ throw new Error('The guest has just paid, so the stay is being booked with Huazhu now. Ask again in a moment to cancel it - a cancellation in the free period gives all the money back.');
2462
+ }
1796
2463
  if (b.pendingSince !== undefined || b.status === 'unconfirmed') {
1797
2464
  throw new Error(waitingWords(b));
1798
2465
  }
@@ -1806,12 +2473,18 @@ export const createHuazhuHotels = (opts) => {
1806
2473
  const fee = typeof out.penaltyAmount === 'number'
1807
2474
  ? money(toMinor(out.penaltyAmount, out.penaltyCurrencyCode || cur), out.penaltyCurrencyCode || cur)
1808
2475
  : money(feeNow(b).fee, cur);
1809
- return publicBooking(store.update(b.id, {
2476
+ const cancelled = store.update(b.id, {
1810
2477
  status: 'cancelled',
1811
2478
  hotelStatus: 'HOTEL_CANCEL',
1812
2479
  cancelledAt: new Date(now()).toISOString(),
1813
2480
  cancellationFee: fee,
1814
- }) ?? b);
2481
+ }) ?? b;
2482
+ // Paid in advance and cancelled free: all of it back. A fee here is
2483
+ // the whole stay (only whole-or-nothing rates are sold this way), so
2484
+ // nothing comes back.
2485
+ if (cancelled.prepaid?.paidAt && fee.minor === 0)
2486
+ await refundOf(b.id, 'cancelled within the free cancellation period');
2487
+ return publicBooking(store.get(b.id) ?? cancelled);
1815
2488
  }
1816
2489
  catch (err) {
1817
2490
  if (err instanceof HuazhuRefusal && err.code === 5402) {