@curless/hotel-storefront 0.31.3 → 0.32.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -189,6 +189,38 @@ const refusalWords = (err, room, currency) => {
189
189
  return `Huazhu did not accept ${room}: ${err.huazhuMessage}${detail}.`;
190
190
  }
191
191
  };
192
+ /** The same refusal as a reason code, for a stay that failed on its own (`FailedStay`). */
193
+ const refusalCode = (err, rooms) => {
194
+ switch (err.code) {
195
+ case 5109:
196
+ case 5210:
197
+ return { code: 'sold-out', ...(rooms > 1 ? { args: { rooms } } : {}) };
198
+ case 5110:
199
+ case 5214:
200
+ return { code: 'too-many-rooms', args: { rooms } };
201
+ case 5111:
202
+ case 5215:
203
+ return { code: 'party' };
204
+ case 5120:
205
+ case 5233:
206
+ return { code: 'closed' };
207
+ case 5114:
208
+ case 5211:
209
+ case 5116:
210
+ case 5213:
211
+ return { code: 'prepaid-only' };
212
+ case 5107:
213
+ case 5207:
214
+ case 5128:
215
+ case 5238:
216
+ return { code: 'too-far' };
217
+ case 5108:
218
+ case 5209:
219
+ return { code: 'price-changed' };
220
+ default:
221
+ return { code: 'unavailable' };
222
+ }
223
+ };
192
224
  /** A road named after a place: "南京路", "香港中路", "Hong Kong Middle Road". */
193
225
  const ROAD_AFTER = /^\s*(?:[中东西南北]?(?:路|街|大道|大街|道|巷)|(?:east |west |north |south |middle )?(?:road|street|avenue|rd\b|st\b))/i;
194
226
  /**
@@ -326,7 +358,9 @@ const deadlineZh = (instant, tz) => {
326
358
  * How a fresh price differs from what the guest was shown - or nothing, when
327
359
  * it does not. See `ShownQuote`.
328
360
  */
