@curless/hotel-storefront 0.39.0 → 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 (70) hide show
  1. package/dist/backend.d.ts +18 -5
  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 +730 -264
  9. package/dist/huazhu/backend.js.map +1 -1
  10. package/dist/huazhu/bookings.d.ts +39 -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/legal.d.ts.map +1 -1
  38. package/dist/legal.js +30 -7
  39. package/dist/legal.js.map +1 -1
  40. package/dist/paid.d.ts.map +1 -1
  41. package/dist/paid.js +68 -34
  42. package/dist/paid.js.map +1 -1
  43. package/dist/payments/checkout.d.ts +15 -2
  44. package/dist/payments/checkout.d.ts.map +1 -1
  45. package/dist/payments/checkout.js +4 -1
  46. package/dist/payments/checkout.js.map +1 -1
  47. package/dist/payments/fake-stripe.d.ts +1 -0
  48. package/dist/payments/fake-stripe.d.ts.map +1 -1
  49. package/dist/payments/fake-stripe.js +20 -4
  50. package/dist/payments/fake-stripe.js.map +1 -1
  51. package/dist/payments/fake-unionpay.d.ts +3 -0
  52. package/dist/payments/fake-unionpay.d.ts.map +1 -1
  53. package/dist/payments/fake-unionpay.js +20 -2
  54. package/dist/payments/fake-unionpay.js.map +1 -1
  55. package/dist/payments/stripe-checkout.d.ts +2 -1
  56. package/dist/payments/stripe-checkout.d.ts.map +1 -1
  57. package/dist/payments/stripe-checkout.js +11 -4
  58. package/dist/payments/stripe-checkout.js.map +1 -1
  59. package/dist/payments/unionpay.d.ts +12 -1
  60. package/dist/payments/unionpay.d.ts.map +1 -1
  61. package/dist/payments/unionpay.js +10 -3
  62. package/dist/payments/unionpay.js.map +1 -1
  63. package/dist/server.d.ts +2 -2
  64. package/dist/server.js +2 -2
  65. package/dist/tools.d.ts.map +1 -1
  66. package/dist/tools.js +129 -24
  67. package/dist/tools.js.map +1 -1
  68. package/dist/types.d.ts +19 -0
  69. package/dist/types.d.ts.map +1 -1
  70. 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 = {
@@ -513,6 +515,16 @@ export const createHuazhuHotels = (opts) => {
513
515
  const requestsByEmail = new WindowCounter(DAY_MS, now);
514
516
  const requestsByIp = new WindowCounter(HOUR_MS, now);
515
517
  const requestsByConversation = new WindowCounter(HOUR_MS, now);
518
+ /**
519
+ * Paying with UnionPay, asked - each ask reaches the gateway and UnionPay's
520
+ * test system, so each is counted, whatever it came to: by booking, by
521
+ * conversation and by address (2026-10-16 audit, P0-E: one booking asked
522
+ * 50 times bound 50 cards' worth of pages, and used up the gateway's rate).
523
+ */
524
+ const unionPayAsks = new WindowCounter(HOUR_MS, now);
525
+ const UNIONPAY_ASKS = { booking: 6, conversation: 12, ip: 120 };
526
+ /** UnionPay's page for binding a card is handed out again this long, not asked for again. */
527
+ const ENROLL_REUSE_MS = 30 * 60_000;
516
528
  /**
517
529
  * Bookings a request has passed the limits for and not yet written down.
518
530
  *
@@ -676,12 +688,25 @@ export const createHuazhuHotels = (opts) => {
676
688
  /** Whether a hotel sells any room by the hour on its today - from its products, no prices. */
677
689
  const sellsByTheHourToday = async (hotelId) => (await productsOf(hotelId)).some((p) => p.checkinType === 'HOUR' && inStayWindow(p.stayWindow, today()));
678
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();
679
698
  /** Every product's prices, rules and rooms left, one element per date from `start` to `end`. */
680
699
  const ariFor = async (hotelId, start, end, fresh = false) => {
681
700
  const key = `${hotelId}|${start}|${end}`;
682
701
  const hit = ariCache.get(key);
683
702
  if (!fresh && hit && now() - hit.at < ariTtl)
684
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
+ }
685
710
  const ranges = ariRanges(start, end);
686
711
  const parts = await Promise.all(ranges.map(async (r) => {
687
712
  const [offers, inventory] = await Promise.all([
@@ -703,7 +728,14 @@ export const createHuazhuHotels = (opts) => {
703
728
  offers: offers.dailyOffers ?? [],
704
729
  inventory: inventory.dailyInventories ?? [],
705
730
  };
706
- }));
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);
707
739
  const out = new Map();
708
740
  const keys = new Set(parts.flatMap((p) => p.offers.map((o) => roomIdOf(o))));
709
741
  for (const k of keys) {
@@ -998,9 +1030,16 @@ export const createHuazhuHotels = (opts) => {
998
1030
  const fails = priced.map((p) => p.res).filter((r) => !r.ok);
999
1031
  if (fails.length > 0 && fails.every((f) => f.kind === 'party'))
1000
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
+ };
1001
1038
  if (fails.some((f) => f.kind === 'prepaid'))
1002
1039
  return {
1003
- 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.",
1004
1043
  code: 'prepaid-only',
1005
1044
  };
1006
1045
  const said = (re) => fails
@@ -1042,9 +1081,18 @@ export const createHuazhuHotels = (opts) => {
1042
1081
  // find a rate paid at the hotel. At Huazhu's test hotel (2026-10-15) 28
1043
1082
  // rates were prepaid and 4 sold out, and the guest was told only that
1044
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
+ };
1045
1091
  if (kinds.has('prepaid') && !kinds.has('party') && !kinds.has('too-many-rooms'))
1046
1092
  return {
1047
- 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.',
1048
1096
  code: 'prepaid-only',
1049
1097
  };
1050
1098
  if (only('sold-out'))
@@ -1266,13 +1314,15 @@ export const createHuazhuHotels = (opts) => {
1266
1314
  // here is taken or refunded.
1267
1315
  const status = b.status === 'cancelled' && p.issuedAt
1268
1316
  ? 'hotel-refunds'
1269
- : p.lapsedAt || p.declined
1270
- ? 'lapsed'
1271
- : b.status === 'confirmed'
1272
- ? 'paid'
1273
- : p.issuedAt
1274
- ? 'issued'
1275
- : 'awaiting';
1317
+ : // Not booked - turned down, written off by the follow-up queries,
1318
+ // lapsed: the hotel never booked it, so never charged the card.
1319
+ p.lapsedAt || p.declined || b.status === 'failed' || b.status === 'expired'
1320
+ ? 'lapsed'
1321
+ : b.status === 'confirmed'
1322
+ ? 'paid'
1323
+ : p.issuedAt
1324
+ ? 'issued'
1325
+ : 'awaiting';
1276
1326
  return {
1277
1327
  method: 'unionpay',
1278
1328
  amount: b.quote.total,
@@ -1315,6 +1365,16 @@ export const createHuazhuHotels = (opts) => {
1315
1365
  }
1316
1366
  : {}),
1317
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
+ : {}),
1318
1378
  };
1319
1379
  };
1320
1380
  const publicBooking = (b) => ({
@@ -1341,7 +1401,14 @@ export const createHuazhuHotels = (opts) => {
1341
1401
  * before 0.27 showed it as its code, and is opened by it only in the
1342
1402
  * conversation that made it.
1343
1403
  */
1344
- 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]
1345
1412
  .filter((x) => !!x)
1346
1413
  .map((x) => x.toLowerCase());
1347
1414
  const findOwn = (ref, who) => {
@@ -1368,16 +1435,16 @@ export const createHuazhuHotels = (opts) => {
1368
1435
  }
1369
1436
  return there;
1370
1437
  };
1371
- /** Huazhu's own word on a booking, at most once a minute each. */
1438
+ /**
1439
+ * Huazhu's own word on a booking, at most once a minute each.
1440
+ *
1441
+ * ⚠️ IT ONLY READS (2026-10-16 audit, P0-A). It used to move a payment on
1442
+ * as it was asked about - outside the booking's lock, from every lookup and
1443
+ * every few seconds from a card waiting on a payment. Payments move on in
1444
+ * the pass, the landing page and the steps a guest takes, one at a time.
1445
+ */
1372
1446
  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
- }
1447
+ const b = store.get(found.id) ?? found;
1381
1448
  if (!b.resId || b.pendingSince !== undefined)
1382
1449
  return b;
1383
1450
  if (b.refreshedAt && now() - b.refreshedAt < 60_000)
@@ -1405,7 +1472,7 @@ export const createHuazhuHotels = (opts) => {
1405
1472
  const waitingWords = (b) => b.status === 'unconfirmed'
1406
1473
  ? `${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
1474
  : `${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) => {
1475
+ const feeNow = (b, at = now()) => {
1409
1476
  const policy = policyOf(b.cancelCode);
1410
1477
  const amounts = {
1411
1478
  currency: b.quote.total.currency,
@@ -1425,7 +1492,7 @@ export const createHuazhuHotels = (opts) => {
1425
1492
  }
1426
1493
  // A room by the hour that has begun is a no-show, whatever the code says
1427
1494
  // about the rest of the day - see `beforeStart`.
1428
- if (b.hourly && now() >= arrivalInstant(b.stay.checkIn, b.hourly.start, b.tz)) {
1495
+ if (b.hourly && at >= arrivalInstant(b.stay.checkIn, b.hourly.start, b.tz)) {
1429
1496
  return {
1430
1497
  fee: total,
1431
1498
  forbidden: false,
@@ -1433,11 +1500,11 @@ export const createHuazhuHotels = (opts) => {
1433
1500
  freeUntil: undefined,
1434
1501
  };
1435
1502
  }
1436
- const penalty = penaltyAt(policy, b.stay.checkIn, b.tz, now());
1503
+ const penalty = penaltyAt(policy, b.stay.checkIn, b.tz, at);
1437
1504
  const fee = feeFor(penalty, amounts);
1438
1505
  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());
1506
+ ? beforeStart(freeUntilOf(policy, b.stay.checkIn, b.tz, at), b.stay.checkIn, b.hourly.start)
1507
+ : freeUntilOf(policy, b.stay.checkIn, b.tz, at);
1441
1508
  return {
1442
1509
  fee: fee ?? total,
1443
1510
  forbidden: fee === null,
@@ -1528,13 +1595,16 @@ export const createHuazhuHotels = (opts) => {
1528
1595
  if (who.ip && requestsByIp.count(who.ip) >= limits.perIpPerHour) {
1529
1596
  throw new Error('Nothing was booked. Too many bookings have been asked for from this network in the last hour - try again later.');
1530
1597
  }
1531
- const madeToday = store
1532
- .all()
1533
- .filter((b) => now() - Date.parse(b.createdAt) < DAY_MS &&
1598
+ const madeToday = store.all().filter((b) => now() - Date.parse(b.createdAt) < DAY_MS &&
1534
1599
  (b.status === 'confirmed' ||
1535
1600
  b.status === 'requested' ||
1536
1601
  b.status === 'unconfirmed' ||
1537
- 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);
1538
1608
  if (madeToday + lines.length > limits.perDeploymentPerDay) {
1539
1609
  // ⚠️ A CEILING HIT IS SOMETHING A PERSON SHOULD KNOW: either business
1540
1610
  // is booming or somebody is filling hotels with names. Logged, and on
@@ -1593,10 +1663,45 @@ export const createHuazhuHotels = (opts) => {
1593
1663
  const contact = contactOf(row.guest);
1594
1664
  if (typeof contact === 'string') {
1595
1665
  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 } } : {}) });
1666
+ patch(id, { status: 'failed' }, card ? { declined: why } : {});
1597
1667
  await refundOf(id, why);
1598
1668
  return;
1599
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;
1600
1705
  const comment = commentOf(row.requests, row.guest.notes);
1601
1706
  const request = {
1602
1707
  hotelId: row.hotelId,
@@ -1633,16 +1738,37 @@ export const createHuazhuHotels = (opts) => {
1633
1738
  },
1634
1739
  ],
1635
1740
  ...(comment ? { guestComment: comment } : {}),
1636
- ...(p.preBookToken ? { preBookToken: p.preBookToken } : {}),
1741
+ ...(token ? { preBookToken: token } : {}),
1637
1742
  };
1638
1743
  // ⚠️ SET BEFORE THE CALL, as every booking is: a process that dies in it
1639
- // leaves a row the reconciler follows up.
1640
- 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
+ }
1641
1760
  let refusal;
1642
1761
  let answer;
1643
1762
  for (let attempt = 0; attempt < 2; attempt += 1) {
1644
1763
  try {
1645
- 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
+ }
1646
1772
  if (got && typeof got.resId === 'string' && got.resId)
1647
1773
  answer = got;
1648
1774
  }
@@ -1668,26 +1794,24 @@ export const createHuazhuHotels = (opts) => {
1668
1794
  });
1669
1795
  return;
1670
1796
  }
1671
- if (refusal && card) {
1672
- // Paid with UnionPay: the hotel charges the card, and a booking it did
1673
- // 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
- },
1681
- });
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, '****') });
1682
1811
  return;
1683
1812
  }
