@curless/hotel-storefront 0.43.1 → 0.44.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/card.generated.d.ts +1 -1
- package/dist/card.generated.js +2 -2
- package/dist/card.generated.js.map +1 -1
- package/dist/config.d.ts +18 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js.map +1 -1
- package/dist/huazhu/backend.d.ts +10 -0
- package/dist/huazhu/backend.d.ts.map +1 -1
- package/dist/huazhu/backend.js +538 -68
- 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/huazhu/client.d.ts +84 -0
- package/dist/huazhu/client.d.ts.map +1 -1
- package/dist/huazhu/client.js +147 -1
- package/dist/huazhu/client.js.map +1 -1
- package/dist/huazhu/make.d.ts.map +1 -1
- package/dist/huazhu/make.js +35 -1
- package/dist/huazhu/make.js.map +1 -1
- package/dist/limits.d.ts +34 -1
- package/dist/limits.d.ts.map +1 -1
- package/dist/limits.js +2 -0
- package/dist/limits.js.map +1 -1
- package/dist/payments/checkout.d.ts +11 -0
- package/dist/payments/checkout.d.ts.map +1 -1
- package/dist/payments/checkout.js.map +1 -1
- package/dist/payments/fake-stripe.d.ts +4 -0
- package/dist/payments/fake-stripe.d.ts.map +1 -1
- package/dist/payments/fake-stripe.js +21 -1
- package/dist/payments/fake-stripe.js.map +1 -1
- package/dist/payments/fake-unionpay.d.ts +2 -0
- package/dist/payments/fake-unionpay.d.ts.map +1 -1
- package/dist/payments/fake-unionpay.js +7 -1
- package/dist/payments/fake-unionpay.js.map +1 -1
- package/dist/payments/stripe-checkout.d.ts +1 -0
- package/dist/payments/stripe-checkout.d.ts.map +1 -1
- package/dist/payments/stripe-checkout.js +4 -2
- package/dist/payments/stripe-checkout.js.map +1 -1
- package/dist/server.d.ts +2 -2
- package/dist/server.js +2 -2
- package/dist/tools.d.ts +30 -1
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +49 -2
- package/dist/tools.js.map +1 -1
- package/package.json +1 -1
package/dist/huazhu/backend.js
CHANGED
|
@@ -4,14 +4,14 @@ import { addDays, arrivalInstant, atHotelTime, nights as nightsBetween, stayProb
|
|
|
4
4
|
import { byWords, splitByStay } from '../demo-hotels.js';
|
|
5
5
|
import { FACILITIES, hasFacility } from '../facilities.js';
|
|
6
6
|
import { endOf, startProblem } from '../hourly.js';
|
|
7
|
-
import { BOOKING_LIMITS, DAY_MS, HOUR_MS, WindowCounter } from '../limits.js';
|
|
7
|
+
import { BOOKING_LIMITS, DAY_MS, HOUR_MS, UNPAID_PER_STRICT, 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
11
|
import { IN_REQUEST_AT_MS, NOT_FOUND, StoreWriteError, nextCheckAt, statusOf, } from './bookings.js';
|
|
12
|
-
import { describePolicy, feeFor, freeUntilOf, nightAuditOf, parseCancelPolicy, penaltyAt, refundableFrom, stepsOf, } from './cancel-policy.js';
|
|
12
|
+
import { describePolicy, feeFor, freeUntilOf, nightAuditOf, parseCancelPolicy, penaltyAt, phasesOf, refundableFrom, stepsOf, } from './cancel-policy.js';
|
|
13
13
|
import { haystackOf, inPlace, namesOf, roomPhoto, toHotel } from './catalog.js';
|
|
14
|
-
import { HuazhuRefusal, HuazhuUnreachable } from './client.js';
|
|
14
|
+
import { HuazhuRefusal, HuazhuTooBusy, 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';
|
|
@@ -191,6 +191,8 @@ const localIsoAt = (instant, tz) => {
|
|
|
191
191
|
.map((x) => [x.type, x.value]));
|
|
192
192
|
return `${p.year}-${p.month}-${p.day}T${p.hour}:${p.minute}`;
|
|
193
193
|
};
|
|
194
|
+
/** A stay paid for - on the card page, or confirmed on UnionPay (whose one-time card Huazhu then charges). */
|
|
195
|
+
const paidInAdvance = (p) => p.method === 'unionpay' ? p.issuedAt !== undefined : p.paidAt !== undefined;
|
|
194
196
|
const pool = async (items, n, fn) => {
|
|
195
197
|
const out = new Array(items.length);
|
|
196
198
|
let next = 0;
|
|
@@ -470,6 +472,19 @@ whole = true) => {
|
|
|
470
472
|
said.push(`The total is now ${formatMoney(q.total)}, not the ${formatMoney(money(total, q.total.currency))} shown.`);
|
|
471
473
|
return said.length ? said.join(' ') : undefined;
|
|
472
474
|
};
|
|
475
|
+
/** A read of a booking's detail is good for this long. */
|
|
476
|
+
const DETAIL_FRESH_MS = 60_000;
|
|
477
|
+
/** A read that failed is not tried again for this long - longer than a good one is kept. */
|
|
478
|
+
const DETAIL_RETRY_MS = 2 * 60_000;
|
|
479
|
+
/** The background watch tries a booking again this soon after a read of it failed (never later than its usual interval). */
|
|
480
|
+
const WATCH_RETRY_MS = 30 * 60_000;
|
|
481
|
+
/**
|
|
482
|
+
* The WHOLE background watch rests this long after a read it made failed
|
|
483
|
+
* (2026-10-21, 总线程): with Huazhu not answering, each 10-second pass tried
|
|
484
|
+
* one more stay and the hour's calls went on failures within minutes. A
|
|
485
|
+
* guest's own lookup is not held by it - it has its own cooldown a booking.
|
|
486
|
+
*/
|
|
487
|
+
const WATCH_PAUSE_MS = 5 * 60_000;
|
|
473
488
|
export const createHuazhuHotels = (opts) => {
|
|
474
489
|
const { client, catalog, store, reconciler } = opts;
|
|
475
490
|
// ⚠️ NO MONEY TAKEN FOR A REAL HUAZHU YET: who takes it, the licence, a
|
|
@@ -512,6 +527,10 @@ export const createHuazhuHotels = (opts) => {
|
|
|
512
527
|
const rateNameOf = (p) => (en ? rateNameEn(p.rateCodeName) : undefined) ?? p.rateCodeName ?? p.rateCode;
|
|
513
528
|
const today = () => todayAt(now(), opts.tz);
|
|
514
529
|
const limits = { ...BOOKING_LIMITS, ...(opts.limits ?? {}) };
|
|
530
|
+
/** The looser ceiling on unpaid requests to pay in advance - a multiple of the strict one unless set (T2-14). */
|
|
531
|
+
const unpaidPerDay = opts.limits?.unpaidPerDay ?? limits.perDeploymentPerDay * UNPAID_PER_STRICT;
|
|
532
|
+
/** When the unpaid ceiling was last said in the log: the strict one's time is the store's. */
|
|
533
|
+
let unpaidCeilingSaidAt;
|
|
515
534
|
const requestsByEmail = new WindowCounter(DAY_MS, now);
|
|
516
535
|
const requestsByIp = new WindowCounter(HOUR_MS, now);
|
|
517
536
|
const requestsByConversation = new WindowCounter(HOUR_MS, now);
|
|
@@ -534,6 +553,15 @@ export const createHuazhuHotels = (opts) => {
|
|
|
534
553
|
*/
|
|
535
554
|
const slots = new Set();
|
|
536
555
|
const slotsWhere = (pred) => [...slots].filter(pred).reduce((a, x) => a + x.n, 0);
|
|
556
|
+
/**
|
|
557
|
+
* A request to pay in advance that has not been paid: waiting for its
|
|
558
|
+
* payment, or lapsed without (2026-10-21 re-audit, T2-14). It costs a
|
|
559
|
+
* prebook at Huazhu and a payment page at Stripe, and is counted against its
|
|
560
|
+
* own, looser daily ceiling - not the strict one that stops all booking.
|
|
561
|
+
*/
|
|
562
|
+
const unpaidAsk = (b) => !!b.prepaid &&
|
|
563
|
+
!paidInAdvance(b.prepaid) &&
|
|
564
|
+
(b.status === 'awaiting-payment' || b.status === 'expired');
|
|
537
565
|
/** Requests in flight, by conversation and by email - see `single`. */
|
|
538
566
|
const flights = new Map();
|
|
539
567
|
/** Set by `drain`: a stopping process starts no booking. */
|
|
@@ -1436,38 +1464,94 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1436
1464
|
return there;
|
|
1437
1465
|
};
|
|
1438
1466
|
/**
|
|
1439
|
-
*
|
|
1467
|
+
* ⚠️ HUAZHU DOWN, OR SAYING "TOO MANY REQUESTS" (2009), IS NOT TO BE ASKED
|
|
1468
|
+
* AGAIN AT ONCE (2026-10-21 re-audit, T2-4 / T2-5; the way `ariBackoff` does
|
|
1469
|
+
* it for prices). One 2009 holds EVERY detail ask - a guest's and the
|
|
1470
|
+
* background watch's - for a minute, doubling to ten while it goes on. What
|
|
1471
|
+
* was last known stands meanwhile, and says so by its age.
|
|
1472
|
+
*/
|
|
1473
|
+
let detailBackoff;
|
|
1474
|
+
const detailHeld = () => detailBackoff !== undefined && now() < detailBackoff.until;
|
|
1475
|
+
/** One ask a booking at a time: a second look at a booking being read waits for that read. */
|
|
1476
|
+
const detailFlights = new Map();
|
|
1477
|
+
/** Whether the last attempt on a booking was a failure, newer than any success. */
|
|
1478
|
+
const failedLast = (b) => b.refreshFailedAt !== undefined &&
|
|
1479
|
+
(b.refreshedAt === undefined || b.refreshFailedAt > b.refreshedAt);
|
|
1480
|
+
/** Asked about so lately that asking again is no use: a success for a minute, a failure for two. */
|
|
1481
|
+
const lookedAtLately = (b) => failedLast(b)
|
|
1482
|
+
? now() - b.refreshFailedAt < DETAIL_RETRY_MS
|
|
1483
|
+
: b.refreshedAt !== undefined && now() - b.refreshedAt < DETAIL_FRESH_MS;
|
|
1484
|
+
/** The one call to Huazhu for a booking, and what it is written down as. */
|
|
1485
|
+
const askDetail = async (b) => {
|
|
1486
|
+
// The booking as it is when the read BEGINS (2026-10-21 re-audit, T2-3):
|
|
1487
|
+
// the row is live, so what it says afterwards is not what it said before.
|
|
1488
|
+
const seen = b.status;
|
|
1489
|
+
let d;
|
|
1490
|
+
try {
|
|
1491
|
+
d = await client.detail(b.resId);
|
|
1492
|
+
}
|
|
1493
|
+
catch (err) {
|
|
1494
|
+
if (err instanceof HuazhuRefusal && err.code === CODES.rateLimited) {
|
|
1495
|
+
const wait = Math.min((detailBackoff?.wait ?? 30_000) * 2, 10 * 60_000);
|
|
1496
|
+
detailBackoff = { until: now() + wait, wait };
|
|
1497
|
+
}
|
|
1498
|
+
// Unreachable or refused: what we last knew stands, and says so by its
|
|
1499
|
+
// age. The attempt is written down so it is not made again at once.
|
|
1500
|
+
return { booking: store.update(b.id, { refreshFailedAt: now() }) ?? b, outcome: 'failed' };
|
|
1501
|
+
}
|
|
1502
|
+
detailBackoff = undefined;
|
|
1503
|
+
// ⚠️ AN ANSWER OLDER THAN THE BOOKING IS NOT WRITTEN (2026-10-21 re-audit,
|
|
1504
|
+
// T2-3, R3): a guest's cancellation completing while this was in flight
|
|
1505
|
+
// was written back as "confirmed", the payment refunding, for a minute.
|
|
1506
|
+
// The booking is read again here, after the wait, and changed only if its
|
|
1507
|
+
// status is what it was when this read began.
|
|
1508
|
+
const cur = store.get(b.id);
|
|
1509
|
+
if (!cur || cur.status !== seen)
|
|
1510
|
+
return { booking: cur ?? b, outcome: 'overtaken' };
|
|
1511
|
+
const status = statusOf(d.status);
|
|
1512
|
+
const line = d.reservations?.[0];
|
|
1513
|
+
const row = store.update(b.id, {
|
|
1514
|
+
hotelStatus: d.status,
|
|
1515
|
+
status,
|
|
1516
|
+
refreshedAt: now(),
|
|
1517
|
+
...(line?.cancelPolicyCode ? { cancelCode: line.cancelPolicyCode } : {}),
|
|
1518
|
+
...(status === 'cancelled' && !cur.cancelledAt
|
|
1519
|
+
? { cancelledAt: new Date(now()).toISOString() }
|
|
1520
|
+
: {}),
|
|
1521
|
+
});
|
|
1522
|
+
return { booking: row ?? cur, outcome: 'read', moved: status !== seen };
|
|
1523
|
+
};
|
|
1524
|
+
/**
|
|
1525
|
+
* Huazhu's own word on a booking, at most once a minute each - and after a
|
|
1526
|
+
* failure, once in two, and not at all while it says "too many requests".
|
|
1527
|
+
* Two looks at one booking at once are one call.
|
|
1440
1528
|
*
|
|
1441
1529
|
* ⚠️ IT ONLY READS (2026-10-16 audit, P0-A). It used to move a payment on
|
|
1442
1530
|
* as it was asked about - outside the booking's lock, from every lookup and
|
|
1443
1531
|
* every few seconds from a card waiting on a payment. Payments move on in
|
|
1444
1532
|
* the pass, the landing page and the steps a guest takes, one at a time.
|
|
1533
|
+
* What it writes is the booking's status and the time of the look; a
|
|
1534
|
+
* cancellation at Huazhu's end is then a payment to settle (`moneyPending`).
|
|
1535
|
+
*
|
|
1536
|
+
* The guest's lookup and the background watch (`watchPass`) are this one
|
|
1537
|
+
* function, so a cancellation found by either is written alike.
|
|
1445
1538
|
*/
|
|
1446
|
-
const
|
|
1539
|
+
const lookAt = (found) => {
|
|
1447
1540
|
const b = store.get(found.id) ?? found;
|
|
1448
1541
|
if (!b.resId || b.pendingSince !== undefined)
|
|
1449
|
-
return b;
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
...(line?.cancelPolicyCode ? { cancelCode: line.cancelPolicyCode } : {}),
|
|
1461
|
-
...(status === 'cancelled' && !b.cancelledAt
|
|
1462
|
-
? { cancelledAt: new Date(now()).toISOString() }
|
|
1463
|
-
: {}),
|
|
1464
|
-
}) ?? b);
|
|
1465
|
-
}
|
|
1466
|
-
catch {
|
|
1467
|
-
// Unreachable or refused: what we last knew stands, and says so by its age.
|
|
1468
|
-
return b;
|
|
1469
|
-
}
|
|
1542
|
+
return Promise.resolve({ booking: b, outcome: 'none' });
|
|
1543
|
+
const flying = detailFlights.get(b.id);
|
|
1544
|
+
if (flying)
|
|
1545
|
+
return flying;
|
|
1546
|
+
if (lookedAtLately(b) || detailHeld())
|
|
1547
|
+
return Promise.resolve({ booking: b, outcome: 'none' });
|
|
1548
|
+
const flight = askDetail(b).finally(() => {
|
|
1549
|
+
detailFlights.delete(b.id);
|
|
1550
|
+
});
|
|
1551
|
+
detailFlights.set(b.id, flight);
|
|
1552
|
+
return flight;
|
|
1470
1553
|
};
|
|
1554
|
+
const refresh = async (found) => (await lookAt(found)).booking;
|
|
1471
1555
|
/** Why a booking cannot be cancelled yet: Huazhu has not said whether it exists. */
|
|
1472
1556
|
const waitingWords = (b) => b.status === 'unconfirmed'
|
|
1473
1557
|
? `${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.`
|
|
@@ -1522,6 +1606,39 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1522
1606
|
steps,
|
|
1523
1607
|
};
|
|
1524
1608
|
};
|
|
1609
|
+
/**
|
|
1610
|
+
* The fee cancelling comes to if it is the SAME whenever in `from`..`to` it
|
|
1611
|
+
* was done - by the rate's own terms, worked out by `feeNow` at both ends and
|
|
1612
|
+
* at every moment in between that a window of the terms begins (and where a
|
|
1613
|
+
* room by the hour begins) - or undefined when it differs, so that WHEN
|
|
1614
|
+
* matters (2026-10-21 re-audit, T2-8). Undefined too when the terms cannot be
|
|
1615
|
+
* read, or say the fee is only the most it could be, or say it cannot be
|
|
1616
|
+
* cancelled: none of those is a fee.
|
|
1617
|
+
*/
|
|
1618
|
+
const constantFee = (b, from, to) => {
|
|
1619
|
+
const policy = policyOf(b.cancelCode);
|
|
1620
|
+
if (!policy || !Number.isFinite(from) || !Number.isFinite(to) || from > to)
|
|
1621
|
+
return undefined;
|
|
1622
|
+
const moments = new Set([from, to]);
|
|
1623
|
+
for (const ph of phasesOf(policy, b.stay.checkIn, b.tz))
|
|
1624
|
+
if (ph.from > from && ph.from <= to)
|
|
1625
|
+
moments.add(ph.from);
|
|
1626
|
+
if (b.hourly) {
|
|
1627
|
+
const begins = arrivalInstant(b.stay.checkIn, b.hourly.start, b.tz);
|
|
1628
|
+
if (begins > from && begins <= to)
|
|
1629
|
+
moments.add(begins);
|
|
1630
|
+
}
|
|
1631
|
+
let fee;
|
|
1632
|
+
for (const at of moments) {
|
|
1633
|
+
const f = feeNow(b, at);
|
|
1634
|
+
if (f.forbidden || f.feeIsCap)
|
|
1635
|
+
return undefined;
|
|
1636
|
+
if (fee !== undefined && f.fee !== fee)
|
|
1637
|
+
return undefined;
|
|
1638
|
+
fee = f.fee;
|
|
1639
|
+
}
|
|
1640
|
+
return fee;
|
|
1641
|
+
};
|
|
1525
1642
|
/** `feeNow`'s steps as the card reads them: money, and the hotel's clock. */
|
|
1526
1643
|
const stepsTold = (b, steps) => steps
|
|
1527
1644
|
? {
|
|
@@ -1614,16 +1731,22 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1614
1731
|
if (who.ip && requestsByIp.count(who.ip) >= limits.perIpPerHour) {
|
|
1615
1732
|
throw new Error('Nothing was booked. Too many bookings have been asked for from this network in the last hour - try again later.');
|
|
1616
1733
|
}
|
|
1734
|
+
// ⚠️ TWO CEILINGS, EACH SAYING WHICH (2026-10-21 re-audit, T2-14). This
|
|
1735
|
+
// is the strict one: stays that are made - booked, being booked, cancelled
|
|
1736
|
+
// since - or paid for and about to be booked. A request to pay in advance
|
|
1737
|
+
// that has not been paid is counted apart, and looser, once it is known to
|
|
1738
|
+
// be one (`bookHeld`). Since 2026-10-16 (audit, P2: each prebook and
|
|
1739
|
+
// payment page had to be bounded) unpaid ones were counted HERE, and fifty
|
|
1740
|
+
// anonymous emails, a request each, could fill the day and stop every real
|
|
1741
|
+
// booking.
|
|
1617
1742
|
const madeToday = store.all().filter((b) => now() - Date.parse(b.createdAt) < DAY_MS &&
|
|
1743
|
+
!unpaidAsk(b) &&
|
|
1618
1744
|
(b.status === 'confirmed' ||
|
|
1619
1745
|
b.status === 'requested' ||
|
|
1620
1746
|
b.status === 'unconfirmed' ||
|
|
1621
1747
|
b.status === 'cancelled' ||
|
|
1622
|
-
//
|
|
1623
|
-
|
|
1624
|
-
// (2026-10-16 audit, P2: unpaid asks were never counted).
|
|
1625
|
-
b.status === 'awaiting-payment' ||
|
|
1626
|
-
(b.status === 'expired' && !!b.prepaid))).length + slotsWhere(() => true);
|
|
1748
|
+
// Paid, and about to be booked: the money is taken.
|
|
1749
|
+
b.status === 'awaiting-payment')).length + slotsWhere((x) => !x.unpaid);
|
|
1627
1750
|
if (madeToday + lines.length > limits.perDeploymentPerDay) {
|
|
1628
1751
|
// ⚠️ A CEILING HIT IS SOMETHING A PERSON SHOULD KNOW: either business
|
|
1629
1752
|
// is booming or somebody is filling hotels with names. Logged, and on
|
|
@@ -1633,7 +1756,7 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1633
1756
|
console.error(`[huazhu bookings] ⚠️ NEEDS A PERSON: ${madeToday} bookings in the last 24 hours reached the ceiling of ${limits.perDeploymentPerDay}; new bookings are refused until the count falls`);
|
|
1634
1757
|
}
|
|
1635
1758
|
store.ceilingHitAt = now();
|
|
1636
|
-
throw new Error(`Nothing was booked: bookings through this connector are paused for now. Try again later, or book with ${opts.brand} directly.`);
|
|
1759
|
+
throw new Error(`Nothing was booked: bookings through this connector are paused for now - the limit on bookings made in a day has been reached. Try again later, or book with ${opts.brand} directly.`);
|
|
1637
1760
|
}
|
|
1638
1761
|
// The network counted once it has passed every limit - a request turned
|
|
1639
1762
|
// away is not one more against the guest, or a guest at a limit could
|
|
@@ -1643,7 +1766,11 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1643
1766
|
if (who.ip)
|
|
1644
1767
|
requestsByIp.add(who.ip);
|
|
1645
1768
|
// ⚠️ AND ITS PLACES TAKEN NOW, before anything is awaited - see `slots`.
|
|
1646
|
-
const slot = {
|
|
1769
|
+
const slot = {
|
|
1770
|
+
sessionId: conversationOf(who),
|
|
1771
|
+
email,
|
|
1772
|
+
n: lines.length,
|
|
1773
|
+
};
|
|
1647
1774
|
slots.add(slot);
|
|
1648
1775
|
try {
|
|
1649
1776
|
return await bookHeld(lines, shown, tripKey, email, guest, contact, who, requests, slot);
|
|
@@ -1778,6 +1905,7 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1778
1905
|
}
|
|
1779
1906
|
let refusal;
|
|
1780
1907
|
let answer;
|
|
1908
|
+
let notSent = false;
|
|
1781
1909
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
1782
1910
|
try {
|
|
1783
1911
|
reconciler.inFlight(id, true);
|
|
@@ -1792,6 +1920,13 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1792
1920
|
answer = got;
|
|
1793
1921
|
}
|
|
1794
1922
|
catch (err) {
|
|
1923
|
+
// ⚠️ NEVER SENT IS NOT "NO ANSWER" (2026-10-21, P1-11): this connector's
|
|
1924
|
+
// own limit on calls to Huazhu was full for too long, so the booking
|
|
1925
|
+
// did not go out. Nothing can exist at Huazhu to be followed up.
|
|
1926
|
+
if (err instanceof HuazhuTooBusy) {
|
|
1927
|
+
notSent = true;
|
|
1928
|
+
break;
|
|
1929
|
+
}
|
|
1795
1930
|
if (err instanceof HuazhuRefusal && err.code === CODES.systemError && attempt === 0) {
|
|
1796
1931
|
await sleep(1_000);
|
|
1797
1932
|
continue;
|
|
@@ -1813,6 +1948,18 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1813
1948
|
});
|
|
1814
1949
|
return;
|
|
1815
1950
|
}
|
|
1951
|
+
if (notSent) {
|
|
1952
|
+
// Put back as it was before the call - paid, waiting to be booked, and
|
|
1953
|
+
// NOT pending: the follow-up queries are for a booking that may exist,
|
|
1954
|
+
// and this one was never asked for. The next pass books it, as it does
|
|
1955
|
+
// when the booking could not be written down above. (Paid with
|
|
1956
|
+
// UnionPay, the gateway hands the one-time card over once and it is
|
|
1957
|
+
// gone: the next pass finds it gone and asks Huazhu whether the stay
|
|
1958
|
+
// exists, as for any lost card - nothing is charged.)
|
|
1959
|
+
store.update(id, { status: 'awaiting-payment', pendingSince: undefined });
|
|
1960
|
+
console.error(`[huazhu prepaid ${id}] the booking was not sent to Huazhu - this connector's own limit on calls to Huazhu stayed full - and is tried again on the next pass`);
|
|
1961
|
+
return;
|
|
1962
|
+
}
|
|
1816
1963
|
if (refusal)
|
|
1817
1964
|
await turnedDown(id, refusal, !!card);
|
|
1818
1965
|
// No answer: the reconciler's eight minutes decide it.
|
|
@@ -2110,6 +2257,46 @@ export const createHuazhuHotels = (opts) => {
|
|
|
2110
2257
|
...(cur.prepaid ? { prepaid: { ...cur.prepaid, ...fields } } : {}),
|
|
2111
2258
|
});
|
|
2112
2259
|
};
|
|
2260
|
+
/**
|
|
2261
|
+
* ⚠️ ONE RULE FOR CLOSING A CARD PAGE (2026-10-21 re-audit, T2-1 / T2-7):
|
|
2262
|
+
* `closed` is closed and `paid` is paid - and EVERYTHING ELSE is no answer.
|
|
2263
|
+
* Stripe's `close` answers `open` when its expire is refused (a 400) while
|
|
2264
|
+
* the page is still open, and may throw; neither means the page is shut, and
|
|
2265
|
+
* neither means it was just paid. On no answer the caller changes nothing on
|
|
2266
|
+
* the booking (no session cleared, no status moved) and leaves the page to
|
|
2267
|
+
* the prepaid pass, which closes it once it is past its time.
|
|
2268
|
+
*/
|
|
2269
|
+
const closePage = async (sessionId) => {
|
|
2270
|
+
if (!checkout)
|
|
2271
|
+
return { state: 'unanswered', why: 'there is no payment provider' };
|
|
2272
|
+
try {
|
|
2273
|
+
const answer = await checkout.close(sessionId);
|
|
2274
|
+
return answer === 'closed' || answer === 'paid'
|
|
2275
|
+
? { state: answer }
|
|
2276
|
+
: { state: 'unanswered', why: 'the page stayed open' };
|
|
2277
|
+
}
|
|
2278
|
+
catch (err) {
|
|
2279
|
+
return { state: 'unanswered', why: err.message };
|
|
2280
|
+
}
|
|
2281
|
+
};
|
|
2282
|
+
/**
|
|
2283
|
+
* The card page for a booking - the one way it is opened: when the booking
|
|
2284
|
+
* is asked for (`bookHeld`), and again when UnionPay's page would not open
|
|
2285
|
+
* after this one was closed for it (`unionPay`, 2026-10-21 re-audit, T2-6).
|
|
2286
|
+
* A page opened AGAIN says which opening it is (`attempt`): under the
|
|
2287
|
+
* booking's first key the provider would answer with the closed page.
|
|
2288
|
+
*/
|
|
2289
|
+
const openCardPage = (row, payBy, attempt) => checkout.open({
|
|
2290
|
+
ref: row.distributorResId,
|
|
2291
|
+
code: row.confirmationCode,
|
|
2292
|
+
title: `${row.hotelName} · ${row.quote.lines[0]?.title ?? ''}`.slice(0, 200),
|
|
2293
|
+
amountMinor: row.quote.total.minor,
|
|
2294
|
+
currency: row.quote.total.currency,
|
|
2295
|
+
email: row.guest.email,
|
|
2296
|
+
// A minute past the booking's own deadline - ours closes it first.
|
|
2297
|
+
expiresAt: payBy + 60_000,
|
|
2298
|
+
...(attempt ? { attempt } : {}),
|
|
2299
|
+
});
|
|
2113
2300
|
/** Whether a booking paid in advance still has money work to do. */
|
|
2114
2301
|
const moneyPending = (b) => {
|
|
2115
2302
|
const p = b.prepaid;
|
|
@@ -2154,8 +2341,15 @@ export const createHuazhuHotels = (opts) => {
|
|
|
2154
2341
|
let state;
|
|
2155
2342
|
try {
|
|
2156
2343
|
state = await checkout.state(p.sessionId);
|
|
2157
|
-
if (state === 'open' && now() >= p.payBy)
|
|
2158
|
-
|
|
2344
|
+
if (state === 'open' && now() >= p.payBy) {
|
|
2345
|
+
// A close that left the page open is no answer either (2026-10-21
|
|
2346
|
+
// re-audit, T2-7): counted below as a page that could not be asked,
|
|
2347
|
+
// so one that will not close is a person's to look at - not "fine".
|
|
2348
|
+
const closing = await closePage(p.sessionId);
|
|
2349
|
+
if (closing.state === 'unanswered')
|
|
2350
|
+
throw new Error(closing.why);
|
|
2351
|
+
state = closing.state;
|
|
2352
|
+
}
|
|
2159
2353
|
}
|
|
2160
2354
|
catch {
|
|
2161
2355
|
// Asked again on the next pass - but a page that cannot be asked
|
|
@@ -2302,6 +2496,105 @@ export const createHuazhuHotels = (opts) => {
|
|
|
2302
2496
|
}
|
|
2303
2497
|
}
|
|
2304
2498
|
};
|
|
2499
|
+
// ------------------------------------------------- watching paid stays ----
|
|
2500
|
+
/**
|
|
2501
|
+
* ⚠️ NOBODY WAS LOOKING AT A PAID STAY (2026-10-21 re-audit, T2-5, R7). The
|
|
2502
|
+
* only read of Huazhu's order detail was a guest's own lookup, so a stay the
|
|
2503
|
+
* hotel cancelled sat "confirmed" with the guest's money here - not given
|
|
2504
|
+
* back, not decided - until the guest asked. This is the low-frequency look
|
|
2505
|
+
* that does not wait to be asked: each paid stay, confirmed and not yet
|
|
2506
|
+
* arrived, is read at most every `everyHours` (6), and the watch as a whole
|
|
2507
|
+
* makes at most `callsPerHour` (20) reads an hour, whatever the number of
|
|
2508
|
+
* stays. Until Huazhu says what it allows (docs/huazhu-card-payment-questions.md,
|
|
2509
|
+
* question 29) these are ours to keep gentle.
|
|
2510
|
+
*/
|
|
2511
|
+
const watchEveryMs = (opts.paidWatch?.everyHours ?? 6) * HOUR_MS;
|
|
2512
|
+
const watchPerHour = opts.paidWatch?.callsPerHour ?? 20;
|
|
2513
|
+
/** When each read the watch made was started, within the last hour. */
|
|
2514
|
+
const watchCalls = [];
|
|
2515
|
+
/** A pass is under way: the timer's next one does not start a second beside it. */
|
|
2516
|
+
let watching = false;
|
|
2517
|
+
/** No pass reads anything before this: a read failed (`WATCH_PAUSE_MS`). */
|
|
2518
|
+
let watchPausedUntil = Number.NEGATIVE_INFINITY;
|
|
2519
|
+
/** A stay paid for - by the page, or by the one-time card UnionPay issued - that Huazhu has confirmed and that has not arrived. */
|
|
2520
|
+
const watchable = (b, t) => {
|
|
2521
|
+
const p = b.prepaid;
|
|
2522
|
+
if (!p || b.status !== 'confirmed' || !b.resId || b.pendingSince !== undefined)
|
|
2523
|
+
return false;
|
|
2524
|
+
const paid = paidInAdvance(p);
|
|
2525
|
+
// Until the arrival day ends: after it the booking is Huazhu's and the hotel's.
|
|
2526
|
+
return paid && t < nightAuditOf(b.stay.checkIn, b.tz);
|
|
2527
|
+
};
|
|
2528
|
+
/** When a watchable stay was last looked at: a read, a try that failed, or - never read - when it was paid. */
|
|
2529
|
+
const lastLookOf = (b) => Math.max(b.refreshedAt ?? 0, b.refreshFailedAt ?? 0, Date.parse(b.createdAt) || 0, b.prepaid?.paidAt ?? 0, b.prepaid?.issuedAt ?? 0);
|
|
2530
|
+
/** Due for a look: the interval since the last one - a short one after a failure. */
|
|
2531
|
+
const watchDue = (b, t) => watchable(b, t) &&
|
|
2532
|
+
t - lastLookOf(b) >= (failedLast(b) ? Math.min(WATCH_RETRY_MS, watchEveryMs) : watchEveryMs);
|
|
2533
|
+
/**
|
|
2534
|
+
* One pass of the watch. It runs after the pass over the money, so a slow
|
|
2535
|
+
* Huazhu never holds a payment up. The stays due, longest unlooked first,
|
|
2536
|
+
* each under its OWN lock - one a guest's step has taken is left for the next
|
|
2537
|
+
* pass - until the hour's reads are spent, Huazhu says "too many requests",
|
|
2538
|
+
* or a read fails (Huazhu is not answering: it is not asked for every other
|
|
2539
|
+
* stay too, but tried again on the next pass).
|
|
2540
|
+
*
|
|
2541
|
+
* ⚠️ THE SAME LOOK AS A GUEST'S LOOKUP (`lookAt`), not a copy of it: what
|
|
2542
|
+
* Huazhu says is written in one place. A stay found cancelled is then a
|
|
2543
|
+
* payment to settle, and is settled in the same step - the money is owed
|
|
2544
|
+
* back (or a person asked) as it would be when the guest next looked.
|
|
2545
|
+
*/
|
|
2546
|
+
const watchPass = async () => {
|
|
2547
|
+
if (closing || watching || watchPerHour <= 0 || now() < watchPausedUntil)
|
|
2548
|
+
return;
|
|
2549
|
+
watching = true;
|
|
2550
|
+
try {
|
|
2551
|
+
const due = store
|
|
2552
|
+
.all()
|
|
2553
|
+
.filter((b) => watchDue(b, now()))
|
|
2554
|
+
.sort((a, b) => lastLookOf(a) - lastLookOf(b));
|
|
2555
|
+
for (const row of due) {
|
|
2556
|
+
const t = now();
|
|
2557
|
+
while (watchCalls.length && watchCalls[0] <= t - HOUR_MS)
|
|
2558
|
+
watchCalls.shift();
|
|
2559
|
+
if (closing || detailHeld() || watchCalls.length >= watchPerHour)
|
|
2560
|
+
return;
|
|
2561
|
+
let outcome;
|
|
2562
|
+
try {
|
|
2563
|
+
outcome = await ifFree(row.id, async () => {
|
|
2564
|
+
const cur = store.get(row.id);
|
|
2565
|
+
// Asked again under the lock: a guest's lookup may have read it meanwhile.
|
|
2566
|
+
if (!cur || !watchDue(cur, now()))
|
|
2567
|
+
return undefined;
|
|
2568
|
+
const started = now();
|
|
2569
|
+
const look = await lookAt(cur);
|
|
2570
|
+
if (look.outcome === 'none')
|
|
2571
|
+
return look.outcome;
|
|
2572
|
+
watchCalls.push(started);
|
|
2573
|
+
if (look.outcome === 'read' && look.moved) {
|
|
2574
|
+
console.log(`[huazhu watch ${cur.id}] Huazhu now shows ${cur.distributorResId} as ${look.booking.hotelStatus}: ${look.booking.status}`);
|
|
2575
|
+
const moved = store.get(cur.id);
|
|
2576
|
+
if (moved && moneyPending(moved))
|
|
2577
|
+
await settlePrepaid(moved);
|
|
2578
|
+
}
|
|
2579
|
+
return look.outcome;
|
|
2580
|
+
});
|
|
2581
|
+
}
|
|
2582
|
+
catch (err) {
|
|
2583
|
+
console.error(`[huazhu watch ${row.id}] the look failed: ${err.message}`);
|
|
2584
|
+
}
|
|
2585
|
+
if (outcome === 'failed') {
|
|
2586
|
+
// Not answering: the whole watch rests. A 2009 ("too many requests")
|
|
2587
|
+
// has its own back-off, which every read waits on already.
|
|
2588
|
+
if (!detailHeld())
|
|
2589
|
+
watchPausedUntil = now() + WATCH_PAUSE_MS;
|
|
2590
|
+
return;
|
|
2591
|
+
}
|
|
2592
|
+
}
|
|
2593
|
+
}
|
|
2594
|
+
finally {
|
|
2595
|
+
watching = false;
|
|
2596
|
+
}
|
|
2597
|
+
};
|
|
2305
2598
|
const bookHeld = async (lines, shown, tripKey, email, guest, contact, who, requests, slot) => {
|
|
2306
2599
|
// Priced afresh, not from the cache: this is the price that will be sent.
|
|
2307
2600
|
const priced = await quoteLines({ lines }, true);
|
|
@@ -2365,6 +2658,26 @@ export const createHuazhuHotels = (opts) => {
|
|
|
2365
2658
|
// The whole trip's rule: nothing is booked.
|
|
2366
2659
|
if (lines.length > 1 && plans.some((p) => p.sold.res.prepaid))
|
|
2367
2660
|
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.');
|
|
2661
|
+
// ⚠️ THE OTHER CEILING (2026-10-21 re-audit, T2-14): a request to pay in
|
|
2662
|
+
// advance costs one prebook at Huazhu and one payment page at Stripe
|
|
2663
|
+
// whether or not it is ever paid, and anybody may make one. Unpaid ones -
|
|
2664
|
+
// waiting, or lapsed - are counted apart from bookings, and looser, so a
|
|
2665
|
+
// flood of them stops more of them and not the bookings that are real. It
|
|
2666
|
+
// is checked before Huazhu is asked anything; this request's own place is
|
|
2667
|
+
// already in `slot`.
|
|
2668
|
+
if (plans.some((p) => p.sold.res.prepaid)) {
|
|
2669
|
+
slot.unpaid = true;
|
|
2670
|
+
const unpaid = store.all().filter((b) => now() - Date.parse(b.createdAt) < DAY_MS && unpaidAsk(b)).length +
|
|
2671
|
+
slotsWhere((x) => !!x.unpaid);
|
|
2672
|
+
if (unpaid > unpaidPerDay) {
|
|
2673
|
+
if (unpaidCeilingSaidAt === undefined || now() - unpaidCeilingSaidAt > HOUR_MS) {
|
|
2674
|
+
unpaidCeilingSaidAt = now();
|
|
2675
|
+
console.error(`[huazhu bookings] ⚠️ NEEDS A PERSON: ${unpaid} requests to pay in advance left unpaid (waiting for payment or lapsed) in the last 24 hours reached the unpaid ceiling of ${unpaidPerDay}; new requests to pay in advance are refused until the count falls`);
|
|
2676
|
+
}
|
|
2677
|
+
store.ceilingHitAt = now();
|
|
2678
|
+
throw new Error(`Nothing was booked: bookings paid in advance through this connector are paused for now - the limit on payment requests left unpaid in a day has been reached. Try again later, pay at the hotel where the rate allows it, or book with ${opts.brand} directly.`);
|
|
2679
|
+
}
|
|
2680
|
+
}
|
|
2368
2681
|
// Prebook is Huazhu's own check of price, rooms and rules - for each stay
|
|
2369
2682
|
// before any is booked, so a stay it turns down is said with the others'
|
|
2370
2683
|
// outcome, not after them.
|
|
@@ -2479,16 +2792,7 @@ export const createHuazhuHotels = (opts) => {
|
|
|
2479
2792
|
slot.n -= 1;
|
|
2480
2793
|
let opened;
|
|
2481
2794
|
try {
|
|
2482
|
-
opened = await
|
|
2483
|
-
ref: dres,
|
|
2484
|
-
code: row.confirmationCode,
|
|
2485
|
-
title: `${plan.qline.hotelName} · ${plan.qline.title}`.slice(0, 200),
|
|
2486
|
-
amountMinor: allIn,
|
|
2487
|
-
currency: cur,
|
|
2488
|
-
email: guest.email,
|
|
2489
|
-
// A minute past the booking's own deadline - ours closes it first.
|
|
2490
|
-
expiresAt: record.payBy + 60_000,
|
|
2491
|
-
});
|
|
2795
|
+
opened = await openCardPage(row, record.payBy);
|
|
2492
2796
|
}
|
|
2493
2797
|
catch (err) {
|
|
2494
2798
|
store.update(id, { status: 'failed' });
|
|
@@ -2622,6 +2926,7 @@ export const createHuazhuHotels = (opts) => {
|
|
|
2622
2926
|
// booking again.
|
|
2623
2927
|
let refusal;
|
|
2624
2928
|
let answer;
|
|
2929
|
+
let notSent;
|
|
2625
2930
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
2626
2931
|
try {
|
|
2627
2932
|
reconciler.inFlight(id, true);
|
|
@@ -2636,6 +2941,11 @@ export const createHuazhuHotels = (opts) => {
|
|
|
2636
2941
|
answer = got;
|
|
2637
2942
|
}
|
|
2638
2943
|
catch (err) {
|
|
2944
|
+
// Never sent (2026-10-21, P1-11): see `bookPaid`.
|
|
2945
|
+
if (err instanceof HuazhuTooBusy) {
|
|
2946
|
+
notSent = err.why;
|
|
2947
|
+
break;
|
|
2948
|
+
}
|
|
2639
2949
|
if (err instanceof HuazhuRefusal && err.code === CODES.systemError && attempt === 0) {
|
|
2640
2950
|
// The documentation's own advice: retry a system error, same id.
|
|
2641
2951
|
await sleep(1_000);
|
|
@@ -2660,6 +2970,15 @@ export const createHuazhuHotels = (opts) => {
|
|
|
2660
2970
|
});
|
|
2661
2971
|
continue;
|
|
2662
2972
|
}
|
|
2973
|
+
if (notSent !== undefined) {
|
|
2974
|
+
// ⚠️ NEVER ASKED OF HUAZHU: nothing to follow up, nothing to show -
|
|
2975
|
+
// not "unknown, the eight minutes decide". Failed, and said as a
|
|
2976
|
+
// stay Huazhu could not be reached for, which asking again is safe for.
|
|
2977
|
+
store.update(id, { pendingSince: undefined, status: 'failed' });
|
|
2978
|
+
made.pop();
|
|
2979
|
+
failStay(plan.line, `Huazhu could not be reached to book ${title} (${notSent}). It was not sent, so nothing was booked. Asking again for this stay is safe.`, 'unreachable', undefined, named);
|
|
2980
|
+
continue;
|
|
2981
|
+
}
|
|
2663
2982
|
if (refusal) {
|
|
2664
2983
|
// Never made at Huazhu: nothing to follow up, nothing to show -
|
|
2665
2984
|
// and the stays booked before it stay booked.
|
|
@@ -2686,6 +3005,35 @@ export const createHuazhuHotels = (opts) => {
|
|
|
2686
3005
|
}
|
|
2687
3006
|
return { booked: made.map((b) => publicBooking(store.get(b.id) ?? b)), failed };
|
|
2688
3007
|
};
|
|
3008
|
+
/**
|
|
3009
|
+
* A new card page for a booking whose card page was closed for UnionPay's
|
|
3010
|
+
* and whose UnionPay page would not open (2026-10-21 re-audit, T2-6): the
|
|
3011
|
+
* same booking and total, a page of a full life from now (the provider
|
|
3012
|
+
* takes none shorter than half an hour), written back as the booking's own.
|
|
3013
|
+
* The count of openings is written BEFORE the call, so a try whose answer
|
|
3014
|
+
* was lost is not answered again by the next one's key.
|
|
3015
|
+
*/
|
|
3016
|
+
const reopenCardPage = async (id) => {
|
|
3017
|
+
const cur = store.get(id);
|
|
3018
|
+
const p = cur?.prepaid;
|
|
3019
|
+
if (!cur || !p || p.method !== 'card' || cur.status !== 'awaiting-payment' || p.sessionId)
|
|
3020
|
+
return { why: 'the booking is not waiting on a card page that is gone' };
|
|
3021
|
+
const attempt = (p.reopened ?? 0) + 1;
|
|
3022
|
+
patch(id, {}, { reopened: attempt });
|
|
3023
|
+
const payBy = now() + payWithin;
|
|
3024
|
+
try {
|
|
3025
|
+
const opened = await openCardPage(cur, payBy, attempt);
|
|
3026
|
+
const row = patch(id, {}, { sessionId: opened.sessionId, url: opened.url, payBy });
|
|
3027
|
+
return {
|
|
3028
|
+
url: opened.url,
|
|
3029
|
+
by: deadlineAt(row?.prepaid?.payBy ?? payBy, cur.tz, 'en'),
|
|
3030
|
+
};
|
|
3031
|
+
}
|
|
3032
|
+
catch (err) {
|
|
3033
|
+
console.error(`[huazhu prepaid ${id}] the card page closed for UnionPay's could not be opened again: ${err.message}`);
|
|
3034
|
+
return { why: err.message };
|
|
3035
|
+
}
|
|
3036
|
+
};
|
|
2689
3037
|
/**
|
|
2690
3038
|
* Pay a booking waiting for payment with UnionPay instead of the card page
|
|
2691
3039
|
* (robin, 2026-10-15). The card page is closed first - paid on it in the
|
|
@@ -2801,11 +3149,21 @@ export const createHuazhuHotels = (opts) => {
|
|
|
2801
3149
|
// lapses (paid on it meanwhile, it goes on as that payment).
|
|
2802
3150
|
if (said === 'price-changed' || said === 'sold-out') {
|
|
2803
3151
|
if (p.sessionId) {
|
|
2804
|
-
|
|
2805
|
-
|
|
3152
|
+
// ⚠️ THE STAY LAPSES ONLY ONCE THE CARD PAGE IS SEEN CLOSED
|
|
3153
|
+
// (2026-10-21 re-audit, T2-1): this branch swallowed a close that
|
|
3154
|
+
// got no answer and lapsed the stay anyway, with the Stripe page
|
|
3155
|
+
// still open - paid there, nothing ever looked at that session
|
|
3156
|
+
// again (an orphan payment). Closed lapses it; paid goes on as
|
|
3157
|
+
// that payment; anything else changes NOTHING (session and url
|
|
3158
|
+
// kept, as on the main path below and in cancelBooking), and the
|
|
3159
|
+
// prepaid pass closes the page when it is past its time.
|
|
3160
|
+
const page = await closePage(p.sessionId);
|
|
3161
|
+
if (page.state === 'paid') {
|
|
2806
3162
|
await settlePrepaid(row);
|
|
2807
3163
|
return { kind: 'paid-on-card', booking: publicBooking(store.get(row.id) ?? row) };
|
|
2808
3164
|
}
|
|
3165
|
+
if (page.state === 'unanswered')
|
|
3166
|
+
return refused(`Huazhu will not take ${row.hotelName} - ${title} paid by card now (${said === 'sold-out' ? 'no rooms are left for those nights' : 'its price moved since it was shown'}), but the card page could not be closed just now (${page.why}). Nothing changed - the card page may still be open and still takes the payment. Ask again to close it.`, 'unreachable');
|
|
2809
3167
|
}
|
|
2810
3168
|
patch(row.id, { status: 'expired' }, { sessionId: undefined, url: undefined, lapsedAt: now() });
|
|
2811
3169
|
return refused(`Nothing was booked or charged: ${refusalWords(err, `${row.hotelName} - ${title}`, p.currency)}`, said);
|
|
@@ -2820,19 +3178,20 @@ export const createHuazhuHotels = (opts) => {
|
|
|
2820
3178
|
}
|
|
2821
3179
|
// Huazhu said yes: now the card page is closed, so it cannot be paid
|
|
2822
3180
|
// after this.
|
|
3181
|
+
let cardPageClosed = false;
|
|
2823
3182
|
if (p.method !== 'unionpay' && p.sessionId) {
|
|
2824
|
-
let
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
return refused(`The card page could not be closed (${
|
|
2830
|
-
|
|
2831
|
-
if (state === 'paid') {
|
|
3183
|
+
// Only a page seen `closed` is let go of (2026-10-21 re-audit, T2-7: an
|
|
3184
|
+
// `open` answer used to pass as closed, and UnionPay's page was opened
|
|
3185
|
+
// beside a card page that could still be paid).
|
|
3186
|
+
const page = await closePage(p.sessionId);
|
|
3187
|
+
if (page.state === 'unanswered')
|
|
3188
|
+
return refused(`The card page could not be closed (${page.why}). Nothing changed - the card page still takes the payment, or ask again.`, 'unreachable');
|
|
3189
|
+
if (page.state === 'paid') {
|
|
2832
3190
|
await settlePrepaid(row);
|
|
2833
3191
|
return { kind: 'paid-on-card', booking: publicBooking(store.get(row.id) ?? row) };
|
|
2834
3192
|
}
|
|
2835
3193
|
patch(row.id, {}, { sessionId: undefined, url: undefined });
|
|
3194
|
+
cardPageClosed = true;
|
|
2836
3195
|
}
|
|
2837
3196
|
const names = hotelNamesOf(row.hotelId);
|
|
2838
3197
|
const nights = row.quote.lines[0]?.nights ?? 1;
|
|
@@ -2856,7 +3215,21 @@ export const createHuazhuHotels = (opts) => {
|
|
|
2856
3215
|
// calls it off (2026-10-16 audit, P2).
|
|
2857
3216
|
if (err instanceof UnionPayError && err.left)
|
|
2858
3217
|
raise(row.id, `UnionPay's page could not be opened, and the payment the gateway opened for it (${err.left}) could not be called off - it charges nothing, but cancel it at the gateway (POST /acp/<merchant>/checkout_sessions/${err.left}/cancel)`);
|
|
2859
|
-
|
|
3218
|
+
const said = `UnionPay's page could not be opened (${err.message}).`;
|
|
3219
|
+
// ⚠️ NEVER A BOOKING WAITING FOR A PAYMENT WITH NOWHERE TO PAY IT
|
|
3220
|
+
// (2026-10-21 re-audit, T2-6, R2). The card page was closed above, and
|
|
3221
|
+
// opening UnionPay's first and closing the card page after would leave
|
|
3222
|
+
// two pages that can be paid at once - so the order stays, and when
|
|
3223
|
+
// UnionPay's page does not come the card page is opened again: a new
|
|
3224
|
+
// page, under a key of its own, for the same booking and total.
|
|
3225
|
+
if (cardPageClosed) {
|
|
3226
|
+
const back = await reopenCardPage(row.id);
|
|
3227
|
+
if (back.url)
|
|
3228
|
+
return refused(`${said} The card page was closed to make way for it, and a new card page is open: ${back.url} - pay on it by ${back.by} hotel time. Nothing was charged.`, 'unreachable');
|
|
3229
|
+
const left = store.get(row.id) ?? row;
|
|
3230
|
+
return refused(`${said} The card page that was closed to make way for it could not be opened again either (${back.why}), so this stay has no page to pay on now. Nothing was charged and nothing is booked: ask again to try UnionPay once more - or, left alone, it lapses by ${deadlineAt(left.prepaid?.payBy ?? now(), left.tz, 'en')} hotel time and the stay can be asked for again.`, 'unreachable');
|
|
3231
|
+
}
|
|
3232
|
+
return refused(`${said} Nothing was charged - ask again.`, 'unreachable');
|
|
2860
3233
|
}
|
|
2861
3234
|
const updated = patch(row.id, { paymentType: 'CCPN' }, {
|
|
2862
3235
|
method: 'unionpay',
|
|
@@ -2870,6 +3243,14 @@ export const createHuazhuHotels = (opts) => {
|
|
|
2870
3243
|
};
|
|
2871
3244
|
/** When each payment page last moved its booking on, for the page a paid guest lands on. */
|
|
2872
3245
|
const returned = new Map();
|
|
3246
|
+
/**
|
|
3247
|
+
* What each unpaid payment page was last asked, and when - for the landing
|
|
3248
|
+
* page's read-only look (2026-10-21 re-audit, T2-9). The answer is the
|
|
3249
|
+
* pending read itself, so requests at the same moment share one call.
|
|
3250
|
+
*/
|
|
3251
|
+
const looked = new Map();
|
|
3252
|
+
/** One read of a page's state in this long - the landing page's step has the same limit. */
|
|
3253
|
+
const LOOK_EVERY_MS = 10_000;
|
|
2873
3254
|
/** What the landing page says of a booking - never the guest's name, email or phone. */
|
|
2874
3255
|
const returnOf = (row) => {
|
|
2875
3256
|
const p = row.prepaid;
|
|
@@ -2956,7 +3337,30 @@ export const createHuazhuHotels = (opts) => {
|
|
|
2956
3337
|
row.prepaid?.method !== 'unionpay' &&
|
|
2957
3338
|
row.prepaid?.sessionId &&
|
|
2958
3339
|
checkout) {
|
|
2959
|
-
|
|
3340
|
+
// ⚠️ AT MOST ONE READ OF STRIPE A PAGE IN 10 SECONDS (2026-10-21
|
|
3341
|
+
// re-audit, T2-9). Anybody holding a card page's address could open this
|
|
3342
|
+
// page as often as they liked, and every open was a call to the Stripe
|
|
3343
|
+
// account all the guests of this connector share - and its rate limit.
|
|
3344
|
+
// A request within 10 seconds of the last read uses that read.
|
|
3345
|
+
const sid = row.prepaid.sessionId;
|
|
3346
|
+
const last = looked.get(sid);
|
|
3347
|
+
const fresh = !last || at - last.at >= LOOK_EVERY_MS;
|
|
3348
|
+
const read = fresh
|
|
3349
|
+
? checkout.state(sid).catch(() => undefined)
|
|
3350
|
+
: last.page;
|
|
3351
|
+
if (fresh) {
|
|
3352
|
+
looked.set(sid, { at, page: read });
|
|
3353
|
+
if (looked.size > 1_000)
|
|
3354
|
+
for (const [k, v] of looked)
|
|
3355
|
+
if (at - v.at >= LOOK_EVERY_MS)
|
|
3356
|
+
looked.delete(k);
|
|
3357
|
+
}
|
|
3358
|
+
let page = await read;
|
|
3359
|
+
// ⚠️ A READ THAT SAID "OPEN" A MOMENT AGO IS NOT "NOT PAID" NOW: the guest
|
|
3360
|
+
// may have paid since, and is told "not paid" only by a page just read
|
|
3361
|
+
// (2026-10-16 audit, P0-D). Said as `checking`, the page looks again.
|
|
3362
|
+
if (!fresh && page === 'open')
|
|
3363
|
+
page = undefined;
|
|
2960
3364
|
if (page === 'paid')
|
|
2961
3365
|
return { ...told, state: 'booking' };
|
|
2962
3366
|
// Cannot be read just now: the page looks again (香格里拉's `checking`).
|
|
@@ -2996,6 +3400,8 @@ export const createHuazhuHotels = (opts) => {
|
|
|
2996
3400
|
console.error(`[huazhu prepaid ${row.id}] the pass's step failed: ${err.message}`);
|
|
2997
3401
|
}
|
|
2998
3402
|
}
|
|
3403
|
+
// Then the look at paid stays that nobody has asked about (T2-5).
|
|
3404
|
+
await watchPass();
|
|
2999
3405
|
},
|
|
3000
3406
|
imageDomains() {
|
|
3001
3407
|
const hosts = new Set();
|
|
@@ -3683,9 +4089,21 @@ export const createHuazhuHotels = (opts) => {
|
|
|
3683
4089
|
const facts = { ...(feeIsCap ? { feeIsCap } : {}), ...stepsTold(b, steps) };
|
|
3684
4090
|
// A test booking says it is one (2026-10-16 audit, P2): Huazhu's test
|
|
3685
4091
|
// system charges nobody and books nothing anyone stays in.
|
|
3686
|
-
|
|
3687
|
-
|
|
3688
|
-
|
|
4092
|
+
// ⚠️ THE SIMULATION SAYS IT TOO (2026-10-21 re-audit, T2-12): this was
|
|
4093
|
+
// said for Huazhu's test system only, so in the sandbox the "nothing
|
|
4094
|
+
// comes back" and "cannot be cancelled" previews said nothing of being a
|
|
4095
|
+
// test - while the "all of it back" one did.
|
|
4096
|
+
const testNote = opts.sandbox
|
|
4097
|
+
? ' This is a test booking in the simulated Huazhu: nothing is charged to anyone.'
|
|
4098
|
+
: opts.testEnvironment
|
|
4099
|
+
? " This is a test booking in Huazhu's test system: nothing is charged to anyone."
|
|
4100
|
+
: '';
|
|
4101
|
+
// A booking paid here in advance, in either: the money is a test too.
|
|
4102
|
+
const paidTestNote = opts.sandbox
|
|
4103
|
+
? ' This is a test booking in the simulated Huazhu, paid in the gateway’s test mode: no real money moves.'
|
|
4104
|
+
: opts.testEnvironment
|
|
4105
|
+
? ' This is a test booking in Huazhu’s test system, paid in test mode: no real money moves.'
|
|
4106
|
+
: '';
|
|
3689
4107
|
// Paid with UnionPay: Huazhu charged the one-time card, and gives back
|
|
3690
4108
|
// to it what its terms allow - nothing passes through here (audit P0-C:
|
|
3691
4109
|
// it was told as "paid at the hotel, nothing paid through here").
|
|
@@ -3716,10 +4134,10 @@ export const createHuazhuHotels = (opts) => {
|
|
|
3716
4134
|
fee: money(forbidden ? 0 : fee, cur),
|
|
3717
4135
|
refund: back ? b.quote.total : zero,
|
|
3718
4136
|
rule: forbidden
|
|
3719
|
-
? `This rate cannot be cancelled now. ${rule} The ${formatMoney(b.quote.total)} paid stays paid
|
|
4137
|
+
? `This rate cannot be cancelled now. ${rule} The ${formatMoney(b.quote.total)} paid stays paid.${paidTestNote}`
|
|
3720
4138
|
: back
|
|
3721
|
-
? `${rule} Cancelling now gives back all ${formatMoney(b.quote.total)} paid, to the card it was paid with.${
|
|
3722
|
-
: `${rule} Cancelling now gives nothing back: the fee is the whole stay.${
|
|
4139
|
+
? `${rule} Cancelling now gives back all ${formatMoney(b.quote.total)} paid, to the card it was paid with.${paidTestNote}`
|
|
4140
|
+
: `${rule} Cancelling now gives nothing back: the fee is the whole stay.${paidTestNote}`,
|
|
3723
4141
|
...(freeUntil ? { freeUntil: new Date(freeUntil).toISOString() } : {}),
|
|
3724
4142
|
cancellable: !forbidden,
|
|
3725
4143
|
...facts,
|
|
@@ -3767,15 +4185,25 @@ export const createHuazhuHotels = (opts) => {
|
|
|
3767
4185
|
? 'The guest has just confirmed on UnionPay, so the stay is being booked with Huazhu now. Ask again in a moment to cancel it.'
|
|
3768
4186
|
: 'UnionPay could not be reached to say whether the guest confirmed. Ask again in a moment - nothing is booked until it can.');
|
|
3769
4187
|
}
|
|
4188
|
+
// ⚠️ NO PAGE, NOTHING THAT CAN BE PAID (2026-10-21, T2-6): a card page
|
|
4189
|
+
// closed to make way for UnionPay's and not opened again leaves a stay
|
|
4190
|
+
// waiting with nowhere to pay it. Cancelling it called it "cancelled"
|
|
4191
|
+
// and changed nothing; with no page there is nothing to close, so it
|
|
4192
|
+
// is called off as a closed page is.
|
|
4193
|
+
if (b.status === 'awaiting-payment' && b.prepaid && !b.prepaid.sessionId && checkout) {
|
|
4194
|
+
return publicBooking(patch(b.id, { status: 'expired' }, { lapsedAt: now(), url: undefined }) ?? b);
|
|
4195
|
+
}
|
|
3770
4196
|
// Not paid yet: the page is closed, so it cannot be paid after this.
|
|
3771
4197
|
if (b.status === 'awaiting-payment' && b.prepaid?.sessionId && checkout) {
|
|
3772
|
-
const
|
|
3773
|
-
if (state === 'closed') {
|
|
4198
|
+
const page = await closePage(b.prepaid.sessionId);
|
|
4199
|
+
if (page.state === 'closed') {
|
|
3774
4200
|
return publicBooking(patch(b.id, { status: 'expired' }, { lapsedAt: now(), url: undefined }) ?? b);
|
|
3775
4201
|
}
|
|
3776
4202
|
// ⚠️ NO ANSWER IS NOT "PAID" (audit P0-C): the page may well still be
|
|
3777
|
-
// open and unpaid.
|
|
3778
|
-
|
|
4203
|
+
// open and unpaid. That includes a close that answered `open`
|
|
4204
|
+
// (2026-10-21 re-audit, T2-7: it was read as "paid in the last
|
|
4205
|
+
// second", settled, and told to the guest as paid).
|
|
4206
|
+
if (page.state === 'unanswered')
|
|
3779
4207
|
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.');
|
|
3780
4208
|
// Paid in the last second: it is booked now, and cancelled as one.
|
|
3781
4209
|
await settlePrepaid(b);
|
|
@@ -3815,13 +4243,55 @@ export const createHuazhuHotels = (opts) => {
|
|
|
3815
4243
|
if (err instanceof HuazhuRefusal && err.code === 5402) {
|
|
3816
4244
|
// Cancelled already - by this guest's own earlier try whose answer
|
|
3817
4245
|
// was lost, most likely: owed as that cancellation was (audit P0-B).
|
|
3818
|
-
|
|
4246
|
+
const when = b.cancelledAt ?? new Date(now()).toISOString();
|
|
4247
|
+
const p = b.prepaid;
|
|
4248
|
+
// Money held here that nobody has decided about yet.
|
|
4249
|
+
const held = !!p?.paidAt && p.method !== 'unionpay' && !p.refund && !p.kept;
|
|
4250
|
+
const constant = held && !b.cancellationFee
|
|
4251
|
+
? constantFee(b, Date.parse(b.createdAt), Date.parse(when))
|
|
4252
|
+
: undefined;
|
|
4253
|
+
if (!held || b.cancellationFee) {
|
|
4254
|
+
// Nothing to decide here: the fee is the hotel's to charge, and
|
|
4255
|
+
// what is shown is only what it would be.
|
|
4256
|
+
cancelled(b.cancellationFee ?? money(feeNow(b).fee, cur), when);
|
|
4257
|
+
}
|
|
4258
|
+
else if (constant !== undefined) {
|
|
4259
|
+
// ⚠️ THE SAME FEE AT EVERY MOMENT UP TO NOW: whenever it was
|
|
4260
|
+
// cancelled, this was the fee - so not knowing when does not
|
|
4261
|
+
// matter. Free is all of it back; the whole stay is kept; a part
|
|
4262
|
+
// is kept and the rest goes back - as a cancellation made here
|
|
4263
|
+
// is owed (`owedOnCancel`).
|
|
4264
|
+
cancelled(money(constant, cur), when);
|
|
4265
|
+
}
|
|
4266
|
+
else {
|
|
4267
|
+
// ⚠️ CANCELLED, BUT WHEN IS NOT KNOWN, AND THE FEE DEPENDS ON WHEN
|
|
4268
|
+
// (2026-10-21 re-audit, T2-8). The fee was worked out as of NOW,
|
|
4269
|
+
// so a cancellation made inside the free period whose answer was
|
|
4270
|
+
// lost, and asked again after it, wrote the guest's money off.
|
|
4271
|
+
// Huazhu's order detail carries no cancellation time (only its
|
|
4272
|
+
// status and the booking's own time), so there is nothing to
|
|
4273
|
+
// work it out from and nothing is guessed: where the fee was not
|
|
4274
|
+
// the same at every moment since booking (`constantFee`), the
|
|
4275
|
+
// booking is cancelled, no refund is opened and none is kept,
|
|
4276
|
+
// and a person judges it with Huazhu.
|
|
4277
|
+
patch(b.id, {
|
|
4278
|
+
status: 'cancelled',
|
|
4279
|
+
hotelStatus: 'HOTEL_CANCEL',
|
|
4280
|
+
cancelledAt: when,
|
|
4281
|
+
});
|
|
4282
|
+
const paid = formatMoney(b.quote.total);
|
|
4283
|
+
raise(b.id, `Huazhu answered 5402 (already cancelled) to the cancellation of ${b.distributorResId}, and does not say when it was cancelled, and what cancelling cost was not the same at every moment since the booking was made (the free period ended before this was found, or the terms cannot be read), so what of the ${paid} paid (gateway session ${p?.sessionId}) comes back - whole if it was cancelled inside the free period, less the fee if after - is to be judged with Huazhu; nothing was refunded or kept`);
|
|
4284
|
+
throw new Error(`Huazhu says ${b.confirmationCode} is already cancelled, but not when - and what cancelling costs depends on when - so what comes back of the ${paid} paid cannot be worked out here. The booking is shown as cancelled; nothing has been refunded or kept yet, and a person here is settling it with Huazhu.`);
|
|
4285
|
+
}
|
|
3819
4286
|
}
|
|
3820
4287
|
else if (err instanceof HuazhuRefusal && (err.code === 5401 || err.code === 5405)) {
|
|
3821
4288
|
throw new Error(err.code === 5405
|
|
3822
4289
|
? 'Huazhu says this stay has already started, so it cannot be cancelled.'
|
|
3823
4290
|
: 'Huazhu says this booking cannot be cancelled.');
|
|
3824
4291
|
}
|
|
4292
|
+
else if (err instanceof HuazhuTooBusy) {
|
|
4293
|
+
throw new Error('Huazhu was not asked: this connector is calling Huazhu at its own limit just now, so nothing was cancelled. Ask again in a moment.');
|
|
4294
|
+
}
|
|
3825
4295
|
else if (err instanceof HuazhuUnreachable) {
|
|
3826
4296
|
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.');
|
|
3827
4297
|
}
|