329
- export const movedSince = (shown, q) => {
361
+ export const movedSince = (shown, q,
362
+ /** Hold the total to the one shown - not when a stay of it has since gone (it is in `failed`). */
363
+ whole = true) => {
330
364
  if (!shown)
331
365
  return "the guest has not been shown this trip's price yet.";
332
366
  const said = [];
@@ -344,7 +378,7 @@ export const movedSince = (shown, q) => {
344
378
  said.push(`${name}'s cancellation terms have changed since they were shown.`);
345
379
  }
346
380
  const total = shown.totals[q.total.currency];
347
- if (!said.length && total !== undefined && total !== q.total.minor)
381
+ if (whole && !said.length && total !== undefined && total !== q.total.minor)
348
382
  said.push(`The total is now ${formatMoney(q.total)}, not the ${formatMoney(money(total, q.total.currency))} shown.`);
349
383
  return said.length ? said.join(' ') : undefined;
350
384
  };
@@ -1294,11 +1328,14 @@ export const createHuazhuHotels = (opts) => {
1294
1328
  now() - Date.parse(b.createdAt) < SAME_TRIP_MS &&
1295
1329
  isLive(b));
1296
1330
  const ref = earlier[0]?.tripRef;
1331
+ const before = ref ? store.intent(ref) : undefined;
1332
+ // What it booked and what it could not - the second asking hears the
1333
+ // first's answer, failed stays and all, and books nothing twice.
1297
1334
  if (ref &&
1298
- earlier.length === lines.length &&
1335
+ earlier.length + (before?.failed?.length ?? 0) === lines.length &&
1299
1336
  earlier.every((b) => b.tripRef === ref) &&
1300
- (store.intent(ref)?.done ?? true)) {
1301
- return earlier.map(publicBooking);
1337
+ (before?.done ?? true)) {
1338
+ return { booked: earlier.map(publicBooking), failed: before?.failed ?? [] };
1302
1339
  }
1303
1340
  // ⚠️ THE LIMITS - see `BookingLimits`. Each is said as what it is, and
1304
1341
  // none suggests a way round itself (another email, another chat).
@@ -1565,32 +1602,70 @@ export const createHuazhuHotels = (opts) => {
1565
1602
  const bookHeld = async (lines, shown, tripKey, email, guest, contact, who, requests, slot) => {
1566
1603
  // Priced afresh, not from the cache: this is the price that will be sent.
1567
1604
  const priced = await quoteLines({ lines }, true);
1568
- if (priced.dropped?.length || priced.sold.size !== lines.length) {
1569
- throw new Error(`Nothing was booked. ${(priced.dropped ?? []).map((d) => `${d.title}: ${d.why}`).join(' ')} The trip is as it was - take that out or change it, then ask again.`);
1570
- }
1571
1605
  // ⚠️ AT THE PRICE AND TERMS THE GUEST SAW, OR NOT AT ALL - see `ShownQuote`.
1572
- const moved = movedSince(shown, priced);
1606
+ // The whole trip's question, asked before any stay is booked: the guest
1607
+ // has not agreed to a price they were not shown, and the trip is shown to
1608
+ // them again (the tools' `price-changed`) - this is not a stay that
1609
+ // "could not be booked". A stay Huazhu turns down at prebook or booking -
1610
+ // its own price moved there, sold out, refused - is that stay's alone.
1611
+ const moved = movedSince(shown, priced, !priced.dropped?.length);
1573
1612
  if (moved) {
1574
1613
  throw Object.assign(new Error(`Nothing was booked: ${moved} Show the guest the trip at today's price and terms, and book it only if they agree.`), { code: 'price-changed' });
1575
1614
  }
1576
1615
  const clean = cleanRequests(requests);
1577
1616
  const comment = commentOf(clean, guest.notes);
1578
- const plans = [];
1617
+ // ⚠️ WHAT WAS BOOKED IS KEPT, AND WHAT WAS NOT IS SAID (robin,
1618
+ // 2026-10-14, in place of 2026-09-28's "all of it or none"): a stay that
1619
+ // cannot be booked - gone since it was priced, refused at prebook (its
1620
+ // price moved there, sold out) or at booking, not written down - is in
1621
+ // `failed` with why, and the others are booked. Never a stay in neither
1622
+ // list.
1623
+ const failed = [];
1624
+ const failStay = (line, why, code, args, named) => {
1625
+ const raw = findHotel(line.hotelId);
1626
+ failed.push({
1627
+ hotelId: line.hotelId,
1628
+ hotelName: named?.hotel ?? (raw ? toHotel(raw, view, now()).name : line.hotelId),
1629
+ stay: line.stay,
1630
+ ...(line.hourly ? { hourly: line.hourly } : {}),
1631
+ ...(named?.room ? { roomName: named.room } : {}),
1632
+ why,
1633
+ code,
1634
+ ...(args ? { args } : {}),
1635
+ });
1636
+ };
1637
+ // `quoteLines` keeps a line's order: the sold ones in `lines`, the rest
1638
+ // in `dropped`, one each.
1639
+ const soldAt = [...priced.sold.keys()].sort((x, y) => x - y);
1640
+ const droppedAt = lines.map((_, i) => i).filter((i) => !priced.sold.has(i));
1641
+ if (soldAt.length !== priced.lines.length || droppedAt.length !== (priced.dropped?.length ?? 0))
1642
+ throw new Error('Nothing was booked: the trip changed while it was being priced.');
1643
+ let plans = [];
1579
1644
  for (const [i, line] of lines.entries()) {
1580
- const sold = priced.sold.get(i);
1581
- const qline = priced.lines[i];
1582
- if (line.kind !== 'room' || !sold || !qline)
1645
+ if (line.kind !== 'room')
1583
1646
  throw new Error('Nothing was booked: the trip changed while it was being priced.');
1647
+ const sold = priced.sold.get(i);
1648
+ if (!sold) {
1649
+ const d = priced.dropped?.[droppedAt.indexOf(i)];
1650
+ failStay(line, d ? `${d.title}: ${d.why}` : 'It cannot be booked now.', 'unavailable');
1651
+ continue;
1652
+ }
1653
+ const qline = priced.lines[soldAt.indexOf(i)];
1584
1654
  plans.push({ i, line, sold, qline });
1585
1655
  }
1586
1656
  // ⚠️ A STAY PAID IN ADVANCE IS BOOKED ON ITS OWN (v1): its money is taken
1587
1657
  // before Huazhu is asked, and a trip half paid and half not - or two paid
1588
1658
  // stays, one refused - has no whole refund to give (总线程, 2026-10-12).
1589
- if (plans.length > 1 && plans.some((p) => p.sold.res.prepaid))
1659
+ // The whole trip's rule: nothing is booked.
1660
+ if (lines.length > 1 && plans.some((p) => p.sold.res.prepaid))
1590
1661
  throw new Error('Nothing was booked: a stay paid in advance is booked on its own here. Take the other stays out of the trip, book this one, then book the rest.');
1662
+ // Prebook is Huazhu's own check of price, rooms and rules - for each stay
1663
+ // before any is booked, so a stay it turns down is said with the others'
1664
+ // outcome, not after them.
1591
1665
  for (const plan of plans) {
1592
1666
  const s = plan.sold.res;
1593
1667
  const title = `${plan.qline.hotelName} - ${plan.qline.title}`;
1668
+ const named = { hotel: plan.qline.hotelName, room: plan.qline.title };
1594
1669
  try {
1595
1670
  const out = await client.prebook({
1596
1671
  hotelId: plan.line.hotelId,
@@ -1612,11 +1687,20 @@ export const createHuazhuHotels = (opts) => {
1612
1687
  plan.token = out?.preBookToken;
1613
1688
  }
1614
1689
  catch (err) {
1615
- if (err instanceof HuazhuRefusal)
1616
- throw new Error(`Nothing was booked. ${refusalWords(err, title, s.currency)}`);
1617
- throw new Error(`Nothing was booked: Huazhu could not be reached to check ${title} (${err.message}). Asking again is safe.`);
1690
+ const rooms = plan.qline.quantity ?? 1;
1691
+ if (err instanceof HuazhuRefusal) {
1692
+ const { code, args } = refusalCode(err, rooms);
1693
+ failStay(plan.line, refusalWords(err, title, s.currency), code, args, named);
1694
+ }
1695
+ else
1696
+ failStay(plan.line, `Huazhu could not be reached to check ${title} (${err.message}). Asking again for this stay is safe.`, 'unreachable', undefined, named);
1697
+ plan.token = undefined;
1698
+ plan.out = true;
1618
1699
  }
1619
1700
  }
1701
+ plans = plans.filter((p) => !p.out);
1702
+ if (plans.length === 0)
1703
+ return { booked: [], failed };
1620
1704
  requestsByEmail.add(email);
1621
1705
  requestsByConversation.add(conversationOf(who));
1622
1706
  const tripRef = randomUUID();
@@ -1700,66 +1784,19 @@ export const createHuazhuHotels = (opts) => {
1700
1784
  store.update(id, { status: 'failed' });
1701
1785
  throw new Error(`Nothing was booked or charged: the payment page could not be opened (${err.message}). Asking again is safe.`);
1702
1786
  }
1703
- return [
1704
- publicBooking(store.update(id, {
1705
- prepaid: { ...record, sessionId: opened.sessionId, url: opened.url },
1706
- }) ?? row),
1707
- ];
1787
+ return {
1788
+ booked: [
1789
+ publicBooking(store.update(id, {
1790
+ prepaid: { ...record, sessionId: opened.sessionId, url: opened.url },
1791
+ }) ?? row),
1792
+ ],
1793
+ failed,
1794
+ };
1708
1795
  }
1709
1796
  const made = [];
1710
- /**
1711
- * Undo what this trip booked, when a later stay of it failed - and say
1712
- * what actually became of each, not what was meant to (2026-10-05 audit,
1713
- * e7: "flagged for a person" of a stay nobody had flagged).
1714
- */
1715
- const takeBackAll = async () => {
1716
- const waiting = [];
1717
- const charged = [];
1718
- const flagged = [];
1719
- const retrying = [];
1720
- for (const b of made) {
1721
- const row = store.update(b.id, { rollback: true });
1722
- if (!row || row.status === 'failed' || row.status === 'cancelled')
1723
- continue;
1724
- if (row.pendingSince !== undefined || row.status !== 'confirmed') {
1725
- // The reconciler cancels it if it turns up.
1726
- waiting.push(named(row));
1727
- continue;
1728
- }
1729
- await reconciler.takeBack(row);
1730
- const after = store.get(b.id) ?? row;
1731
- if (after.status === 'cancelled') {
1732
- if (after.cancellationFee && after.cancellationFee.minor > 0)
1733
- charged.push(`${named(after)}: ${formatMoney(after.cancellationFee)}`);
1734
- }
1735
- else if (after.attention && !after.resolvedAt)
1736
- flagged.push(named(after));
1737
- else
1738
- retrying.push(named(after));
1739
- }
1740
- const and = (xs) => xs.join(' and ');
1741
- return [
1742
- charged.length
1743
- ? ` Cancelling again cost a fee under the rate's terms - ${charged.join('; ')}. A person here has been told and will follow it up.`
1744
- : '',
1745
- flagged.length
1746
- ? ` ${and(flagged)} could not be cancelled automatically and has been flagged for a person to cancel with the hotel.`
1747
- : '',
1748
- retrying.length
1749
- ? ` ${and(retrying)} could not be cancelled yet: it is tried again automatically, and a person is told if that keeps failing.`
1750
- : '',
1751
- waiting.length
1752
- ? ` Whether ${and(waiting)} was booked is not known yet; if it turns up in the next few minutes it is cancelled automatically.`
1753
- : '',
1754
- ].join('');
1755
- };
1756
- const notWritten = async (title) => {
1757
- const back = await takeBackAll();
1758
- throw new Error(`Nothing was booked: ${title} could not be written down here before asking Huazhu, so it was not asked for. A person has been told.${back}`);
1759
- };
1760
1797
  // ⚠️ THE REQUEST, WRITTEN DOWN BEFORE ITS FIRST STAY IS BOOKED. A
1761
- // process that dies half way through a trip leaves this open, and the
1762
- // next one takes back what it booked (`Reconciler.recover`).
1798
+ // process that dies half way through a trip leaves this open; the next
1799
+ // one closes it and keeps what it booked (`Reconciler.recover`).
1763
1800
  try {
1764
1801
  store.open({
1765
1802
  tripRef,
@@ -1772,12 +1809,13 @@ export const createHuazhuHotels = (opts) => {
1772
1809
  }
1773
1810
  catch (err) {
1774
1811
  if (err instanceof StoreWriteError)
1775
- return notWritten('the trip');
1812
+ throw new Error('Nothing was booked: the trip could not be written down here before asking Huazhu, so it was not asked for. A person has been told.');
1776
1813
  throw err;
1777
1814
  }
1778
1815
  try {
1779
- for (const plan of plans) {
1816
+ for (const [k, plan] of plans.entries()) {
1780
1817
  const s = plan.sold.res;
1818
+ const named = { hotel: plan.qline.hotelName, room: plan.qline.title };
1781
1819
  const rooms = plan.qline.quantity ?? 1;
1782
1820
  const qTotal = plan.qline.total.minor;
1783
1821
  const nightlyMinor = s.after.map((x) => toMinor(x, s.currency));
@@ -1830,9 +1868,15 @@ export const createHuazhuHotels = (opts) => {
1830
1868
  store.put(row);
1831
1869
  }
1832
1870
  catch (err) {
1833
- if (err instanceof StoreWriteError)
1834
- return notWritten(title);
1835
- throw err;
1871
+ if (!(err instanceof StoreWriteError))
1872
+ throw err;
1873
+ // Not written down, not asked for - and nor is any stay after it:
1874
+ // a disk that took nothing now will not take the next one. What was
1875
+ // booked before it is kept.
1876
+ for (const rest of plans.slice(k))
1877
+ failStay(rest.line, `${rest.qline.hotelName} - ${rest.qline.title} could not be written down here before asking Huazhu, so it was not asked for. A person has been told.`, 'unavailable', undefined, { hotel: rest.qline.hotelName, room: rest.qline.title });
1878
+ console.error(`[huazhu trip ${tripRef}] ⚠️ NEEDS A PERSON: the booking file could not be written - ${plans.length - k} stay(s) not asked for`);
1879
+ break;
1836
1880
  }
1837
1881
  made.push(row);
1838
1882
  slot.n -= 1;
@@ -1900,33 +1944,30 @@ export const createHuazhuHotels = (opts) => {
1900
1944
  continue;
1901
1945
  }
1902
1946
  if (refusal) {
1903
- // Never made at Huazhu: nothing to follow up, nothing to show.
1947
+ // Never made at Huazhu: nothing to follow up, nothing to show -
1948
+ // and the stays booked before it stay booked.
1904
1949
  store.update(id, { pendingSince: undefined, status: 'failed' });
1905
1950
  made.pop();
1906
- const before = made.length;
1907
- const back = await takeBackAll();
1908
- throw new Error(`Nothing was booked. ${refusalWords(refusal, title, cur)}${before && !back ? ' The stays booked before it in this trip were cancelled again.' : ''}${back}`);
1951
+ const { code, args } = refusalCode(refusal, rooms);
1952
+ failStay(plan.line, refusalWords(refusal, title, cur), code, args, named);
1953
+ continue;
1909
1954
  }
1910
1955
  // Unknown: the eight minutes run from the moment that was known.
1911
1956
  store.update(id, { pendingSince: now() });
1912
1957
  const settled = await settle(id);
1913
- if (settled?.status === 'confirmed' && settled.pendingSince === undefined)
1914
- continue;
1915
- if (plans.length > 1 || settled?.status === 'failed') {
1916
- const back = await takeBackAll();
1917
- if (settled?.status === 'failed') {
1918
- throw new Error(`Nothing was booked: Huazhu did not confirm ${plan.qline.hotelName}.${back}`);
1919
- }
1920
- throw new Error(`Nothing was booked: Huazhu did not answer in time for ${plan.qline.hotelName}, so the trip was stopped.${back}`);
1958
+ if (settled?.status === 'failed') {
1959
+ made.pop();
1960
+ failStay(plan.line, `Huazhu did not confirm ${plan.qline.hotelName}: it was looked for and not found, so it was not booked.`, 'unavailable', undefined, named);
1921
1961
  }
1922
- // One stay, outcome still unknown: it is the guest's booking, marked
1923
- // as waiting, and the reconciler settles it within eight minutes.
1962
+ // Confirmed - or still unknown: then it is the guest's booking, marked
1963
+ // as waiting (`requested`), and the reconciler settles it within eight
1964
+ // minutes from its distributorResId, written down before it was sent.
1924
1965
  }
1925
1966
  }
1926
1967
  finally {
1927
- store.close(tripRef);
1968
+ store.close(tripRef, failed.length ? { failed } : {});
1928
1969
  }
1929
- return made.map((b) => publicBooking(store.get(b.id) ?? b));
1970
+ return { booked: made.map((b) => publicBooking(store.get(b.id) ?? b)), failed };
1930
1971
  };
1931
1972
  return {
1932
1973
  brand: opts.brand,