1684
- if (refusal) {
1685
- store.update(id, { status: 'failed', pendingSince: undefined });
1686
- // A price that moved since the prebook is one of these (5209): the
1687
- // guest paid for a price Huazhu no longer gives, and gets it all back.
1688
- await refundOf(id, `Huazhu did not take the booking: ${refusal.huazhuMessage} (${refusal.code})`);
1689
- }
1690
- // No answer: the reconciler's eight minutes decide it.
1813
+ store.update(id, { status: 'failed', pendingSince: undefined });
1814
+ await refundOf(id, said);
1691
1815
  };
1692
1816
  /**
1693
1817
  * Gives a guest's money back, in full - the only refund the gateway makes.
@@ -1701,21 +1825,117 @@ export const createHuazhuHotels = (opts) => {
1701
1825
  return;
1702
1826
  const tries = (p.refund?.tries ?? 0) + 1;
1703
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;
1704
1832
  try {
1705
1833
  // Named by when it was decided: the same refund retried is the same refund.
1706
- 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 } },
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
+ },
1709
1845
  });
1710
- console.error(`[huazhu prepaid ${id}] refund ${r.requestId} (${r.status}) of session ${p.sessionId}: ${reason}`);
1846
+ console.error(`[huazhu prepaid ${id}] refund ${r.requestId} (${r.status}) of session ${p.sessionId}: ${why}`);
1711
1847
  }
1712
1848
  catch (err) {
1713
- store.update(id, { prepaid: { ...p, refund: { reason, at, tries } } });
1714
- if (tries >= 8 && !row.attention) {
1715
- const why = `the guest paid (gateway session ${p.sessionId}) and the refund could not be opened: ${err.message}`;
1716
- store.update(id, { attention: why, attentionAt: new Date(now()).toISOString() });
1717
- console.error(`[huazhu prepaid ${id}] ⚠️ NEEDS A PERSON: ${why}`);
1718
- }
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`);
1860
+ }
1861
+ };
1862
+ /**
1863
+ * What a cancelled booking paid here in advance owes the guest - written in
1864
+ * the SAME write as the cancellation (2026-10-16 audit, P0-B: written after
1865
+ * it, a process stopped in between, Huazhu's 5402 and a cancellation made at
1866
+ * Huazhu's end all left the money with us for good).
1867
+ *
1868
+ * - Free: all of it back - a refund to open.
1869
+ * - Cancelled by the guest here, the fee the whole stay (the only kind of
1870
+ * rate sold paid in advance): kept as that fee.
1871
+ * - Anything else - a part fee, or cancelled at Huazhu's end after the free
1872
+ * period, by the hotel or by the guest some other way: a person's to judge.
1873
+ */
1874
+ const owedOnCancel = (b, feeMinor, byGuest) => {
1875
+ const p = b.prepaid;
1876
+ if (!p?.paidAt || p.method === 'unionpay' || p.refund || p.kept)
1877
+ return { row: {}, fields: {} };
1878
+ if (feeMinor <= 0)
1879
+ return {
1880
+ row: {},
1881
+ fields: {
1882
+ refund: {
1883
+ reason: byGuest
1884
+ ? 'cancelled within the free cancellation period'
1885
+ : 'cancelled at Huazhu within the free cancellation period',
1886
+ at: now(),
1887
+ tries: 0,
1888
+ },
1889
+ },
1890
+ };
1891
+ if (byGuest && feeMinor >= b.quote.total.minor)
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
+ };
1907
+ const fee = formatMoney(money(feeMinor, b.quote.total.currency));
1908
+ const why = byGuest
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`
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`;
1911
+ console.error(`[huazhu prepaid ${b.id}] ⚠️ NEEDS A PERSON: ${why}`);
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}`);
1719
1939
  }
1720
1940
  };
1721
1941
  /**
@@ -1723,6 +1943,13 @@ export const createHuazhuHotels = (opts) => {
1723
1943
  * callback is seconds behind the guest. Later than this, it is never used.
1724
1944
  */
1725
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;
1726
1953
  /**
1727
1954
  * One step for a booking paid with UnionPay: confirmed on UnionPay's page →
1728
1955
  * the one-time card, once, straight into a CCPN booking with Huazhu;
@@ -1733,13 +1960,13 @@ export const createHuazhuHotels = (opts) => {
1733
1960
  const p = row.prepaid;
1734
1961
  if (row.status !== 'awaiting-payment' || !unionpay)
1735
1962
  return;
1736
- const lapse = () => store.update(row.id, {
1737
- status: 'expired',
1738
- prepaid: { ...p, lapsedAt: now(), url: undefined },
1739
- });
1963
+ const lapse = async () => {
1964
+ patch(row.id, { status: 'expired' }, { lapsedAt: now(), url: undefined });
1965
+ await discardCard(row.id);
1966
+ };
1740
1967
  if (!p.sessionId) {
1741
1968
  if (now() >= p.payBy)
1742
- lapse();
1969
+ await lapse();
1743
1970
  return;
1744
1971
  }
1745
1972
  let state;
@@ -1747,7 +1974,11 @@ export const createHuazhuHotels = (opts) => {
1747
1974
  state = await unionpay.state(p.sessionId);
1748
1975
  }
1749
1976
  catch {
1750
- 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;
1751
1982
  }
1752
1983
  if (state === 'issued' && now() < p.payBy + ISSUED_GRACE_MS) {
1753
1984
  // ⚠️ ONCE: the gateway hands the card over a single time, then forgets it.
@@ -1763,20 +1994,21 @@ export const createHuazhuHotels = (opts) => {
1763
1994
  return;
1764
1995
  }
1765
1996
  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}`);
