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