@curless/hotel-storefront 0.31.3 → 0.32.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.
- package/dist/aggregate/hotels.d.ts.map +1 -1
- package/dist/aggregate/hotels.js +4 -1
- package/dist/aggregate/hotels.js.map +1 -1
- package/dist/backend.d.ts +15 -9
- package/dist/backend.d.ts.map +1 -1
- package/dist/backend.js.map +1 -1
- package/dist/card.generated.d.ts +1 -1
- package/dist/card.generated.js +2 -2
- package/dist/card.generated.js.map +1 -1
- package/dist/demo-hotels.d.ts +0 -8
- package/dist/demo-hotels.d.ts.map +1 -1
- package/dist/demo-hotels.js +80 -43
- package/dist/demo-hotels.js.map +1 -1
- package/dist/huazhu/backend.d.ts +3 -1
- package/dist/huazhu/backend.d.ts.map +1 -1
- package/dist/huazhu/backend.js +136 -95
- package/dist/huazhu/backend.js.map +1 -1
- package/dist/huazhu/bookings.d.ts +9 -6
- package/dist/huazhu/bookings.d.ts.map +1 -1
- package/dist/huazhu/bookings.js +7 -17
- package/dist/huazhu/bookings.js.map +1 -1
- package/dist/lib.d.ts +1 -1
- package/dist/lib.d.ts.map +1 -1
- package/dist/server.d.ts +2 -2
- package/dist/server.js +2 -2
- package/dist/surface.d.ts +0 -10
- package/dist/surface.d.ts.map +1 -1
- package/dist/surface.js +13 -7
- package/dist/surface.js.map +1 -1
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +172 -11
- package/dist/tools.js.map +1 -1
- package/dist/types.d.ts +33 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/huazhu/backend.js
CHANGED
|
@@ -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
|
-
(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
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
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
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
|
|
1762
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
1835
|
-
|
|
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
|
|
1907
|
-
|
|
1908
|
-
|
|
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 === '
|
|
1914
|
-
|
|
1915
|
-
|
|
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
|
-
//
|
|
1923
|
-
// as waiting, and the reconciler settles it within eight
|
|
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,
|