@curless/hotel-storefront 0.39.1 → 0.40.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 (61) hide show
  1. package/dist/backend.d.ts +3 -1
  2. package/dist/backend.d.ts.map +1 -1
  3. package/dist/backend.js.map +1 -1
  4. package/dist/card.generated.d.ts +1 -1
  5. package/dist/card.generated.js +2 -2
  6. package/dist/card.generated.js.map +1 -1
  7. package/dist/huazhu/backend.d.ts.map +1 -1
  8. package/dist/huazhu/backend.js +391 -82
  9. package/dist/huazhu/backend.js.map +1 -1
  10. package/dist/huazhu/bookings.d.ts +24 -0
  11. package/dist/huazhu/bookings.d.ts.map +1 -1
  12. package/dist/huazhu/bookings.js +17 -1
  13. package/dist/huazhu/bookings.js.map +1 -1
  14. package/dist/huazhu/catalog.d.ts +9 -1
  15. package/dist/huazhu/catalog.d.ts.map +1 -1
  16. package/dist/huazhu/catalog.js +12 -3
  17. package/dist/huazhu/catalog.js.map +1 -1
  18. package/dist/huazhu/client.js +1 -1
  19. package/dist/huazhu/client.js.map +1 -1
  20. package/dist/huazhu/english.d.ts.map +1 -1
  21. package/dist/huazhu/english.js +5 -2
  22. package/dist/huazhu/english.js.map +1 -1
  23. package/dist/huazhu/fake.d.ts +3 -1
  24. package/dist/huazhu/fake.d.ts.map +1 -1
  25. package/dist/huazhu/fake.js +19 -1
  26. package/dist/huazhu/fake.js.map +1 -1
  27. package/dist/huazhu/labels.d.ts.map +1 -1
  28. package/dist/huazhu/labels.js +17 -0
  29. package/dist/huazhu/labels.js.map +1 -1
  30. package/dist/huazhu/make.d.ts.map +1 -1
  31. package/dist/huazhu/make.js +31 -7
  32. package/dist/huazhu/make.js.map +1 -1
  33. package/dist/huazhu/sellable.d.ts +3 -1
  34. package/dist/huazhu/sellable.d.ts.map +1 -1
  35. package/dist/huazhu/sellable.js +14 -4
  36. package/dist/huazhu/sellable.js.map +1 -1
  37. package/dist/payments/checkout.d.ts +15 -2
  38. package/dist/payments/checkout.d.ts.map +1 -1
  39. package/dist/payments/checkout.js +4 -1
  40. package/dist/payments/checkout.js.map +1 -1
  41. package/dist/payments/fake-stripe.d.ts +1 -0
  42. package/dist/payments/fake-stripe.d.ts.map +1 -1
  43. package/dist/payments/fake-stripe.js +20 -4
  44. package/dist/payments/fake-stripe.js.map +1 -1
  45. package/dist/payments/fake-unionpay.d.ts +3 -0
  46. package/dist/payments/fake-unionpay.d.ts.map +1 -1
  47. package/dist/payments/fake-unionpay.js +20 -2
  48. package/dist/payments/fake-unionpay.js.map +1 -1
  49. package/dist/payments/stripe-checkout.d.ts +2 -1
  50. package/dist/payments/stripe-checkout.d.ts.map +1 -1
  51. package/dist/payments/stripe-checkout.js +11 -4
  52. package/dist/payments/stripe-checkout.js.map +1 -1
  53. package/dist/payments/unionpay.d.ts +12 -1
  54. package/dist/payments/unionpay.d.ts.map +1 -1
  55. package/dist/payments/unionpay.js +10 -3
  56. package/dist/payments/unionpay.js.map +1 -1
  57. package/dist/server.d.ts +2 -2
  58. package/dist/server.js +2 -2
  59. package/dist/types.d.ts +8 -0
  60. package/dist/types.d.ts.map +1 -1
  61. package/package.json +1 -1
