@curless/hotel-storefront 0.28.0 → 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 (62) hide show
  1. package/dist/backend.d.ts +7 -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 +38 -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/huazhu/backend.d.ts +11 -1
  14. package/dist/huazhu/backend.d.ts.map +1 -1
  15. package/dist/huazhu/backend.js +516 -32
  16. package/dist/huazhu/backend.js.map +1 -1
  17. package/dist/huazhu/bookings.d.ts +37 -1
  18. package/dist/huazhu/bookings.d.ts.map +1 -1
  19. package/dist/huazhu/bookings.js.map +1 -1
  20. package/dist/huazhu/make.d.ts.map +1 -1
  21. package/dist/huazhu/make.js +25 -0
  22. package/dist/huazhu/make.js.map +1 -1
  23. package/dist/huazhu/sandbox-photos.d.ts +7 -0
  24. package/dist/huazhu/sandbox-photos.d.ts.map +1 -1
  25. package/dist/huazhu/sandbox-photos.js +7 -0
  26. package/dist/huazhu/sandbox-photos.js.map +1 -1
  27. package/dist/huazhu/sandbox.d.ts +2 -0
  28. package/dist/huazhu/sandbox.d.ts.map +1 -1
  29. package/dist/huazhu/sandbox.js +14 -6
  30. package/dist/huazhu/sandbox.js.map +1 -1
  31. package/dist/huazhu/sellable.d.ts +17 -0
  32. package/dist/huazhu/sellable.d.ts.map +1 -1
  33. package/dist/huazhu/sellable.js +37 -8
  34. package/dist/huazhu/sellable.js.map +1 -1
  35. package/dist/legal.d.ts.map +1 -1
  36. package/dist/legal.js +19 -8
  37. package/dist/legal.js.map +1 -1
  38. package/dist/payments/checkout.d.ts +71 -0
  39. package/dist/payments/checkout.d.ts.map +1 -0
  40. package/dist/payments/checkout.js +133 -0
  41. package/dist/payments/checkout.js.map +1 -0
  42. package/dist/payments/fake-gateway.d.ts +43 -0
  43. package/dist/payments/fake-gateway.d.ts.map +1 -0
  44. package/dist/payments/fake-gateway.js +135 -0
  45. package/dist/payments/fake-gateway.js.map +1 -0
  46. package/dist/server.d.ts +4 -2
  47. package/dist/server.d.ts.map +1 -1
  48. package/dist/server.js +19 -4
  49. package/dist/server.js.map +1 -1
  50. package/dist/supplier.d.ts +2 -0
  51. package/dist/supplier.d.ts.map +1 -1
  52. package/dist/supplier.js.map +1 -1
  53. package/dist/surface.d.ts.map +1 -1
  54. package/dist/surface.js +7 -2
  55. package/dist/surface.js.map +1 -1
  56. package/dist/tools.d.ts +15 -1
  57. package/dist/tools.d.ts.map +1 -1
  58. package/dist/tools.js +127 -16
  59. package/dist/tools.js.map +1 -1
  60. package/dist/types.d.ts +41 -1
  61. package/dist/types.d.ts.map +1 -1
  62. package/package.json +1 -1
