@curless/hotel-storefront 0.43.0 → 0.44.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 (48) hide show
  1. package/dist/card.generated.d.ts +3 -1
  2. package/dist/card.generated.d.ts.map +1 -1
  3. package/dist/card.generated.js +4 -2
  4. package/dist/card.generated.js.map +1 -1
  5. package/dist/config.d.ts +18 -0
  6. package/dist/config.d.ts.map +1 -1
  7. package/dist/config.js.map +1 -1
  8. package/dist/huazhu/backend.d.ts +10 -0
  9. package/dist/huazhu/backend.d.ts.map +1 -1
  10. package/dist/huazhu/backend.js +538 -68
  11. package/dist/huazhu/backend.js.map +1 -1
  12. package/dist/huazhu/bookings.d.ts +15 -0
  13. package/dist/huazhu/bookings.d.ts.map +1 -1
  14. package/dist/huazhu/bookings.js.map +1 -1
  15. package/dist/huazhu/client.d.ts +84 -0
  16. package/dist/huazhu/client.d.ts.map +1 -1
  17. package/dist/huazhu/client.js +147 -1
  18. package/dist/huazhu/client.js.map +1 -1
  19. package/dist/huazhu/make.d.ts.map +1 -1
  20. package/dist/huazhu/make.js +35 -1
  21. package/dist/huazhu/make.js.map +1 -1
  22. package/dist/index.js +0 -0
  23. package/dist/limits.d.ts +34 -1
  24. package/dist/limits.d.ts.map +1 -1
  25. package/dist/limits.js +2 -0
  26. package/dist/limits.js.map +1 -1
  27. package/dist/payments/checkout.d.ts +11 -0
  28. package/dist/payments/checkout.d.ts.map +1 -1
  29. package/dist/payments/checkout.js.map +1 -1
  30. package/dist/payments/fake-stripe.d.ts +4 -0
  31. package/dist/payments/fake-stripe.d.ts.map +1 -1
  32. package/dist/payments/fake-stripe.js +21 -1
  33. package/dist/payments/fake-stripe.js.map +1 -1
  34. package/dist/payments/fake-unionpay.d.ts +2 -0
  35. package/dist/payments/fake-unionpay.d.ts.map +1 -1
  36. package/dist/payments/fake-unionpay.js +7 -1
  37. package/dist/payments/fake-unionpay.js.map +1 -1
  38. package/dist/payments/stripe-checkout.d.ts +1 -0
  39. package/dist/payments/stripe-checkout.d.ts.map +1 -1
  40. package/dist/payments/stripe-checkout.js +4 -2
  41. package/dist/payments/stripe-checkout.js.map +1 -1
  42. package/dist/server.d.ts +2 -2
  43. package/dist/server.js +2 -2
  44. package/dist/tools.d.ts +30 -1
  45. package/dist/tools.d.ts.map +1 -1
  46. package/dist/tools.js +49 -2
  47. package/dist/tools.js.map +1 -1
  48. package/package.json +13 -14
@@ -4,14 +4,14 @@ import { addDays, arrivalInstant, atHotelTime, nights as nightsBetween, stayProb
4
4
  import { byWords, splitByStay } from '../demo-hotels.js';
5
5
  import { FACILITIES, hasFacility } from '../facilities.js';
6
6
  import { endOf, startProblem } from '../hourly.js';
7
- import { BOOKING_LIMITS, DAY_MS, HOUR_MS, WindowCounter } from '../limits.js';
7
+ import { BOOKING_LIMITS, DAY_MS, HOUR_MS, UNPAID_PER_STRICT, WindowCounter, } from '../limits.js';
8
8
  import { formatMoney, minorFactor, money, multiplyMoney, toMinor } from '../money.js';
9
9
  import { UnionPayError, expiryMMyy, lastFour, } from '../payments/unionpay.js';
10
10
  import { MAX_ROOMS_PER_LINE, quoteLineKey } from '../trip.js';
11
11
  import { IN_REQUEST_AT_MS, NOT_FOUND, StoreWriteError, nextCheckAt, statusOf, } from './bookings.js';
12
- import { describePolicy, feeFor, freeUntilOf, nightAuditOf, parseCancelPolicy, penaltyAt, refundableFrom, stepsOf, } from './cancel-policy.js';
12
+ import { describePolicy, feeFor, freeUntilOf, nightAuditOf, parseCancelPolicy, penaltyAt, phasesOf, refundableFrom, stepsOf, } from './cancel-policy.js';
13
13
  import { haystackOf, inPlace, namesOf, roomPhoto, toHotel } from './catalog.js';
14
- import { HuazhuRefusal, HuazhuUnreachable } from './client.js';
14
+ import { HuazhuRefusal, HuazhuTooBusy, HuazhuUnreachable } from './client.js';
15
15
  import { bedNameEn, offerNameEn, rateNameEn, roomNameEn } from './english.js';
16
16
  import { rateLabels } from './labels.js';
17
17
  import { ariRanges, bookableNow, clockAt, goneWhy, hourlyWindow, inStayWindow, offeredAtAll, sell, } from './sellable.js';
@@ -191,6 +191,8 @@ const localIsoAt = (instant, tz) => {
191
191
  .map((x) => [x.type, x.value]));
192
192
  return `${p.year}-${p.month}-${p.day}T${p.hour}:${p.minute}`;
193
193
  };
194
+ /** A stay paid for - on the card page, or confirmed on UnionPay (whose one-time card Huazhu then charges). */
195
+ const paidInAdvance = (p) => p.method === 'unionpay' ? p.issuedAt !== undefined : p.paidAt !== undefined;
194
196
  const pool = async (items, n, fn) => {
195
197
  const out = new Array(items.length);
196
198
  let next = 0;
@@ -470,6 +472,19 @@ whole = true) => {
470
472
  said.push(`The total is now ${formatMoney(q.total)}, not the ${formatMoney(money(total, q.total.currency))} shown.`);
471
473
  return said.length ? said.join(' ') : undefined;
472
474
  };