1997
+ // ⚠️ GONE FROM THE GATEWAY (410), OR NOT THIS MERCHANT'S (404) - NEVER
1998
+ // "NOT BOOKED" BY GUESS (2026-10-16 audit, P0-A). Handed over once, to
1999
+ // a step that may have booked it with Huazhu and then died: what
2000
+ // Huazhu has decides. The stay waits on the follow-up queries
2001
+ // (`Reconciler`) as any booking whose answer was lost does - booked
2002
+ // if Huazhu has it, failed (nothing charged) if Huazhu says late in
2003
+ // the eight minutes that it does not.
2004
+ const cur = store.get(row.id);
2005
+ if (!cur || cur.status !== 'awaiting-payment')
2006
+ return;
2007
+ patch(row.id, { status: 'requested', pendingSince: now() }, { issuedAt: cur.prepaid?.issuedAt ?? now(), url: undefined });
2008
+ 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
2009
  return;
1776
2010
  }
1777
- store.update(row.id, {
1778
- prepaid: { ...p, issuedAt: now(), cardLastFour: lastFour(card), url: undefined },
1779
- });
2011
+ patch(row.id, {}, { issuedAt: now(), cardLastFour: lastFour(card), url: undefined });
1780
2012
  console.log(`[huazhu prepaid ${row.id}] UnionPay issued a one-time card ···· ${lastFour(card)}: booking it with Huazhu as CCPN`);
