@curless/hotel-storefront 0.39.1 → 0.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/backend.d.ts +3 -1
- 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 +391 -82
- package/dist/huazhu/backend.js.map +1 -1
- package/dist/huazhu/bookings.d.ts +24 -0
- package/dist/huazhu/bookings.d.ts.map +1 -1
- package/dist/huazhu/bookings.js +17 -1
- package/dist/huazhu/bookings.js.map +1 -1
- package/dist/huazhu/catalog.d.ts +9 -1
- package/dist/huazhu/catalog.d.ts.map +1 -1
- package/dist/huazhu/catalog.js +12 -3
- package/dist/huazhu/catalog.js.map +1 -1
- package/dist/huazhu/client.js +1 -1
- package/dist/huazhu/client.js.map +1 -1
- package/dist/huazhu/english.d.ts.map +1 -1
- package/dist/huazhu/english.js +5 -2
- package/dist/huazhu/english.js.map +1 -1
- package/dist/huazhu/fake.d.ts +3 -1
- package/dist/huazhu/fake.d.ts.map +1 -1
- package/dist/huazhu/fake.js +19 -1
- package/dist/huazhu/fake.js.map +1 -1
- package/dist/huazhu/labels.d.ts.map +1 -1
- package/dist/huazhu/labels.js +17 -0
- package/dist/huazhu/labels.js.map +1 -1
- package/dist/huazhu/make.d.ts.map +1 -1
- package/dist/huazhu/make.js +31 -7
- package/dist/huazhu/make.js.map +1 -1
- package/dist/huazhu/sellable.d.ts +3 -1
- package/dist/huazhu/sellable.d.ts.map +1 -1
- package/dist/huazhu/sellable.js +14 -4
- package/dist/huazhu/sellable.js.map +1 -1
- package/dist/payments/checkout.d.ts +15 -2
- package/dist/payments/checkout.d.ts.map +1 -1
- package/dist/payments/checkout.js +4 -1
- package/dist/payments/checkout.js.map +1 -1
- package/dist/payments/fake-stripe.d.ts +1 -0
- package/dist/payments/fake-stripe.d.ts.map +1 -1
- package/dist/payments/fake-stripe.js +20 -4
- package/dist/payments/fake-stripe.js.map +1 -1
- package/dist/payments/fake-unionpay.d.ts +3 -0
- package/dist/payments/fake-unionpay.d.ts.map +1 -1
- package/dist/payments/fake-unionpay.js +20 -2
- package/dist/payments/fake-unionpay.js.map +1 -1
- package/dist/payments/stripe-checkout.d.ts +2 -1
- package/dist/payments/stripe-checkout.d.ts.map +1 -1
- package/dist/payments/stripe-checkout.js +11 -4
- package/dist/payments/stripe-checkout.js.map +1 -1
- package/dist/payments/unionpay.d.ts +12 -1
- package/dist/payments/unionpay.d.ts.map +1 -1
- package/dist/payments/unionpay.js +10 -3
- package/dist/payments/unionpay.js.map +1 -1
- package/dist/server.d.ts +2 -2
- package/dist/server.js +2 -2
- package/dist/types.d.ts +8 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/huazhu/backend.js
CHANGED
|
@@ -8,14 +8,14 @@ import { BOOKING_LIMITS, DAY_MS, HOUR_MS, WindowCounter } from '../limits.js';
|
|
|
8
8
|
import { formatMoney, minorFactor, money, multiplyMoney, toMinor } from '../money.js';
|
|
9
9
|
import { UnionPayError, expiryMMyy, lastFour, } from '../payments/unionpay.js';
|
|
10
10
|
import { MAX_ROOMS_PER_LINE, quoteLineKey } from '../trip.js';
|
|
11
|
-
import { IN_REQUEST_AT_MS, StoreWriteError, nextCheckAt, statusOf, } from './bookings.js';
|
|
11
|
+
import { IN_REQUEST_AT_MS, NOT_FOUND, StoreWriteError, nextCheckAt, statusOf, } from './bookings.js';
|
|
12
12
|
import { describePolicy, feeFor, freeUntilOf, nightAuditOf, parseCancelPolicy, penaltyAt, refundableFrom, } from './cancel-policy.js';
|
|
13
13
|
import { haystackOf, inPlace, namesOf, roomPhoto, toHotel } from './catalog.js';
|
|
14
14
|
import { HuazhuRefusal, HuazhuUnreachable } from './client.js';
|
|
15
15
|
import { bedNameEn, offerNameEn, rateNameEn, roomNameEn } from './english.js';
|
|
16
16
|
import { rateLabels } from './labels.js';
|
|
17
17
|
import { ariRanges, bookableNow, clockAt, goneWhy, hourlyWindow, inStayWindow, offeredAtAll, sell, } from './sellable.js';
|
|
18
|
-
import { BOOK_EXISTS, BOOK_REFUSALS, CODES, } from './wire.js';
|
|
18
|
+
import { BOOK_EXISTS, BOOK_REFUSALS, CODES, PAYMENT_FOR_GUARANTEE, } from './wire.js';
|
|
19
19
|
export const HUAZHU_CAPABILITIES = {
|
|
20
20
|
members: false,
|
|
21
21
|
extras: false,
|
|
@@ -92,14 +92,16 @@ const WINDOW = {
|
|
|
92
92
|
'1': 'Window',
|
|
93
93
|
'2': 'Some rooms without a window',
|
|
94
94
|
'3': 'No window',
|
|
95
|
-
|
|
95
|
+
// Huazhu's dictionary says 内景 - an inner view - and no more: no courtyard
|
|
96
|
+
// was ever said (2026-10-16 audit, P2).
|
|
97
|
+
'4': 'Inner-view window',
|
|
96
98
|
};
|
|
97
99
|
/** The same, as Huazhu's own dictionary says it - for a connector that speaks Chinese. */
|
|
98
100
|
const WINDOW_ZH = {
|
|
99
101
|
'1': '有窗',
|
|
100
102
|
'2': '部分有窗',
|
|
101
103
|
'3': '无窗',
|
|
102
|
-
'4': '
|
|
104
|
+
'4': '内景窗',
|
|
103
105
|
};
|
|
104
106
|
/** A rate's meal in Chinese, by its code; room only is 不含早. */
|
|
105
107
|
const MEAL_ZH = {
|
|
@@ -686,12 +688,25 @@ export const createHuazhuHotels = (opts) => {
|
|
|
686
688
|
/** Whether a hotel sells any room by the hour on its today - from its products, no prices. */
|
|
687
689
|
const sellsByTheHourToday = async (hotelId) => (await productsOf(hotelId)).some((p) => p.checkinType === 'HOUR' && inStayWindow(p.stayWindow, today()));
|
|
688
690
|
const ariCache = new Map();
|
|
691
|
+
/**
|
|
692
|
+
* A hotel Huazhu answered "too many requests" (2009) for, let be until then -
|
|
693
|
+
* a minute, doubling to ten while it goes on (2026-10-16 audit, P1-11: asked
|
|
694
|
+
* again at once, every guest's ask). Prices last read are used meanwhile; a
|
|
695
|
+
* booking's own fresh ask is not held back.
|
|
696
|
+
*/
|
|
697
|
+
const ariBackoff = new Map();
|
|
689
698
|
/** Every product's prices, rules and rooms left, one element per date from `start` to `end`. */
|
|
690
699
|
const ariFor = async (hotelId, start, end, fresh = false) => {
|
|
691
700
|
const key = `${hotelId}|${start}|${end}`;
|
|
692
701
|
const hit = ariCache.get(key);
|
|
693
702
|
if (!fresh && hit && now() - hit.at < ariTtl)
|
|
694
703
|
return hit.value;
|
|
704
|
+
const held = ariBackoff.get(hotelId);
|
|
705
|
+
if (!fresh && held && now() < held.until) {
|
|
706
|
+
if (hit)
|
|
707
|
+
return hit.value;
|
|
708
|
+
throw new HuazhuRefusal(CODES.rateLimited, 'Too many requests', '', '/ari/daily/offers');
|
|
709
|
+
}
|
|
695
710
|
const ranges = ariRanges(start, end);
|
|
696
711
|
const parts = await Promise.all(ranges.map(async (r) => {
|
|
697
712
|
const [offers, inventory] = await Promise.all([
|
|
@@ -713,7 +728,14 @@ export const createHuazhuHotels = (opts) => {
|
|
|
713
728
|
offers: offers.dailyOffers ?? [],
|
|
714
729
|
inventory: inventory.dailyInventories ?? [],
|
|
715
730
|
};
|
|
716
|
-
}))
|
|
731
|
+
})).catch((err) => {
|
|
732
|
+
if (err instanceof HuazhuRefusal && err.code === CODES.rateLimited) {
|
|
733
|
+
const wait = Math.min((ariBackoff.get(hotelId)?.wait ?? 30_000) * 2, 10 * 60_000);
|
|
734
|
+
ariBackoff.set(hotelId, { until: now() + wait, wait });
|
|
735
|
+
}
|
|
736
|
+
throw err;
|
|
737
|
+
});
|
|
738
|
+
ariBackoff.delete(hotelId);
|
|
717
739
|
const out = new Map();
|
|
718
740
|
const keys = new Set(parts.flatMap((p) => p.offers.map((o) => roomIdOf(o))));
|
|
719
741
|
for (const k of keys) {
|
|
@@ -1008,9 +1030,16 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1008
1030
|
const fails = priced.map((p) => p.res).filter((r) => !r.ok);
|
|
1009
1031
|
if (fails.length > 0 && fails.every((f) => f.kind === 'party'))
|
|
1010
1032
|
return partyNeedsOf(guests, largestOf(fails));
|
|
1033
|
+
if (fails.some((f) => f.kind === 'partial-refund'))
|
|
1034
|
+
return {
|
|
1035
|
+
why: "This hotel's rooms by the hour are paid in advance on terms that keep part of the price if cancelled - and a stay paid in advance here goes back whole or not at all.",
|
|
1036
|
+
code: 'partial-refund',
|
|
1037
|
+
};
|
|
1011
1038
|
if (fails.some((f) => f.kind === 'prepaid'))
|
|
1012
1039
|
return {
|
|
1013
|
-
why:
|
|
1040
|
+
why: prepaidType
|
|
1041
|
+
? "This hotel's rooms by the hour are sold only on payment terms this connector cannot take."
|
|
1042
|
+
: "This hotel's rooms by the hour are paid in advance, and this connector books rates paid at the hotel.",
|
|
1014
1043
|
code: 'prepaid-only',
|
|
1015
1044
|
};
|
|
1016
1045
|
const said = (re) => fails
|
|
@@ -1052,9 +1081,18 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1052
1081
|
// find a rate paid at the hotel. At Huazhu's test hotel (2026-10-15) 28
|
|
1053
1082
|
// rates were prepaid and 4 sold out, and the guest was told only that
|
|
1054
1083
|
// nothing could be booked (总线程).
|
|
1084
|
+
// Paid in advance here, but on terms that keep part of the stay - true
|
|
1085
|
+
// of a "one night's fee" rate over two nights or more (audit P1-9).
|
|
1086
|
+
if (kinds.has('partial-refund') && !kinds.has('party') && !kinds.has('too-many-rooms'))
|
|
1087
|
+
return {
|
|
1088
|
+
why: `The rooms still open here for those nights are paid in advance on terms that keep part of the stay if cancelled - and a stay paid in advance here goes back whole or not at all, so they are not sold for ${nightsBetween(stay.checkIn, stay.checkOut)} nights. A shorter stay may be.`,
|
|
1089
|
+
code: 'partial-refund',
|
|
1090
|
+
};
|
|
1055
1091
|
if (kinds.has('prepaid') && !kinds.has('party') && !kinds.has('too-many-rooms'))
|
|
1056
1092
|
return {
|
|
1057
|
-
why:
|
|
1093
|
+
why: prepaidType
|
|
1094
|
+
? 'Every room still open here for those nights is sold only on payment terms this connector cannot take.'
|
|
1095
|
+
: 'Every room still open here for those nights is paid in advance, and this connector books rates paid at the hotel.',
|
|
1058
1096
|
code: 'prepaid-only',
|
|
1059
1097
|
};
|
|
1060
1098
|
if (only('sold-out'))
|
|
@@ -1327,6 +1365,16 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1327
1365
|
}
|
|
1328
1366
|
: {}),
|
|
1329
1367
|
...(refund ? { refundReason: refund.reason } : {}),
|
|
1368
|
+
...(refund?.amountMinor !== undefined && refund.amountMinor < b.quote.total.minor
|
|
1369
|
+
? { refundAmount: money(refund.amountMinor, b.quote.total.currency) }
|
|
1370
|
+
: {}),
|
|
1371
|
+
// Opened and refused, or never opened after every try: a person sees to it (audit P1-1).
|
|
1372
|
+
...(refund &&
|
|
1373
|
+
(refund.status === 'failed' ||
|
|
1374
|
+
refund.status === 'rejected' ||
|
|
1375
|
+
(!refund.requestId && (refund.tries ?? 0) >= 8))
|
|
1376
|
+
? { refundStuck: true }
|
|
1377
|
+
: {}),
|
|
1330
1378
|
};
|
|
1331
1379
|
};
|
|
1332
1380
|
const publicBooking = (b) => ({
|
|
@@ -1353,7 +1401,14 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1353
1401
|
* before 0.27 showed it as its code, and is opened by it only in the
|
|
1354
1402
|
* conversation that made it.
|
|
1355
1403
|
*/
|
|
1356
|
-
|
|
1404
|
+
/**
|
|
1405
|
+
* What a booking answers to with its email - the code the guest was given.
|
|
1406
|
+
* ⚠️ NOT `distributorResId` (2026-10-16 audit, P2): no guest is ever told
|
|
1407
|
+
* it, and it rides on the payment's metadata and UnionPay's item - with the
|
|
1408
|
+
* email beside it on the payment page, anyone who could see those could
|
|
1409
|
+
* have opened and cancelled the booking.
|
|
1410
|
+
*/
|
|
1411
|
+
const codesOf = (b) => [b.confirmationCode !== b.resId ? b.confirmationCode : undefined]
|
|
1357
1412
|
.filter((x) => !!x)
|
|
1358
1413
|
.map((x) => x.toLowerCase());
|
|
1359
1414
|
const findOwn = (ref, who) => {
|
|
@@ -1540,13 +1595,16 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1540
1595
|
if (who.ip && requestsByIp.count(who.ip) >= limits.perIpPerHour) {
|
|
1541
1596
|
throw new Error('Nothing was booked. Too many bookings have been asked for from this network in the last hour - try again later.');
|
|
1542
1597
|
}
|
|
1543
|
-
const madeToday = store
|
|
1544
|
-
.all()
|
|
1545
|
-
.filter((b) => now() - Date.parse(b.createdAt) < DAY_MS &&
|
|
1598
|
+
const madeToday = store.all().filter((b) => now() - Date.parse(b.createdAt) < DAY_MS &&
|
|
1546
1599
|
(b.status === 'confirmed' ||
|
|
1547
1600
|
b.status === 'requested' ||
|
|
1548
1601
|
b.status === 'unconfirmed' ||
|
|
1549
|
-
b.status === 'cancelled'
|
|
1602
|
+
b.status === 'cancelled' ||
|
|
1603
|
+
// A stay paid in advance asked Huazhu (its prebook) before any
|
|
1604
|
+
// payment - waiting for one, or lapsed without, it counts too
|
|
1605
|
+
// (2026-10-16 audit, P2: unpaid asks were never counted).
|
|
1606
|
+
b.status === 'awaiting-payment' ||
|
|
1607
|
+
(b.status === 'expired' && !!b.prepaid))).length + slotsWhere(() => true);
|
|
1550
1608
|
if (madeToday + lines.length > limits.perDeploymentPerDay) {
|
|
1551
1609
|
// ⚠️ A CEILING HIT IS SOMETHING A PERSON SHOULD KNOW: either business
|
|
1552
1610
|
// is booming or somebody is filling hotels with names. Logged, and on
|
|
@@ -1609,6 +1667,41 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1609
1667
|
await refundOf(id, why);
|
|
1610
1668
|
return;
|
|
1611
1669
|
}
|
|
1670
|
+
// ⚠️ PREBOOKED AGAIN WHEN ITS TOKEN IS STALE (2026-10-16 audit, P2):
|
|
1671
|
+
// Huazhu's token holds 10 minutes, and a page may be paid 30 minutes
|
|
1672
|
+
// after the prebook. A price that moved, or no room left, is Huazhu's no
|
|
1673
|
+
// to this stay - taken as one (all of it back). No answer: booked
|
|
1674
|
+
// without a token - Huazhu checks the price at booking anyway.
|
|
1675
|
+
if (p.prebookAt === undefined || now() - p.prebookAt >= PREBOOK_FRESH_MS) {
|
|
1676
|
+
try {
|
|
1677
|
+
const out = await client.prebook({
|
|
1678
|
+
hotelId: row.hotelId,
|
|
1679
|
+
checkinType: row.hourly ? 'HOUR' : 'DAY',
|
|
1680
|
+
paymentType: card ? 'CCPN' : row.paymentType,
|
|
1681
|
+
reservations: [
|
|
1682
|
+
{
|
|
1683
|
+
roomTypeCode: row.roomTypeCode,
|
|
1684
|
+
rateCode: row.rateCode,
|
|
1685
|
+
...stayDatesOf(row),
|
|
1686
|
+
amountAfterTax: p.after,
|
|
1687
|
+
...(p.before ? { amountBeforeTax: p.before } : {}),
|
|
1688
|
+
currencyCode: p.currency,
|
|
1689
|
+
roomCount: row.rooms,
|
|
1690
|
+
adultCount: row.stay.guests,
|
|
1691
|
+
},
|
|
1692
|
+
],
|
|
1693
|
+
});
|
|
1694
|
+
patch(id, {}, { preBookToken: out?.preBookToken, prebookAt: now() });
|
|
1695
|
+
}
|
|
1696
|
+
catch (err) {
|
|
1697
|
+
if (err instanceof HuazhuRefusal) {
|
|
1698
|
+
await turnedDown(id, err, !!card);
|
|
1699
|
+
return;
|
|
1700
|
+
}
|
|
1701
|
+
patch(id, {}, { preBookToken: undefined });
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
const token = store.get(id)?.prepaid?.preBookToken;
|
|
1612
1705
|
const comment = commentOf(row.requests, row.guest.notes);
|
|
1613
1706
|
const request = {
|
|
1614
1707
|
hotelId: row.hotelId,
|
|
@@ -1645,16 +1738,37 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1645
1738
|
},
|
|
1646
1739
|
],
|
|
1647
1740
|
...(comment ? { guestComment: comment } : {}),
|
|
1648
|
-
...(
|
|
1741
|
+
...(token ? { preBookToken: token } : {}),
|
|
1649
1742
|
};
|
|
1650
1743
|
// ⚠️ SET BEFORE THE CALL, as every booking is: a process that dies in it
|
|
1651
|
-
// leaves a row the reconciler follows up.
|
|
1652
|
-
|
|
1744
|
+
// leaves a row the reconciler follows up. ⚠️ WRITTEN, OR NOT SENT
|
|
1745
|
+
// (2026-10-05 audit P1-8, kept on this path - 2026-10-16 audit P2): a
|
|
1746
|
+
// booking whose call is not written down could not be followed up. Left
|
|
1747
|
+
// waiting, paid; the next pass tries again.
|
|
1748
|
+
const current = store.get(id);
|
|
1749
|
+
if (!current)
|
|
1750
|
+
return;
|
|
1751
|
+
try {
|
|
1752
|
+
store.put({ ...current, status: 'requested', pendingSince: now() });
|
|
1753
|
+
}
|
|
1754
|
+
catch (err) {
|
|
1755
|
+
if (!(err instanceof StoreWriteError))
|
|
1756
|
+
throw err;
|
|
1757
|
+
raise(id, `the booking could not be written down before it was sent to Huazhu, so it was not sent (${err.message}) - it is tried again on the next pass`);
|
|
1758
|
+
return;
|
|
1759
|
+
}
|
|
1653
1760
|
let refusal;
|
|
1654
1761
|
let answer;
|
|
1655
1762
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
1656
1763
|
try {
|
|
1657
|
-
|
|
1764
|
+
reconciler.inFlight(id, true);
|
|
1765
|
+
let got;
|
|
1766
|
+
try {
|
|
1767
|
+
got = await client.book(request);
|
|
1768
|
+
}
|
|
1769
|
+
finally {
|
|
1770
|
+
reconciler.inFlight(id, false);
|
|
1771
|
+
}
|
|
1658
1772
|
if (got && typeof got.resId === 'string' && got.resId)
|
|
1659
1773
|
answer = got;
|
|
1660
1774
|
}
|
|
@@ -1680,21 +1794,24 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1680
1794
|
});
|
|
1681
1795
|
return;
|
|
1682
1796
|
}
|
|
1683
|
-
if (refusal
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1797
|
+
if (refusal)
|
|
1798
|
+
await turnedDown(id, refusal, !!card);
|
|
1799
|
+
// No answer: the reconciler's eight minutes decide it.
|
|
1800
|
+
};
|
|
1801
|
+
/**
|
|
1802
|
+
* Huazhu's no to a stay paid for, at its prebook or its booking. Paid with
|
|
1803
|
+
* UnionPay, the hotel charges the card, and a booking it did not make it
|
|
1804
|
+
* never charged - nothing here to give back. Paid on the card page (a price
|
|
1805
|
+
* that moved since the prebook is one of these, 5209), all of it back.
|
|
1806
|
+
*/
|
|
1807
|
+
const turnedDown = async (id, refusal, card) => {
|
|
1808
|
+
const said = `Huazhu did not take the booking: ${refusal.huazhuMessage} (${refusal.code})`;
|
|
1809
|
+
if (card) {
|
|
1810
|
+
patch(id, { status: 'failed', pendingSince: undefined }, { declined: said.replace(/\d(?:[ -]?\d){11,18}/g, '****') });
|
|
1689
1811
|
return;
|
|
1690
1812
|
}
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
// A price that moved since the prebook is one of these (5209): the
|
|
1694
|
-
// guest paid for a price Huazhu no longer gives, and gets it all back.
|
|
1695
|
-
await refundOf(id, `Huazhu did not take the booking: ${refusal.huazhuMessage} (${refusal.code})`);
|
|
1696
|
-
}
|
|
1697
|
-
// No answer: the reconciler's eight minutes decide it.
|
|
1813
|
+
store.update(id, { status: 'failed', pendingSince: undefined });
|
|
1814
|
+
await refundOf(id, said);
|
|
1698
1815
|
};
|
|
1699
1816
|
/**
|
|
1700
1817
|
* Gives a guest's money back, in full - the only refund the gateway makes.
|
|
@@ -1708,19 +1825,38 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1708
1825
|
return;
|
|
1709
1826
|
const tries = (p.refund?.tries ?? 0) + 1;
|
|
1710
1827
|
const at = p.refund?.at ?? now();
|
|
1828
|
+
// Said once: a retry sends what the first try sent, word for word - Stripe
|
|
1829
|
+
// refuses the same key with anything else, every time after (2026-10-16
|
|
1830
|
+
// audit, P2).
|
|
1831
|
+
const why = p.refund?.reason ?? reason;
|
|
1711
1832
|
try {
|
|
1712
1833
|
// Named by when it was decided: the same refund retried is the same refund.
|
|
1713
|
-
const r = await checkout.refund(p.sessionId,
|
|
1714
|
-
patch(id, {}, {
|
|
1715
|
-
|
|
1834
|
+
const r = await checkout.refund(p.sessionId, why, `${row.distributorResId}-${at}`, p.refund?.amountMinor);
|
|
1835
|
+
patch(id, {}, {
|
|
1836
|
+
refund: {
|
|
1837
|
+
reason: why,
|
|
1838
|
+
at,
|
|
1839
|
+
requestId: r.requestId,
|
|
1840
|
+
status: r.status,
|
|
1841
|
+
tries,
|
|
1842
|
+
triedAt: now(),
|
|
1843
|
+
...(p.refund?.amountMinor !== undefined ? { amountMinor: p.refund.amountMinor } : {}),
|
|
1844
|
+
},
|
|
1845
|
+
});
|
|
1846
|
+
console.error(`[huazhu prepaid ${id}] refund ${r.requestId} (${r.status}) of session ${p.sessionId}: ${why}`);
|
|
1716
1847
|
}
|
|
1717
1848
|
catch (err) {
|
|
1718
|
-
patch(id, {}, {
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1849
|
+
patch(id, {}, {
|
|
1850
|
+
refund: {
|
|
1851
|
+
reason: why,
|
|
1852
|
+
at,
|
|
1853
|
+
tries,
|
|
1854
|
+
triedAt: now(),
|
|
1855
|
+
...(p.refund?.amountMinor !== undefined ? { amountMinor: p.refund.amountMinor } : {}),
|
|
1856
|
+
},
|
|
1857
|
+
});
|
|
1858
|
+
if (tries >= 8)
|
|
1859
|
+
raise(id, `the guest paid (gateway session ${p.sessionId}) and the refund could not be opened`);
|
|
1724
1860
|
}
|
|
1725
1861
|
};
|
|
1726
1862
|
/**
|
|
@@ -1754,18 +1890,66 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1754
1890
|
};
|
|
1755
1891
|
if (byGuest && feeMinor >= b.quote.total.minor)
|
|
1756
1892
|
return { row: {}, fields: { kept: { feeMinor, at: now() } } };
|
|
1893
|
+
// A part kept by the hotel, cancelled by the guest here: the rest back
|
|
1894
|
+
// (2026-10-16 audit, P2 - a fee above nothing gave back nothing).
|
|
1895
|
+
if (byGuest)
|
|
1896
|
+
return {
|
|
1897
|
+
row: {},
|
|
1898
|
+
fields: {
|
|
1899
|
+
refund: {
|
|
1900
|
+
reason: `cancelled with a fee of ${formatMoney(money(feeMinor, b.quote.total.currency))} kept by the hotel - the rest back`,
|
|
1901
|
+
at: now(),
|
|
1902
|
+
tries: 0,
|
|
1903
|
+
amountMinor: b.quote.total.minor - feeMinor,
|
|
1904
|
+
},
|
|
1905
|
+
},
|
|
1906
|
+
};
|
|
1757
1907
|
const fee = formatMoney(money(feeMinor, b.quote.total.currency));
|
|
1758
1908
|
const why = byGuest
|
|
1759
1909
|
? `cancelled with a fee of ${fee} of the ${formatMoney(b.quote.total)} paid (gateway session ${p.sessionId}): the difference is owed back, and a part refund is not made here`
|
|
1760
1910
|
: `Huazhu shows ${b.distributorResId} cancelled - not by the guest here - after its free period (a fee of ${fee} by its terms) with ${formatMoney(b.quote.total)} paid (gateway session ${p.sessionId}): whether the guest is owed it back is to be judged with Huazhu`;
|
|
1761
1911
|
console.error(`[huazhu prepaid ${b.id}] ⚠️ NEEDS A PERSON: ${why}`);
|
|
1762
|
-
return {
|
|
1912
|
+
return {
|
|
1913
|
+
row: {
|
|
1914
|
+
attention: why,
|
|
1915
|
+
attentionAt: new Date(now()).toISOString(),
|
|
1916
|
+
resolvedAt: undefined,
|
|
1917
|
+
resolution: undefined,
|
|
1918
|
+
},
|
|
1919
|
+
fields: {},
|
|
1920
|
+
};
|
|
1921
|
+
};
|
|
1922
|
+
/**
|
|
1923
|
+
* The one-time card a lapsed or cancelled UnionPay stay will never use,
|
|
1924
|
+
* thrown away at the gateway - now if it is there, refused when it comes
|
|
1925
|
+
* (网关 a974cc2a; 2026-10-16 audit, P1-6 and P2). "Handed over already"
|
|
1926
|
+
* means a step here took it: a person checks with Huazhu.
|
|
1927
|
+
*/
|
|
1928
|
+
const discardCard = async (id) => {
|
|
1929
|
+
const row = store.get(id);
|
|
1930
|
+
const sid = row?.prepaid?.method === 'unionpay' ? row.prepaid.sessionId : undefined;
|
|
1931
|
+
if (!sid || !unionpay)
|
|
1932
|
+
return;
|
|
1933
|
+
try {
|
|
1934
|
+
if ((await unionpay.discard(sid)) === 'released')
|
|
1935
|
+
raise(id, `UnionPay's one-time card for gateway payment ${sid} was handed over here, yet the stay lapsed or was cancelled before it was booked: check with Huazhu whether ${row?.distributorResId} exists, and cancel it if it does`);
|
|
1936
|
+
}
|
|
1937
|
+
catch (err) {
|
|
1938
|
+
console.error(`[huazhu prepaid ${id}] the gateway could not be told to throw away UnionPay's card for ${sid}: ${err.message}`);
|
|
1939
|
+
}
|
|
1763
1940
|
};
|
|
1764
1941
|
/**
|
|
1765
1942
|
* A confirmation that lands a little after `payBy` was made in time - the
|
|
1766
1943
|
* callback is seconds behind the guest. Later than this, it is never used.
|
|
1767
1944
|
*/
|
|
1768
1945
|
const ISSUED_GRACE_MS = 5 * 60_000;
|
|
1946
|
+
/** Huazhu's prebook token holds 10 minutes (v1.17): a paid stay older than this is prebooked again. */
|
|
1947
|
+
const PREBOOK_FRESH_MS = 9 * 60_000;
|
|
1948
|
+
/** Passes in a row without word from a card payment page, past its time, before a person is told. */
|
|
1949
|
+
const UNANSWERED_PASSES = 30;
|
|
1950
|
+
/** A paid booking nobody could confirm: Huazhu asked again this often, for this long (audit P1-2). */
|
|
1951
|
+
const HELD_CHECK_MS = 30 * 60_000;
|
|
1952
|
+
const HELD_LIMIT_MS = DAY_MS;
|
|
1769
1953
|
/**
|
|
1770
1954
|
* One step for a booking paid with UnionPay: confirmed on UnionPay's page →
|
|
1771
1955
|
* the one-time card, once, straight into a CCPN booking with Huazhu;
|
|
@@ -1776,10 +1960,13 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1776
1960
|
const p = row.prepaid;
|
|
1777
1961
|
if (row.status !== 'awaiting-payment' || !unionpay)
|
|
1778
1962
|
return;
|
|
1779
|
-
const lapse = () =>
|
|
1963
|
+
const lapse = async () => {
|
|
1964
|
+
patch(row.id, { status: 'expired' }, { lapsedAt: now(), url: undefined });
|
|
1965
|
+
await discardCard(row.id);
|
|
1966
|
+
};
|
|
1780
1967
|
if (!p.sessionId) {
|
|
1781
1968
|
if (now() >= p.payBy)
|
|
1782
|
-
lapse();
|
|
1969
|
+
await lapse();
|
|
1783
1970
|
return;
|
|
1784
1971
|
}
|
|
1785
1972
|
let state;
|
|
@@ -1787,7 +1974,11 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1787
1974
|
state = await unionpay.state(p.sessionId);
|
|
1788
1975
|
}
|
|
1789
1976
|
catch {
|
|
1790
|
-
|
|
1977
|
+
// Asked again on the next pass - until past its time and its grace: a
|
|
1978
|
+
// card never taken is never charged, so it simply lapses (audit P1-4).
|
|
1979
|
+
if (now() >= p.payBy + ISSUED_GRACE_MS)
|
|
1980
|
+
await lapse();
|
|
1981
|
+
return;
|
|
1791
1982
|
}
|
|
1792
1983
|
if (state === 'issued' && now() < p.payBy + ISSUED_GRACE_MS) {
|
|
1793
1984
|
// ⚠️ ONCE: the gateway hands the card over a single time, then forgets it.
|
|
@@ -1825,7 +2016,7 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1825
2016
|
// Unconfirmed in time: lapsed. A confirmation after this is never handed
|
|
1826
2017
|
// on, so nothing is ever charged for it.
|
|
1827
2018
|
if (state === 'closed' || now() >= p.payBy)
|
|
1828
|
-
lapse();
|
|
2019
|
+
await lapse();
|
|
1829
2020
|
};
|
|
1830
2021
|
/**
|
|
1831
2022
|
* ⚠️ ONE STEP AT A TIME ON A BOOKING - every step that moves a payment on:
|
|
@@ -1865,6 +2056,27 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1865
2056
|
throw new Error('A payment on this booking is being settled right now. Ask again in a moment.');
|
|
1866
2057
|
return (await ifFree(id, fn));
|
|
1867
2058
|
};
|
|
2059
|
+
/**
|
|
2060
|
+
* ⚠️ A PERSON ASKED - EVERY TIME THERE IS SOMETHING NEW (2026-10-16 audit,
|
|
2061
|
+
* P1-1): an earlier ask, even one already seen to, kept a refund that later
|
|
2062
|
+
* failed from being asked about at all. The same thing still open is not
|
|
2063
|
+
* asked twice; a new one opens the ask again.
|
|
2064
|
+
*/
|
|
2065
|
+
const raise = (id, why) => {
|
|
2066
|
+
const cur = store.get(id);
|
|
2067
|
+
if (!cur)
|
|
2068
|
+
return;
|
|
2069
|
+
const open = !!cur.attention && !cur.resolvedAt;
|
|
2070
|
+
if (open && cur.attention?.includes(why))
|
|
2071
|
+
return;
|
|
2072
|
+
store.update(id, {
|
|
2073
|
+
attention: open ? `${cur.attention} | ${why}` : why,
|
|
2074
|
+
attentionAt: new Date(now()).toISOString(),
|
|
2075
|
+
resolvedAt: undefined,
|
|
2076
|
+
resolution: undefined,
|
|
2077
|
+
});
|
|
2078
|
+
console.error(`[huazhu prepaid ${id}] ⚠️ NEEDS A PERSON: ${why}`);
|
|
2079
|
+
};
|
|
1868
2080
|
/**
|
|
1869
2081
|
* Writes a booking as it is NOW, with `row` and the prepaid `fields` on top -
|
|
1870
2082
|
* never a whole record read before an await (audit P0-A: each of two steps
|
|
@@ -1927,8 +2139,17 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1927
2139
|
state = await checkout.close(p.sessionId);
|
|
1928
2140
|
}
|
|
1929
2141
|
catch {
|
|
1930
|
-
|
|
2142
|
+
// Asked again on the next pass - but a page that cannot be asked
|
|
2143
|
+
// about long past its time is a person's to look at: it may have been
|
|
2144
|
+
// paid (2026-10-16 audit, P1-4).
|
|
2145
|
+
const misses = (p.unanswered ?? 0) + 1;
|
|
2146
|
+
patch(row.id, {}, { unanswered: misses });
|
|
2147
|
+
if (misses >= UNANSWERED_PASSES && now() >= p.payBy)
|
|
2148
|
+
raise(row.id, `payment page ${p.sessionId} could not be asked where it stands, past its time: whether it was paid is not known - look it up with the payment provider`);
|
|
2149
|
+
return;
|
|
1931
2150
|
}
|
|
2151
|
+
if (p.unanswered)
|
|
2152
|
+
patch(row.id, {}, { unanswered: undefined });
|
|
1932
2153
|
if (state === 'paid') {
|
|
1933
2154
|
// ⚠️ THE MONEY HELD TO THE QUOTE BEFORE ANYTHING IS BOOKED (总线程,
|
|
1934
2155
|
// 2026-10-15): what the page took, to the fen and in the currency,
|
|
@@ -1985,6 +2206,42 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1985
2206
|
await refundOf(row.id, owed.fields.refund.reason);
|
|
1986
2207
|
return;
|
|
1987
2208
|
}
|
|
2209
|
+
// ⚠️ NOBODY COULD CONFIRM IT: THE MONEY IS HELD, NOT GIVEN BACK (2026-10-16
|
|
2210
|
+
// audit, P1-2). Refunded at once, a booking that did exist at Huazhu was
|
|
2211
|
+
// kept unpaid - and past its free period, cost the whole stay to cancel.
|
|
2212
|
+
// Huazhu is asked again every half hour: "none" refunds it all, "here it
|
|
2213
|
+
// is" keeps it; a day without an answer is a person's.
|
|
2214
|
+
if (row.status === 'unconfirmed' && !p.refund) {
|
|
2215
|
+
const since = p.heldSince ?? now();
|
|
2216
|
+
if (p.heldSince === undefined)
|
|
2217
|
+
patch(row.id, {}, { heldSince: since });
|
|
2218
|
+
if (p.checkedAt !== undefined && now() - p.checkedAt < HELD_CHECK_MS)
|
|
2219
|
+
return;
|
|
2220
|
+
patch(row.id, {}, { checkedAt: now() });
|
|
2221
|
+
let found;
|
|
2222
|
+
try {
|
|
2223
|
+
found = await client.consistency(row.distributorResId);
|
|
2224
|
+
}
|
|
2225
|
+
catch (err) {
|
|
2226
|
+
if (err instanceof HuazhuRefusal && err.code === NOT_FOUND) {
|
|
2227
|
+
const reason = 'Huazhu says it never booked the stay';
|
|
2228
|
+
patch(row.id, { status: 'failed', hotelStatus: 'HOTEL_FAILED' }, { refund: { reason, at: now(), tries: 0 } });
|
|
2229
|
+
store.resolve(row.id, `${reason}: refunded in full`);
|
|
2230
|
+
await refundOf(row.id, reason);
|
|
2231
|
+
return;
|
|
2232
|
+
}
|
|
2233
|
+
if (now() - since >= HELD_LIMIT_MS)
|
|
2234
|
+
raise(row.id, `nobody has been able to confirm ${row.distributorResId} with Huazhu for 24 hours and the guest's money is held: decide with Huazhu whether to keep it or refund it`);
|
|
2235
|
+
return;
|
|
2236
|
+
}
|
|
2237
|
+
if (found?.resId && found.status) {
|
|
2238
|
+
const status = statusOf(found.status);
|
|
2239
|
+
patch(row.id, { status, resId: found.resId, hotelStatus: found.status });
|
|
2240
|
+
if (status === 'confirmed')
|
|
2241
|
+
store.resolve(row.id, 'Huazhu has the booking: kept, and paid');
|
|
2242
|
+
}
|
|
2243
|
+
return;
|
|
2244
|
+
}
|
|
1988
2245
|
// ⚠️ HUAZHU'S ORDER NUMBER ON THE PAYMENT, ONCE (总线程, 2026-10-15), so
|
|
1989
2246
|
// Stripe's dashboard and Huazhu's order are one look apart. Tried once:
|
|
1990
2247
|
// a failure is said in the log and never touches the booking.
|
|
@@ -2001,12 +2258,14 @@ export const createHuazhuHotels = (opts) => {
|
|
|
2001
2258
|
}
|
|
2002
2259
|
return;
|
|
2003
2260
|
}
|
|
2004
|
-
if (!p.refund?.requestId &&
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2261
|
+
if (!p.refund?.requestId && (p.refund || row.status === 'failed')) {
|
|
2262
|
+
// Not opened after three tries: tried further apart - a minute,
|
|
2263
|
+
// doubling to an hour - not every ten seconds for good (audit P2).
|
|
2264
|
+
const tries = p.refund?.tries ?? 0;
|
|
2265
|
+
const wait = tries < 3 ? 0 : Math.min(60_000 * 2 ** (tries - 3), 3_600_000);
|
|
2266
|
+
if (p.refund?.triedAt !== undefined && now() < p.refund.triedAt + wait)
|
|
2267
|
+
return;
|
|
2268
|
+
await refundOf(row.id, p.refund?.reason ?? 'Huazhu did not take the booking');
|
|
2010
2269
|
return;
|
|
2011
2270
|
}
|
|
2012
2271
|
const refund = p.refund;
|
|
@@ -2016,11 +2275,8 @@ export const createHuazhuHotels = (opts) => {
|
|
|
2016
2275
|
const latest = store.get(row.id)?.prepaid?.refund;
|
|
2017
2276
|
if (latest && status !== latest.status)
|
|
2018
2277
|
patch(row.id, {}, { refund: { ...latest, status } });
|
|
2019
|
-
if (
|
|
2020
|
-
|
|
2021
|
-
store.update(row.id, { attention: why, attentionAt: new Date(now()).toISOString() });
|
|
2022
|
-
console.error(`[huazhu prepaid ${row.id}] ⚠️ NEEDS A PERSON: ${why}`);
|
|
2023
|
-
}
|
|
2278
|
+
if (status === 'failed' || status === 'rejected')
|
|
2279
|
+
raise(row.id, `refund ${refund.requestId} of gateway session ${p.sessionId} came back ${status}: the guest's money is not back`);
|
|
2024
2280
|
}
|
|
2025
2281
|
catch {
|
|
2026
2282
|
// asked again on the next pass
|
|
@@ -2154,7 +2410,7 @@ export const createHuazhuHotels = (opts) => {
|
|
|
2154
2410
|
const record = {
|
|
2155
2411
|
method: checkout.method,
|
|
2156
2412
|
payBy: now() + payWithin,
|
|
2157
|
-
...(plan.token ? { preBookToken: plan.token } : {}),
|
|
2413
|
+
...(plan.token ? { preBookToken: plan.token, prebookAt: now() } : {}),
|
|
2158
2414
|
after: s.after,
|
|
2159
2415
|
...(s.before ? { before: s.before } : {}),
|
|
2160
2416
|
...(s.rack ? { rack: s.rack } : {}),
|
|
@@ -2211,6 +2467,8 @@ export const createHuazhuHotels = (opts) => {
|
|
|
2211
2467
|
amountMinor: allIn,
|
|
2212
2468
|
currency: cur,
|
|
2213
2469
|
email: guest.email,
|
|
2470
|
+
// A minute past the booking's own deadline - ours closes it first.
|
|
2471
|
+
expiresAt: record.payBy + 60_000,
|
|
2214
2472
|
});
|
|
2215
2473
|
}
|
|
2216
2474
|
catch (err) {
|
|
@@ -2347,7 +2605,14 @@ export const createHuazhuHotels = (opts) => {
|
|
|
2347
2605
|
let answer;
|
|
2348
2606
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
2349
2607
|
try {
|
|
2350
|
-
|
|
2608
|
+
reconciler.inFlight(id, true);
|
|
2609
|
+
let got;
|
|
2610
|
+
try {
|
|
2611
|
+
got = await client.book(request);
|
|
2612
|
+
}
|
|
2613
|
+
finally {
|
|
2614
|
+
reconciler.inFlight(id, false);
|
|
2615
|
+
}
|
|
2351
2616
|
if (got && typeof got.resId === 'string' && got.resId)
|
|
2352
2617
|
answer = got;
|
|
2353
2618
|
}
|
|
@@ -2432,6 +2697,10 @@ export const createHuazhuHotels = (opts) => {
|
|
|
2432
2697
|
const no = notWaiting(first);
|
|
2433
2698
|
if (no)
|
|
2434
2699
|
return no;
|
|
2700
|
+
// Huazhu books this rate paid by card (CCPN) or not at all - its
|
|
2701
|
+
// guarantee says which (2026-10-16 audit, P1-8).
|
|
2702
|
+
if (!(PAYMENT_FOR_GUARANTEE[first.guaranteeCode] ?? []).includes('CCPN'))
|
|
2703
|
+
return refused(`Huazhu does not take ${first.confirmationCode}'s rate paid by card, so it cannot be paid with UnionPay. Nothing changed - the card page still takes the payment.`, 'hotel-refused');
|
|
2435
2704
|
// ⚠️ COUNTED BEFORE ANYTHING REACHES THE GATEWAY (audit P0-E).
|
|
2436
2705
|
const keys = [
|
|
2437
2706
|
[`b:${first.id}`, UNIONPAY_ASKS.booking],
|
|
@@ -2465,6 +2734,10 @@ export const createHuazhuHotels = (opts) => {
|
|
|
2465
2734
|
return { kind: 'enroll', url };
|
|
2466
2735
|
}
|
|
2467
2736
|
catch (err) {
|
|
2737
|
+
// The gateway's own limit: three binding pages a guest an hour (网关
|
|
2738
|
+
// a974cc2a). Said as "later" - never a failure, never asked again now.
|
|
2739
|
+
if (err instanceof UnionPayError && err.status === 429)
|
|
2740
|
+
return refused("UnionPay's page for adding a card was asked for too often for this guest this hour. Nothing changed - the card page still takes the payment, or try UnionPay again later.", 'too-many-tries');
|
|
2468
2741
|
return refused(`UnionPay's page for adding a card could not be opened (${err.message}). Nothing changed - ask again.`, 'unreachable');
|
|
2469
2742
|
}
|
|
2470
2743
|
}
|
|
@@ -2476,21 +2749,6 @@ export const createHuazhuHotels = (opts) => {
|
|
|
2476
2749
|
const p = row.prepaid;
|
|
2477
2750
|
if (p.method === 'unionpay' && p.url)
|
|
2478
2751
|
return { kind: 'confirm', url: p.url, booking: publicBooking(row) };
|
|
2479
|
-
// The card page first: closed, it cannot be paid after this.
|
|
2480
|
-
if (p.method !== 'unionpay' && p.sessionId) {
|
|
2481
|
-
let state;
|
|
2482
|
-
try {
|
|
2483
|
-
state = await checkout.close(p.sessionId);
|
|
2484
|
-
}
|
|
2485
|
-
catch (err) {
|
|
2486
|
-
return refused(`The card page could not be closed (${err.message}). Nothing changed - ask again.`, 'unreachable');
|
|
2487
|
-
}
|
|
2488
|
-
if (state === 'paid') {
|
|
2489
|
-
await settlePrepaid(row);
|
|
2490
|
-
return { kind: 'paid-on-card', booking: publicBooking(store.get(row.id) ?? row) };
|
|
2491
|
-
}
|
|
2492
|
-
patch(row.id, {}, { sessionId: undefined, url: undefined });
|
|
2493
|
-
}
|
|
2494
2752
|
const title = row.quote.lines[0]?.title ?? row.hotelName;
|
|
2495
2753
|
// Prebooked again the way it will be booked: Huazhu's token holds only
|
|
2496
2754
|
// for the same request (wire.ts).
|
|
@@ -2515,13 +2773,48 @@ export const createHuazhuHotels = (opts) => {
|
|
|
2515
2773
|
}))?.preBookToken;
|
|
2516
2774
|
}
|
|
2517
2775
|
catch (err) {
|
|
2776
|
+
// ⚠️ NOTHING CHANGES ON A NO (audit P1-8): asked before the card page
|
|
2777
|
+
// is closed, so the card page still takes the payment.
|
|
2518
2778
|
if (err instanceof HuazhuRefusal) {
|
|
2519
|
-
patch(row.id, { status: 'expired' }, { sessionId: undefined, url: undefined, lapsedAt: now() });
|
|
2520
2779
|
const said = refusalCode(err, row.rooms).code;
|
|
2521
|
-
|
|
2780
|
+
// A price that moved, or no room left: neither way books it at what
|
|
2781
|
+
// the guest was shown - the card page is closed too, and the stay
|
|
2782
|
+
// lapses (paid on it meanwhile, it goes on as that payment).
|
|
2783
|
+
if (said === 'price-changed' || said === 'sold-out') {
|
|
2784
|
+
if (p.sessionId) {
|
|
2785
|
+
const page = await checkout.close(p.sessionId).catch(() => undefined);
|
|
2786
|
+
if (page === 'paid') {
|
|
2787
|
+
await settlePrepaid(row);
|
|
2788
|
+
return { kind: 'paid-on-card', booking: publicBooking(store.get(row.id) ?? row) };
|
|
2789
|
+
}
|
|
2790
|
+
}
|
|
2791
|
+
patch(row.id, { status: 'expired' }, { sessionId: undefined, url: undefined, lapsedAt: now() });
|
|
2792
|
+
return refused(`Nothing was booked or charged: ${refusalWords(err, `${row.hotelName} - ${title}`, p.currency)}`, said);
|
|
2793
|
+
}
|
|
2794
|
+
// Anything else - Huazhu not taking this rate paid by card, say -
|
|
2795
|
+
// changes nothing: the card page still takes the payment.
|
|
2796
|
+
return refused(said === 'prepaid-only'
|
|
2797
|
+
? 'Huazhu does not take this rate paid by card, so it cannot be paid with UnionPay. Nothing changed - the card page still takes the payment.'
|
|
2798
|
+
: `Huazhu did not take it paid by card: ${refusalWords(err, `${row.hotelName} - ${title}`, p.currency)} Nothing changed - the card page still takes the payment.`, 'hotel-refused');
|
|
2522
2799
|
}
|
|
2523
2800
|
return refused(`Huazhu could not be reached to check the stay (${err.message}). Nothing was charged - ask again.`, 'unreachable');
|
|
2524
2801
|
}
|
|
2802
|
+
// Huazhu said yes: now the card page is closed, so it cannot be paid
|
|
2803
|
+
// after this.
|
|
2804
|
+
if (p.method !== 'unionpay' && p.sessionId) {
|
|
2805
|
+
let state;
|
|
2806
|
+
try {
|
|
2807
|
+
state = await checkout.close(p.sessionId);
|
|
2808
|
+
}
|
|
2809
|
+
catch (err) {
|
|
2810
|
+
return refused(`The card page could not be closed (${err.message}). Nothing changed - ask again.`, 'unreachable');
|
|
2811
|
+
}
|
|
2812
|
+
if (state === 'paid') {
|
|
2813
|
+
await settlePrepaid(row);
|
|
2814
|
+
return { kind: 'paid-on-card', booking: publicBooking(store.get(row.id) ?? row) };
|
|
2815
|
+
}
|
|
2816
|
+
patch(row.id, {}, { sessionId: undefined, url: undefined });
|
|
2817
|
+
}
|
|
2525
2818
|
const names = hotelNamesOf(row.hotelId);
|
|
2526
2819
|
const nights = row.quote.lines[0]?.nights ?? 1;
|
|
2527
2820
|
let opened;
|
|
@@ -2546,7 +2839,7 @@ export const createHuazhuHotels = (opts) => {
|
|
|
2546
2839
|
method: 'unionpay',
|
|
2547
2840
|
sessionId: opened.sessionId,
|
|
2548
2841
|
url: opened.url,
|
|
2549
|
-
...(token ? { preBookToken: token } : {}),
|
|
2842
|
+
...(token ? { preBookToken: token, prebookAt: now() } : {}),
|
|
2550
2843
|
payBy: now() + payWithin,
|
|
2551
2844
|
}) ?? row;
|
|
2552
2845
|
return { kind: 'confirm', url: opened.url, booking: publicBooking(updated) };
|
|
@@ -2569,14 +2862,27 @@ export const createHuazhuHotels = (opts) => {
|
|
|
2569
2862
|
: 'not-paid'
|
|
2570
2863
|
: row.status === 'expired'
|
|
2571
2864
|
? 'not-paid'
|
|
2572
|
-
:
|
|
2573
|
-
?
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2865
|
+
: row.status === 'unconfirmed' && p?.paidAt && !p.refund
|
|
2866
|
+
? // Held while Huazhu is asked (audit P1-2): paid, the hotel still being asked.
|
|
2867
|
+
'booking'
|
|
2868
|
+
: p?.paidAt && (p.refund || row.status === 'failed')
|
|
2869
|
+
? 'refunded'
|
|
2870
|
+
: row.status === 'failed' && !p?.paidAt
|
|
2871
|
+
? 'not-paid'
|
|
2872
|
+
: 'unknown';
|
|
2577
2873
|
const names = hotelNamesOf(row.hotelId);
|
|
2874
|
+
// Owed back, but not sent - a person is seeing to it: never said as "on its way" (audit P1-1).
|
|
2875
|
+
const stuck = !!p?.refund &&
|
|
2876
|
+
(p.refund.status === 'failed' ||
|
|
2877
|
+
p.refund.status === 'rejected' ||
|
|
2878
|
+
(!p.refund.requestId && (p.refund.tries ?? 0) >= 8));
|
|
2879
|
+
const part = p?.refund?.amountMinor;
|
|
2578
2880
|
return {
|
|
2579
2881
|
state,
|
|
2882
|
+
...(state === 'refunded' && stuck ? { refundStuck: true } : {}),
|
|
2883
|
+
...(state === 'refunded' && part !== undefined && part < row.quote.total.minor
|
|
2884
|
+
? { refundAmount: money(part, row.quote.total.currency) }
|
|
2885
|
+
: {}),
|
|
2580
2886
|
code: row.confirmationCode,
|
|
2581
2887
|
hotelName: row.hotelName,
|
|
2582
2888
|
...(names ? { hotelNames: names } : {}),
|
|
@@ -2630,8 +2936,9 @@ export const createHuazhuHotels = (opts) => {
|
|
|
2630
2936
|
const page = await checkout.state(row.prepaid.sessionId).catch(() => undefined);
|
|
2631
2937
|
if (page === 'paid')
|
|
2632
2938
|
return { ...told, state: 'booking' };
|
|
2939
|
+
// Cannot be read just now: the page looks again (香格里拉's `checking`).
|
|
2633
2940
|
if (page === undefined)
|
|
2634
|
-
return { ...told, state: '
|
|
2941
|
+
return { ...told, state: 'checking' };
|
|
2635
2942
|
}
|
|
2636
2943
|
return told;
|
|
2637
2944
|
};
|
|
@@ -3416,7 +3723,9 @@ export const createHuazhuHotels = (opts) => {
|
|
|
3416
3723
|
? await unionpay.state(b.prepaid.sessionId).catch(() => undefined)
|
|
3417
3724
|
: 'closed';
|
|
3418
3725
|
if (state !== 'issued' && state !== undefined) {
|
|
3419
|
-
|
|
3726
|
+
const lapsed = patch(b.id, { status: 'expired' }, { lapsedAt: now(), url: undefined }) ?? b;
|
|
3727
|
+
await discardCard(b.id);
|
|
3728
|
+
return publicBooking(store.get(b.id) ?? lapsed);
|
|
3420
3729
|
}
|
|
3421
3730
|
throw new Error(state === 'issued'
|
|
3422
3731
|
? 'The guest has just confirmed on UnionPay, so the stay is being booked with Huazhu now. Ask again in a moment to cancel it.'
|