@curless/hotel-storefront 0.39.0 → 0.39.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -513,6 +513,16 @@ export const createHuazhuHotels = (opts) => {
513
513
  const requestsByEmail = new WindowCounter(DAY_MS, now);
514
514
  const requestsByIp = new WindowCounter(HOUR_MS, now);
515
515
  const requestsByConversation = new WindowCounter(HOUR_MS, now);
516
+ /**
517
+ * Paying with UnionPay, asked - each ask reaches the gateway and UnionPay's
518
+ * test system, so each is counted, whatever it came to: by booking, by
519
+ * conversation and by address (2026-10-16 audit, P0-E: one booking asked
520
+ * 50 times bound 50 cards' worth of pages, and used up the gateway's rate).
521
+ */
522
+ const unionPayAsks = new WindowCounter(HOUR_MS, now);
523
+ const UNIONPAY_ASKS = { booking: 6, conversation: 12, ip: 120 };
524
+ /** UnionPay's page for binding a card is handed out again this long, not asked for again. */
525
+ const ENROLL_REUSE_MS = 30 * 60_000;
516
526
  /**
517
527
  * Bookings a request has passed the limits for and not yet written down.
518
528
  *
@@ -1266,13 +1276,15 @@ export const createHuazhuHotels = (opts) => {
1266
1276
  // here is taken or refunded.
1267
1277
  const status = b.status === 'cancelled' && p.issuedAt
1268
1278
  ? 'hotel-refunds'
1269
- : p.lapsedAt || p.declined
1270
- ? 'lapsed'
1271
- : b.status === 'confirmed'
1272
- ? 'paid'
1273
- : p.issuedAt
1274
- ? 'issued'
1275
- : 'awaiting';
1279
+ : // Not booked - turned down, written off by the follow-up queries,
1280
+ // lapsed: the hotel never booked it, so never charged the card.
1281
+ p.lapsedAt || p.declined || b.status === 'failed' || b.status === 'expired'
1282
+ ? 'lapsed'
1283
+ : b.status === 'confirmed'
1284
+ ? 'paid'
1285
+ : p.issuedAt
1286
+ ? 'issued'
1287
+ : 'awaiting';
1276
1288
  return {
1277
1289
  method: 'unionpay',
1278
1290
  amount: b.quote.total,
@@ -1368,16 +1380,16 @@ export const createHuazhuHotels = (opts) => {
1368
1380
  }
1369
1381
  return there;
1370
1382
  };
1371
- /** Huazhu's own word on a booking, at most once a minute each. */
1383
+ /**
1384
+ * Huazhu's own word on a booking, at most once a minute each.
1385
+ *
1386
+ * ⚠️ IT ONLY READS (2026-10-16 audit, P0-A). It used to move a payment on
1387
+ * as it was asked about - outside the booking's lock, from every lookup and
1388
+ * every few seconds from a card waiting on a payment. Payments move on in
1389
+ * the pass, the landing page and the steps a guest takes, one at a time.
1390
+ */
1372
1391
  const refresh = async (found) => {
1373
- // A page open for payment, or money going back, is looked at as it is
1374
- // asked about - the guest who just paid sees it booked without waiting
1375
- // for the next pass.
1376
- let b = found;
1377
- if (b.prepaid && moneyPending(b)) {
1378
- await settlePrepaid(b);
1379
- b = store.get(b.id) ?? b;
1380
- }
1392
+ const b = store.get(found.id) ?? found;
1381
1393
  if (!b.resId || b.pendingSince !== undefined)
1382
1394
  return b;
1383
1395
  if (b.refreshedAt && now() - b.refreshedAt < 60_000)
@@ -1405,7 +1417,7 @@ export const createHuazhuHotels = (opts) => {
1405
1417
  const waitingWords = (b) => b.status === 'unconfirmed'
1406
1418
  ? `${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.`
1407
1419
  : `${opts.sandbox ? 'The simulated Huazhu' : 'Huazhu'} has not confirmed this booking yet - that takes up to eight minutes. It can be cancelled once it is confirmed.`;
1408
- const feeNow = (b) => {
1420
+ const feeNow = (b, at = now()) => {
1409
1421
  const policy = policyOf(b.cancelCode);
1410
1422
  const amounts = {
1411
1423
  currency: b.quote.total.currency,
@@ -1425,7 +1437,7 @@ export const createHuazhuHotels = (opts) => {
1425
1437
  }
1426
1438
  // A room by the hour that has begun is a no-show, whatever the code says
1427
1439
  // about the rest of the day - see `beforeStart`.
1428
- if (b.hourly && now() >= arrivalInstant(b.stay.checkIn, b.hourly.start, b.tz)) {
1440
+ if (b.hourly && at >= arrivalInstant(b.stay.checkIn, b.hourly.start, b.tz)) {
1429
1441
  return {
1430
1442
  fee: total,
1431
1443
  forbidden: false,
@@ -1433,11 +1445,11 @@ export const createHuazhuHotels = (opts) => {
1433
1445
  freeUntil: undefined,
1434
1446
  };
1435
1447
  }
1436
- const penalty = penaltyAt(policy, b.stay.checkIn, b.tz, now());
1448
+ const penalty = penaltyAt(policy, b.stay.checkIn, b.tz, at);
1437
1449
  const fee = feeFor(penalty, amounts);
1438
1450
  const freeUntil = b.hourly
1439
- ? beforeStart(freeUntilOf(policy, b.stay.checkIn, b.tz, now()), b.stay.checkIn, b.hourly.start)
1440
- : freeUntilOf(policy, b.stay.checkIn, b.tz, now());
1451
+ ? beforeStart(freeUntilOf(policy, b.stay.checkIn, b.tz, at), b.stay.checkIn, b.hourly.start)
1452
+ : freeUntilOf(policy, b.stay.checkIn, b.tz, at);
1441
1453
  return {
1442
1454
  fee: fee ?? total,
1443
1455
  forbidden: fee === null,
@@ -1593,7 +1605,7 @@ export const createHuazhuHotels = (opts) => {
1593
1605
  const contact = contactOf(row.guest);
1594
1606
  if (typeof contact === 'string') {
1595
1607
  const why = "the guest's name could not be sent to Huazhu as written";
1596
- store.update(id, { status: 'failed', ...(card ? { prepaid: { ...p, declined: why } } : {}) });
1608
+ patch(id, { status: 'failed' }, card ? { declined: why } : {});
1597
1609
  await refundOf(id, why);
1598
1610
  return;
1599
1611
  }
@@ -1671,13 +1683,8 @@ export const createHuazhuHotels = (opts) => {
1671
1683
  if (refusal && card) {
1672
1684
  // Paid with UnionPay: the hotel charges the card, and a booking it did
1673
1685
  // not make it never charged - there is nothing here to give back.
1674
- store.update(id, {
1675
- status: 'failed',
1676
- pendingSince: undefined,
1677
- prepaid: {
1678
- ...p,
1679
- declined: `Huazhu did not take the booking: ${refusal.huazhuMessage} (${refusal.code})`.replace(/\d{12,19}/g, '****'),
1680
- },
1686
+ patch(id, { status: 'failed', pendingSince: undefined }, {
1687
+ declined: `Huazhu did not take the booking: ${refusal.huazhuMessage} (${refusal.code})`.replace(/\d{12,19}/g, '****'),
1681
1688
  });
1682
1689
  return;
1683
1690
  }
@@ -1704,13 +1711,11 @@ export const createHuazhuHotels = (opts) => {
1704
1711
  try {
1705
1712
  // Named by when it was decided: the same refund retried is the same refund.
1706
1713
  const r = await checkout.refund(p.sessionId, reason, `${row.distributorResId}-${at}`);
1707
- store.update(id, {
1708
- prepaid: { ...p, refund: { reason, at, requestId: r.requestId, status: r.status, tries } },
1709
- });
1714
+ patch(id, {}, { refund: { reason, at, requestId: r.requestId, status: r.status, tries } });
1710
1715
  console.error(`[huazhu prepaid ${id}] refund ${r.requestId} (${r.status}) of session ${p.sessionId}: ${reason}`);
1711
1716
  }
1712
1717
  catch (err) {
1713
- store.update(id, { prepaid: { ...p, refund: { reason, at, tries } } });
1718
+ patch(id, {}, { refund: { reason, at, tries } });
1714
1719
  if (tries >= 8 && !row.attention) {
1715
1720
  const why = `the guest paid (gateway session ${p.sessionId}) and the refund could not be opened: ${err.message}`;
1716
1721
  store.update(id, { attention: why, attentionAt: new Date(now()).toISOString() });
@@ -1718,6 +1723,44 @@ export const createHuazhuHotels = (opts) => {
1718
1723
  }
1719
1724
  }
1720
1725
  };
1726
+ /**
1727
+ * What a cancelled booking paid here in advance owes the guest - written in
1728
+ * the SAME write as the cancellation (2026-10-16 audit, P0-B: written after
1729
+ * it, a process stopped in between, Huazhu's 5402 and a cancellation made at
1730
+ * Huazhu's end all left the money with us for good).
1731
+ *
1732
+ * - Free: all of it back - a refund to open.
1733
+ * - Cancelled by the guest here, the fee the whole stay (the only kind of
1734
+ * rate sold paid in advance): kept as that fee.
1735
+ * - Anything else - a part fee, or cancelled at Huazhu's end after the free
1736
+ * period, by the hotel or by the guest some other way: a person's to judge.
1737
+ */
1738
+ const owedOnCancel = (b, feeMinor, byGuest) => {
1739
+ const p = b.prepaid;
1740
+ if (!p?.paidAt || p.method === 'unionpay' || p.refund || p.kept)
1741
+ return { row: {}, fields: {} };
1742
+ if (feeMinor <= 0)
1743
+ return {
1744
+ row: {},
1745
+ fields: {
1746
+ refund: {
1747
+ reason: byGuest
1748
+ ? 'cancelled within the free cancellation period'
1749
+ : 'cancelled at Huazhu within the free cancellation period',
1750
+ at: now(),
1751
+ tries: 0,
1752
+ },
1753
+ },
1754
+ };
1755
+ if (byGuest && feeMinor >= b.quote.total.minor)
1756
+ return { row: {}, fields: { kept: { feeMinor, at: now() } } };
1757
+ const fee = formatMoney(money(feeMinor, b.quote.total.currency));
1758
+ const why = byGuest
1759
+ ? `cancelled with a fee of ${fee} of the ${formatMoney(b.quote.total)} paid (gateway session ${p.sessionId}): the difference is owed back, and a part refund is not made here`
1760
+ : `Huazhu shows ${b.distributorResId} cancelled - not by the guest here - after its free period (a fee of ${fee} by its terms) with ${formatMoney(b.quote.total)} paid (gateway session ${p.sessionId}): whether the guest is owed it back is to be judged with Huazhu`;
1761
+ console.error(`[huazhu prepaid ${b.id}] ⚠️ NEEDS A PERSON: ${why}`);
1762
+ return { row: { attention: why, attentionAt: new Date(now()).toISOString() }, fields: {} };
1763
+ };
1721
1764
  /**
1722
1765
  * A confirmation that lands a little after `payBy` was made in time - the
1723
1766
  * callback is seconds behind the guest. Later than this, it is never used.
@@ -1733,10 +1776,7 @@ export const createHuazhuHotels = (opts) => {
1733
1776
  const p = row.prepaid;
1734
1777
  if (row.status !== 'awaiting-payment' || !unionpay)
1735
1778
  return;
1736
- const lapse = () => store.update(row.id, {
1737
- status: 'expired',
1738
- prepaid: { ...p, lapsedAt: now(), url: undefined },
1739
- });
1779
+ const lapse = () => patch(row.id, { status: 'expired' }, { lapsedAt: now(), url: undefined });
1740
1780
  if (!p.sessionId) {
1741
1781
  if (now() >= p.payBy)
1742
1782
  lapse();
@@ -1763,20 +1803,21 @@ export const createHuazhuHotels = (opts) => {
1763
1803
  return;
1764
1804
  }
1765
1805
  if (!card) {
1766
- // Handed over by the gateway and lost on the way, or never readable: the
1767
- // gateway gives it once, so this stay cannot be booked with it (总线程,
1768
- // 2026-10-15) - the guest books again and confirms on UnionPay again.
1769
- const why = 'UnionPay confirmed the payment, but its one-time card did not reach this connector, so the stay was not booked and nothing was charged. Ask for the booking again and confirm on UnionPay once more.';
1770
- store.update(row.id, {
1771
- status: 'failed',
1772
- prepaid: { ...p, url: undefined, declined: why },
1773
- });
1774
- console.error(`[huazhu prepaid ${row.id}] ${why}`);
1806
+ // ⚠️ GONE FROM THE GATEWAY (410), OR NOT THIS MERCHANT'S (404) - NEVER
1807
+ // "NOT BOOKED" BY GUESS (2026-10-16 audit, P0-A). Handed over once, to
1808
+ // a step that may have booked it with Huazhu and then died: what
1809
+ // Huazhu has decides. The stay waits on the follow-up queries
1810
+ // (`Reconciler`) as any booking whose answer was lost does - booked
1811
+ // if Huazhu has it, failed (nothing charged) if Huazhu says late in
1812
+ // the eight minutes that it does not.
1813
+ const cur = store.get(row.id);
1814
+ if (!cur || cur.status !== 'awaiting-payment')
1815
+ return;
1816
+ patch(row.id, { status: 'requested', pendingSince: now() }, { issuedAt: cur.prepaid?.issuedAt ?? now(), url: undefined });
1817
+ console.error(`[huazhu prepaid ${row.id}] UnionPay's one-time card was not there to take (handed over already, or not this merchant's): asking Huazhu whether ${row.distributorResId} was booked`);
1775
1818
  return;
1776
1819
  }
1777
- store.update(row.id, {
1778
- prepaid: { ...p, issuedAt: now(), cardLastFour: lastFour(card), url: undefined },
1779
- });
1820
+ patch(row.id, {}, { issuedAt: now(), cardLastFour: lastFour(card), url: undefined });
1780
1821
  console.log(`[huazhu prepaid ${row.id}] UnionPay issued a one-time card ···· ${lastFour(card)}: booking it with Huazhu as CCPN`);
1781
1822
  await bookPaid(row.id, card);
1782
1823
  return;
@@ -1786,8 +1827,59 @@ export const createHuazhuHotels = (opts) => {
1786
1827
  if (state === 'closed' || now() >= p.payBy)
1787
1828
  lapse();
1788
1829
  };
1830
+ /**
1831
+ * ⚠️ ONE STEP AT A TIME ON A BOOKING - every step that moves a payment on:
1832
+ * the pass, the landing page, paying with UnionPay instead, a cancellation
1833
+ * (2026-10-16 audit, P0-A). A single flag for the pass alone was walked
1834
+ * round by every lookup, which moved payments on unlocked: the one-time card
1835
+ * taken twice, a stay Huazhu had booked written off as "not booked", a
1836
+ * refund's record wiped and then reported as never opened.
1837
+ */
1838
+ const busy = new Set();
1839
+ /** Every step under way - what a deploy's SIGTERM waits for (audit P1-3). */
1840
+ const steps = new Set();
1841
+ /** A step on this booking if nobody is taking one - undefined if somebody is, or the process is stopping. */
1842
+ const ifFree = (id, fn) => {
1843
+ if (busy.has(id) || closing)
1844
+ return Promise.resolve(undefined);
1845
+ busy.add(id);
1846
+ const step = (async () => {
1847
+ try {
1848
+ return await fn();
1849
+ }
1850
+ finally {
1851
+ busy.delete(id);
1852
+ }
1853
+ })();
1854
+ steps.add(step);
1855
+ void step.finally(() => steps.delete(step)).catch(() => { });
1856
+ return step;
1857
+ };
1858
+ /** A step on this booking once it is its turn - one under way is waited for, a few seconds at most. */
1859
+ const inTurnFor = async (id, fn) => {
1860
+ for (let i = 0; busy.has(id) && i < 400; i += 1)
1861
+ await new Promise((r) => setTimeout(r, 25));
1862
+ if (closing)
1863
+ throw new Error('This connector is restarting. Nothing changed - ask again in a moment.');
1864
+ if (busy.has(id))
1865
+ throw new Error('A payment on this booking is being settled right now. Ask again in a moment.');
1866
+ return (await ifFree(id, fn));
1867
+ };
1868
+ /**
1869
+ * Writes a booking as it is NOW, with `row` and the prepaid `fields` on top -
1870
+ * never a whole record read before an await (audit P0-A: each of two steps
1871
+ * wrote back what it had read, and the later one undid the earlier).
1872
+ */
1873
+ const patch = (id, row, fields = {}) => {
1874
+ const cur = store.get(id);
1875
+ if (!cur)
1876
+ return undefined;
1877
+ return store.update(id, {
1878
+ ...row,
1879
+ ...(cur.prepaid ? { prepaid: { ...cur.prepaid, ...fields } } : {}),
1880
+ });
1881
+ };
1789
1882
  /** Whether a booking paid in advance still has money work to do. */
1790
- let ticking = false;
1791
1883
  const moneyPending = (b) => {
1792
1884
  const p = b.prepaid;
1793
1885
  if (!p)
@@ -1799,6 +1891,9 @@ export const createHuazhuHotels = (opts) => {
1799
1891
  return false;
1800
1892
  if (!p.paidAt)
1801
1893
  return false;
1894
+ // Cancelled - at Huazhu's end, say - with nothing decided about the money yet.
1895
+ if (b.status === 'cancelled' && !p.refund && !p.kept && !b.attention)
1896
+ return true;
1802
1897
  // Booked: Huazhu's order number still to be written onto the payment, once.
1803
1898
  if (b.status === 'confirmed' && b.resId && !p.noted && !p.refund && checkout?.annotate)
1804
1899
  return true;
@@ -1822,7 +1917,7 @@ export const createHuazhuHotels = (opts) => {
1822
1917
  if (row.status === 'awaiting-payment') {
1823
1918
  if (!p.sessionId) {
1824
1919
  if (now() >= p.payBy)
1825
- store.update(row.id, { status: 'expired', prepaid: { ...p, lapsedAt: now() } });
1920
+ patch(row.id, { status: 'expired' }, { lapsedAt: now() });
1826
1921
  return;
1827
1922
  }
1828
1923
  let state;
@@ -1849,40 +1944,52 @@ export const createHuazhuHotels = (opts) => {
1849
1944
  const want = row.quote.total;
1850
1945
  if (!took || took.amountMinor !== want.minor || took.currency !== want.currency) {
1851
1946
  const said = took ? `${took.amountMinor} ${took.currency}` : 'an amount it could not say';
1852
- store.update(row.id, {
1853
- status: 'failed',
1854
- prepaid: {
1855
- ...p,
1856
- paidAt: now(),
1857
- url: undefined,
1858
- refund: {
1859
- reason: `the payment page took ${said}, not the ${want.minor} ${want.currency} quoted - nothing was booked`,
1860
- at: now(),
1861
- tries: 0,
1862
- },
1947
+ // Shown to the guest (`refundReason`): money as `formatMoney` writes
1948
+ // it, so the card can say it in the guest's own way (香格里拉,
1949
+ // 2026-10-16) - never minor units.
1950
+ const shown = took
1951
+ ? formatMoney(money(took.amountMinor, took.currency))
1952
+ : 'an amount it could not say';
1953
+ patch(row.id, { status: 'failed' }, {
1954
+ paidAt: now(),
1955
+ url: undefined,
1956
+ refund: {
1957
+ reason: `the payment page took ${shown}, not the ${formatMoney(want)} quoted - nothing was booked`,
1958
+ at: now(),
1959
+ tries: 0,
1863
1960
  },
1864
1961
  });
1865
1962
  console.error(`[huazhu prepaid ${row.id}] ⚠️ paid ${said} against ${want.minor} ${want.currency} quoted: not booked, refunding`);
1866
1963
  return;
1867
1964
  }
1868
- store.update(row.id, { prepaid: { ...p, paidAt: now(), url: undefined } });
1965
+ patch(row.id, {}, { paidAt: now(), url: undefined });
1869
1966
  await bookPaid(row.id);
1870
1967
  }
1871
1968
  else if (state === 'closed') {
1872
- store.update(row.id, {
1873
- status: 'expired',
1874
- prepaid: { ...p, lapsedAt: now(), url: undefined },
1875
- });
1969
+ patch(row.id, { status: 'expired' }, { lapsedAt: now(), url: undefined });
1876
1970
  }
1877
1971
  return;
1878
1972
  }
1879
1973
  if (!p.paidAt)
1880
1974
  return;
1975
+ // Cancelled with the money not decided - at Huazhu's end (a lookup or a
1976
+ // follow-up query saw HOTEL_CANCEL): free means all of it back, anything
1977
+ // else a person's to judge (audit P0-B).
1978
+ if (row.status === 'cancelled' && !p.refund && !p.kept) {
1979
+ if (row.attention)
1980
+ return;
1981
+ const at = row.cancelledAt ? Date.parse(row.cancelledAt) : now();
1982
+ const owed = owedOnCancel(row, row.cancellationFee?.minor ?? feeNow(row, at).fee, false);
1983
+ patch(row.id, owed.row, owed.fields);
1984
+ if (owed.fields.refund)
1985
+ await refundOf(row.id, owed.fields.refund.reason);
1986
+ return;
1987
+ }
1881
1988
  // ⚠️ HUAZHU'S ORDER NUMBER ON THE PAYMENT, ONCE (总线程, 2026-10-15), so
1882
1989
  // Stripe's dashboard and Huazhu's order are one look apart. Tried once:
1883
1990
  // a failure is said in the log and never touches the booking.
1884
1991
  if (row.status === 'confirmed' && row.resId && !p.noted && !p.refund && checkout.annotate) {
1885
- store.update(row.id, { prepaid: { ...p, noted: now() } });
1992
+ patch(row.id, {}, { noted: now() });
1886
1993
  try {
1887
1994
  await checkout.annotate(p.sessionId ?? '', {
1888
1995
  huazhu_res_id: row.resId,
@@ -1906,8 +2013,9 @@ export const createHuazhuHotels = (opts) => {
1906
2013
  if (refund?.requestId && !['refunded', 'failed', 'rejected'].includes(refund.status ?? '')) {
1907
2014
  try {
1908
2015
  const status = await checkout.refundState(refund.requestId);
1909
- if (status !== refund.status)
1910
- store.update(row.id, { prepaid: { ...p, refund: { ...refund, status } } });
2016
+ const latest = store.get(row.id)?.prepaid?.refund;
2017
+ if (latest && status !== latest.status)
2018
+ patch(row.id, {}, { refund: { ...latest, status } });
1911
2019
  if ((status === 'failed' || status === 'rejected') && !row.attention) {
1912
2020
  const why = `refund ${refund.requestId} of gateway session ${p.sessionId} came back ${status}: the guest's money is not back`;
1913
2021
  store.update(row.id, { attention: why, attentionAt: new Date(now()).toISOString() });
@@ -2294,23 +2402,6 @@ export const createHuazhuHotels = (opts) => {
2294
2402
  }
2295
2403
  return { booked: made.map((b) => publicBooking(store.get(b.id) ?? b)), failed };
2296
2404
  };
2297
- /**
2298
- * Waits its turn behind the pass and the landing page - the one at a time
2299
- * every step on a booking paid in advance takes - then runs.
2300
- */
2301
- const inTurn = async (fn) => {
2302
- for (let i = 0; ticking && i < 400; i += 1)
2303
- await new Promise((r) => setTimeout(r, 25));
2304
- if (ticking)
2305
- throw new Error('A payment here is being settled right now. Ask again in a moment.');
2306
- ticking = true;
2307
- try {
2308
- return await fn();
2309
- }
2310
- finally {
2311
- ticking = false;
2312
- }
2313
- };
2314
2405
  /**
2315
2406
  * Pay a booking waiting for payment with UnionPay instead of the card page
2316
2407
  * (robin, 2026-10-15). The card page is closed first - paid on it in the
@@ -2341,6 +2432,16 @@ export const createHuazhuHotels = (opts) => {
2341
2432
  const no = notWaiting(first);
2342
2433
  if (no)
2343
2434
  return no;
2435
+ // ⚠️ COUNTED BEFORE ANYTHING REACHES THE GATEWAY (audit P0-E).
2436
+ const keys = [
2437
+ [`b:${first.id}`, UNIONPAY_ASKS.booking],
2438
+ [`s:${conversationOf(who)}`, UNIONPAY_ASKS.conversation],
2439
+ ...(who.ip ? [[`ip:${who.ip}`, UNIONPAY_ASKS.ip]] : []),
2440
+ ];
2441
+ if (keys.some(([k, most]) => unionPayAsks.count(k) >= most))
2442
+ return refused(`Paying ${first.confirmationCode} with UnionPay was asked for too often this hour. Nothing changed - the card page still takes the payment, or ask again later.`, 'too-many-tries');
2443
+ for (const [k] of keys)
2444
+ unionPayAsks.add(k);
2344
2445
  if (first.prepaid?.method === 'unionpay' && first.prepaid.url)
2345
2446
  return { kind: 'confirm', url: first.prepaid.url, booking: publicBooking(first) };
2346
2447
  let cards;
@@ -2352,15 +2453,22 @@ export const createHuazhuHotels = (opts) => {
2352
2453
  }
2353
2454
  const card = cards[0];
2354
2455
  // No card yet: UnionPay's page to bind one - and nothing else changes.
2456
+ // The same page for half an hour (audit P0-E): asked again, it is handed
2457
+ // out again, not asked of the gateway and UnionPay again.
2355
2458
  if (!card) {
2459
+ const p0 = first.prepaid;
2460
+ if (p0?.enrollUrl && p0.enrollAt !== undefined && now() - p0.enrollAt < ENROLL_REUSE_MS)
2461
+ return { kind: 'enroll', url: p0.enrollUrl };
2356
2462
  try {
2357
- return { kind: 'enroll', url: (await unionpay.enroll(first.guest.email)).url };
2463
+ const { url } = await unionpay.enroll(first.guest.email);
2464
+ patch(first.id, {}, { enrollUrl: url, enrollAt: now() });
2465
+ return { kind: 'enroll', url };
2358
2466
  }
2359
2467
  catch (err) {
2360
2468
  return refused(`UnionPay's page for adding a card could not be opened (${err.message}). Nothing changed - ask again.`, 'unreachable');
2361
2469
  }
2362
2470
  }
2363
- return inTurn(async () => {
2471
+ return inTurnFor(first.id, async () => {
2364
2472
  const row = store.get(first.id) ?? first;
2365
2473
  const again = notWaiting(row);
2366
2474
  if (again)
@@ -2381,7 +2489,7 @@ export const createHuazhuHotels = (opts) => {
2381
2489
  await settlePrepaid(row);
2382
2490
  return { kind: 'paid-on-card', booking: publicBooking(store.get(row.id) ?? row) };
2383
2491
  }
2384
- store.update(row.id, { prepaid: { ...p, sessionId: undefined, url: undefined } });
2492
+ patch(row.id, {}, { sessionId: undefined, url: undefined });
2385
2493
  }
2386
2494
  const title = row.quote.lines[0]?.title ?? row.hotelName;
2387
2495
  // Prebooked again the way it will be booked: Huazhu's token holds only
@@ -2408,10 +2516,7 @@ export const createHuazhuHotels = (opts) => {
2408
2516
  }
2409
2517
  catch (err) {
2410
2518
  if (err instanceof HuazhuRefusal) {
2411
- store.update(row.id, {
2412
- status: 'expired',
2413
- prepaid: { ...p, sessionId: undefined, url: undefined, lapsedAt: now() },
2414
- });
2519
+ patch(row.id, { status: 'expired' }, { sessionId: undefined, url: undefined, lapsedAt: now() });
2415
2520
  const said = refusalCode(err, row.rooms).code;
2416
2521
  return refused(`Nothing was booked or charged: ${refusalWords(err, `${row.hotelName} - ${title}`, p.currency)}`, said === 'price-changed' || said === 'sold-out' ? said : 'hotel-refused');
2417
2522
  }
@@ -2437,16 +2542,12 @@ export const createHuazhuHotels = (opts) => {
2437
2542
  catch (err) {
2438
2543
  return refused(`UnionPay's page could not be opened (${err.message}). Nothing was charged - ask again.`, 'unreachable');
2439
2544
  }
2440
- const updated = store.update(row.id, {
2441
- paymentType: 'CCPN',
2442
- prepaid: {
2443
- ...p,
2444
- method: 'unionpay',
2445
- sessionId: opened.sessionId,
2446
- url: opened.url,
2447
- ...(token ? { preBookToken: token } : {}),
2448
- payBy: now() + payWithin,
2449
- },
2545
+ const updated = patch(row.id, { paymentType: 'CCPN' }, {
2546
+ method: 'unionpay',
2547
+ sessionId: opened.sessionId,
2548
+ url: opened.url,
2549
+ ...(token ? { preBookToken: token } : {}),
2550
+ payBy: now() + payWithin,
2450
2551
  }) ?? row;
2451
2552
  return { kind: 'confirm', url: opened.url, booking: publicBooking(updated) };
2452
2553
  });
@@ -2500,25 +2601,39 @@ export const createHuazhuHotels = (opts) => {
2500
2601
  return undefined;
2501
2602
  const at = now();
2502
2603
  if (at - (returned.get(sessionId) ?? Number.NEGATIVE_INFINITY) >= 10_000 &&
2503
- !ticking &&
2504
2604
  moneyPending(found)) {
2505
2605
  returned.set(sessionId, at);
2506
2606
  if (returned.size > 1_000)
2507
2607
  for (const [k, v] of returned)
2508
2608
  if (at - v > 10 * 60_000)
2509
2609
  returned.delete(k);
2510
- ticking = true;
2511
2610
  try {
2512
- await settlePrepaid(found);
2611
+ // Under the booking's own lock - taken by the pass meanwhile, this
2612
+ // step is the pass's to take (audit P0-A).
2613
+ await ifFree(found.id, () => settlePrepaid(found));
2513
2614
  }
2514
2615
  catch (err) {
2515
2616
  console.error(`[huazhu prepaid ${found.id}] the landing page's step failed: ${err.message}`);
2516
2617
  }
2517
- finally {
2518
- ticking = false;
2519
- }
2520
2618
  }
2521
- return returnOf(store.get(found.id) ?? found);
2619
+ const row = store.get(found.id) ?? found;
2620
+ const told = returnOf(row);
2621
+ // ⚠️ "NOT PAID" ONLY WHEN STRIPE SAYS SO (audit P0-D): a pass busy on this
2622
+ // booking, or behind on others, left a guest who had paid told "nothing
2623
+ // was charged - book again". Not paid here yet, the page is asked - only
2624
+ // read, never moved on.
2625
+ if (told.state === 'not-paid' &&
2626
+ row.status === 'awaiting-payment' &&
2627
+ row.prepaid?.method !== 'unionpay' &&
2628
+ row.prepaid?.sessionId &&
2629
+ checkout) {
2630
+ const page = await checkout.state(row.prepaid.sessionId).catch(() => undefined);
2631
+ if (page === 'paid')
2632
+ return { ...told, state: 'booking' };
2633
+ if (page === undefined)
2634
+ return { ...told, state: 'unknown' };
2635
+ }
2636
+ return told;
2522
2637
  };
2523
2638
  return {
2524
2639
  brand: opts.brand,
@@ -2536,18 +2651,20 @@ export const createHuazhuHotels = (opts) => {
2536
2651
  ...(checkout ? { paymentReturn } : {}),
2537
2652
  ...(checkout && unionpay ? { unionPay } : {}),
2538
2653
  async prepaidTick() {
2539
- // ⚠️ ONE PASS AT A TIME: a pass slower than the ten seconds between
2540
- // them would otherwise book a paid stay, or give money back, twice.
2541
- if (ticking)
2654
+ // ⚠️ EACH BOOKING UNDER ITS OWN LOCK (audit P0-A): two passes, or a
2655
+ // pass and the landing page, never take a step on one booking at once.
2656
+ // One booking's failure holds up none of the others (audit P2).
2657
+ if (closing)
2542
2658
  return;
2543
- ticking = true;
2544
- try {
2545
- for (const row of store.all())
2546
- if (moneyPending(row))
2547
- await settlePrepaid(row);
2548
- }
2549
- finally {
2550
- ticking = false;
2659
+ for (const row of store.all()) {
2660
+ if (!moneyPending(row))
2661
+ continue;
2662
+ try {
2663
+ await ifFree(row.id, () => settlePrepaid(row));
2664
+ }
2665
+ catch (err) {
2666
+ console.error(`[huazhu prepaid ${row.id}] the pass's step failed: ${err.message}`);
2667
+ }
2551
2668
  }
2552
2669
  },
2553
2670
  imageDomains() {
@@ -2612,7 +2729,9 @@ export const createHuazhuHotels = (opts) => {
2612
2729
  */
2613
2730
  async drain() {
2614
2731
  closing = true;
2615
- await Promise.allSettled([...new Set(flights.values())]);
2732
+ // The steps on payments too - a refund or a booking half taken when the
2733
+ // process stopped was lost (audit P1-3, P0-B).
2734
+ await Promise.allSettled([...new Set(flights.values()), ...steps]);
2616
2735
  },
2617
2736
  async searchHotels(q) {
2618
2737
  const terms = (q.query ?? '')
@@ -3222,6 +3341,30 @@ export const createHuazhuHotels = (opts) => {
3222
3341
  };
3223
3342
  }
3224
3343
  const { fee, forbidden, rule, freeUntil } = feeNow(b);
3344
+ // A test booking says it is one (2026-10-16 audit, P2): Huazhu's test
3345
+ // system charges nobody and books nothing anyone stays in.
3346
+ const testNote = opts.testEnvironment
3347
+ ? " This is a test booking in Huazhu's test system: nothing is charged to anyone."
3348
+ : '';
3349
+ // Paid with UnionPay: Huazhu charged the one-time card, and gives back
3350
+ // to it what its terms allow - nothing passes through here (audit P0-C:
3351
+ // it was told as "paid at the hotel, nothing paid through here").
3352
+ if (b.prepaid?.method === 'unionpay' && b.prepaid.issuedAt) {
3353
+ const back = !forbidden && fee === 0;
3354
+ return {
3355
+ bookingId: b.id,
3356
+ confirmationCode: b.confirmationCode,
3357
+ fee: money(forbidden ? 0 : fee, cur),
3358
+ refund: back ? b.quote.total : zero,
3359
+ rule: forbidden
3360
+ ? `This rate cannot be cancelled now. ${rule} Huazhu keeps the ${formatMoney(b.quote.total)} it charged to the UnionPay card.${testNote}`
3361
+ : back
3362
+ ? `${rule} Paid with a UnionPay card that Huazhu charged: cancelling now, Huazhu gives all ${formatMoney(b.quote.total)} back to that same card - not through here.${testNote}`
3363
+ : `${rule} Paid with a UnionPay card that Huazhu charged: cancelling now gives nothing back - the fee is the whole stay, and Huazhu keeps it.${testNote}`,
3364
+ ...(freeUntil ? { freeUntil: new Date(freeUntil).toISOString() } : {}),
3365
+ cancellable: !forbidden,
3366
+ };
3367
+ }
3225
3368
  if (b.prepaid?.paidAt) {
3226
3369
  // Paid here in advance: free means all of it back, anything else -
3227
3370
  // the whole stay, on these rates - means none.
@@ -3234,8 +3377,8 @@ export const createHuazhuHotels = (opts) => {
3234
3377
  rule: forbidden
3235
3378
  ? `This rate cannot be cancelled now. ${rule} The ${formatMoney(b.quote.total)} paid stays paid.`
3236
3379
  : back
3237
- ? `${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.' : ''}`
3238
- : `${rule} Cancelling now gives nothing back: the fee is the whole stay.`,
3380
+ ? `${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.' : ''}`
3381
+ : `${rule} Cancelling now gives nothing back: the fee is the whole stay.${testNote}`,
3239
3382
  ...(freeUntil ? { freeUntil: new Date(freeUntil).toISOString() } : {}),
3240
3383
  cancellable: !forbidden,
3241
3384
  };
@@ -3250,7 +3393,9 @@ export const createHuazhuHotels = (opts) => {
3250
3393
  ? `This rate cannot be cancelled now. ${rule}`
3251
3394
  : opts.sandbox
3252
3395
  ? `${rule} This is a test booking in the simulated Huazhu: the fee is worked out as a real booking's would be, and nothing is charged to anyone.`
3253
- : `${rule} Nothing was paid through this connector - the booking is paid at the hotel - so any fee is charged under the hotel's terms, and Huazhu works out the exact amount when it cancels.`,
3396
+ : opts.testEnvironment
3397
+ ? `${rule} This is a test booking in Huazhu's test system: the fee is worked out as a real booking's would be, and nothing is charged to anyone.`
3398
+ : `${rule} Nothing was paid through this connector - the booking is paid at the hotel - so any fee is charged under the hotel's terms, and Huazhu works out the exact amount when it cancels.`,
3254
3399
  ...(freeUntil ? { freeUntil: new Date(freeUntil).toISOString() } : {}),
3255
3400
  cancellable: !forbidden,
3256
3401
  };
@@ -3259,80 +3404,92 @@ export const createHuazhuHotels = (opts) => {
3259
3404
  const found = findOwn(ref, who);
3260
3405
  if (!found)
3261
3406
  return null;
3262
- const b = await refresh(found);
3263
- // Waiting on UnionPay: a confirmation already made is being booked by
3264
- // the pass - which alone takes the card, once; one not made never will be.
3265
- if (b.status === 'awaiting-payment' && b.prepaid?.method === 'unionpay') {
3266
- const state = b.prepaid.sessionId && unionpay
3267
- ? await unionpay.state(b.prepaid.sessionId).catch(() => undefined)
3268
- : 'closed';
3269
- if (state !== 'issued' && state !== undefined) {
3270
- return publicBooking(store.update(b.id, {
3271
- status: 'expired',
3272
- prepaid: { ...b.prepaid, lapsedAt: now(), url: undefined },
3273
- }) ?? b);
3407
+ const first = await refresh(found);
3408
+ // A booking paid in advance is cancelled under its own lock - the pass,
3409
+ // the landing page and a switch to UnionPay wait (audit P0-A).
3410
+ const run = async () => {
3411
+ const b = store.get(first.id) ?? first;
3412
+ // Waiting on UnionPay: a confirmation already made is being booked by
3413
+ // the pass - which alone takes the card, once; one not made never will be.
3414
+ if (b.status === 'awaiting-payment' && b.prepaid?.method === 'unionpay') {
3415
+ const state = b.prepaid.sessionId && unionpay
3416
+ ? await unionpay.state(b.prepaid.sessionId).catch(() => undefined)
3417
+ : 'closed';
3418
+ if (state !== 'issued' && state !== undefined) {
3419
+ return publicBooking(patch(b.id, { status: 'expired' }, { lapsedAt: now(), url: undefined }) ?? b);
3420
+ }
3421
+ throw new Error(state === 'issued'
3422
+ ? 'The guest has just confirmed on UnionPay, so the stay is being booked with Huazhu now. Ask again in a moment to cancel it.'
3423
+ : 'UnionPay could not be reached to say whether the guest confirmed. Ask again in a moment - nothing is booked until it can.');
3274
3424
  }
3275
- throw new Error(state === 'issued'
3276
- ? 'The guest has just confirmed on UnionPay, so the stay is being booked with Huazhu now. Ask again in a moment to cancel it.'
3277
- : 'UnionPay could not be reached to say whether the guest confirmed. Ask again in a moment - nothing is booked until it can.');
3278
- }
3279
- // Not paid yet: the page is closed, so it cannot be paid after this.
3280
- if (b.status === 'awaiting-payment' && b.prepaid?.sessionId && checkout) {
3281
- const state = await checkout.close(b.prepaid.sessionId).catch(() => undefined);
3282
- if (state === 'closed') {
3283
- return publicBooking(store.update(b.id, {
3284
- status: 'expired',
3285
- prepaid: { ...b.prepaid, lapsedAt: now(), url: undefined },
3286
- }) ?? b);
3425
+ // Not paid yet: the page is closed, so it cannot be paid after this.
3426
+ if (b.status === 'awaiting-payment' && b.prepaid?.sessionId && checkout) {
3427
+ const state = await checkout.close(b.prepaid.sessionId).catch(() => undefined);
3428
+ if (state === 'closed') {
3429
+ return publicBooking(patch(b.id, { status: 'expired' }, { lapsedAt: now(), url: undefined }) ?? b);
3430
+ }
3431
+ // ⚠️ NO ANSWER IS NOT "PAID" (audit P0-C): the page may well still be
3432
+ // open and unpaid.
3433
+ if (state === undefined)
3434
+ 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.');
3435
+ // Paid in the last second: it is booked now, and cancelled as one.
3436
+ await settlePrepaid(b);
3437
+ 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.');
3287
3438
  }
3288
- // Paid in the last second: it is booked now, and cancelled as one.
3289
- await settlePrepaid(b);
3290
- 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.');
3291
- }
3292
- if (b.pendingSince !== undefined || b.status === 'unconfirmed') {
3293
- throw new Error(waitingWords(b));
3294
- }
3295
- if (!b.resId)
3296
- return publicBooking(b);
3297
- if (b.status !== 'confirmed')
3298
- return publicBooking(b);
3299
- const cur = b.quote.total.currency;
3300
- try {
3301
- const out = await client.cancel(b.resId);
3302
- const fee = typeof out.penaltyAmount === 'number'
3303
- ? money(toMinor(out.penaltyAmount, out.penaltyCurrencyCode || cur), out.penaltyCurrencyCode || cur)
3304
- : money(feeNow(b).fee, cur);
3305
- const cancelled = store.update(b.id, {
3306
- status: 'cancelled',
3307
- hotelStatus: 'HOTEL_CANCEL',
3308
- cancelledAt: new Date(now()).toISOString(),
3309
- cancellationFee: fee,
3310
- }) ?? b;
3311
- // Paid in advance and cancelled free: all of it back. A fee here is
3312
- // the whole stay (only whole-or-nothing rates are sold this way), so
3313
- // nothing comes back.
3314
- if (cancelled.prepaid?.paidAt && fee.minor === 0)
3315
- await refundOf(b.id, 'cancelled within the free cancellation period');
3316
- return publicBooking(store.get(b.id) ?? cancelled);
3317
- }
3318
- catch (err) {
3319
- if (err instanceof HuazhuRefusal && err.code === 5402) {
3320
- return publicBooking(store.update(b.id, {
3439
+ if (b.pendingSince !== undefined || b.status === 'unconfirmed') {
3440
+ throw new Error(waitingWords(b));
3441
+ }
3442
+ if (!b.resId)
3443
+ return publicBooking(b);
3444
+ if (b.status !== 'confirmed')
3445
+ return publicBooking(b);
3446
+ const cur = b.quote.total.currency;
3447
+ // ⚠️ THE CANCELLATION AND WHAT IT OWES IN ONE WRITE, then the refund
3448
+ // (audit P0-B) - a process stopped in between leaves the refund to
3449
+ // open, which the pass opens.
3450
+ const cancelled = (fee, at) => {
3451
+ const latest = store.get(b.id) ?? b;
3452
+ const owed = owedOnCancel(latest, fee.minor, true);
3453
+ const done = patch(b.id, {
3321
3454
  status: 'cancelled',
3322
3455
  hotelStatus: 'HOTEL_CANCEL',
3323
- cancelledAt: b.cancelledAt ?? new Date(now()).toISOString(),
3324
- }) ?? b);
3325
- }
3326
- if (err instanceof HuazhuRefusal && (err.code === 5401 || err.code === 5405)) {
3327
- throw new Error(err.code === 5405
3328
- ? 'Huazhu says this stay has already started, so it cannot be cancelled.'
3329
- : 'Huazhu says this booking cannot be cancelled.');
3456
+ cancelledAt: at,
3457
+ cancellationFee: fee,
3458
+ ...owed.row,
3459
+ }, owed.fields) ?? b;
3460
+ return publicBooking(done);
3461
+ };
3462
+ try {
3463
+ const out = await client.cancel(b.resId);
3464
+ const fee = typeof out.penaltyAmount === 'number'
3465
+ ? money(toMinor(out.penaltyAmount, out.penaltyCurrencyCode || cur), out.penaltyCurrencyCode || cur)
3466
+ : money(feeNow(b).fee, cur);
3467
+ cancelled(fee, new Date(now()).toISOString());
3330
3468
  }
3331
- if (err instanceof HuazhuUnreachable) {
3332
- 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.');
3469
+ catch (err) {
3470
+ if (err instanceof HuazhuRefusal && err.code === 5402) {
3471
+ // Cancelled already - by this guest's own earlier try whose answer
3472
+ // was lost, most likely: owed as that cancellation was (audit P0-B).
3473
+ cancelled(b.cancellationFee ?? money(feeNow(b).fee, cur), b.cancelledAt ?? new Date(now()).toISOString());
3474
+ }
3475
+ else if (err instanceof HuazhuRefusal && (err.code === 5401 || err.code === 5405)) {
3476
+ throw new Error(err.code === 5405
3477
+ ? 'Huazhu says this stay has already started, so it cannot be cancelled.'
3478
+ : 'Huazhu says this booking cannot be cancelled.');
3479
+ }
3480
+ else if (err instanceof HuazhuUnreachable) {
3481
+ 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.');
3482
+ }
3483
+ else
3484
+ throw err;
3333
3485
  }
3334
- throw err;
3335
- }
3486
+ const after = store.get(b.id);
3487
+ const refund = after?.prepaid?.refund;
3488
+ if (refund && !refund.requestId)
3489
+ await refundOf(b.id, refund.reason);
3490
+ return publicBooking(store.get(b.id) ?? after ?? b);
3491
+ };
3492
+ return first.prepaid ? inTurnFor(first.id, run) : run();
3336
3493
  },
3337
3494
  async policies() {
3338
3495
  // Huazhu publishes no policy text: each RATE carries its terms, and those