@@ -86,6 +86,21 @@ const WINDOW = {
86
86
  '3': 'No window',
87
87
  '4': 'Window onto an inner courtyard',
88
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
+ };
89
104
  const pool = async (items, n, fn) => {
90
105
  const out = new Array(items.length);
91
106
  let next = 0;
@@ -268,12 +283,43 @@ export const englishFits = (hotels, ref) => {
268
283
  * "Sun 11 Oct, 00:00" for a stay arriving on Saturday the 10th - read as a
269
284
  * day more than there is (2026-10-05 audit, P3). Said as the 10th, 24:00.
270
285
  */
271
- export const deadlineAt = (instant, tz) => {
286
+ export const deadlineAt = (instant, tz, language = 'en') => {
287
+ if (language === 'zh')
288
+ return deadlineZh(instant, tz);
272
289
  const shown = atHotelTime(instant, tz);
273
290
  if (!/, 00:00\b/.test(shown))
274
291
  return shown;
275
292
  return atHotelTime(instant - 60_000, tz).replace(/, 23:59\b/, ', 24:00');
276
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
+ };
277
323
  /**
278
324
  * How a fresh price differs from what the guest was shown - or nothing, when
279
325
  * it does not. See `ShownQuote`.
@@ -302,6 +348,15 @@ export const movedSince = (shown, q) => {
302
348
  };
303
349
  export const createHuazhuHotels = (opts) => {
304
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;
305
360
  const now = opts.now ?? (() => Date.now());
306
361
  const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
307
362
  const ariTtl = opts.ariTtlMs ?? 60_000;
@@ -314,6 +369,14 @@ export const createHuazhuHotels = (opts) => {
314
369
  /** A room as offered - type and rate - in the connector's language. */
315
370
  const offerOf = (p) => (en ? offerNameEn(p.roomTypeName, p.rateCodeName) : undefined) ??
316
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
+ : '不含早';
317
380
  /** A rate's own name in the connector's language, or Huazhu's words as they are. */
318
381
  const rateNameOf = (p) => (en ? rateNameEn(p.rateCodeName) : undefined) ?? p.rateCodeName ?? p.rateCode;
319
382
  const today = () => todayAt(now(), opts.tz);
@@ -529,6 +592,7 @@ export const createHuazhuHotels = (opts) => {
529
592
  today: today(),
530
593
  clock: clockAt(now(), opts.tz),
531
594
  paymentType: opts.paymentType,
595
+ ...(prepaidType ? { prepaidType } : {}),
532
596
  allowTags,
533
597
  };
534
598
  return products
@@ -605,6 +669,7 @@ export const createHuazhuHotels = (opts) => {
605
669
  today: date,
606
670
  clock,
607
671
  paymentType: opts.paymentType,
672
+ ...(prepaidType ? { prepaidType } : {}),
608
673
  allowTags,
609
674
  };
610
675
  return live
@@ -645,7 +710,9 @@ export const createHuazhuHotels = (opts) => {
645
710
  sleeps: s.sleeps,
646
711
  ...(rt?.hotelArea ? { sizeSqm: rt.hotelArea } : {}),
647
712
  ...(beds.length ? { beds: beds.join(' / ') } : {}),
648
- ...(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
+ : {}),
649
716
  breakfast: !!s.meal,
650
717
  ...(roomPhoto(h, p.product.roomTypeCode)
651
718
  ? { imageUrl: roomPhoto(h, p.product.roomTypeCode) }
@@ -666,21 +733,41 @@ export const createHuazhuHotels = (opts) => {
666
733
  // ⚠️ ON THE HOTEL'S CLOCK. Without it the guest read "10:00 (UTC)"
667
734
  // for what a Shanghai desk calls 18:00 - found by the Huazhu evals
668
735
  // (2026-10-05).
669
- freeCancellationLocal: deadlineAt(freeUntil, opts.tz),
736
+ freeCancellationLocal: deadlineAt(freeUntil, opts.tz, en ? 'en' : 'zh'),
670
737
  }
671
738
  : {}),
672
739
  roomsLeft: s.roomsLeft,
673
740
  roomType: { id: p.product.roomTypeCode, name: roomTypeOf(p.product) },
674
741
  rate: { id: p.product.rateCode, name: rateNameOf(p.product) },
675
742
  labels: rateLabels(p.product, { type: s.mealType, count: s.mealCount }),
676
- // ⚠️ AT THE HOTEL, ALWAYS, HERE: prepaid rates are left out before
677
- // this (`sell`, kind 'prepaid') until robin decides how that money is
678
- // taken - a live deployment never offers one (总线程, 2026-10-12).
679
- payment: 'at-hotel',
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',
680
747
  ...(hourly ? { hourly } : {}),
681
748
  ...(maxChildren > 0 && maxChildren < 99 ? { maxChildren } : {}),
682
749
  };
683
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
+ };
684
771
  /** Why nothing here can be sold for these nights - the most useful reason first. */
685
772
  const noRoomsWhy = (priced, stay, rooms) => {
686
773
  if (priced.length === 0)
@@ -688,10 +775,8 @@ export const createHuazhuHotels = (opts) => {
688
775
  const fails = priced.map((p) => p.res).filter((r) => !r.ok);
689
776
  const kinds = new Set(fails.map((f) => f.kind));
690
777
  const only = (k) => kinds.size === 1 && kinds.has(k);
691
- if (only('party')) {
692
- const largest = Math.max(...fails.map((f) => Number(/\d+/.exec(f.why)?.[0] ?? 0)));
693
- return partyNeeds(stay.guests, largest);
694
- }
778
+ if (only('party'))
779
+ return partyNeeds(stay.guests, largestOf(fails));
695
780
  if (only('prepaid'))
696
781
  return 'Every rate here for those nights is paid in advance, and this connector books rates paid at the hotel.';
697
782
  if (only('sold-out'))
@@ -819,9 +904,7 @@ export const createHuazhuHotels = (opts) => {
819
904
  detail: [
820
905
  room.beds,
821
906
  room.view,
822
- p.res.meal
823
- ? `${p.res.meal}${p.res.mealCount > 1 ? ` × ${p.res.mealCount}` : ''}`
824
- : 'Room only',
907
+ mealWords(p.res),
825
908
  hourly ? `${hourly.start}–${endOf(hourly.start, hourly.hours)}` : undefined,
826
909
  ]
827
910
  .filter(Boolean)
@@ -829,11 +912,15 @@ export const createHuazhuHotels = (opts) => {
829
912
  stay: line.stay,
830
913
  nights: room.nights,
831
914
  ...(hourly ? { hourly } : {}),
915
+ ...(room.payment === 'prepaid' ? { payment: 'prepaid' } : {}),
832
916
  quantity: wanted,
833
917
  unit: room.nightly,
834
918
  total: money(preTax, room.stayTotal.currency),
835
919
  refundable: room.refundable,
836
920
  freeCancellationUntil: room.freeCancellationUntil,
921
+ ...(room.freeCancellationLocal
922
+ ? { freeCancellationLocal: room.freeCancellationLocal }
923
+ : {}),
837
924
  day: line.stay.checkIn,
838
925
  });
839
926
  }
@@ -869,6 +956,33 @@ export const createHuazhuHotels = (opts) => {
869
956
  return q;
870
957
  };
871
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
+ };
872
986
  const publicBooking = (b) => ({
873
987
  id: b.id,
874
988
  confirmationCode: b.confirmationCode,
@@ -883,6 +997,7 @@ export const createHuazhuHotels = (opts) => {
883
997
  ...(b.cancelledAt ? { cancelledAt: b.cancelledAt } : {}),
884
998
  ...(b.cancellationFee ? { cancellationFee: b.cancellationFee } : {}),
885
999
  ...(b.requests ? { requests: b.requests } : {}),
1000
+ ...(b.prepaid ? { payment: paymentOf(b) } : {}),
886
1001
  });
887
1002
  /**
888
1003
  * The codes a guest may have been given for a booking: ours, and the
@@ -919,7 +1034,15 @@ export const createHuazhuHotels = (opts) => {
919
1034
  return there;
920
1035
  };
921
1036
  /** Huazhu's own word on a booking, at most once a minute each. */
922
- 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
+ }
923
1046
  if (!b.resId || b.pendingSince !== undefined)
924
1047
  return b;
925
1048
  if (b.refreshedAt && now() - b.refreshedAt < 60_000)
@@ -1003,7 +1126,11 @@ export const createHuazhuHotels = (opts) => {
1003
1126
  const conversationOf = (who) => who.tripOwner ?? who.sessionId;
1004
1127
  /** A name for a booking in what the guest is told. */
1005
1128
  const named = (b) => `${b.hotelName} (${b.confirmationCode})`;
1006
- 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') &&
1007
1134
  !b.rollback &&
1008
1135
  b.stay.checkOut >= today();
1009
1136
  /** The booking itself, one request at a time per conversation and email - see `single`. */
@@ -1097,6 +1224,197 @@ export const createHuazhuHotels = (opts) => {
1097
1224
  },
1098
1225
  }
1099
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
+ };
1100
1418
  const bookHeld = async (lines, shown, tripKey, email, guest, contact, who, requests, slot) => {
1101
1419
  // Priced afresh, not from the cache: this is the price that will be sent.
1102
1420
  const priced = await quoteLines({ lines }, true);
@@ -1118,6 +1436,11 @@ export const createHuazhuHotels = (opts) => {
1118
1436
  throw new Error('Nothing was booked: the trip changed while it was being priced.');
1119
1437
  plans.push({ i, line, sold, qline });
1120
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.');
1121
1444
  for (const plan of plans) {
1122
1445
  const s = plan.sold.res;
1123
1446
  const title = `${plan.qline.hotelName} - ${plan.qline.title}`;
@@ -1125,7 +1448,7 @@ export const createHuazhuHotels = (opts) => {
1125
1448
  const out = await client.prebook({
1126
1449
  hotelId: plan.line.hotelId,
1127
1450
  checkinType: plan.line.hourly ? 'HOUR' : 'DAY',
1128
- paymentType: opts.paymentType,
1451
+ paymentType: plan.sold.res.paymentType,
1129
1452
  reservations: [
1130
1453
  {
1131
1454
  roomTypeCode: plan.sold.product.roomTypeCode,
@@ -1150,6 +1473,92 @@ export const createHuazhuHotels = (opts) => {
1150
1473
  requestsByEmail.add(email);
1151
1474
  requestsByConversation.add(conversationOf(who));
1152
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
+ }
1153
1562
  const made = [];
1154
1563
  /**
1155
1564
  * Undo what this trip booked, when a later stay of it failed - and say
@@ -1254,7 +1663,7 @@ export const createHuazhuHotels = (opts) => {
1254
1663
  distributorResId: dres,
1255
1664
  tripRef,
1256
1665
  tripKey,
1257
- paymentType: opts.paymentType,
1666
+ paymentType: s.paymentType,
1258
1667
  guaranteeCode: s.guaranteeCode,
1259
1668
  cancelCode: s.cancelCode,
1260
1669
  nightlyMinor,
@@ -1299,7 +1708,7 @@ export const createHuazhuHotels = (opts) => {
1299
1708
  checkinType: plan.line.hourly ? 'HOUR' : 'DAY',
1300
1709
  contactPerson: contact,
1301
1710
  distributorResId: dres,
1302
- paymentType: opts.paymentType,
1711
+ paymentType: s.paymentType,
1303
1712
  reservations: [reservation],
1304
1713
  ...(comment ? { guestComment: comment } : {}),
1305
1714
  ...(plan.token ? { preBookToken: plan.token } : {}),
@@ -1374,7 +1783,21 @@ export const createHuazhuHotels = (opts) => {
1374
1783
  };
1375
1784
  return {
1376
1785
  brand: opts.brand,
1377
- capabilities: { ...HUAZHU_CAPABILITIES, ...(opts.hourly ? { hourly: true } : {}) },
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
+ },
1378
1801
  imageDomains() {
1379
1802
  const hosts = new Set();
1380
1803
  for (const h of catalog.all()) {
@@ -1524,7 +1947,7 @@ export const createHuazhuHotels = (opts) => {
1524
1947
  const priced = await priceHourly(raw, stay.guests, wantRooms);
1525
1948
  pricedRooms.set(raw.hotelId, priced);
1526
1949
  if (!priced.some((p) => p.res.ok))
1527
- why.set(raw.hotelId, 'No room by the hour here today.');
1950
+ why.set(raw.hotelId, hourlyNoRoomsWhy(priced, stay.guests));
1528
1951
  }
1529
1952
  catch {
1530
1953
  pricedRooms.set(raw.hotelId, []);
@@ -1574,8 +1997,16 @@ export const createHuazhuHotels = (opts) => {
1574
1997
  if (priceEverything)
1575
1998
  await pool(matches.map((m) => m.raw), PRICE_CONCURRENCY, priceHotel);
1576
1999
  const canRoomFilter = priceEverything;
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
+ };
1577
2008
  if (canRoomFilter && (roomTerms || hourlyOnly))
1578
- matches = matches.filter(({ raw }) => bestFor(raw, f) !== null);
2009
+ matches = matches.filter(({ raw }) => bestFor(raw, f) !== null || (hourlyOnly && !roomTerms && tooSmallOnly(raw)));
1579
2010
  const currencies = [...new Set(matches.map((m) => m.hotel.currency))].sort();
1580
2011
  const onePrice = currencies.length === 1;
1581
2012
  const ceiling = priceEverything && onePrice && Number(q.maxNightly) > 0 ? Number(q.maxNightly) : undefined;
@@ -1774,13 +2205,12 @@ export const createHuazhuHotels = (opts) => {
1774
2205
  .filter((p) => p.res.ok)
1775
2206
  .map((p) => toRoom(raw, p, stay))
1776
2207
  .sort((a, b) => a.stayTotal.minor - b.stayTotal.minor);
1777
- const why = priced.length === 0
1778
- ? 'This hotel sells no rooms by the hour today.'
1779
- : priced.some((p) => !p.res.ok && p.res.kind === 'prepaid') &&
1780
- priced.every((p) => !p.res.ok)
1781
- ? "This hotel's rooms by the hour are paid in advance, and this connector books rates paid at the hotel."
1782
- : 'No room by the hour is left here today.';
1783
- return { hotel, stay, rooms, ...(rooms.length === 0 ? { noRooms: why } : {}) };
2208
+ return {
2209
+ hotel,
2210
+ stay,
2211
+ rooms,
2212
+ ...(rooms.length === 0 ? { noRooms: hourlyNoRoomsWhy(priced, guests) } : {}),
2213
+ };
1784
2214
  },
1785
2215
  async datesAround(id, stay, o) {
1786
2216
  const raw = findHotel(id);
@@ -1820,6 +2250,7 @@ export const createHuazhuHotels = (opts) => {
1820
2250
  today: from,
1821
2251
  clock: clockAt(now(), opts.tz),
1822
2252
  paymentType: opts.paymentType,
2253
+ ...(prepaidType ? { prepaidType } : {}),
1823
2254
  allowTags,
1824
2255
  };
1825
2256
  let cheapest;
@@ -1899,7 +2330,13 @@ export const createHuazhuHotels = (opts) => {
1899
2330
  const mine = store
1900
2331
  .all()
1901
2332
  .filter((b) => b.owners.includes(who.sessionId) &&
1902
- !(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))
1903
2340
  .sort((a, b) => b.createdAt.localeCompare(a.createdAt))
1904
2341
  .slice(0, most);
1905
2342
  const fresh = await pool(mine, 3, (b) => refresh(b));
@@ -1939,6 +2376,16 @@ export const createHuazhuHotels = (opts) => {
1939
2376
  const cur = b.quote.total.currency;
1940
2377
  const zero = money(0, cur);
1941
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
+ }
1942
2389
  if (b.pendingSince !== undefined || b.status === 'unconfirmed') {
1943
2390
  return {
1944
2391
  bookingId: b.id,
@@ -1962,6 +2409,24 @@ export const createHuazhuHotels = (opts) => {
1962
2409
  };
1963
2410
  }
1964
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
+ }
1965
2430
  return {
1966
2431
  bookingId: b.id,
1967
2432
  confirmationCode: b.confirmationCode,
@@ -1982,6 +2447,19 @@ export const createHuazhuHotels = (opts) => {
1982
2447
  if (!found)
1983
2448
  return null;
1984
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
+ }
1985
2463
  if (b.pendingSince !== undefined || b.status === 'unconfirmed') {
1986
2464
  throw new Error(waitingWords(b));
1987
2465
  }
@@ -1995,12 +2473,18 @@ export const createHuazhuHotels = (opts) => {
1995
2473
  const fee = typeof out.penaltyAmount === 'number'
1996
2474
  ? money(toMinor(out.penaltyAmount, out.penaltyCurrencyCode || cur), out.penaltyCurrencyCode || cur)
1997
2475
  : money(feeNow(b).fee, cur);
1998
- return publicBooking(store.update(b.id, {
2476
+ const cancelled = store.update(b.id, {
1999
2477
  status: 'cancelled',
2000
2478
  hotelStatus: 'HOTEL_CANCEL',
2001
2479
  cancelledAt: new Date(now()).toISOString(),
2002
2480
  cancellationFee: fee,
2003
- }) ?? 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);
2004
2488
  }
2005
2489
  catch (err) {
2006
2490
  if (err instanceof HuazhuRefusal && err.code === 5402) {