475
+ /** A read of a booking's detail is good for this long. */
476
+ const DETAIL_FRESH_MS = 60_000;
477
+ /** A read that failed is not tried again for this long - longer than a good one is kept. */
478
+ const DETAIL_RETRY_MS = 2 * 60_000;
479
+ /** The background watch tries a booking again this soon after a read of it failed (never later than its usual interval). */
480
+ const WATCH_RETRY_MS = 30 * 60_000;
481
+ /**
482
+ * The WHOLE background watch rests this long after a read it made failed
483
+ * (2026-10-21, 总线程): with Huazhu not answering, each 10-second pass tried
484
+ * one more stay and the hour's calls went on failures within minutes. A
485
+ * guest's own lookup is not held by it - it has its own cooldown a booking.
486
+ */
487
+ const WATCH_PAUSE_MS = 5 * 60_000;
473
488
  export const createHuazhuHotels = (opts) => {
474
489
  const { client, catalog, store, reconciler } = opts;
475
490
  // ⚠️ NO MONEY TAKEN FOR A REAL HUAZHU YET: who takes it, the licence, a
@@ -512,6 +527,10 @@ export const createHuazhuHotels = (opts) => {
512
527
  const rateNameOf = (p) => (en ? rateNameEn(p.rateCodeName) : undefined) ?? p.rateCodeName ?? p.rateCode;
513
528
  const today = () => todayAt(now(), opts.tz);
514
529
  const limits = { ...BOOKING_LIMITS, ...(opts.limits ?? {}) };
530
+ /** The looser ceiling on unpaid requests to pay in advance - a multiple of the strict one unless set (T2-14). */
531
+ const unpaidPerDay = opts.limits?.unpaidPerDay ?? limits.perDeploymentPerDay * UNPAID_PER_STRICT;
532
+ /** When the unpaid ceiling was last said in the log: the strict one's time is the store's. */
533
+ let unpaidCeilingSaidAt;
515
534
  const requestsByEmail = new WindowCounter(DAY_MS, now);
516
535
  const requestsByIp = new WindowCounter(HOUR_MS, now);
517
536
  const requestsByConversation = new WindowCounter(HOUR_MS, now);
@@ -534,6 +553,15 @@ export const createHuazhuHotels = (opts) => {
534
553
  */
535
554
  const slots = new Set();
536
555
  const slotsWhere = (pred) => [...slots].filter(pred).reduce((a, x) => a + x.n, 0);
556
+ /**
557
+ * A request to pay in advance that has not been paid: waiting for its
558
+ * payment, or lapsed without (2026-10-21 re-audit, T2-14). It costs a
559
+ * prebook at Huazhu and a payment page at Stripe, and is counted against its
560
+ * own, looser daily ceiling - not the strict one that stops all booking.
561
+ */
562
+ const unpaidAsk = (b) => !!b.prepaid &&
563
+ !paidInAdvance(b.prepaid) &&
564
+ (b.status === 'awaiting-payment' || b.status === 'expired');
537
565
  /** Requests in flight, by conversation and by email - see `single`. */
538
566
  const flights = new Map();
539
567
  /** Set by `drain`: a stopping process starts no booking. */
@@ -1436,38 +1464,94 @@ export const createHuazhuHotels = (opts) => {
1436
1464
  return there;
1437
1465
  };
1438
1466
  /**
1439
- * Huazhu's own word on a booking, at most once a minute each.
1467
+ * ⚠️ HUAZHU DOWN, OR SAYING "TOO MANY REQUESTS" (2009), IS NOT TO BE ASKED
1468
+ * AGAIN AT ONCE (2026-10-21 re-audit, T2-4 / T2-5; the way `ariBackoff` does
1469
+ * it for prices). One 2009 holds EVERY detail ask - a guest's and the
1470
+ * background watch's - for a minute, doubling to ten while it goes on. What
1471
+ * was last known stands meanwhile, and says so by its age.
1472
+ */
1473
+ let detailBackoff;
1474
+ const detailHeld = () => detailBackoff !== undefined && now() < detailBackoff.until;
1475
+ /** One ask a booking at a time: a second look at a booking being read waits for that read. */
1476
+ const detailFlights = new Map();
1477
+ /** Whether the last attempt on a booking was a failure, newer than any success. */
1478
+ const failedLast = (b) => b.refreshFailedAt !== undefined &&
1479
+ (b.refreshedAt === undefined || b.refreshFailedAt > b.refreshedAt);
1480
+ /** Asked about so lately that asking again is no use: a success for a minute, a failure for two. */
1481
+ const lookedAtLately = (b) => failedLast(b)
1482
+ ? now() - b.refreshFailedAt < DETAIL_RETRY_MS
1483
+ : b.refreshedAt !== undefined && now() - b.refreshedAt < DETAIL_FRESH_MS;
1484
+ /** The one call to Huazhu for a booking, and what it is written down as. */
1485
+ const askDetail = async (b) => {
1486
+ // The booking as it is when the read BEGINS (2026-10-21 re-audit, T2-3):
1487
+ // the row is live, so what it says afterwards is not what it said before.
1488
+ const seen = b.status;
1489
+ let d;
1490
+ try {
1491
+ d = await client.detail(b.resId);
1492
+ }
1493
+ catch (err) {
1494
+ if (err instanceof HuazhuRefusal && err.code === CODES.rateLimited) {
1495
+ const wait = Math.min((detailBackoff?.wait ?? 30_000) * 2, 10 * 60_000);
1496
+ detailBackoff = { until: now() + wait, wait };
1497
+ }
1498
+ // Unreachable or refused: what we last knew stands, and says so by its
1499
+ // age. The attempt is written down so it is not made again at once.
1500
+ return { booking: store.update(b.id, { refreshFailedAt: now() }) ?? b, outcome: 'failed' };
1501
+ }
1502
+ detailBackoff = undefined;
1503
+ // ⚠️ AN ANSWER OLDER THAN THE BOOKING IS NOT WRITTEN (2026-10-21 re-audit,
1504
+ // T2-3, R3): a guest's cancellation completing while this was in flight
1505
+ // was written back as "confirmed", the payment refunding, for a minute.
1506
+ // The booking is read again here, after the wait, and changed only if its
1507
+ // status is what it was when this read began.
1508
+ const cur = store.get(b.id);
1509
+ if (!cur || cur.status !== seen)
1510
+ return { booking: cur ?? b, outcome: 'overtaken' };
1511
+ const status = statusOf(d.status);
1512
+ const line = d.reservations?.[0];
1513
+ const row = store.update(b.id, {
1514
+ hotelStatus: d.status,
1515
+ status,
1516
+ refreshedAt: now(),
1517
+ ...(line?.cancelPolicyCode ? { cancelCode: line.cancelPolicyCode } : {}),
1518
+ ...(status === 'cancelled' && !cur.cancelledAt
1519
+ ? { cancelledAt: new Date(now()).toISOString() }
1520
+ : {}),
1521
+ });
1522
+ return { booking: row ?? cur, outcome: 'read', moved: status !== seen };
1523
+ };
1524
+ /**
1525
+ * Huazhu's own word on a booking, at most once a minute each - and after a
1526
+ * failure, once in two, and not at all while it says "too many requests".
1527
+ * Two looks at one booking at once are one call.
1440
1528
  *
1441
1529
  * ⚠️ IT ONLY READS (2026-10-16 audit, P0-A). It used to move a payment on
1442
1530
  * as it was asked about - outside the booking's lock, from every lookup and
1443
1531
  * every few seconds from a card waiting on a payment. Payments move on in
1444
1532
  * the pass, the landing page and the steps a guest takes, one at a time.
1533
+ * What it writes is the booking's status and the time of the look; a
1534
+ * cancellation at Huazhu's end is then a payment to settle (`moneyPending`).
1535
+ *
1536
+ * The guest's lookup and the background watch (`watchPass`) are this one
1537
+ * function, so a cancellation found by either is written alike.
1445
1538
  */
1446
- const refresh = async (found) => {
1539
+ const lookAt = (found) => {
1447
1540
  const b = store.get(found.id) ?? found;
1448
1541
  if (!b.resId || b.pendingSince !== undefined)
1449
- return b;
1450
- if (b.refreshedAt && now() - b.refreshedAt < 60_000)
1451
- return b;
1452
- try {
1453
- const d = await client.detail(b.resId);
1454
- const status = statusOf(d.status);
1455
- const line = d.reservations?.[0];
1456
- return (store.update(b.id, {
1457
- hotelStatus: d.status,
1458
- status,
1459
- refreshedAt: now(),
1460
- ...(line?.cancelPolicyCode ? { cancelCode: line.cancelPolicyCode } : {}),
1461
- ...(status === 'cancelled' && !b.cancelledAt
1462
- ? { cancelledAt: new Date(now()).toISOString() }
1463
- : {}),
1464
- }) ?? b);
1465
- }
1466
- catch {
1467
- // Unreachable or refused: what we last knew stands, and says so by its age.
1468
- return b;
1469
- }
1542
+ return Promise.resolve({ booking: b, outcome: 'none' });
1543
+ const flying = detailFlights.get(b.id);
1544
+ if (flying)
1545
+ return flying;
1546
+ if (lookedAtLately(b) || detailHeld())
1547
+ return Promise.resolve({ booking: b, outcome: 'none' });
1548
+ const flight = askDetail(b).finally(() => {
1549
+ detailFlights.delete(b.id);
1550
+ });
1551
+ detailFlights.set(b.id, flight);
1552
+ return flight;
1470
1553
  };
1554
+ const refresh = async (found) => (await lookAt(found)).booking;
1471
1555
  /** Why a booking cannot be cancelled yet: Huazhu has not said whether it exists. */
1472
1556
  const waitingWords = (b) => b.status === 'unconfirmed'
1473
1557
  ? `${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.`
@@ -1522,6 +1606,39 @@ export const createHuazhuHotels = (opts) => {
1522
1606
  steps,
1523
1607
  };
1524
1608
  };
1609
+ /**
1610
+ * The fee cancelling comes to if it is the SAME whenever in `from`..`to` it
1611
+ * was done - by the rate's own terms, worked out by `feeNow` at both ends and
1612
+ * at every moment in between that a window of the terms begins (and where a
1613
+ * room by the hour begins) - or undefined when it differs, so that WHEN
1614
+ * matters (2026-10-21 re-audit, T2-8). Undefined too when the terms cannot be
1615
+ * read, or say the fee is only the most it could be, or say it cannot be
1616
+ * cancelled: none of those is a fee.
1617
+ */
1618
+ const constantFee = (b, from, to) => {
1619
+ const policy = policyOf(b.cancelCode);
1620
+ if (!policy || !Number.isFinite(from) || !Number.isFinite(to) || from > to)
1621
+ return undefined;
1622
+ const moments = new Set([from, to]);
1623
+ for (const ph of phasesOf(policy, b.stay.checkIn, b.tz))
1624
+ if (ph.from > from && ph.from <= to)
1625
+ moments.add(ph.from);
1626
+ if (b.hourly) {
1627
+ const begins = arrivalInstant(b.stay.checkIn, b.hourly.start, b.tz);
1628
+ if (begins > from && begins <= to)
1629
+ moments.add(begins);
1630
+ }
1631
+ let fee;
1632
+ for (const at of moments) {
1633
+ const f = feeNow(b, at);
1634
+ if (f.forbidden || f.feeIsCap)
1635
+ return undefined;
1636
+ if (fee !== undefined && f.fee !== fee)
1637
+ return undefined;
1638
+ fee = f.fee;
1639
+ }
1640
+ return fee;
1641
+ };
1525
1642
  /** `feeNow`'s steps as the card reads them: money, and the hotel's clock. */
1526
1643
  const stepsTold = (b, steps) => steps
1527
1644
  ? {
@@ -1614,16 +1731,22 @@ export const createHuazhuHotels = (opts) => {
1614
1731
  if (who.ip && requestsByIp.count(who.ip) >= limits.perIpPerHour) {
1615
1732
  throw new Error('Nothing was booked. Too many bookings have been asked for from this network in the last hour - try again later.');
1616
1733
  }
1734
+ // ⚠️ TWO CEILINGS, EACH SAYING WHICH (2026-10-21 re-audit, T2-14). This
1735
+ // is the strict one: stays that are made - booked, being booked, cancelled
1736
+ // since - or paid for and about to be booked. A request to pay in advance
1737
+ // that has not been paid is counted apart, and looser, once it is known to
1738
+ // be one (`bookHeld`). Since 2026-10-16 (audit, P2: each prebook and
1739
+ // payment page had to be bounded) unpaid ones were counted HERE, and fifty
1740
+ // anonymous emails, a request each, could fill the day and stop every real
1741
+ // booking.
1617
1742
  const madeToday = store.all().filter((b) => now() - Date.parse(b.createdAt) < DAY_MS &&
1743
+ !unpaidAsk(b) &&
1618
1744
  (b.status === 'confirmed' ||
1619
1745
  b.status === 'requested' ||
1620
1746
  b.status === 'unconfirmed' ||
1621
1747
  b.status === 'cancelled' ||
1622
- // A stay paid in advance asked Huazhu (its prebook) before any
1623
- // payment - waiting for one, or lapsed without, it counts too
1624
- // (2026-10-16 audit, P2: unpaid asks were never counted).
1625
- b.status === 'awaiting-payment' ||
1626
- (b.status === 'expired' && !!b.prepaid))).length + slotsWhere(() => true);
1748
+ // Paid, and about to be booked: the money is taken.
1749
+ b.status === 'awaiting-payment')).length + slotsWhere((x) => !x.unpaid);
1627
1750
  if (madeToday + lines.length > limits.perDeploymentPerDay) {
1628
1751
  // ⚠️ A CEILING HIT IS SOMETHING A PERSON SHOULD KNOW: either business
1629
1752
  // is booming or somebody is filling hotels with names. Logged, and on
@@ -1633,7 +1756,7 @@ export const createHuazhuHotels = (opts) => {
1633
1756
  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`);
1634
1757
  }
1635
1758
  store.ceilingHitAt = now();
1636
- throw new Error(`Nothing was booked: bookings through this connector are paused for now. Try again later, or book with ${opts.brand} directly.`);
1759
+ throw new Error(`Nothing was booked: bookings through this connector are paused for now - the limit on bookings made in a day has been reached. Try again later, or book with ${opts.brand} directly.`);
1637
1760
  }
1638
1761
  // The network counted once it has passed every limit - a request turned
1639
1762
  // away is not one more against the guest, or a guest at a limit could
@@ -1643,7 +1766,11 @@ export const createHuazhuHotels = (opts) => {
1643
1766
  if (who.ip)
1644
1767
  requestsByIp.add(who.ip);
1645
1768
  // ⚠️ AND ITS PLACES TAKEN NOW, before anything is awaited - see `slots`.
1646
- const slot = { sessionId: conversationOf(who), email, n: lines.length };
1769
+ const slot = {
1770
+ sessionId: conversationOf(who),
1771
+ email,
1772
+ n: lines.length,
1773
+ };
1647
1774
  slots.add(slot);
1648
1775
  try {
1649
1776
  return await bookHeld(lines, shown, tripKey, email, guest, contact, who, requests, slot);
@@ -1778,6 +1905,7 @@ export const createHuazhuHotels = (opts) => {
1778
1905
  }
1779
1906
  let refusal;
1780
1907
  let answer;
1908
+ let notSent = false;
1781
1909
  for (let attempt = 0; attempt < 2; attempt += 1) {
1782
1910
  try {
1783
1911
  reconciler.inFlight(id, true);
@@ -1792,6 +1920,13 @@ export const createHuazhuHotels = (opts) => {
1792
1920
  answer = got;
1793
1921
  }
1794
1922
  catch (err) {
1923
+ // ⚠️ NEVER SENT IS NOT "NO ANSWER" (2026-10-21, P1-11): this connector's
1924
+ // own limit on calls to Huazhu was full for too long, so the booking
1925
+ // did not go out. Nothing can exist at Huazhu to be followed up.
1926
+ if (err instanceof HuazhuTooBusy) {
1927
+ notSent = true;
1928
+ break;
1929
+ }
1795
1930
  if (err instanceof HuazhuRefusal && err.code === CODES.systemError && attempt === 0) {
1796
1931
  await sleep(1_000);
1797
1932
  continue;
@@ -1813,6 +1948,18 @@ export const createHuazhuHotels = (opts) => {
1813
1948
  });
1814
1949
  return;
1815
1950
  }
1951
+ if (notSent) {
1952
+ // Put back as it was before the call - paid, waiting to be booked, and
1953
+ // NOT pending: the follow-up queries are for a booking that may exist,
1954
+ // and this one was never asked for. The next pass books it, as it does
1955
+ // when the booking could not be written down above. (Paid with
1956
+ // UnionPay, the gateway hands the one-time card over once and it is
1957
+ // gone: the next pass finds it gone and asks Huazhu whether the stay
1958
+ // exists, as for any lost card - nothing is charged.)
1959
+ store.update(id, { status: 'awaiting-payment', pendingSince: undefined });
1960
+ console.error(`[huazhu prepaid ${id}] the booking was not sent to Huazhu - this connector's own limit on calls to Huazhu stayed full - and is tried again on the next pass`);
1961
+ return;
1962
+ }
1816
1963
  if (refusal)
1817
1964
  await turnedDown(id, refusal, !!card);
1818
1965
  // No answer: the reconciler's eight minutes decide it.
@@ -2110,6 +2257,46 @@ export const createHuazhuHotels = (opts) => {
2110
2257
  ...(cur.prepaid ? { prepaid: { ...cur.prepaid, ...fields } } : {}),
2111
2258
  });
2112
2259
  };
2260
+ /**
2261
+ * ⚠️ ONE RULE FOR CLOSING A CARD PAGE (2026-10-21 re-audit, T2-1 / T2-7):
2262
+ * `closed` is closed and `paid` is paid - and EVERYTHING ELSE is no answer.
2263
+ * Stripe's `close` answers `open` when its expire is refused (a 400) while
2264
+ * the page is still open, and may throw; neither means the page is shut, and
2265
+ * neither means it was just paid. On no answer the caller changes nothing on
2266
+ * the booking (no session cleared, no status moved) and leaves the page to
2267
+ * the prepaid pass, which closes it once it is past its time.
2268
+ */
2269
+ const closePage = async (sessionId) => {
2270
+ if (!checkout)
2271
+ return { state: 'unanswered', why: 'there is no payment provider' };
2272
+ try {
2273
+ const answer = await checkout.close(sessionId);
2274
+ return answer === 'closed' || answer === 'paid'
2275
+ ? { state: answer }
2276
+ : { state: 'unanswered', why: 'the page stayed open' };
2277
+ }
2278
+ catch (err) {
2279
+ return { state: 'unanswered', why: err.message };
2280
+ }
2281
+ };
2282
+ /**
2283
+ * The card page for a booking - the one way it is opened: when the booking
2284
+ * is asked for (`bookHeld`), and again when UnionPay's page would not open
2285
+ * after this one was closed for it (`unionPay`, 2026-10-21 re-audit, T2-6).
2286
+ * A page opened AGAIN says which opening it is (`attempt`): under the
2287
+ * booking's first key the provider would answer with the closed page.
2288
+ */
2289
+ const openCardPage = (row, payBy, attempt) => checkout.open({
2290
+ ref: row.distributorResId,
2291
+ code: row.confirmationCode,
2292
+ title: `${row.hotelName} · ${row.quote.lines[0]?.title ?? ''}`.slice(0, 200),
2293
+ amountMinor: row.quote.total.minor,
2294
+ currency: row.quote.total.currency,
2295
+ email: row.guest.email,
2296
+ // A minute past the booking's own deadline - ours closes it first.
2297
+ expiresAt: payBy + 60_000,
2298
+ ...(attempt ? { attempt } : {}),
2299
+ });
2113
2300
  /** Whether a booking paid in advance still has money work to do. */
2114
2301
  const moneyPending = (b) => {
2115
2302
  const p = b.prepaid;
@@ -2154,8 +2341,15 @@ export const createHuazhuHotels = (opts) => {
2154
2341
  let state;
2155
2342
  try {
2156
2343
  state = await checkout.state(p.sessionId);
2157
- if (state === 'open' && now() >= p.payBy)
2158
- state = await checkout.close(p.sessionId);
2344
+ if (state === 'open' && now() >= p.payBy) {
2345
+ // A close that left the page open is no answer either (2026-10-21
2346
+ // re-audit, T2-7): counted below as a page that could not be asked,
2347
+ // so one that will not close is a person's to look at - not "fine".
2348
+ const closing = await closePage(p.sessionId);
2349
+ if (closing.state === 'unanswered')
2350
+ throw new Error(closing.why);
2351
+ state = closing.state;
2352
+ }
2159
2353
  }
2160
2354
  catch {
2161
2355
  // Asked again on the next pass - but a page that cannot be asked
@@ -2302,6 +2496,105 @@ export const createHuazhuHotels = (opts) => {
2302
2496
  }
2303
2497
  }
2304
2498
  };
2499
+ // ------------------------------------------------- watching paid stays ----
2500
+ /**
2501
+ * ⚠️ NOBODY WAS LOOKING AT A PAID STAY (2026-10-21 re-audit, T2-5, R7). The
2502
+ * only read of Huazhu's order detail was a guest's own lookup, so a stay the
2503
+ * hotel cancelled sat "confirmed" with the guest's money here - not given
2504
+ * back, not decided - until the guest asked. This is the low-frequency look
2505
+ * that does not wait to be asked: each paid stay, confirmed and not yet
2506
+ * arrived, is read at most every `everyHours` (6), and the watch as a whole
2507
+ * makes at most `callsPerHour` (20) reads an hour, whatever the number of
2508
+ * stays. Until Huazhu says what it allows (docs/huazhu-card-payment-questions.md,
2509
+ * question 29) these are ours to keep gentle.
2510
+ */
2511
+ const watchEveryMs = (opts.paidWatch?.everyHours ?? 6) * HOUR_MS;
2512
+ const watchPerHour = opts.paidWatch?.callsPerHour ?? 20;
2513
+ /** When each read the watch made was started, within the last hour. */
2514
+ const watchCalls = [];
2515
+ /** A pass is under way: the timer's next one does not start a second beside it. */
2516
+ let watching = false;
2517
+ /** No pass reads anything before this: a read failed (`WATCH_PAUSE_MS`). */
2518
+ let watchPausedUntil = Number.NEGATIVE_INFINITY;
2519
+ /** A stay paid for - by the page, or by the one-time card UnionPay issued - that Huazhu has confirmed and that has not arrived. */
2520
+ const watchable = (b, t) => {
2521
+ const p = b.prepaid;
2522
+ if (!p || b.status !== 'confirmed' || !b.resId || b.pendingSince !== undefined)
2523
+ return false;
2524
+ const paid = paidInAdvance(p);
2525
+ // Until the arrival day ends: after it the booking is Huazhu's and the hotel's.
2526
+ return paid && t < nightAuditOf(b.stay.checkIn, b.tz);
2527
+ };
2528
+ /** When a watchable stay was last looked at: a read, a try that failed, or - never read - when it was paid. */
2529
+ const lastLookOf = (b) => Math.max(b.refreshedAt ?? 0, b.refreshFailedAt ?? 0, Date.parse(b.createdAt) || 0, b.prepaid?.paidAt ?? 0, b.prepaid?.issuedAt ?? 0);
2530
+ /** Due for a look: the interval since the last one - a short one after a failure. */
2531
+ const watchDue = (b, t) => watchable(b, t) &&
2532
+ t - lastLookOf(b) >= (failedLast(b) ? Math.min(WATCH_RETRY_MS, watchEveryMs) : watchEveryMs);
2533
+ /**
2534
+ * One pass of the watch. It runs after the pass over the money, so a slow
2535
+ * Huazhu never holds a payment up. The stays due, longest unlooked first,
2536
+ * each under its OWN lock - one a guest's step has taken is left for the next
2537
+ * pass - until the hour's reads are spent, Huazhu says "too many requests",
2538
+ * or a read fails (Huazhu is not answering: it is not asked for every other
2539
+ * stay too, but tried again on the next pass).
2540
+ *
2541
+ * ⚠️ THE SAME LOOK AS A GUEST'S LOOKUP (`lookAt`), not a copy of it: what
2542
+ * Huazhu says is written in one place. A stay found cancelled is then a
2543
+ * payment to settle, and is settled in the same step - the money is owed
2544
+ * back (or a person asked) as it would be when the guest next looked.
2545
+ */
2546
+ const watchPass = async () => {
2547
+ if (closing || watching || watchPerHour <= 0 || now() < watchPausedUntil)
2548
+ return;
2549
+ watching = true;
2550
+ try {
2551
+ const due = store
2552
+ .all()
2553
+ .filter((b) => watchDue(b, now()))
2554
+ .sort((a, b) => lastLookOf(a) - lastLookOf(b));
2555
+ for (const row of due) {
2556
+ const t = now();
2557
+ while (watchCalls.length && watchCalls[0] <= t - HOUR_MS)
2558
+ watchCalls.shift();
2559
+ if (closing || detailHeld() || watchCalls.length >= watchPerHour)
2560
+ return;
2561
+ let outcome;
2562
+ try {
2563
+ outcome = await ifFree(row.id, async () => {
2564
+ const cur = store.get(row.id);
2565
+ // Asked again under the lock: a guest's lookup may have read it meanwhile.
2566
+ if (!cur || !watchDue(cur, now()))
2567
+ return undefined;
2568
+ const started = now();
2569
+ const look = await lookAt(cur);
2570
+ if (look.outcome === 'none')
2571
+ return look.outcome;
2572
+ watchCalls.push(started);
2573
+ if (look.outcome === 'read' && look.moved) {
2574
+ console.log(`[huazhu watch ${cur.id}] Huazhu now shows ${cur.distributorResId} as ${look.booking.hotelStatus}: ${look.booking.status}`);
2575
+ const moved = store.get(cur.id);
2576
+ if (moved && moneyPending(moved))
2577
+ await settlePrepaid(moved);
2578
+ }
2579
+ return look.outcome;
2580
+ });
2581
+ }
2582
+ catch (err) {
2583
+ console.error(`[huazhu watch ${row.id}] the look failed: ${err.message}`);
2584
+ }
2585
+ if (outcome === 'failed') {
2586
+ // Not answering: the whole watch rests. A 2009 ("too many requests")
2587
+ // has its own back-off, which every read waits on already.
2588
+ if (!detailHeld())
2589
+ watchPausedUntil = now() + WATCH_PAUSE_MS;
2590
+ return;
2591
+ }
2592
+ }
2593
+ }
2594
+ finally {
2595
+ watching = false;
2596
+ }
2597
+ };
2305
2598
  const bookHeld = async (lines, shown, tripKey, email, guest, contact, who, requests, slot) => {
2306
2599
  // Priced afresh, not from the cache: this is the price that will be sent.
2307
2600
  const priced = await quoteLines({ lines }, true);
@@ -2365,6 +2658,26 @@ export const createHuazhuHotels = (opts) => {
2365
2658
  // The whole trip's rule: nothing is booked.
2366
2659
  if (lines.length > 1 && plans.some((p) => p.sold.res.prepaid))
2367
2660
  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.');
2661
+ // ⚠️ THE OTHER CEILING (2026-10-21 re-audit, T2-14): a request to pay in
2662
+ // advance costs one prebook at Huazhu and one payment page at Stripe
2663
+ // whether or not it is ever paid, and anybody may make one. Unpaid ones -
2664
+ // waiting, or lapsed - are counted apart from bookings, and looser, so a
2665
+ // flood of them stops more of them and not the bookings that are real. It
2666
+ // is checked before Huazhu is asked anything; this request's own place is
2667
+ // already in `slot`.
2668
+ if (plans.some((p) => p.sold.res.prepaid)) {
2669
+ slot.unpaid = true;
2670
+ const unpaid = store.all().filter((b) => now() - Date.parse(b.createdAt) < DAY_MS && unpaidAsk(b)).length +
2671
+ slotsWhere((x) => !!x.unpaid);
2672
+ if (unpaid > unpaidPerDay) {
2673
+ if (unpaidCeilingSaidAt === undefined || now() - unpaidCeilingSaidAt > HOUR_MS) {
2674
+ unpaidCeilingSaidAt = now();
2675
+ console.error(`[huazhu bookings] ⚠️ NEEDS A PERSON: ${unpaid} requests to pay in advance left unpaid (waiting for payment or lapsed) in the last 24 hours reached the unpaid ceiling of ${unpaidPerDay}; new requests to pay in advance are refused until the count falls`);
2676
+ }
2677
+ store.ceilingHitAt = now();
2678
+ throw new Error(`Nothing was booked: bookings paid in advance through this connector are paused for now - the limit on payment requests left unpaid in a day has been reached. Try again later, pay at the hotel where the rate allows it, or book with ${opts.brand} directly.`);
2679
+ }
2680
+ }
2368
2681
  // Prebook is Huazhu's own check of price, rooms and rules - for each stay
2369
2682
  // before any is booked, so a stay it turns down is said with the others'
2370
2683
  // outcome, not after them.
@@ -2479,16 +2792,7 @@ export const createHuazhuHotels = (opts) => {
2479
2792
  slot.n -= 1;
2480
2793
  let opened;
2481
2794
  try {
2482
- opened = await checkout.open({
2483
- ref: dres,
2484
- code: row.confirmationCode,
2485
- title: `${plan.qline.hotelName} · ${plan.qline.title}`.slice(0, 200),
2486
- amountMinor: allIn,
2487
- currency: cur,
2488
- email: guest.email,
2489
- // A minute past the booking's own deadline - ours closes it first.
2490
- expiresAt: record.payBy + 60_000,
2491
- });
2795
+ opened = await openCardPage(row, record.payBy);
2492
2796
  }
2493
2797
  catch (err) {
2494
2798
  store.update(id, { status: 'failed' });
@@ -2622,6 +2926,7 @@ export const createHuazhuHotels = (opts) => {
2622
2926
  // booking again.
2623
2927
  let refusal;
2624
2928
  let answer;
2929
+ let notSent;
2625
2930
  for (let attempt = 0; attempt < 2; attempt += 1) {
2626
2931
  try {
2627
2932
  reconciler.inFlight(id, true);
@@ -2636,6 +2941,11 @@ export const createHuazhuHotels = (opts) => {
2636
2941
  answer = got;
2637
2942
  }
2638
2943
  catch (err) {
2944
+ // Never sent (2026-10-21, P1-11): see `bookPaid`.
2945
+ if (err instanceof HuazhuTooBusy) {
2946
+ notSent = err.why;
2947
+ break;
2948
+ }
2639
2949
  if (err instanceof HuazhuRefusal && err.code === CODES.systemError && attempt === 0) {
2640
2950
  // The documentation's own advice: retry a system error, same id.
2641
2951
  await sleep(1_000);
@@ -2660,6 +2970,15 @@ export const createHuazhuHotels = (opts) => {
2660
2970
  });
2661
2971
  continue;
2662
2972
  }
2973
+ if (notSent !== undefined) {
2974
+ // ⚠️ NEVER ASKED OF HUAZHU: nothing to follow up, nothing to show -
2975
+ // not "unknown, the eight minutes decide". Failed, and said as a
2976
+ // stay Huazhu could not be reached for, which asking again is safe for.
2977
+ store.update(id, { pendingSince: undefined, status: 'failed' });
2978
+ made.pop();
2979
+ failStay(plan.line, `Huazhu could not be reached to book ${title} (${notSent}). It was not sent, so nothing was booked. Asking again for this stay is safe.`, 'unreachable', undefined, named);
2980
+ continue;
2981
+ }
2663
2982
  if (refusal) {
2664
2983
  // Never made at Huazhu: nothing to follow up, nothing to show -
2665
2984
  // and the stays booked before it stay booked.
@@ -2686,6 +3005,35 @@ export const createHuazhuHotels = (opts) => {
2686
3005
  }
2687
3006
  return { booked: made.map((b) => publicBooking(store.get(b.id) ?? b)), failed };
2688
3007
  };
3008
+ /**
3009
+ * A new card page for a booking whose card page was closed for UnionPay's
3010
+ * and whose UnionPay page would not open (2026-10-21 re-audit, T2-6): the
3011
+ * same booking and total, a page of a full life from now (the provider
3012
+ * takes none shorter than half an hour), written back as the booking's own.
3013
+ * The count of openings is written BEFORE the call, so a try whose answer
3014
+ * was lost is not answered again by the next one's key.
3015
+ */
3016
+ const reopenCardPage = async (id) => {
3017
+ const cur = store.get(id);
3018
+ const p = cur?.prepaid;
3019
+ if (!cur || !p || p.method !== 'card' || cur.status !== 'awaiting-payment' || p.sessionId)
3020
+ return { why: 'the booking is not waiting on a card page that is gone' };
3021
+ const attempt = (p.reopened ?? 0) + 1;
3022
+ patch(id, {}, { reopened: attempt });
3023
+ const payBy = now() + payWithin;
3024
+ try {
3025
+ const opened = await openCardPage(cur, payBy, attempt);
3026
+ const row = patch(id, {}, { sessionId: opened.sessionId, url: opened.url, payBy });
3027
+ return {
3028
+ url: opened.url,
3029
+ by: deadlineAt(row?.prepaid?.payBy ?? payBy, cur.tz, 'en'),
3030
+ };
3031
+ }
3032
+ catch (err) {
3033
+ console.error(`[huazhu prepaid ${id}] the card page closed for UnionPay's could not be opened again: ${err.message}`);
3034
+ return { why: err.message };
3035
+ }
3036
+ };
2689
3037
  /**
2690
3038
  * Pay a booking waiting for payment with UnionPay instead of the card page
2691
3039
  * (robin, 2026-10-15). The card page is closed first - paid on it in the
@@ -2801,11 +3149,21 @@ export const createHuazhuHotels = (opts) => {
2801
3149
  // lapses (paid on it meanwhile, it goes on as that payment).
2802
3150
  if (said === 'price-changed' || said === 'sold-out') {
2803
3151
  if (p.sessionId) {
2804
- const page = await checkout.close(p.sessionId).catch(() => undefined);
2805
- if (page === 'paid') {
3152
+ // ⚠️ THE STAY LAPSES ONLY ONCE THE CARD PAGE IS SEEN CLOSED
3153
+ // (2026-10-21 re-audit, T2-1): this branch swallowed a close that
3154
+ // got no answer and lapsed the stay anyway, with the Stripe page
3155
+ // still open - paid there, nothing ever looked at that session
3156
+ // again (an orphan payment). Closed lapses it; paid goes on as
3157
+ // that payment; anything else changes NOTHING (session and url
3158
+ // kept, as on the main path below and in cancelBooking), and the
3159
+ // prepaid pass closes the page when it is past its time.
3160
+ const page = await closePage(p.sessionId);
3161
+ if (page.state === 'paid') {
2806
3162
  await settlePrepaid(row);
2807
3163
  return { kind: 'paid-on-card', booking: publicBooking(store.get(row.id) ?? row) };
2808
3164
  }
3165
+ if (page.state === 'unanswered')
3166
+ return refused(`Huazhu will not take ${row.hotelName} - ${title} paid by card now (${said === 'sold-out' ? 'no rooms are left for those nights' : 'its price moved since it was shown'}), but the card page could not be closed just now (${page.why}). Nothing changed - the card page may still be open and still takes the payment. Ask again to close it.`, 'unreachable');
2809
3167
  }
2810
3168
  patch(row.id, { status: 'expired' }, { sessionId: undefined, url: undefined, lapsedAt: now() });
2811
3169
  return refused(`Nothing was booked or charged: ${refusalWords(err, `${row.hotelName} - ${title}`, p.currency)}`, said);
@@ -2820,19 +3178,20 @@ export const createHuazhuHotels = (opts) => {
2820
3178
  }
2821
3179
  // Huazhu said yes: now the card page is closed, so it cannot be paid
2822
3180
  // after this.
3181
+ let cardPageClosed = false;
2823
3182
  if (p.method !== 'unionpay' && p.sessionId) {
2824
- let state;
2825
- try {
2826
- state = await checkout.close(p.sessionId);
2827
- }
2828
- catch (err) {
2829
- return refused(`The card page could not be closed (${err.message}). Nothing changed - ask again.`, 'unreachable');
2830
- }
2831
- if (state === 'paid') {
3183
+ // Only a page seen `closed` is let go of (2026-10-21 re-audit, T2-7: an
3184
+ // `open` answer used to pass as closed, and UnionPay's page was opened
3185
+ // beside a card page that could still be paid).
3186
+ const page = await closePage(p.sessionId);
3187
+ if (page.state === 'unanswered')
3188
+ return refused(`The card page could not be closed (${page.why}). Nothing changed - the card page still takes the payment, or ask again.`, 'unreachable');
3189
+ if (page.state === 'paid') {
2832
3190
  await settlePrepaid(row);
2833
3191
  return { kind: 'paid-on-card', booking: publicBooking(store.get(row.id) ?? row) };
2834
3192
  }
2835
3193
  patch(row.id, {}, { sessionId: undefined, url: undefined });
3194
+ cardPageClosed = true;
2836
3195
  }
2837
3196
  const names = hotelNamesOf(row.hotelId);
2838
3197
  const nights = row.quote.lines[0]?.nights ?? 1;
@@ -2856,7 +3215,21 @@ export const createHuazhuHotels = (opts) => {
2856
3215
  // calls it off (2026-10-16 audit, P2).
2857
3216
  if (err instanceof UnionPayError && err.left)
2858
3217
  raise(row.id, `UnionPay's page could not be opened, and the payment the gateway opened for it (${err.left}) could not be called off - it charges nothing, but cancel it at the gateway (POST /acp/<merchant>/checkout_sessions/${err.left}/cancel)`);
2859
- return refused(`UnionPay's page could not be opened (${err.message}). Nothing was charged - ask again.`, 'unreachable');
3218
+ const said = `UnionPay's page could not be opened (${err.message}).`;
3219
+ // ⚠️ NEVER A BOOKING WAITING FOR A PAYMENT WITH NOWHERE TO PAY IT
3220
+ // (2026-10-21 re-audit, T2-6, R2). The card page was closed above, and
3221
+ // opening UnionPay's first and closing the card page after would leave
3222
+ // two pages that can be paid at once - so the order stays, and when
3223
+ // UnionPay's page does not come the card page is opened again: a new
3224
+ // page, under a key of its own, for the same booking and total.
3225
+ if (cardPageClosed) {
3226
+ const back = await reopenCardPage(row.id);
3227
+ if (back.url)
3228
+ return refused(`${said} The card page was closed to make way for it, and a new card page is open: ${back.url} - pay on it by ${back.by} hotel time. Nothing was charged.`, 'unreachable');
3229
+ const left = store.get(row.id) ?? row;
3230
+ return refused(`${said} The card page that was closed to make way for it could not be opened again either (${back.why}), so this stay has no page to pay on now. Nothing was charged and nothing is booked: ask again to try UnionPay once more - or, left alone, it lapses by ${deadlineAt(left.prepaid?.payBy ?? now(), left.tz, 'en')} hotel time and the stay can be asked for again.`, 'unreachable');
3231
+ }
3232
+ return refused(`${said} Nothing was charged - ask again.`, 'unreachable');
2860
3233
  }
2861
3234
  const updated = patch(row.id, { paymentType: 'CCPN' }, {
2862
3235
  method: 'unionpay',
@@ -2870,6 +3243,14 @@ export const createHuazhuHotels = (opts) => {
2870
3243
  };
2871
3244
  /** When each payment page last moved its booking on, for the page a paid guest lands on. */
2872
3245
  const returned = new Map();
3246
+ /**
3247
+ * What each unpaid payment page was last asked, and when - for the landing
3248
+ * page's read-only look (2026-10-21 re-audit, T2-9). The answer is the
3249
+ * pending read itself, so requests at the same moment share one call.
3250
+ */
3251
+ const looked = new Map();
3252
+ /** One read of a page's state in this long - the landing page's step has the same limit. */
3253
+ const LOOK_EVERY_MS = 10_000;
2873
3254
  /** What the landing page says of a booking - never the guest's name, email or phone. */
2874
3255
  const returnOf = (row) => {
2875
3256
  const p = row.prepaid;
@@ -2956,7 +3337,30 @@ export const createHuazhuHotels = (opts) => {
2956
3337
  row.prepaid?.method !== 'unionpay' &&
2957
3338
  row.prepaid?.sessionId &&
2958
3339
  checkout) {
2959
- const page = await checkout.state(row.prepaid.sessionId).catch(() => undefined);
3340
+ // ⚠️ AT MOST ONE READ OF STRIPE A PAGE IN 10 SECONDS (2026-10-21
3341
+ // re-audit, T2-9). Anybody holding a card page's address could open this
3342
+ // page as often as they liked, and every open was a call to the Stripe
3343
+ // account all the guests of this connector share - and its rate limit.
3344
+ // A request within 10 seconds of the last read uses that read.
3345
+ const sid = row.prepaid.sessionId;
3346
+ const last = looked.get(sid);
3347
+ const fresh = !last || at - last.at >= LOOK_EVERY_MS;
3348
+ const read = fresh
3349
+ ? checkout.state(sid).catch(() => undefined)
3350
+ : last.page;
3351
+ if (fresh) {
3352
+ looked.set(sid, { at, page: read });
3353
+ if (looked.size > 1_000)
3354
+ for (const [k, v] of looked)
3355
+ if (at - v.at >= LOOK_EVERY_MS)
3356
+ looked.delete(k);
3357
+ }
3358
+ let page = await read;
3359
+ // ⚠️ A READ THAT SAID "OPEN" A MOMENT AGO IS NOT "NOT PAID" NOW: the guest
3360
+ // may have paid since, and is told "not paid" only by a page just read
3361
+ // (2026-10-16 audit, P0-D). Said as `checking`, the page looks again.
3362
+ if (!fresh && page === 'open')
3363
+ page = undefined;
2960
3364
  if (page === 'paid')
2961
3365
  return { ...told, state: 'booking' };
2962
3366
  // Cannot be read just now: the page looks again (香格里拉's `checking`).
@@ -2996,6 +3400,8 @@ export const createHuazhuHotels = (opts) => {
2996
3400
  console.error(`[huazhu prepaid ${row.id}] the pass's step failed: ${err.message}`);
2997
3401
  }
2998
3402
  }
3403
+ // Then the look at paid stays that nobody has asked about (T2-5).
3404
+ await watchPass();
2999
3405
  },
3000
3406
  imageDomains() {
3001
3407
  const hosts = new Set();
@@ -3683,9 +4089,21 @@ export const createHuazhuHotels = (opts) => {
3683
4089
  const facts = { ...(feeIsCap ? { feeIsCap } : {}), ...stepsTold(b, steps) };
3684
4090
  // A test booking says it is one (2026-10-16 audit, P2): Huazhu's test
3685
4091
  // system charges nobody and books nothing anyone stays in.
3686
- const testNote = opts.testEnvironment
3687
- ? " This is a test booking in Huazhu's test system: nothing is charged to anyone."
3688
- : '';
4092
+ // ⚠️ THE SIMULATION SAYS IT TOO (2026-10-21 re-audit, T2-12): this was
4093
+ // said for Huazhu's test system only, so in the sandbox the "nothing
4094
+ // comes back" and "cannot be cancelled" previews said nothing of being a
4095
+ // test - while the "all of it back" one did.
4096
+ const testNote = opts.sandbox
4097
+ ? ' This is a test booking in the simulated Huazhu: nothing is charged to anyone.'
4098
+ : opts.testEnvironment
4099
+ ? " This is a test booking in Huazhu's test system: nothing is charged to anyone."
4100
+ : '';
4101
+ // A booking paid here in advance, in either: the money is a test too.
4102
+ const paidTestNote = opts.sandbox
4103
+ ? ' This is a test booking in the simulated Huazhu, paid in the gateway’s test mode: no real money moves.'
4104
+ : opts.testEnvironment
4105
+ ? ' This is a test booking in Huazhu’s test system, paid in test mode: no real money moves.'
4106
+ : '';
3689
4107
  // Paid with UnionPay: Huazhu charged the one-time card, and gives back
3690
4108
  // to it what its terms allow - nothing passes through here (audit P0-C:
3691
4109
  // it was told as "paid at the hotel, nothing paid through here").
@@ -3716,10 +4134,10 @@ export const createHuazhuHotels = (opts) => {
3716
4134
  fee: money(forbidden ? 0 : fee, cur),
3717
4135
  refund: back ? b.quote.total : zero,
3718
4136
  rule: forbidden
3719
- ? `This rate cannot be cancelled now. ${rule} The ${formatMoney(b.quote.total)} paid stays paid.`
4137
+ ? `This rate cannot be cancelled now. ${rule} The ${formatMoney(b.quote.total)} paid stays paid.${paidTestNote}`
3720
4138
  : back
3721
- ? `${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.' : opts.testEnvironment ? ' This is a test booking in Huazhu’s test system, paid in test mode: no real money moves.' : ''}`
3722
- : `${rule} Cancelling now gives nothing back: the fee is the whole stay.${testNote}`,
4139
+ ? `${rule} Cancelling now gives back all ${formatMoney(b.quote.total)} paid, to the card it was paid with.${paidTestNote}`
4140
+ : `${rule} Cancelling now gives nothing back: the fee is the whole stay.${paidTestNote}`,
3723
4141
  ...(freeUntil ? { freeUntil: new Date(freeUntil).toISOString() } : {}),
3724
4142
  cancellable: !forbidden,
3725
4143
  ...facts,
@@ -3767,15 +4185,25 @@ export const createHuazhuHotels = (opts) => {
3767
4185
  ? 'The guest has just confirmed on UnionPay, so the stay is being booked with Huazhu now. Ask again in a moment to cancel it.'
3768
4186
  : 'UnionPay could not be reached to say whether the guest confirmed. Ask again in a moment - nothing is booked until it can.');
3769
4187
  }
4188
+ // ⚠️ NO PAGE, NOTHING THAT CAN BE PAID (2026-10-21, T2-6): a card page
4189
+ // closed to make way for UnionPay's and not opened again leaves a stay
4190
+ // waiting with nowhere to pay it. Cancelling it called it "cancelled"
4191
+ // and changed nothing; with no page there is nothing to close, so it
4192
+ // is called off as a closed page is.
4193
+ if (b.status === 'awaiting-payment' && b.prepaid && !b.prepaid.sessionId && checkout) {
4194
+ return publicBooking(patch(b.id, { status: 'expired' }, { lapsedAt: now(), url: undefined }) ?? b);
4195
+ }
3770
4196
  // Not paid yet: the page is closed, so it cannot be paid after this.
3771
4197
  if (b.status === 'awaiting-payment' && b.prepaid?.sessionId && checkout) {
3772
- const state = await checkout.close(b.prepaid.sessionId).catch(() => undefined);
3773
- if (state === 'closed') {
4198
+ const page = await closePage(b.prepaid.sessionId);
4199
+ if (page.state === 'closed') {
3774
4200
  return publicBooking(patch(b.id, { status: 'expired' }, { lapsedAt: now(), url: undefined }) ?? b);
3775
4201
  }
3776
4202
  // ⚠️ NO ANSWER IS NOT "PAID" (audit P0-C): the page may well still be
3777
- // open and unpaid.
3778
- if (state === undefined)
4203
+ // open and unpaid. That includes a close that answered `open`
4204
+ // (2026-10-21 re-audit, T2-7: it was read as "paid in the last
4205
+ // second", settled, and told to the guest as paid).
4206
+ if (page.state === 'unanswered')
3779
4207
  throw new Error('The payment page could not be reached to close it, so nothing was cancelled yet. Ask again in a moment - nothing is booked or charged until the page is paid.');
3780
4208
  // Paid in the last second: it is booked now, and cancelled as one.
3781
4209
  await settlePrepaid(b);
@@ -3815,13 +4243,55 @@ export const createHuazhuHotels = (opts) => {
3815
4243
  if (err instanceof HuazhuRefusal && err.code === 5402) {
3816
4244
  // Cancelled already - by this guest's own earlier try whose answer
3817
4245
  // was lost, most likely: owed as that cancellation was (audit P0-B).
3818
- cancelled(b.cancellationFee ?? money(feeNow(b).fee, cur), b.cancelledAt ?? new Date(now()).toISOString());
4246
+ const when = b.cancelledAt ?? new Date(now()).toISOString();
4247
+ const p = b.prepaid;
4248
+ // Money held here that nobody has decided about yet.
4249
+ const held = !!p?.paidAt && p.method !== 'unionpay' && !p.refund && !p.kept;
4250
+ const constant = held && !b.cancellationFee
4251
+ ? constantFee(b, Date.parse(b.createdAt), Date.parse(when))
4252
+ : undefined;
4253
+ if (!held || b.cancellationFee) {
4254
+ // Nothing to decide here: the fee is the hotel's to charge, and
4255
+ // what is shown is only what it would be.
4256
+ cancelled(b.cancellationFee ?? money(feeNow(b).fee, cur), when);
4257
+ }
4258
+ else if (constant !== undefined) {
4259
+ // ⚠️ THE SAME FEE AT EVERY MOMENT UP TO NOW: whenever it was
4260
+ // cancelled, this was the fee - so not knowing when does not
4261
+ // matter. Free is all of it back; the whole stay is kept; a part
4262
+ // is kept and the rest goes back - as a cancellation made here
4263
+ // is owed (`owedOnCancel`).
4264
+ cancelled(money(constant, cur), when);
4265
+ }
4266
+ else {
4267
+ // ⚠️ CANCELLED, BUT WHEN IS NOT KNOWN, AND THE FEE DEPENDS ON WHEN
4268
+ // (2026-10-21 re-audit, T2-8). The fee was worked out as of NOW,
4269
+ // so a cancellation made inside the free period whose answer was
4270
+ // lost, and asked again after it, wrote the guest's money off.
4271
+ // Huazhu's order detail carries no cancellation time (only its
4272
+ // status and the booking's own time), so there is nothing to
4273
+ // work it out from and nothing is guessed: where the fee was not
4274
+ // the same at every moment since booking (`constantFee`), the
4275
+ // booking is cancelled, no refund is opened and none is kept,
4276
+ // and a person judges it with Huazhu.
4277
+ patch(b.id, {
4278
+ status: 'cancelled',
4279
+ hotelStatus: 'HOTEL_CANCEL',
4280
+ cancelledAt: when,
4281
+ });
4282
+ const paid = formatMoney(b.quote.total);
4283
+ raise(b.id, `Huazhu answered 5402 (already cancelled) to the cancellation of ${b.distributorResId}, and does not say when it was cancelled, and what cancelling cost was not the same at every moment since the booking was made (the free period ended before this was found, or the terms cannot be read), so what of the ${paid} paid (gateway session ${p?.sessionId}) comes back - whole if it was cancelled inside the free period, less the fee if after - is to be judged with Huazhu; nothing was refunded or kept`);
4284
+ throw new Error(`Huazhu says ${b.confirmationCode} is already cancelled, but not when - and what cancelling costs depends on when - so what comes back of the ${paid} paid cannot be worked out here. The booking is shown as cancelled; nothing has been refunded or kept yet, and a person here is settling it with Huazhu.`);
4285
+ }
3819
4286
  }
3820
4287
  else if (err instanceof HuazhuRefusal && (err.code === 5401 || err.code === 5405)) {
3821
4288
  throw new Error(err.code === 5405
3822
4289
  ? 'Huazhu says this stay has already started, so it cannot be cancelled.'
3823
4290
  : 'Huazhu says this booking cannot be cancelled.');
3824
4291
  }
4292
+ else if (err instanceof HuazhuTooBusy) {
4293
+ throw new Error('Huazhu was not asked: this connector is calling Huazhu at its own limit just now, so nothing was cancelled. Ask again in a moment.');
4294
+ }
3825
4295
  else if (err instanceof HuazhuUnreachable) {
3826
4296
  throw new Error('Huazhu did not answer, so it is not known whether the booking was cancelled. Asking again is safe - cancelling twice does not charge twice.');
3827
4297
  }