@@ -8,14 +8,14 @@ import { BOOKING_LIMITS, DAY_MS, HOUR_MS, 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
- import { IN_REQUEST_AT_MS, StoreWriteError, nextCheckAt, statusOf, } from './bookings.js';
11
+ import { IN_REQUEST_AT_MS, NOT_FOUND, StoreWriteError, nextCheckAt, statusOf, } from './bookings.js';
12
12
  import { describePolicy, feeFor, freeUntilOf, nightAuditOf, parseCancelPolicy, penaltyAt, refundableFrom, } from './cancel-policy.js';
13
13
  import { haystackOf, inPlace, namesOf, roomPhoto, toHotel } from './catalog.js';
14
14
  import { HuazhuRefusal, 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';
18
- import { BOOK_EXISTS, BOOK_REFUSALS, CODES, } from './wire.js';
18
+ import { BOOK_EXISTS, BOOK_REFUSALS, CODES, PAYMENT_FOR_GUARANTEE, } from './wire.js';
19
19
  export const HUAZHU_CAPABILITIES = {
20
20
  members: false,
21
21
  extras: false,
@@ -92,14 +92,16 @@ const WINDOW = {
92
92
  '1': 'Window',
93
93
  '2': 'Some rooms without a window',
94
94
  '3': 'No window',
95
- '4': 'Window onto an inner courtyard',
95
+ // Huazhu's dictionary says 内景 - an inner view - and no more: no courtyard
96
+ // was ever said (2026-10-16 audit, P2).
97
+ '4': 'Inner-view window',
96
98
  };
97
99
  /** The same, as Huazhu's own dictionary says it - for a connector that speaks Chinese. */
98
100
  const WINDOW_ZH = {
99
101
  '1': '有窗',
100
102
  '2': '部分有窗',
101
103
  '3': '无窗',
102
- '4': '内窗(朝天井)',
104
+ '4': '内景窗',
103
105
  };
104
106
  /** A rate's meal in Chinese, by its code; room only is 不含早. */
105
107
  const MEAL_ZH = {
@@ -686,12 +688,25 @@ export const createHuazhuHotels = (opts) => {
686
688
  /** Whether a hotel sells any room by the hour on its today - from its products, no prices. */
687
689
  const sellsByTheHourToday = async (hotelId) => (await productsOf(hotelId)).some((p) => p.checkinType === 'HOUR' && inStayWindow(p.stayWindow, today()));
688
690
  const ariCache = new Map();
691
+ /**
692
+ * A hotel Huazhu answered "too many requests" (2009) for, let be until then -
693
+ * a minute, doubling to ten while it goes on (2026-10-16 audit, P1-11: asked
694
+ * again at once, every guest's ask). Prices last read are used meanwhile; a
695
+ * booking's own fresh ask is not held back.
696
+ */
697
+ const ariBackoff = new Map();
689
698
  /** Every product's prices, rules and rooms left, one element per date from `start` to `end`. */
690
699
  const ariFor = async (hotelId, start, end, fresh = false) => {
691
700
  const key = `${hotelId}|${start}|${end}`;
692
701
  const hit = ariCache.get(key);
693
702
  if (!fresh && hit && now() - hit.at < ariTtl)
694
703
  return hit.value;
704
+ const held = ariBackoff.get(hotelId);
705
+ if (!fresh && held && now() < held.until) {
706
+ if (hit)
707
+ return hit.value;
708
+ throw new HuazhuRefusal(CODES.rateLimited, 'Too many requests', '', '/ari/daily/offers');
709
+ }
695
710
  const ranges = ariRanges(start, end);
696
711
  const parts = await Promise.all(ranges.map(async (r) => {
697
712
  const [offers, inventory] = await Promise.all([
@@ -713,7 +728,14 @@ export const createHuazhuHotels = (opts) => {
713
728
  offers: offers.dailyOffers ?? [],
714
729
  inventory: inventory.dailyInventories ?? [],
715
730
  };
716
- }));
731
+ })).catch((err) => {
732
+ if (err instanceof HuazhuRefusal && err.code === CODES.rateLimited) {
733
+ const wait = Math.min((ariBackoff.get(hotelId)?.wait ?? 30_000) * 2, 10 * 60_000);
734
+ ariBackoff.set(hotelId, { until: now() + wait, wait });
735
+ }
736
+ throw err;
737
+ });
738
+ ariBackoff.delete(hotelId);
717
739
  const out = new Map();
718
740
  const keys = new Set(parts.flatMap((p) => p.offers.map((o) => roomIdOf(o))));
719
741
  for (const k of keys) {
@@ -1008,9 +1030,16 @@ export const createHuazhuHotels = (opts) => {
1008
1030
  const fails = priced.map((p) => p.res).filter((r) => !r.ok);
1009
1031
  if (fails.length > 0 && fails.every((f) => f.kind === 'party'))
1010
1032
  return partyNeedsOf(guests, largestOf(fails));
1033
+ if (fails.some((f) => f.kind === 'partial-refund'))
1034
+ return {
1035
+ why: "This hotel's rooms by the hour are paid in advance on terms that keep part of the price if cancelled - and a stay paid in advance here goes back whole or not at all.",
1036
+ code: 'partial-refund',
1037
+ };
1011
1038
  if (fails.some((f) => f.kind === 'prepaid'))
1012
1039
  return {
1013
- why: "This hotel's rooms by the hour are paid in advance, and this connector books rates paid at the hotel.",
1040
+ why: prepaidType
1041
+ ? "This hotel's rooms by the hour are sold only on payment terms this connector cannot take."
1042
+ : "This hotel's rooms by the hour are paid in advance, and this connector books rates paid at the hotel.",
1014
1043
  code: 'prepaid-only',
1015
1044
  };
1016
1045
  const said = (re) => fails
@@ -1052,9 +1081,18 @@ export const createHuazhuHotels = (opts) => {
1052
1081
  // find a rate paid at the hotel. At Huazhu's test hotel (2026-10-15) 28
1053
1082
  // rates were prepaid and 4 sold out, and the guest was told only that
1054
1083
  // nothing could be booked (总线程).
1084
+ // Paid in advance here, but on terms that keep part of the stay - true
1085
+ // of a "one night's fee" rate over two nights or more (audit P1-9).
1086
+ if (kinds.has('partial-refund') && !kinds.has('party') && !kinds.has('too-many-rooms'))
1087
+ return {
1088
+ why: `The rooms still open here for those nights are paid in advance on terms that keep part of the stay if cancelled - and a stay paid in advance here goes back whole or not at all, so they are not sold for ${nightsBetween(stay.checkIn, stay.checkOut)} nights. A shorter stay may be.`,
1089
+ code: 'partial-refund',
1090
+ };
1055
1091
  if (kinds.has('prepaid') && !kinds.has('party') && !kinds.has('too-many-rooms'))
1056
1092
  return {
1057
- why: 'Every room still open here for those nights is paid in advance, and this connector books rates paid at the hotel.',
1093
+ why: prepaidType
1094
+ ? 'Every room still open here for those nights is sold only on payment terms this connector cannot take.'
1095
+ : 'Every room still open here for those nights is paid in advance, and this connector books rates paid at the hotel.',
1058
1096
  code: 'prepaid-only',
1059
1097
  };
1060
1098
  if (only('sold-out'))
@@ -1327,6 +1365,16 @@ export const createHuazhuHotels = (opts) => {
1327
1365
  }
1328
1366
  : {}),
1329
1367
  ...(refund ? { refundReason: refund.reason } : {}),
1368
+ ...(refund?.amountMinor !== undefined && refund.amountMinor < b.quote.total.minor
1369
+ ? { refundAmount: money(refund.amountMinor, b.quote.total.currency) }
1370
+ : {}),
1371
+ // Opened and refused, or never opened after every try: a person sees to it (audit P1-1).
1372
+ ...(refund &&
1373
+ (refund.status === 'failed' ||
1374
+ refund.status === 'rejected' ||
1375
+ (!refund.requestId && (refund.tries ?? 0) >= 8))
1376
+ ? { refundStuck: true }
1377
+ : {}),
1330
1378
  };
1331
1379
  };
1332
1380
  const publicBooking = (b) => ({
@@ -1353,7 +1401,14 @@ export const createHuazhuHotels = (opts) => {
1353
1401
  * before 0.27 showed it as its code, and is opened by it only in the
1354
1402
  * conversation that made it.
1355
1403
  */
1356
- const codesOf = (b) => [b.confirmationCode !== b.resId ? b.confirmationCode : undefined, b.distributorResId]
1404
+ /**
1405
+ * What a booking answers to with its email - the code the guest was given.
1406
+ * ⚠️ NOT `distributorResId` (2026-10-16 audit, P2): no guest is ever told
1407
+ * it, and it rides on the payment's metadata and UnionPay's item - with the
1408
+ * email beside it on the payment page, anyone who could see those could
1409
+ * have opened and cancelled the booking.
1410
+ */
1411
+ const codesOf = (b) => [b.confirmationCode !== b.resId ? b.confirmationCode : undefined]
1357
1412
  .filter((x) => !!x)
1358
1413
  .map((x) => x.toLowerCase());
1359
1414
  const findOwn = (ref, who) => {
@@ -1540,13 +1595,16 @@ export const createHuazhuHotels = (opts) => {
1540
1595
  if (who.ip && requestsByIp.count(who.ip) >= limits.perIpPerHour) {
1541
1596
  throw new Error('Nothing was booked. Too many bookings have been asked for from this network in the last hour - try again later.');
1542
1597
  }
1543
- const madeToday = store
1544
- .all()
1545
- .filter((b) => now() - Date.parse(b.createdAt) < DAY_MS &&
1598
+ const madeToday = store.all().filter((b) => now() - Date.parse(b.createdAt) < DAY_MS &&
1546
1599
  (b.status === 'confirmed' ||
1547
1600
  b.status === 'requested' ||
1548
1601
  b.status === 'unconfirmed' ||
1549
- b.status === 'cancelled')).length + slotsWhere(() => true);
1602
+ b.status === 'cancelled' ||
1603
+ // A stay paid in advance asked Huazhu (its prebook) before any
1604
+ // payment - waiting for one, or lapsed without, it counts too
1605
+ // (2026-10-16 audit, P2: unpaid asks were never counted).
1606
+ b.status === 'awaiting-payment' ||
1607
+ (b.status === 'expired' && !!b.prepaid))).length + slotsWhere(() => true);
1550
1608
  if (madeToday + lines.length > limits.perDeploymentPerDay) {
1551
1609
  // ⚠️ A CEILING HIT IS SOMETHING A PERSON SHOULD KNOW: either business
1552
1610
  // is booming or somebody is filling hotels with names. Logged, and on
@@ -1609,6 +1667,41 @@ export const createHuazhuHotels = (opts) => {
1609
1667
  await refundOf(id, why);
1610
1668
  return;
1611
1669
  }
1670
+ // ⚠️ PREBOOKED AGAIN WHEN ITS TOKEN IS STALE (2026-10-16 audit, P2):
1671
+ // Huazhu's token holds 10 minutes, and a page may be paid 30 minutes
1672
+ // after the prebook. A price that moved, or no room left, is Huazhu's no
1673
+ // to this stay - taken as one (all of it back). No answer: booked
1674
+ // without a token - Huazhu checks the price at booking anyway.
1675
+ if (p.prebookAt === undefined || now() - p.prebookAt >= PREBOOK_FRESH_MS) {
1676
+ try {
1677
+ const out = await client.prebook({
1678
+ hotelId: row.hotelId,
1679
+ checkinType: row.hourly ? 'HOUR' : 'DAY',
1680
+ paymentType: card ? 'CCPN' : row.paymentType,
1681
+ reservations: [
1682
+ {
1683
+ roomTypeCode: row.roomTypeCode,
1684
+ rateCode: row.rateCode,
1685
+ ...stayDatesOf(row),
1686
+ amountAfterTax: p.after,
1687
+ ...(p.before ? { amountBeforeTax: p.before } : {}),
1688
+ currencyCode: p.currency,
1689
+ roomCount: row.rooms,
1690
+ adultCount: row.stay.guests,
1691
+ },
1692
+ ],
1693
+ });
1694
+ patch(id, {}, { preBookToken: out?.preBookToken, prebookAt: now() });
1695
+ }
1696
+ catch (err) {
1697
+ if (err instanceof HuazhuRefusal) {
1698
+ await turnedDown(id, err, !!card);
1699
+ return;
1700
+ }
1701
+ patch(id, {}, { preBookToken: undefined });
1702
+ }
1703
+ }
1704
+ const token = store.get(id)?.prepaid?.preBookToken;
1612
1705
  const comment = commentOf(row.requests, row.guest.notes);
1613
1706
  const request = {
1614
1707
  hotelId: row.hotelId,
@@ -1645,16 +1738,37 @@ export const createHuazhuHotels = (opts) => {
1645
1738
  },
1646
1739
  ],
1647
1740
  ...(comment ? { guestComment: comment } : {}),
1648
- ...(p.preBookToken ? { preBookToken: p.preBookToken } : {}),
1741
+ ...(token ? { preBookToken: token } : {}),
1649
1742
  };
1650
1743
  // ⚠️ SET BEFORE THE CALL, as every booking is: a process that dies in it
1651
- // leaves a row the reconciler follows up.
1652
- store.update(id, { status: 'requested', pendingSince: now() });
1744
+ // leaves a row the reconciler follows up. ⚠️ WRITTEN, OR NOT SENT
1745
+ // (2026-10-05 audit P1-8, kept on this path - 2026-10-16 audit P2): a
1746
+ // booking whose call is not written down could not be followed up. Left
1747
+ // waiting, paid; the next pass tries again.
1748
+ const current = store.get(id);
1749
+ if (!current)
1750
+ return;
1751
+ try {
1752
+ store.put({ ...current, status: 'requested', pendingSince: now() });
1753
+ }
1754
+ catch (err) {
1755
+ if (!(err instanceof StoreWriteError))
1756
+ throw err;
1757
+ raise(id, `the booking could not be written down before it was sent to Huazhu, so it was not sent (${err.message}) - it is tried again on the next pass`);
1758
+ return;
1759
+ }
1653
1760
  let refusal;
1654
1761
  let answer;
1655
1762
  for (let attempt = 0; attempt < 2; attempt += 1) {
1656
1763
  try {
1657
- const got = await client.book(request);
1764
+ reconciler.inFlight(id, true);
1765
+ let got;
1766
+ try {
1767
+ got = await client.book(request);
1768
+ }
1769
+ finally {
1770
+ reconciler.inFlight(id, false);
1771
+ }
1658
1772
  if (got && typeof got.resId === 'string' && got.resId)
1659
1773
  answer = got;
1660
1774
  }
@@ -1680,21 +1794,24 @@ export const createHuazhuHotels = (opts) => {
1680
1794
  });
1681
1795
  return;
1682
1796
  }
1683
- if (refusal && card) {
1684
- // Paid with UnionPay: the hotel charges the card, and a booking it did
1685
- // not make it never charged - there is nothing here to give back.
1686
- patch(id, { status: 'failed', pendingSince: undefined }, {
1687
- declined: `Huazhu did not take the booking: ${refusal.huazhuMessage} (${refusal.code})`.replace(/\d{12,19}/g, '****'),
1688
- });
1797
+ if (refusal)
1798
+ await turnedDown(id, refusal, !!card);
1799
+ // No answer: the reconciler's eight minutes decide it.
1800
+ };
1801
+ /**
1802
+ * Huazhu's no to a stay paid for, at its prebook or its booking. Paid with
1803
+ * UnionPay, the hotel charges the card, and a booking it did not make it
1804
+ * never charged - nothing here to give back. Paid on the card page (a price
1805
+ * that moved since the prebook is one of these, 5209), all of it back.
1806
+ */
1807
+ const turnedDown = async (id, refusal, card) => {
1808
+ const said = `Huazhu did not take the booking: ${refusal.huazhuMessage} (${refusal.code})`;
1809
+ if (card) {
1810
+ patch(id, { status: 'failed', pendingSince: undefined }, { declined: said.replace(/\d(?:[ -]?\d){11,18}/g, '****') });
1689
1811
  return;
1690
1812
  }
1691
- if (refusal) {
1692
- store.update(id, { status: 'failed', pendingSince: undefined });
1693
- // A price that moved since the prebook is one of these (5209): the
1694
- // guest paid for a price Huazhu no longer gives, and gets it all back.
1695
- await refundOf(id, `Huazhu did not take the booking: ${refusal.huazhuMessage} (${refusal.code})`);
1696
- }
1697
- // No answer: the reconciler's eight minutes decide it.
1813
+ store.update(id, { status: 'failed', pendingSince: undefined });
1814
+ await refundOf(id, said);
1698
1815
  };
1699
1816
  /**
1700
1817
  * Gives a guest's money back, in full - the only refund the gateway makes.
@@ -1708,19 +1825,38 @@ export const createHuazhuHotels = (opts) => {
1708
1825
  return;
1709
1826
  const tries = (p.refund?.tries ?? 0) + 1;
1710
1827
  const at = p.refund?.at ?? now();
1828
+ // Said once: a retry sends what the first try sent, word for word - Stripe
1829
+ // refuses the same key with anything else, every time after (2026-10-16
1830
+ // audit, P2).
1831
+ const why = p.refund?.reason ?? reason;
1711
1832
  try {
1712
1833
  // Named by when it was decided: the same refund retried is the same refund.
1713
- const r = await checkout.refund(p.sessionId, reason, `${row.distributorResId}-${at}`);
1714
- patch(id, {}, { refund: { reason, at, requestId: r.requestId, status: r.status, tries } });
1715
- console.error(`[huazhu prepaid ${id}] refund ${r.requestId} (${r.status}) of session ${p.sessionId}: ${reason}`);
1834
+ const r = await checkout.refund(p.sessionId, why, `${row.distributorResId}-${at}`, p.refund?.amountMinor);
1835
+ patch(id, {}, {
1836
+ refund: {
1837
+ reason: why,
1838
+ at,
1839
+ requestId: r.requestId,
1840
+ status: r.status,
1841
+ tries,
1842
+ triedAt: now(),
1843
+ ...(p.refund?.amountMinor !== undefined ? { amountMinor: p.refund.amountMinor } : {}),
1844
+ },
1845
+ });
1846
+ console.error(`[huazhu prepaid ${id}] refund ${r.requestId} (${r.status}) of session ${p.sessionId}: ${why}`);
1716
1847
  }
1717
1848
  catch (err) {
1718
- patch(id, {}, { refund: { reason, at, tries } });
1719
- if (tries >= 8 && !row.attention) {
1720
- const why = `the guest paid (gateway session ${p.sessionId}) and the refund could not be opened: ${err.message}`;
1721
- store.update(id, { attention: why, attentionAt: new Date(now()).toISOString() });
1722
- console.error(`[huazhu prepaid ${id}] ⚠️ NEEDS A PERSON: ${why}`);
1723
- }
1849
+ patch(id, {}, {
1850
+ refund: {
1851
+ reason: why,
1852
+ at,
1853
+ tries,
1854
+ triedAt: now(),
1855
+ ...(p.refund?.amountMinor !== undefined ? { amountMinor: p.refund.amountMinor } : {}),
1856
+ },
1857
+ });
1858
+ if (tries >= 8)
1859
+ raise(id, `the guest paid (gateway session ${p.sessionId}) and the refund could not be opened`);
1724
1860
  }
1725
1861
  };
1726
1862
  /**
@@ -1754,18 +1890,66 @@ export const createHuazhuHotels = (opts) => {
1754
1890
  };
1755
1891
  if (byGuest && feeMinor >= b.quote.total.minor)
1756
1892
  return { row: {}, fields: { kept: { feeMinor, at: now() } } };
1893
+ // A part kept by the hotel, cancelled by the guest here: the rest back
1894
+ // (2026-10-16 audit, P2 - a fee above nothing gave back nothing).
1895
+ if (byGuest)
1896
+ return {
1897
+ row: {},
1898
+ fields: {
1899
+ refund: {
1900
+ reason: `cancelled with a fee of ${formatMoney(money(feeMinor, b.quote.total.currency))} kept by the hotel - the rest back`,
1901
+ at: now(),
1902
+ tries: 0,
1903
+ amountMinor: b.quote.total.minor - feeMinor,
1904
+ },
1905
+ },
1906
+ };
1757
1907
  const fee = formatMoney(money(feeMinor, b.quote.total.currency));
1758
1908
  const why = byGuest
1759
1909
  ? `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
1910
  : `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
1911
  console.error(`[huazhu prepaid ${b.id}] ⚠️ NEEDS A PERSON: ${why}`);
1762
- return { row: { attention: why, attentionAt: new Date(now()).toISOString() }, fields: {} };
1912
+ return {
1913
+ row: {
1914
+ attention: why,
1915
+ attentionAt: new Date(now()).toISOString(),
1916
+ resolvedAt: undefined,
1917
+ resolution: undefined,
1918
+ },
1919
+ fields: {},
1920
+ };
1921
+ };
1922
+ /**
1923
+ * The one-time card a lapsed or cancelled UnionPay stay will never use,
1924
+ * thrown away at the gateway - now if it is there, refused when it comes
1925
+ * (网关 a974cc2a; 2026-10-16 audit, P1-6 and P2). "Handed over already"
1926
+ * means a step here took it: a person checks with Huazhu.
1927
+ */
1928
+ const discardCard = async (id) => {
1929
+ const row = store.get(id);
1930
+ const sid = row?.prepaid?.method === 'unionpay' ? row.prepaid.sessionId : undefined;
1931
+ if (!sid || !unionpay)
1932
+ return;
1933
+ try {
1934
+ if ((await unionpay.discard(sid)) === 'released')
1935
+ raise(id, `UnionPay's one-time card for gateway payment ${sid} was handed over here, yet the stay lapsed or was cancelled before it was booked: check with Huazhu whether ${row?.distributorResId} exists, and cancel it if it does`);
1936
+ }
1937
+ catch (err) {
1938
+ console.error(`[huazhu prepaid ${id}] the gateway could not be told to throw away UnionPay's card for ${sid}: ${err.message}`);
1939
+ }
1763
1940
  };
1764
1941
  /**
1765
1942
  * A confirmation that lands a little after `payBy` was made in time - the
1766
1943
  * callback is seconds behind the guest. Later than this, it is never used.
1767
1944
  */
1768
1945
  const ISSUED_GRACE_MS = 5 * 60_000;
1946
+ /** Huazhu's prebook token holds 10 minutes (v1.17): a paid stay older than this is prebooked again. */
1947
+ const PREBOOK_FRESH_MS = 9 * 60_000;
1948
+ /** Passes in a row without word from a card payment page, past its time, before a person is told. */
1949
+ const UNANSWERED_PASSES = 30;
1950
+ /** A paid booking nobody could confirm: Huazhu asked again this often, for this long (audit P1-2). */
1951
+ const HELD_CHECK_MS = 30 * 60_000;
1952
+ const HELD_LIMIT_MS = DAY_MS;
1769
1953
  /**
1770
1954
  * One step for a booking paid with UnionPay: confirmed on UnionPay's page →
1771
1955
  * the one-time card, once, straight into a CCPN booking with Huazhu;
@@ -1776,10 +1960,13 @@ export const createHuazhuHotels = (opts) => {
1776
1960
  const p = row.prepaid;
1777
1961
  if (row.status !== 'awaiting-payment' || !unionpay)
1778
1962
  return;
1779
- const lapse = () => patch(row.id, { status: 'expired' }, { lapsedAt: now(), url: undefined });
1963
+ const lapse = async () => {
1964
+ patch(row.id, { status: 'expired' }, { lapsedAt: now(), url: undefined });
1965
+ await discardCard(row.id);
1966
+ };
1780
1967
  if (!p.sessionId) {
1781
1968
  if (now() >= p.payBy)
1782
- lapse();
1969
+ await lapse();
1783
1970
  return;
1784
1971
  }
1785
1972
  let state;
@@ -1787,7 +1974,11 @@ export const createHuazhuHotels = (opts) => {
1787
1974
  state = await unionpay.state(p.sessionId);
1788
1975
  }
1789
1976
  catch {
1790
- return; // asked again on the next pass
1977
+ // Asked again on the next pass - until past its time and its grace: a
1978
+ // card never taken is never charged, so it simply lapses (audit P1-4).
1979
+ if (now() >= p.payBy + ISSUED_GRACE_MS)
1980
+ await lapse();
1981
+ return;
1791
1982
  }
1792
1983
  if (state === 'issued' && now() < p.payBy + ISSUED_GRACE_MS) {
1793
1984
  // ⚠️ ONCE: the gateway hands the card over a single time, then forgets it.
@@ -1825,7 +2016,7 @@ export const createHuazhuHotels = (opts) => {
1825
2016
  // Unconfirmed in time: lapsed. A confirmation after this is never handed
1826
2017
  // on, so nothing is ever charged for it.
1827
2018
  if (state === 'closed' || now() >= p.payBy)
1828
- lapse();
2019
+ await lapse();
1829
2020
  };
1830
2021
  /**
1831
2022
  * ⚠️ ONE STEP AT A TIME ON A BOOKING - every step that moves a payment on:
@@ -1865,6 +2056,27 @@ export const createHuazhuHotels = (opts) => {
1865
2056
  throw new Error('A payment on this booking is being settled right now. Ask again in a moment.');
1866
2057
  return (await ifFree(id, fn));
1867
2058
  };
2059
+ /**
2060
+ * ⚠️ A PERSON ASKED - EVERY TIME THERE IS SOMETHING NEW (2026-10-16 audit,
2061
+ * P1-1): an earlier ask, even one already seen to, kept a refund that later
2062
+ * failed from being asked about at all. The same thing still open is not
2063
+ * asked twice; a new one opens the ask again.
2064
+ */
2065
+ const raise = (id, why) => {
2066
+ const cur = store.get(id);
2067
+ if (!cur)
2068
+ return;
2069
+ const open = !!cur.attention && !cur.resolvedAt;
2070
+ if (open && cur.attention?.includes(why))
2071
+ return;
2072
+ store.update(id, {
2073
+ attention: open ? `${cur.attention} | ${why}` : why,
2074
+ attentionAt: new Date(now()).toISOString(),
2075
+ resolvedAt: undefined,
2076
+ resolution: undefined,
2077
+ });
2078
+ console.error(`[huazhu prepaid ${id}] ⚠️ NEEDS A PERSON: ${why}`);
2079
+ };
1868
2080
  /**
1869
2081
  * Writes a booking as it is NOW, with `row` and the prepaid `fields` on top -
1870
2082
  * never a whole record read before an await (audit P0-A: each of two steps
@@ -1927,8 +2139,17 @@ export const createHuazhuHotels = (opts) => {
1927
2139
  state = await checkout.close(p.sessionId);
1928
2140
  }
1929
2141
  catch {
1930
- return; // asked again on the next pass
2142
+ // Asked again on the next pass - but a page that cannot be asked
2143
+ // about long past its time is a person's to look at: it may have been
2144
+ // paid (2026-10-16 audit, P1-4).
2145
+ const misses = (p.unanswered ?? 0) + 1;
2146
+ patch(row.id, {}, { unanswered: misses });
2147
+ if (misses >= UNANSWERED_PASSES && now() >= p.payBy)
2148
+ raise(row.id, `payment page ${p.sessionId} could not be asked where it stands, past its time: whether it was paid is not known - look it up with the payment provider`);
2149
+ return;
1931
2150
  }
2151
+ if (p.unanswered)
2152
+ patch(row.id, {}, { unanswered: undefined });
1932
2153
  if (state === 'paid') {
1933
2154
  // ⚠️ THE MONEY HELD TO THE QUOTE BEFORE ANYTHING IS BOOKED (总线程,
1934
2155
  // 2026-10-15): what the page took, to the fen and in the currency,
@@ -1985,6 +2206,42 @@ export const createHuazhuHotels = (opts) => {
1985
2206
  await refundOf(row.id, owed.fields.refund.reason);
1986
2207
  return;
1987
2208
  }
2209
+ // ⚠️ NOBODY COULD CONFIRM IT: THE MONEY IS HELD, NOT GIVEN BACK (2026-10-16
2210
+ // audit, P1-2). Refunded at once, a booking that did exist at Huazhu was
2211
+ // kept unpaid - and past its free period, cost the whole stay to cancel.
2212
+ // Huazhu is asked again every half hour: "none" refunds it all, "here it
2213
+ // is" keeps it; a day without an answer is a person's.
2214
+ if (row.status === 'unconfirmed' && !p.refund) {
2215
+ const since = p.heldSince ?? now();
2216
+ if (p.heldSince === undefined)
2217
+ patch(row.id, {}, { heldSince: since });
2218
+ if (p.checkedAt !== undefined && now() - p.checkedAt < HELD_CHECK_MS)
2219
+ return;
2220
+ patch(row.id, {}, { checkedAt: now() });
2221
+ let found;
2222
+ try {
2223
+ found = await client.consistency(row.distributorResId);
2224
+ }
2225
+ catch (err) {
2226
+ if (err instanceof HuazhuRefusal && err.code === NOT_FOUND) {
2227
+ const reason = 'Huazhu says it never booked the stay';
2228
+ patch(row.id, { status: 'failed', hotelStatus: 'HOTEL_FAILED' }, { refund: { reason, at: now(), tries: 0 } });
2229
+ store.resolve(row.id, `${reason}: refunded in full`);
2230
+ await refundOf(row.id, reason);
2231
+ return;
2232
+ }
2233
+ if (now() - since >= HELD_LIMIT_MS)
2234
+ raise(row.id, `nobody has been able to confirm ${row.distributorResId} with Huazhu for 24 hours and the guest's money is held: decide with Huazhu whether to keep it or refund it`);
2235
+ return;
2236
+ }
2237
+ if (found?.resId && found.status) {
2238
+ const status = statusOf(found.status);
2239
+ patch(row.id, { status, resId: found.resId, hotelStatus: found.status });
2240
+ if (status === 'confirmed')
2241
+ store.resolve(row.id, 'Huazhu has the booking: kept, and paid');
2242
+ }
2243
+ return;
2244
+ }
1988
2245
  // ⚠️ HUAZHU'S ORDER NUMBER ON THE PAYMENT, ONCE (总线程, 2026-10-15), so
1989
2246
  // Stripe's dashboard and Huazhu's order are one look apart. Tried once:
1990
2247
  // a failure is said in the log and never touches the booking.
@@ -2001,12 +2258,14 @@ export const createHuazhuHotels = (opts) => {
2001
2258
  }
2002
2259
  return;
2003
2260
  }
2004
- if (!p.refund?.requestId &&
2005
- (p.refund || row.status === 'failed' || row.status === 'unconfirmed')) {
2006
- await refundOf(row.id, p.refund?.reason ??
2007
- (row.status === 'failed'
2008
- ? 'Huazhu did not take the booking'
2009
- : 'nobody could confirm the booking with Huazhu - if it turns up there, a person cancels it'));
2261
+ if (!p.refund?.requestId && (p.refund || row.status === 'failed')) {
2262
+ // Not opened after three tries: tried further apart - a minute,
2263
+ // doubling to an hour - not every ten seconds for good (audit P2).
2264
+ const tries = p.refund?.tries ?? 0;
2265
+ const wait = tries < 3 ? 0 : Math.min(60_000 * 2 ** (tries - 3), 3_600_000);
2266
+ if (p.refund?.triedAt !== undefined && now() < p.refund.triedAt + wait)
2267
+ return;
2268
+ await refundOf(row.id, p.refund?.reason ?? 'Huazhu did not take the booking');
2010
2269
  return;
2011
2270
  }
2012
2271
  const refund = p.refund;
@@ -2016,11 +2275,8 @@ export const createHuazhuHotels = (opts) => {
2016
2275
  const latest = store.get(row.id)?.prepaid?.refund;
2017
2276
  if (latest && status !== latest.status)
2018
2277
  patch(row.id, {}, { refund: { ...latest, status } });
2019
- if ((status === 'failed' || status === 'rejected') && !row.attention) {
2020
- const why = `refund ${refund.requestId} of gateway session ${p.sessionId} came back ${status}: the guest's money is not back`;
2021
- store.update(row.id, { attention: why, attentionAt: new Date(now()).toISOString() });
2022
- console.error(`[huazhu prepaid ${row.id}] ⚠️ NEEDS A PERSON: ${why}`);
2023
- }
2278
+ if (status === 'failed' || status === 'rejected')
2279
+ raise(row.id, `refund ${refund.requestId} of gateway session ${p.sessionId} came back ${status}: the guest's money is not back`);
2024
2280
  }
2025
2281
  catch {
2026
2282
  // asked again on the next pass
@@ -2154,7 +2410,7 @@ export const createHuazhuHotels = (opts) => {
2154
2410
  const record = {
2155
2411
  method: checkout.method,
2156
2412
  payBy: now() + payWithin,
2157
- ...(plan.token ? { preBookToken: plan.token } : {}),
2413
+ ...(plan.token ? { preBookToken: plan.token, prebookAt: now() } : {}),
2158
2414
  after: s.after,
2159
2415
  ...(s.before ? { before: s.before } : {}),
2160
2416
  ...(s.rack ? { rack: s.rack } : {}),
@@ -2211,6 +2467,8 @@ export const createHuazhuHotels = (opts) => {
2211
2467
  amountMinor: allIn,
2212
2468
  currency: cur,
2213
2469
  email: guest.email,
2470
+ // A minute past the booking's own deadline - ours closes it first.
2471
+ expiresAt: record.payBy + 60_000,
2214
2472
  });
2215
2473
  }
2216
2474
  catch (err) {
@@ -2347,7 +2605,14 @@ export const createHuazhuHotels = (opts) => {
2347
2605
  let answer;
2348
2606
  for (let attempt = 0; attempt < 2; attempt += 1) {
2349
2607
  try {
2350
- const got = await client.book(request);
2608
+ reconciler.inFlight(id, true);
2609
+ let got;
2610
+ try {
2611
+ got = await client.book(request);
2612
+ }
2613
+ finally {
2614
+ reconciler.inFlight(id, false);
2615
+ }
2351
2616
  if (got && typeof got.resId === 'string' && got.resId)
2352
2617
  answer = got;
2353
2618
  }
@@ -2432,6 +2697,10 @@ export const createHuazhuHotels = (opts) => {
2432
2697
  const no = notWaiting(first);
2433
2698
  if (no)
2434
2699
  return no;
2700
+ // Huazhu books this rate paid by card (CCPN) or not at all - its
2701
+ // guarantee says which (2026-10-16 audit, P1-8).
2702
+ if (!(PAYMENT_FOR_GUARANTEE[first.guaranteeCode] ?? []).includes('CCPN'))
2703
+ return refused(`Huazhu does not take ${first.confirmationCode}'s rate paid by card, so it cannot be paid with UnionPay. Nothing changed - the card page still takes the payment.`, 'hotel-refused');
2435
2704
  // ⚠️ COUNTED BEFORE ANYTHING REACHES THE GATEWAY (audit P0-E).
2436
2705
  const keys = [
2437
2706
  [`b:${first.id}`, UNIONPAY_ASKS.booking],
@@ -2465,6 +2734,10 @@ export const createHuazhuHotels = (opts) => {
2465
2734
  return { kind: 'enroll', url };
2466
2735
  }
2467
2736
  catch (err) {
2737
+ // The gateway's own limit: three binding pages a guest an hour (网关
2738
+ // a974cc2a). Said as "later" - never a failure, never asked again now.
2739
+ if (err instanceof UnionPayError && err.status === 429)
2740
+ return refused("UnionPay's page for adding a card was asked for too often for this guest this hour. Nothing changed - the card page still takes the payment, or try UnionPay again later.", 'too-many-tries');
2468
2741
  return refused(`UnionPay's page for adding a card could not be opened (${err.message}). Nothing changed - ask again.`, 'unreachable');
2469
2742
  }
2470
2743
  }
@@ -2476,21 +2749,6 @@ export const createHuazhuHotels = (opts) => {
2476
2749
  const p = row.prepaid;
2477
2750
  if (p.method === 'unionpay' && p.url)
2478
2751
  return { kind: 'confirm', url: p.url, booking: publicBooking(row) };
2479
- // The card page first: closed, it cannot be paid after this.
2480
- if (p.method !== 'unionpay' && p.sessionId) {
2481
- let state;
2482
- try {
2483
- state = await checkout.close(p.sessionId);
2484
- }
2485
- catch (err) {
2486
- return refused(`The card page could not be closed (${err.message}). Nothing changed - ask again.`, 'unreachable');
2487
- }
2488
- if (state === 'paid') {
2489
- await settlePrepaid(row);
2490
- return { kind: 'paid-on-card', booking: publicBooking(store.get(row.id) ?? row) };
2491
- }
2492
- patch(row.id, {}, { sessionId: undefined, url: undefined });
2493
- }
2494
2752
  const title = row.quote.lines[0]?.title ?? row.hotelName;
2495
2753
  // Prebooked again the way it will be booked: Huazhu's token holds only
2496
2754
  // for the same request (wire.ts).
@@ -2515,13 +2773,48 @@ export const createHuazhuHotels = (opts) => {
2515
2773
  }))?.preBookToken;
2516
2774
  }
2517
2775
  catch (err) {
2776
+ // ⚠️ NOTHING CHANGES ON A NO (audit P1-8): asked before the card page
2777
+ // is closed, so the card page still takes the payment.
2518
2778
  if (err instanceof HuazhuRefusal) {
2519
- patch(row.id, { status: 'expired' }, { sessionId: undefined, url: undefined, lapsedAt: now() });
2520
2779
  const said = refusalCode(err, row.rooms).code;
2521
- return refused(`Nothing was booked or charged: ${refusalWords(err, `${row.hotelName} - ${title}`, p.currency)}`, said === 'price-changed' || said === 'sold-out' ? said : 'hotel-refused');
2780
+ // A price that moved, or no room left: neither way books it at what
2781
+ // the guest was shown - the card page is closed too, and the stay
2782
+ // lapses (paid on it meanwhile, it goes on as that payment).
2783
+ if (said === 'price-changed' || said === 'sold-out') {
2784
+ if (p.sessionId) {
2785
+ const page = await checkout.close(p.sessionId).catch(() => undefined);
2786
+ if (page === 'paid') {
2787
+ await settlePrepaid(row);
2788
+ return { kind: 'paid-on-card', booking: publicBooking(store.get(row.id) ?? row) };
2789
+ }
2790
+ }
2791
+ patch(row.id, { status: 'expired' }, { sessionId: undefined, url: undefined, lapsedAt: now() });
2792
+ return refused(`Nothing was booked or charged: ${refusalWords(err, `${row.hotelName} - ${title}`, p.currency)}`, said);
2793
+ }
2794
+ // Anything else - Huazhu not taking this rate paid by card, say -
2795
+ // changes nothing: the card page still takes the payment.
2796
+ return refused(said === 'prepaid-only'
2797
+ ? 'Huazhu does not take this rate paid by card, so it cannot be paid with UnionPay. Nothing changed - the card page still takes the payment.'
2798
+ : `Huazhu did not take it paid by card: ${refusalWords(err, `${row.hotelName} - ${title}`, p.currency)} Nothing changed - the card page still takes the payment.`, 'hotel-refused');
2522
2799
  }
2523
2800
  return refused(`Huazhu could not be reached to check the stay (${err.message}). Nothing was charged - ask again.`, 'unreachable');
2524
2801
  }
2802
+ // Huazhu said yes: now the card page is closed, so it cannot be paid
2803
+ // after this.
2804
+ if (p.method !== 'unionpay' && p.sessionId) {
2805
+ let state;
2806
+ try {
2807
+ state = await checkout.close(p.sessionId);
2808
+ }
2809
+ catch (err) {
2810
+ return refused(`The card page could not be closed (${err.message}). Nothing changed - ask again.`, 'unreachable');
2811
+ }
2812
+ if (state === 'paid') {
2813
+ await settlePrepaid(row);
2814
+ return { kind: 'paid-on-card', booking: publicBooking(store.get(row.id) ?? row) };
2815
+ }
2816
+ patch(row.id, {}, { sessionId: undefined, url: undefined });
2817
+ }
2525
2818
  const names = hotelNamesOf(row.hotelId);
2526
2819
  const nights = row.quote.lines[0]?.nights ?? 1;
2527
2820
  let opened;
@@ -2546,7 +2839,7 @@ export const createHuazhuHotels = (opts) => {
2546
2839
  method: 'unionpay',
2547
2840
  sessionId: opened.sessionId,
2548
2841
  url: opened.url,
2549
- ...(token ? { preBookToken: token } : {}),
2842
+ ...(token ? { preBookToken: token, prebookAt: now() } : {}),
2550
2843
  payBy: now() + payWithin,
2551
2844
  }) ?? row;
2552
2845
  return { kind: 'confirm', url: opened.url, booking: publicBooking(updated) };
@@ -2569,14 +2862,27 @@ export const createHuazhuHotels = (opts) => {
2569
2862
  : 'not-paid'
2570
2863
  : row.status === 'expired'
2571
2864
  ? 'not-paid'
2572
- : p?.paidAt && (p.refund || row.status === 'failed' || row.status === 'unconfirmed')
2573
- ? 'refunded'
2574
- : row.status === 'failed' && !p?.paidAt
2575
- ? 'not-paid'
2576
- : 'unknown';
2865
+ : row.status === 'unconfirmed' && p?.paidAt && !p.refund
2866
+ ? // Held while Huazhu is asked (audit P1-2): paid, the hotel still being asked.
2867
+ 'booking'
2868
+ : p?.paidAt && (p.refund || row.status === 'failed')
2869
+ ? 'refunded'
2870
+ : row.status === 'failed' && !p?.paidAt
2871
+ ? 'not-paid'
2872
+ : 'unknown';
2577
2873
  const names = hotelNamesOf(row.hotelId);
2874
+ // Owed back, but not sent - a person is seeing to it: never said as "on its way" (audit P1-1).
2875
+ const stuck = !!p?.refund &&
2876
+ (p.refund.status === 'failed' ||
2877
+ p.refund.status === 'rejected' ||
2878
+ (!p.refund.requestId && (p.refund.tries ?? 0) >= 8));
2879
+ const part = p?.refund?.amountMinor;
2578
2880
  return {
2579
2881
  state,
2882
+ ...(state === 'refunded' && stuck ? { refundStuck: true } : {}),
2883
+ ...(state === 'refunded' && part !== undefined && part < row.quote.total.minor
2884
+ ? { refundAmount: money(part, row.quote.total.currency) }
2885
+ : {}),
2580
2886
  code: row.confirmationCode,
2581
2887
  hotelName: row.hotelName,
2582
2888
  ...(names ? { hotelNames: names } : {}),
@@ -2630,8 +2936,9 @@ export const createHuazhuHotels = (opts) => {
2630
2936
  const page = await checkout.state(row.prepaid.sessionId).catch(() => undefined);
2631
2937
  if (page === 'paid')
2632
2938
  return { ...told, state: 'booking' };
2939
+ // Cannot be read just now: the page looks again (香格里拉's `checking`).
2633
2940
  if (page === undefined)
2634
- return { ...told, state: 'unknown' };
2941
+ return { ...told, state: 'checking' };
2635
2942
  }
2636
2943
  return told;
2637
2944
  };
@@ -3416,7 +3723,9 @@ export const createHuazhuHotels = (opts) => {
3416
3723
  ? await unionpay.state(b.prepaid.sessionId).catch(() => undefined)
3417
3724
  : 'closed';
3418
3725
  if (state !== 'issued' && state !== undefined) {
3419
- return publicBooking(patch(b.id, { status: 'expired' }, { lapsedAt: now(), url: undefined }) ?? b);
3726
+ const lapsed = patch(b.id, { status: 'expired' }, { lapsedAt: now(), url: undefined }) ?? b;
3727
+ await discardCard(b.id);
3728
+ return publicBooking(store.get(b.id) ?? lapsed);
3420
3729
  }
3421
3730
  throw new Error(state === 'issued'
3422
3731
  ? 'The guest has just confirmed on UnionPay, so the stay is being booked with Huazhu now. Ask again in a moment to cancel it.'