1781
2013
  await bookPaid(row.id, card);
1782
2014
  return;
@@ -1784,10 +2016,82 @@ export const createHuazhuHotels = (opts) => {
1784
2016
  // Unconfirmed in time: lapsed. A confirmation after this is never handed
1785
2017
  // on, so nothing is ever charged for it.
1786
2018
  if (state === 'closed' || now() >= p.payBy)
1787
- lapse();
2019
+ await lapse();
2020
+ };
2021
+ /**
2022
+ * ⚠️ ONE STEP AT A TIME ON A BOOKING - every step that moves a payment on:
2023
+ * the pass, the landing page, paying with UnionPay instead, a cancellation
2024
+ * (2026-10-16 audit, P0-A). A single flag for the pass alone was walked
2025
+ * round by every lookup, which moved payments on unlocked: the one-time card
2026
+ * taken twice, a stay Huazhu had booked written off as "not booked", a
2027
+ * refund's record wiped and then reported as never opened.
2028
+ */
2029
+ const busy = new Set();
2030
+ /** Every step under way - what a deploy's SIGTERM waits for (audit P1-3). */
2031
+ const steps = new Set();
2032
+ /** A step on this booking if nobody is taking one - undefined if somebody is, or the process is stopping. */
2033
+ const ifFree = (id, fn) => {
2034
+ if (busy.has(id) || closing)
2035
+ return Promise.resolve(undefined);
2036
+ busy.add(id);
2037
+ const step = (async () => {
2038
+ try {
2039
+ return await fn();
2040
+ }
2041
+ finally {
2042
+ busy.delete(id);
2043
+ }
2044
+ })();
2045
+ steps.add(step);
2046
+ void step.finally(() => steps.delete(step)).catch(() => { });
2047
+ return step;
2048
+ };
2049
+ /** A step on this booking once it is its turn - one under way is waited for, a few seconds at most. */
2050
+ const inTurnFor = async (id, fn) => {
2051
+ for (let i = 0; busy.has(id) && i < 400; i += 1)
2052
+ await new Promise((r) => setTimeout(r, 25));
2053
+ if (closing)
2054
+ throw new Error('This connector is restarting. Nothing changed - ask again in a moment.');
2055
+ if (busy.has(id))
2056
+ throw new Error('A payment on this booking is being settled right now. Ask again in a moment.');
2057
+ return (await ifFree(id, fn));
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
+ };
2080
+ /**
2081
+ * Writes a booking as it is NOW, with `row` and the prepaid `fields` on top -
2082
+ * never a whole record read before an await (audit P0-A: each of two steps
2083
+ * wrote back what it had read, and the later one undid the earlier).
2084
+ */
2085
+ const patch = (id, row, fields = {}) => {
2086
+ const cur = store.get(id);
2087
+ if (!cur)
2088
+ return undefined;
2089
+ return store.update(id, {
2090
+ ...row,
2091
+ ...(cur.prepaid ? { prepaid: { ...cur.prepaid, ...fields } } : {}),
2092
+ });
1788
2093
  };
1789
2094
  /** Whether a booking paid in advance still has money work to do. */
1790
- let ticking = false;
1791
2095
  const moneyPending = (b) => {
1792
2096
  const p = b.prepaid;
1793
2097
  if (!p)
@@ -1799,6 +2103,9 @@ export const createHuazhuHotels = (opts) => {
1799
2103
  return false;
1800
2104
  if (!p.paidAt)
1801
2105
  return false;
2106
+ // Cancelled - at Huazhu's end, say - with nothing decided about the money yet.
2107
+ if (b.status === 'cancelled' && !p.refund && !p.kept && !b.attention)
2108
+ return true;
1802
2109
  // Booked: Huazhu's order number still to be written onto the payment, once.
1803
2110
  if (b.status === 'confirmed' && b.resId && !p.noted && !p.refund && checkout?.annotate)
1804
2111
  return true;
@@ -1822,7 +2129,7 @@ export const createHuazhuHotels = (opts) => {
1822
2129
  if (row.status === 'awaiting-payment') {
1823
2130
  if (!p.sessionId) {
1824
2131
  if (now() >= p.payBy)
1825
- store.update(row.id, { status: 'expired', prepaid: { ...p, lapsedAt: now() } });
2132
+ patch(row.id, { status: 'expired' }, { lapsedAt: now() });
1826
2133
  return;
1827
2134
  }
1828
2135
  let state;
@@ -1832,8 +2139,17 @@ export const createHuazhuHotels = (opts) => {
1832
2139
  state = await checkout.close(p.sessionId);
1833
2140
  }
1834
2141
  catch {
1835
- 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;
1836
2150
  }
2151
+ if (p.unanswered)
2152
+ patch(row.id, {}, { unanswered: undefined });
1837
2153
  if (state === 'paid') {
1838
2154
  // ⚠️ THE MONEY HELD TO THE QUOTE BEFORE ANYTHING IS BOOKED (总线程,
1839
2155
  // 2026-10-15): what the page took, to the fen and in the currency,
@@ -1849,40 +2165,88 @@ export const createHuazhuHotels = (opts) => {
1849
2165
  const want = row.quote.total;
1850
2166
  if (!took || took.amountMinor !== want.minor || took.currency !== want.currency) {
1851
2167
  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
- },
2168
+ // Shown to the guest (`refundReason`): money as `formatMoney` writes
2169
+ // it, so the card can say it in the guest's own way (香格里拉,
2170
+ // 2026-10-16) - never minor units.
2171
+ const shown = took
2172
+ ? formatMoney(money(took.amountMinor, took.currency))
2173
+ : 'an amount it could not say';
2174
+ patch(row.id, { status: 'failed' }, {
2175
+ paidAt: now(),
2176
+ url: undefined,
2177
+ refund: {
2178
+ reason: `the payment page took ${shown}, not the ${formatMoney(want)} quoted - nothing was booked`,
2179
+ at: now(),
2180
+ tries: 0,
1863
2181
  },
1864
2182
  });
1865
2183
  console.error(`[huazhu prepaid ${row.id}] ⚠️ paid ${said} against ${want.minor} ${want.currency} quoted: not booked, refunding`);
1866
2184
  return;
1867
2185
  }
1868
- store.update(row.id, { prepaid: { ...p, paidAt: now(), url: undefined } });
2186
+ patch(row.id, {}, { paidAt: now(), url: undefined });
1869
2187
  await bookPaid(row.id);
1870
2188
  }
1871
2189
  else if (state === 'closed') {
1872
- store.update(row.id, {
1873
- status: 'expired',
1874
- prepaid: { ...p, lapsedAt: now(), url: undefined },
1875
- });
2190
+ patch(row.id, { status: 'expired' }, { lapsedAt: now(), url: undefined });
1876
2191
  }
1877
2192
  return;
1878
2193
  }
1879
2194
  if (!p.paidAt)
1880
2195
  return;
2196
+ // Cancelled with the money not decided - at Huazhu's end (a lookup or a
2197
+ // follow-up query saw HOTEL_CANCEL): free means all of it back, anything
2198
+ // else a person's to judge (audit P0-B).
2199
+ if (row.status === 'cancelled' && !p.refund && !p.kept) {
2200
+ if (row.attention)
2201
+ return;
2202
+ const at = row.cancelledAt ? Date.parse(row.cancelledAt) : now();
2203
+ const owed = owedOnCancel(row, row.cancellationFee?.minor ?? feeNow(row, at).fee, false);
2204
+ patch(row.id, owed.row, owed.fields);
2205
+ if (owed.fields.refund)
2206
+ await refundOf(row.id, owed.fields.refund.reason);
2207
+ return;
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
+ }
1881
2245
  // ⚠️ HUAZHU'S ORDER NUMBER ON THE PAYMENT, ONCE (总线程, 2026-10-15), so
1882
2246
  // Stripe's dashboard and Huazhu's order are one look apart. Tried once:
1883
2247
  // a failure is said in the log and never touches the booking.
1884
2248
  if (row.status === 'confirmed' && row.resId && !p.noted && !p.refund && checkout.annotate) {
1885
- store.update(row.id, { prepaid: { ...p, noted: now() } });
2249
+ patch(row.id, {}, { noted: now() });
1886
2250
  try {
1887
2251
  await checkout.annotate(p.sessionId ?? '', {
1888
2252
  huazhu_res_id: row.resId,
@@ -1894,25 +2258,25 @@ export const createHuazhuHotels = (opts) => {
1894
2258
  }
1895
2259
  return;
1896
2260
  }
1897
- if (!p.refund?.requestId &&
1898
- (p.refund || row.status === 'failed' || row.status === 'unconfirmed')) {
1899
- await refundOf(row.id, p.refund?.reason ??
1900
- (row.status === 'failed'
1901
- ? 'Huazhu did not take the booking'
1902
- : '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');
1903
2269
  return;
1904
2270
  }
1905
2271
  const refund = p.refund;
1906
2272
  if (refund?.requestId && !['refunded', 'failed', 'rejected'].includes(refund.status ?? '')) {
1907
2273
  try {
1908
2274
  const status = await checkout.refundState(refund.requestId);
1909
- if (status !== refund.status)
1910
- store.update(row.id, { prepaid: { ...p, refund: { ...refund, status } } });
1911
- if ((status === 'failed' || status === 'rejected') && !row.attention) {
1912
- const why = `refund ${refund.requestId} of gateway session ${p.sessionId} came back ${status}: the guest's money is not back`;
1913
- store.update(row.id, { attention: why, attentionAt: new Date(now()).toISOString() });
1914
- console.error(`[huazhu prepaid ${row.id}] ⚠️ NEEDS A PERSON: ${why}`);
1915
- }
2275
+ const latest = store.get(row.id)?.prepaid?.refund;
2276
+ if (latest && status !== latest.status)
2277
+ patch(row.id, {}, { refund: { ...latest, status } });
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`);
1916
2280
  }
1917
2281
  catch {
1918
2282
  // asked again on the next pass
@@ -2046,7 +2410,7 @@ export const createHuazhuHotels = (opts) => {
2046
2410
  const record = {
2047
2411
  method: checkout.method,
2048
2412
  payBy: now() + payWithin,
2049
- ...(plan.token ? { preBookToken: plan.token } : {}),
2413
+ ...(plan.token ? { preBookToken: plan.token, prebookAt: now() } : {}),
2050
2414
  after: s.after,
2051
2415
  ...(s.before ? { before: s.before } : {}),
2052
2416
  ...(s.rack ? { rack: s.rack } : {}),
@@ -2103,6 +2467,8 @@ export const createHuazhuHotels = (opts) => {
2103
2467
  amountMinor: allIn,
2104
2468
  currency: cur,
2105
2469
  email: guest.email,
2470
+ // A minute past the booking's own deadline - ours closes it first.
2471
+ expiresAt: record.payBy + 60_000,
2106
2472
  });
2107
2473
  }
2108
2474
  catch (err) {
@@ -2239,7 +2605,14 @@ export const createHuazhuHotels = (opts) => {
2239
2605
  let answer;
2240
2606
  for (let attempt = 0; attempt < 2; attempt += 1) {
2241
2607
  try {
2242
- 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
+ }
2243
2616
  if (got && typeof got.resId === 'string' && got.resId)
2244
2617
  answer = got;
2245
2618
  }
@@ -2294,23 +2667,6 @@ export const createHuazhuHotels = (opts) => {
2294
2667
  }
2295
2668
  return { booked: made.map((b) => publicBooking(store.get(b.id) ?? b)), failed };
2296
2669
  };
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
2670
  /**
2315
2671
  * Pay a booking waiting for payment with UnionPay instead of the card page
2316
2672
  * (robin, 2026-10-15). The card page is closed first - paid on it in the
@@ -2341,6 +2697,20 @@ export const createHuazhuHotels = (opts) => {
2341
2697
  const no = notWaiting(first);
2342
2698
  if (no)
2343
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');
2704
+ // ⚠️ COUNTED BEFORE ANYTHING REACHES THE GATEWAY (audit P0-E).
2705
+ const keys = [
2706
+ [`b:${first.id}`, UNIONPAY_ASKS.booking],
2707
+ [`s:${conversationOf(who)}`, UNIONPAY_ASKS.conversation],
2708
+ ...(who.ip ? [[`ip:${who.ip}`, UNIONPAY_ASKS.ip]] : []),
2709
+ ];
2710
+ if (keys.some(([k, most]) => unionPayAsks.count(k) >= most))
2711
+ 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');
2712
+ for (const [k] of keys)
2713
+ unionPayAsks.add(k);
2344
2714
  if (first.prepaid?.method === 'unionpay' && first.prepaid.url)
2345
2715
  return { kind: 'confirm', url: first.prepaid.url, booking: publicBooking(first) };
2346
2716
  let cards;
@@ -2352,15 +2722,26 @@ export const createHuazhuHotels = (opts) => {
2352
2722
  }
2353
2723
  const card = cards[0];
2354
2724
  // No card yet: UnionPay's page to bind one - and nothing else changes.
2725
+ // The same page for half an hour (audit P0-E): asked again, it is handed
2726
+ // out again, not asked of the gateway and UnionPay again.
2355
2727
  if (!card) {
2728
+ const p0 = first.prepaid;
2729
+ if (p0?.enrollUrl && p0.enrollAt !== undefined && now() - p0.enrollAt < ENROLL_REUSE_MS)
2730
+ return { kind: 'enroll', url: p0.enrollUrl };
2356
2731
  try {
2357
- return { kind: 'enroll', url: (await unionpay.enroll(first.guest.email)).url };
2732
+ const { url } = await unionpay.enroll(first.guest.email);
2733
+ patch(first.id, {}, { enrollUrl: url, enrollAt: now() });
2734
+ return { kind: 'enroll', url };
2358
2735
  }
2359
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');
2360
2741
  return refused(`UnionPay's page for adding a card could not be opened (${err.message}). Nothing changed - ask again.`, 'unreachable');
2361
2742
  }
2362
2743
  }
2363
- return inTurn(async () => {
2744
+ return inTurnFor(first.id, async () => {
2364
2745
  const row = store.get(first.id) ?? first;
2365
2746
  const again = notWaiting(row);
2366
2747
  if (again)
@@ -2368,21 +2749,6 @@ export const createHuazhuHotels = (opts) => {
2368
2749
  const p = row.prepaid;
2369
2750
  if (p.method === 'unionpay' && p.url)
2370
2751
  return { kind: 'confirm', url: p.url, booking: publicBooking(row) };
2371
- // The card page first: closed, it cannot be paid after this.
2372
- if (p.method !== 'unionpay' && p.sessionId) {
2373
- let state;
2374
- try {
2375
- state = await checkout.close(p.sessionId);
2376
- }
2377
- catch (err) {
2378
- return refused(`The card page could not be closed (${err.message}). Nothing changed - ask again.`, 'unreachable');
2379
- }
2380
- if (state === 'paid') {
2381
- await settlePrepaid(row);
2382
- return { kind: 'paid-on-card', booking: publicBooking(store.get(row.id) ?? row) };
2383
- }
2384
- store.update(row.id, { prepaid: { ...p, sessionId: undefined, url: undefined } });
2385
- }
2386
2752
  const title = row.quote.lines[0]?.title ?? row.hotelName;
2387
2753
  // Prebooked again the way it will be booked: Huazhu's token holds only
2388
2754
  // for the same request (wire.ts).
@@ -2407,16 +2773,48 @@ export const createHuazhuHotels = (opts) => {
2407
2773
  }))?.preBookToken;
2408
2774
  }
2409
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.
2410
2778
  if (err instanceof HuazhuRefusal) {
2411
- store.update(row.id, {
2412
- status: 'expired',
2413
- prepaid: { ...p, sessionId: undefined, url: undefined, lapsedAt: now() },
2414
- });
2415
2779
  const said = refusalCode(err, row.rooms).code;
2416
- 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');
2417
2799
  }
2418
2800
  return refused(`Huazhu could not be reached to check the stay (${err.message}). Nothing was charged - ask again.`, 'unreachable');
2419
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
+ }
2420
2818
  const names = hotelNamesOf(row.hotelId);
2421
2819
  const nights = row.quote.lines[0]?.nights ?? 1;
2422
2820
  let opened;
@@ -2437,16 +2835,12 @@ export const createHuazhuHotels = (opts) => {
2437
2835
  catch (err) {
2438
2836
  return refused(`UnionPay's page could not be opened (${err.message}). Nothing was charged - ask again.`, 'unreachable');
2439
2837
  }
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
- },
2838
+ const updated = patch(row.id, { paymentType: 'CCPN' }, {
2839
+ method: 'unionpay',
2840
+ sessionId: opened.sessionId,
2841
+ url: opened.url,
2842
+ ...(token ? { preBookToken: token, prebookAt: now() } : {}),
2843
+ payBy: now() + payWithin,
2450
2844
  }) ?? row;
2451
2845
  return { kind: 'confirm', url: opened.url, booking: publicBooking(updated) };
2452
2846
  });
@@ -2468,14 +2862,27 @@ export const createHuazhuHotels = (opts) => {
2468
2862
  : 'not-paid'
2469
2863
  : row.status === 'expired'
2470
2864
  ? 'not-paid'
2471
- : p?.paidAt && (p.refund || row.status === 'failed' || row.status === 'unconfirmed')
2472
- ? 'refunded'
2473
- : row.status === 'failed' && !p?.paidAt
2474
- ? 'not-paid'
2475
- : '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';
2476
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;
2477
2880
  return {
2478
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
+ : {}),
2479
2886
  code: row.confirmationCode,
2480
2887
  hotelName: row.hotelName,
2481
2888
  ...(names ? { hotelNames: names } : {}),
@@ -2500,25 +2907,40 @@ export const createHuazhuHotels = (opts) => {
2500
2907
  return undefined;
2501
2908
  const at = now();
2502
2909
  if (at - (returned.get(sessionId) ?? Number.NEGATIVE_INFINITY) >= 10_000 &&
2503
- !ticking &&
2504
2910
  moneyPending(found)) {
2505
2911
  returned.set(sessionId, at);
2506
2912
  if (returned.size > 1_000)
2507
2913
  for (const [k, v] of returned)
2508
2914
  if (at - v > 10 * 60_000)
2509
2915
  returned.delete(k);
2510
- ticking = true;
2511
2916
  try {
2512
- await settlePrepaid(found);
2917
+ // Under the booking's own lock - taken by the pass meanwhile, this
2918
+ // step is the pass's to take (audit P0-A).
2919
+ await ifFree(found.id, () => settlePrepaid(found));
2513
2920
  }
2514
2921
  catch (err) {
2515
2922
  console.error(`[huazhu prepaid ${found.id}] the landing page's step failed: ${err.message}`);
2516
2923
  }
2517
- finally {
2518
- ticking = false;
2519
- }
2520
2924
  }
2521
- return returnOf(store.get(found.id) ?? found);
2925
+ const row = store.get(found.id) ?? found;
2926
+ const told = returnOf(row);
2927
+ // ⚠️ "NOT PAID" ONLY WHEN STRIPE SAYS SO (audit P0-D): a pass busy on this
2928
+ // booking, or behind on others, left a guest who had paid told "nothing
2929
+ // was charged - book again". Not paid here yet, the page is asked - only
2930
+ // read, never moved on.
2931
+ if (told.state === 'not-paid' &&
2932
+ row.status === 'awaiting-payment' &&
2933
+ row.prepaid?.method !== 'unionpay' &&
2934
+ row.prepaid?.sessionId &&
2935
+ checkout) {
2936
+ const page = await checkout.state(row.prepaid.sessionId).catch(() => undefined);
2937
+ if (page === 'paid')
2938
+ return { ...told, state: 'booking' };
2939
+ // Cannot be read just now: the page looks again (香格里拉's `checking`).
2940
+ if (page === undefined)
2941
+ return { ...told, state: 'checking' };
2942
+ }
2943
+ return told;
2522
2944
  };
2523
2945
  return {
2524
2946
  brand: opts.brand,
@@ -2536,18 +2958,20 @@ export const createHuazhuHotels = (opts) => {
2536
2958
  ...(checkout ? { paymentReturn } : {}),
2537
2959
  ...(checkout && unionpay ? { unionPay } : {}),
2538
2960
  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)
2961
+ // ⚠️ EACH BOOKING UNDER ITS OWN LOCK (audit P0-A): two passes, or a
2962
+ // pass and the landing page, never take a step on one booking at once.
2963
+ // One booking's failure holds up none of the others (audit P2).
2964
+ if (closing)
2542
2965
  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;
2966
+ for (const row of store.all()) {
2967
+ if (!moneyPending(row))
2968
+ continue;
2969
+ try {
2970
+ await ifFree(row.id, () => settlePrepaid(row));
2971
+ }
2972
+ catch (err) {
2973
+ console.error(`[huazhu prepaid ${row.id}] the pass's step failed: ${err.message}`);
2974
+ }
2551
2975
  }
2552
2976
  },
2553
2977
  imageDomains() {
@@ -2612,7 +3036,9 @@ export const createHuazhuHotels = (opts) => {
2612
3036
  */
2613
3037
  async drain() {
2614
3038
  closing = true;
2615
- await Promise.allSettled([...new Set(flights.values())]);
3039
+ // The steps on payments too - a refund or a booking half taken when the
3040
+ // process stopped was lost (audit P1-3, P0-B).
3041
+ await Promise.allSettled([...new Set(flights.values()), ...steps]);
2616
3042
  },
2617
3043
  async searchHotels(q) {
2618
3044
  const terms = (q.query ?? '')
@@ -3222,6 +3648,30 @@ export const createHuazhuHotels = (opts) => {
3222
3648
  };
3223
3649
  }
3224
3650
  const { fee, forbidden, rule, freeUntil } = feeNow(b);
3651
+ // A test booking says it is one (2026-10-16 audit, P2): Huazhu's test
3652
+ // system charges nobody and books nothing anyone stays in.
3653
+ const testNote = opts.testEnvironment
3654
+ ? " This is a test booking in Huazhu's test system: nothing is charged to anyone."
3655
+ : '';
3656
+ // Paid with UnionPay: Huazhu charged the one-time card, and gives back
3657
+ // to it what its terms allow - nothing passes through here (audit P0-C:
3658
+ // it was told as "paid at the hotel, nothing paid through here").
3659
+ if (b.prepaid?.method === 'unionpay' && b.prepaid.issuedAt) {
3660
+ const back = !forbidden && fee === 0;
3661
+ return {
3662
+ bookingId: b.id,
3663
+ confirmationCode: b.confirmationCode,
3664
+ fee: money(forbidden ? 0 : fee, cur),
3665
+ refund: back ? b.quote.total : zero,
3666
+ rule: forbidden
3667
+ ? `This rate cannot be cancelled now. ${rule} Huazhu keeps the ${formatMoney(b.quote.total)} it charged to the UnionPay card.${testNote}`
3668
+ : back
3669
+ ? `${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}`
3670
+ : `${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}`,
3671
+ ...(freeUntil ? { freeUntil: new Date(freeUntil).toISOString() } : {}),
3672
+ cancellable: !forbidden,
3673
+ };
3674
+ }
3225
3675
  if (b.prepaid?.paidAt) {
3226
3676
  // Paid here in advance: free means all of it back, anything else -
3227
3677
  // the whole stay, on these rates - means none.
@@ -3234,8 +3684,8 @@ export const createHuazhuHotels = (opts) => {
3234
3684
  rule: forbidden
3235
3685
  ? `This rate cannot be cancelled now. ${rule} The ${formatMoney(b.quote.total)} paid stays paid.`
3236
3686
  : 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.`,
3687
+ ? `${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.' : ''}`
3688
+ : `${rule} Cancelling now gives nothing back: the fee is the whole stay.${testNote}`,
3239
3689
  ...(freeUntil ? { freeUntil: new Date(freeUntil).toISOString() } : {}),
3240
3690
  cancellable: !forbidden,
3241
3691
  };
@@ -3250,7 +3700,9 @@ export const createHuazhuHotels = (opts) => {
3250
3700
  ? `This rate cannot be cancelled now. ${rule}`
3251
3701
  : opts.sandbox
3252
3702
  ? `${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.`,
3703
+ : opts.testEnvironment
3704
+ ? `${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.`
3705
+ : `${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
3706
  ...(freeUntil ? { freeUntil: new Date(freeUntil).toISOString() } : {}),
3255
3707
  cancellable: !forbidden,
3256
3708
  };
@@ -3259,80 +3711,94 @@ export const createHuazhuHotels = (opts) => {
3259
3711
  const found = findOwn(ref, who);
3260
3712
  if (!found)
3261
3713
  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);
3714
+ const first = await refresh(found);
3715
+ // A booking paid in advance is cancelled under its own lock - the pass,
3716
+ // the landing page and a switch to UnionPay wait (audit P0-A).
3717
+ const run = async () => {
3718
+ const b = store.get(first.id) ?? first;
3719
+ // Waiting on UnionPay: a confirmation already made is being booked by
3720
+ // the pass - which alone takes the card, once; one not made never will be.
3721
+ if (b.status === 'awaiting-payment' && b.prepaid?.method === 'unionpay') {
3722
+ const state = b.prepaid.sessionId && unionpay
3723
+ ? await unionpay.state(b.prepaid.sessionId).catch(() => undefined)
3724
+ : 'closed';
3725
+ if (state !== 'issued' && state !== undefined) {
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);
3729
+ }
3730
+ throw new Error(state === 'issued'
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.'
3732
+ : 'UnionPay could not be reached to say whether the guest confirmed. Ask again in a moment - nothing is booked until it can.');
3274
3733
  }
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);
3734
+ // Not paid yet: the page is closed, so it cannot be paid after this.
3735
+ if (b.status === 'awaiting-payment' && b.prepaid?.sessionId && checkout) {
3736
+ const state = await checkout.close(b.prepaid.sessionId).catch(() => undefined);
3737
+ if (state === 'closed') {
3738
+ return publicBooking(patch(b.id, { status: 'expired' }, { lapsedAt: now(), url: undefined }) ?? b);
3739
+ }
3740
+ // ⚠️ NO ANSWER IS NOT "PAID" (audit P0-C): the page may well still be
3741
+ // open and unpaid.
3742
+ if (state === undefined)
3743
+ 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.');
3744
+ // Paid in the last second: it is booked now, and cancelled as one.
3745
+ await settlePrepaid(b);
3746
+ 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
3747
  }
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, {
3748
+ if (b.pendingSince !== undefined || b.status === 'unconfirmed') {
3749
+ throw new Error(waitingWords(b));
3750
+ }
3751
+ if (!b.resId)
3752
+ return publicBooking(b);
3753
+ if (b.status !== 'confirmed')
3754
+ return publicBooking(b);
3755
+ const cur = b.quote.total.currency;
3756
+ // ⚠️ THE CANCELLATION AND WHAT IT OWES IN ONE WRITE, then the refund
3757
+ // (audit P0-B) - a process stopped in between leaves the refund to
3758
+ // open, which the pass opens.
3759
+ const cancelled = (fee, at) => {
3760
+ const latest = store.get(b.id) ?? b;
3761
+ const owed = owedOnCancel(latest, fee.minor, true);
3762
+ const done = patch(b.id, {
3321
3763
  status: 'cancelled',
3322
3764
  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.');
3765
+ cancelledAt: at,
3766
+ cancellationFee: fee,
3767
+ ...owed.row,
3768
+ }, owed.fields) ?? b;
3769
+ return publicBooking(done);
3770
+ };
3771
+ try {
3772
+ const out = await client.cancel(b.resId);
3773
+ const fee = typeof out.penaltyAmount === 'number'
3774
+ ? money(toMinor(out.penaltyAmount, out.penaltyCurrencyCode || cur), out.penaltyCurrencyCode || cur)
3775
+ : money(feeNow(b).fee, cur);
3776
+ cancelled(fee, new Date(now()).toISOString());
3330
3777
  }
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.');
3778
+ catch (err) {
3779
+ if (err instanceof HuazhuRefusal && err.code === 5402) {
3780
+ // Cancelled already - by this guest's own earlier try whose answer
3781
+ // was lost, most likely: owed as that cancellation was (audit P0-B).
3782
+ cancelled(b.cancellationFee ?? money(feeNow(b).fee, cur), b.cancelledAt ?? new Date(now()).toISOString());
3783
+ }
3784
+ else if (err instanceof HuazhuRefusal && (err.code === 5401 || err.code === 5405)) {
3785
+ throw new Error(err.code === 5405
3786
+ ? 'Huazhu says this stay has already started, so it cannot be cancelled.'
3787
+ : 'Huazhu says this booking cannot be cancelled.');
3788
+ }
3789
+ else if (err instanceof HuazhuUnreachable) {
3790
+ 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.');
3791
+ }
3792
+ else
3793
+ throw err;
3333
3794
  }
3334
- throw err;
3335
- }
3795
+ const after = store.get(b.id);
3796
+ const refund = after?.prepaid?.refund;
3797
+ if (refund && !refund.requestId)
3798
+ await refundOf(b.id, refund.reason);
3799
+ return publicBooking(store.get(b.id) ?? after ?? b);
3800
+ };
3801
+ return first.prepaid ? inTurnFor(first.id, run) : run();
3336
3802
  },
3337
3803
  async policies() {
3338
3804
  // Huazhu publishes no policy text: each RATE carries its terms, and those