@curless/hotel-storefront 0.39.0 → 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 +18 -5
- 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 +730 -264
- package/dist/huazhu/backend.js.map +1 -1
- package/dist/huazhu/bookings.d.ts +39 -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/legal.d.ts.map +1 -1
- package/dist/legal.js +30 -7
- package/dist/legal.js.map +1 -1
- package/dist/paid.d.ts.map +1 -1
- package/dist/paid.js +68 -34
- package/dist/paid.js.map +1 -1
- package/dist/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/tools.d.ts.map +1 -1
- package/dist/tools.js +129 -24
- package/dist/tools.js.map +1 -1
- package/dist/types.d.ts +19 -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 = {
|
|
@@ -513,6 +515,16 @@ export const createHuazhuHotels = (opts) => {
|
|
|
513
515
|
const requestsByEmail = new WindowCounter(DAY_MS, now);
|
|
514
516
|
const requestsByIp = new WindowCounter(HOUR_MS, now);
|
|
515
517
|
const requestsByConversation = new WindowCounter(HOUR_MS, now);
|
|
518
|
+
/**
|
|
519
|
+
* Paying with UnionPay, asked - each ask reaches the gateway and UnionPay's
|
|
520
|
+
* test system, so each is counted, whatever it came to: by booking, by
|
|
521
|
+
* conversation and by address (2026-10-16 audit, P0-E: one booking asked
|
|
522
|
+
* 50 times bound 50 cards' worth of pages, and used up the gateway's rate).
|
|
523
|
+
*/
|
|
524
|
+
const unionPayAsks = new WindowCounter(HOUR_MS, now);
|
|
525
|
+
const UNIONPAY_ASKS = { booking: 6, conversation: 12, ip: 120 };
|
|
526
|
+
/** UnionPay's page for binding a card is handed out again this long, not asked for again. */
|
|
527
|
+
const ENROLL_REUSE_MS = 30 * 60_000;
|
|
516
528
|
/**
|
|
517
529
|
* Bookings a request has passed the limits for and not yet written down.
|
|
518
530
|
*
|
|
@@ -676,12 +688,25 @@ export const createHuazhuHotels = (opts) => {
|
|
|
676
688
|
/** Whether a hotel sells any room by the hour on its today - from its products, no prices. */
|
|
677
689
|
const sellsByTheHourToday = async (hotelId) => (await productsOf(hotelId)).some((p) => p.checkinType === 'HOUR' && inStayWindow(p.stayWindow, today()));
|
|
678
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();
|
|
679
698
|
/** Every product's prices, rules and rooms left, one element per date from `start` to `end`. */
|
|
680
699
|
const ariFor = async (hotelId, start, end, fresh = false) => {
|
|
681
700
|
const key = `${hotelId}|${start}|${end}`;
|
|
682
701
|
const hit = ariCache.get(key);
|
|
683
702
|
if (!fresh && hit && now() - hit.at < ariTtl)
|
|
684
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
|
+
}
|
|
685
710
|
const ranges = ariRanges(start, end);
|
|
686
711
|
const parts = await Promise.all(ranges.map(async (r) => {
|
|
687
712
|
const [offers, inventory] = await Promise.all([
|
|
@@ -703,7 +728,14 @@ export const createHuazhuHotels = (opts) => {
|
|
|
703
728
|
offers: offers.dailyOffers ?? [],
|
|
704
729
|
inventory: inventory.dailyInventories ?? [],
|
|
705
730
|
};
|
|
706
|
-
}))
|
|
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);
|
|
707
739
|
const out = new Map();
|
|
708
740
|
const keys = new Set(parts.flatMap((p) => p.offers.map((o) => roomIdOf(o))));
|
|
709
741
|
for (const k of keys) {
|
|
@@ -998,9 +1030,16 @@ export const createHuazhuHotels = (opts) => {
|
|
|
998
1030
|
const fails = priced.map((p) => p.res).filter((r) => !r.ok);
|
|
999
1031
|
if (fails.length > 0 && fails.every((f) => f.kind === 'party'))
|
|
1000
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
|
+
};
|
|
1001
1038
|
if (fails.some((f) => f.kind === 'prepaid'))
|
|
1002
1039
|
return {
|
|
1003
|
-
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.",
|
|
1004
1043
|
code: 'prepaid-only',
|
|
1005
1044
|
};
|
|
1006
1045
|
const said = (re) => fails
|
|
@@ -1042,9 +1081,18 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1042
1081
|
// find a rate paid at the hotel. At Huazhu's test hotel (2026-10-15) 28
|
|
1043
1082
|
// rates were prepaid and 4 sold out, and the guest was told only that
|
|
1044
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
|
+
};
|
|
1045
1091
|
if (kinds.has('prepaid') && !kinds.has('party') && !kinds.has('too-many-rooms'))
|
|
1046
1092
|
return {
|
|
1047
|
-
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.',
|
|
1048
1096
|
code: 'prepaid-only',
|
|
1049
1097
|
};
|
|
1050
1098
|
if (only('sold-out'))
|
|
@@ -1266,13 +1314,15 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1266
1314
|
// here is taken or refunded.
|
|
1267
1315
|
const status = b.status === 'cancelled' && p.issuedAt
|
|
1268
1316
|
? 'hotel-refunds'
|
|
1269
|
-
:
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
? '
|
|
1273
|
-
:
|
|
1274
|
-
? '
|
|
1275
|
-
:
|
|
1317
|
+
: // Not booked - turned down, written off by the follow-up queries,
|
|
1318
|
+
// lapsed: the hotel never booked it, so never charged the card.
|
|
1319
|
+
p.lapsedAt || p.declined || b.status === 'failed' || b.status === 'expired'
|
|
1320
|
+
? 'lapsed'
|
|
1321
|
+
: b.status === 'confirmed'
|
|
1322
|
+
? 'paid'
|
|
1323
|
+
: p.issuedAt
|
|
1324
|
+
? 'issued'
|
|
1325
|
+
: 'awaiting';
|
|
1276
1326
|
return {
|
|
1277
1327
|
method: 'unionpay',
|
|
1278
1328
|
amount: b.quote.total,
|
|
@@ -1315,6 +1365,16 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1315
1365
|
}
|
|
1316
1366
|
: {}),
|
|
1317
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
|
+
: {}),
|
|
1318
1378
|
};
|
|
1319
1379
|
};
|
|
1320
1380
|
const publicBooking = (b) => ({
|
|
@@ -1341,7 +1401,14 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1341
1401
|
* before 0.27 showed it as its code, and is opened by it only in the
|
|
1342
1402
|
* conversation that made it.
|
|
1343
1403
|
*/
|
|
1344
|
-
|
|
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]
|
|
1345
1412
|
.filter((x) => !!x)
|
|
1346
1413
|
.map((x) => x.toLowerCase());
|
|
1347
1414
|
const findOwn = (ref, who) => {
|
|
@@ -1368,16 +1435,16 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1368
1435
|
}
|
|
1369
1436
|
return there;
|
|
1370
1437
|
};
|
|
1371
|
-
/**
|
|
1438
|
+
/**
|
|
1439
|
+
* Huazhu's own word on a booking, at most once a minute each.
|
|
1440
|
+
*
|
|
1441
|
+
* ⚠️ IT ONLY READS (2026-10-16 audit, P0-A). It used to move a payment on
|
|
1442
|
+
* as it was asked about - outside the booking's lock, from every lookup and
|
|
1443
|
+
* every few seconds from a card waiting on a payment. Payments move on in
|
|
1444
|
+
* the pass, the landing page and the steps a guest takes, one at a time.
|
|
1445
|
+
*/
|
|
1372
1446
|
const refresh = async (found) => {
|
|
1373
|
-
|
|
1374
|
-
// asked about - the guest who just paid sees it booked without waiting
|
|
1375
|
-
// for the next pass.
|
|
1376
|
-
let b = found;
|
|
1377
|
-
if (b.prepaid && moneyPending(b)) {
|
|
1378
|
-
await settlePrepaid(b);
|
|
1379
|
-
b = store.get(b.id) ?? b;
|
|
1380
|
-
}
|
|
1447
|
+
const b = store.get(found.id) ?? found;
|
|
1381
1448
|
if (!b.resId || b.pendingSince !== undefined)
|
|
1382
1449
|
return b;
|
|
1383
1450
|
if (b.refreshedAt && now() - b.refreshedAt < 60_000)
|
|
@@ -1405,7 +1472,7 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1405
1472
|
const waitingWords = (b) => b.status === 'unconfirmed'
|
|
1406
1473
|
? `${opts.sandbox ? 'The simulated Huazhu' : 'Huazhu'} has not said whether this booking was made, and a person here is confirming it. It cannot be cancelled here until then - ask again later.`
|
|
1407
1474
|
: `${opts.sandbox ? 'The simulated Huazhu' : 'Huazhu'} has not confirmed this booking yet - that takes up to eight minutes. It can be cancelled once it is confirmed.`;
|
|
1408
|
-
const feeNow = (b) => {
|
|
1475
|
+
const feeNow = (b, at = now()) => {
|
|
1409
1476
|
const policy = policyOf(b.cancelCode);
|
|
1410
1477
|
const amounts = {
|
|
1411
1478
|
currency: b.quote.total.currency,
|
|
@@ -1425,7 +1492,7 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1425
1492
|
}
|
|
1426
1493
|
// A room by the hour that has begun is a no-show, whatever the code says
|
|
1427
1494
|
// about the rest of the day - see `beforeStart`.
|
|
1428
|
-
if (b.hourly &&
|
|
1495
|
+
if (b.hourly && at >= arrivalInstant(b.stay.checkIn, b.hourly.start, b.tz)) {
|
|
1429
1496
|
return {
|
|
1430
1497
|
fee: total,
|
|
1431
1498
|
forbidden: false,
|
|
@@ -1433,11 +1500,11 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1433
1500
|
freeUntil: undefined,
|
|
1434
1501
|
};
|
|
1435
1502
|
}
|
|
1436
|
-
const penalty = penaltyAt(policy, b.stay.checkIn, b.tz,
|
|
1503
|
+
const penalty = penaltyAt(policy, b.stay.checkIn, b.tz, at);
|
|
1437
1504
|
const fee = feeFor(penalty, amounts);
|
|
1438
1505
|
const freeUntil = b.hourly
|
|
1439
|
-
? beforeStart(freeUntilOf(policy, b.stay.checkIn, b.tz,
|
|
1440
|
-
: freeUntilOf(policy, b.stay.checkIn, b.tz,
|
|
1506
|
+
? beforeStart(freeUntilOf(policy, b.stay.checkIn, b.tz, at), b.stay.checkIn, b.hourly.start)
|
|
1507
|
+
: freeUntilOf(policy, b.stay.checkIn, b.tz, at);
|
|
1441
1508
|
return {
|
|
1442
1509
|
fee: fee ?? total,
|
|
1443
1510
|
forbidden: fee === null,
|
|
@@ -1528,13 +1595,16 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1528
1595
|
if (who.ip && requestsByIp.count(who.ip) >= limits.perIpPerHour) {
|
|
1529
1596
|
throw new Error('Nothing was booked. Too many bookings have been asked for from this network in the last hour - try again later.');
|
|
1530
1597
|
}
|
|
1531
|
-
const madeToday = store
|
|
1532
|
-
.all()
|
|
1533
|
-
.filter((b) => now() - Date.parse(b.createdAt) < DAY_MS &&
|
|
1598
|
+
const madeToday = store.all().filter((b) => now() - Date.parse(b.createdAt) < DAY_MS &&
|
|
1534
1599
|
(b.status === 'confirmed' ||
|
|
1535
1600
|
b.status === 'requested' ||
|
|
1536
1601
|
b.status === 'unconfirmed' ||
|
|
1537
|
-
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);
|
|
1538
1608
|
if (madeToday + lines.length > limits.perDeploymentPerDay) {
|
|
1539
1609
|
// ⚠️ A CEILING HIT IS SOMETHING A PERSON SHOULD KNOW: either business
|
|
1540
1610
|
// is booming or somebody is filling hotels with names. Logged, and on
|
|
@@ -1593,10 +1663,45 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1593
1663
|
const contact = contactOf(row.guest);
|
|
1594
1664
|
if (typeof contact === 'string') {
|
|
1595
1665
|
const why = "the guest's name could not be sent to Huazhu as written";
|
|
1596
|
-
|
|
1666
|
+
patch(id, { status: 'failed' }, card ? { declined: why } : {});
|
|
1597
1667
|
await refundOf(id, why);
|
|
1598
1668
|
return;
|
|
1599
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;
|
|
1600
1705
|
const comment = commentOf(row.requests, row.guest.notes);
|
|
1601
1706
|
const request = {
|
|
1602
1707
|
hotelId: row.hotelId,
|
|
@@ -1633,16 +1738,37 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1633
1738
|
},
|
|
1634
1739
|
],
|
|
1635
1740
|
...(comment ? { guestComment: comment } : {}),
|
|
1636
|
-
...(
|
|
1741
|
+
...(token ? { preBookToken: token } : {}),
|
|
1637
1742
|
};
|
|
1638
1743
|
// ⚠️ SET BEFORE THE CALL, as every booking is: a process that dies in it
|
|
1639
|
-
// leaves a row the reconciler follows up.
|
|
1640
|
-
|
|
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
|
+
}
|
|
1641
1760
|
let refusal;
|
|
1642
1761
|
let answer;
|
|
1643
1762
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
1644
1763
|
try {
|
|
1645
|
-
|
|
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
|
+
}
|
|
1646
1772
|
if (got && typeof got.resId === 'string' && got.resId)
|
|
1647
1773
|
answer = got;
|
|
1648
1774
|
}
|
|
@@ -1668,26 +1794,24 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1668
1794
|
});
|
|
1669
1795
|
return;
|
|
1670
1796
|
}
|
|
1671
|
-
if (refusal
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
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, '****') });
|
|
1682
1811
|
return;
|
|
1683
1812
|
}
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
// A price that moved since the prebook is one of these (5209): the
|
|
1687
|
-
// guest paid for a price Huazhu no longer gives, and gets it all back.
|
|
1688
|
-
await refundOf(id, `Huazhu did not take the booking: ${refusal.huazhuMessage} (${refusal.code})`);
|
|
1689
|
-
}
|
|
1690
|
-
// No answer: the reconciler's eight minutes decide it.
|
|
1813
|
+
store.update(id, { status: 'failed', pendingSince: undefined });
|
|
1814
|
+
await refundOf(id, said);
|
|
1691
1815
|
};
|
|
1692
1816
|
/**
|
|
1693
1817
|
* Gives a guest's money back, in full - the only refund the gateway makes.
|
|
@@ -1701,21 +1825,117 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1701
1825
|
return;
|
|
1702
1826
|
const tries = (p.refund?.tries ?? 0) + 1;
|
|
1703
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;
|
|
1704
1832
|
try {
|
|
1705
1833
|
// Named by when it was decided: the same refund retried is the same refund.
|
|
1706
|
-
const r = await checkout.refund(p.sessionId,
|
|
1707
|
-
|
|
1708
|
-
|
|
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
|
+
},
|
|
1709
1845
|
});
|
|
1710
|
-
console.error(`[huazhu prepaid ${id}] refund ${r.requestId} (${r.status}) of session ${p.sessionId}: ${
|
|
1846
|
+
console.error(`[huazhu prepaid ${id}] refund ${r.requestId} (${r.status}) of session ${p.sessionId}: ${why}`);
|
|
1711
1847
|
}
|
|
1712
1848
|
catch (err) {
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
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`);
|
|
1860
|
+
}
|
|
1861
|
+
};
|
|
1862
|
+
/**
|
|
1863
|
+
* What a cancelled booking paid here in advance owes the guest - written in
|
|
1864
|
+
* the SAME write as the cancellation (2026-10-16 audit, P0-B: written after
|
|
1865
|
+
* it, a process stopped in between, Huazhu's 5402 and a cancellation made at
|
|
1866
|
+
* Huazhu's end all left the money with us for good).
|
|
1867
|
+
*
|
|
1868
|
+
* - Free: all of it back - a refund to open.
|
|
1869
|
+
* - Cancelled by the guest here, the fee the whole stay (the only kind of
|
|
1870
|
+
* rate sold paid in advance): kept as that fee.
|
|
1871
|
+
* - Anything else - a part fee, or cancelled at Huazhu's end after the free
|
|
1872
|
+
* period, by the hotel or by the guest some other way: a person's to judge.
|
|
1873
|
+
*/
|
|
1874
|
+
const owedOnCancel = (b, feeMinor, byGuest) => {
|
|
1875
|
+
const p = b.prepaid;
|
|
1876
|
+
if (!p?.paidAt || p.method === 'unionpay' || p.refund || p.kept)
|
|
1877
|
+
return { row: {}, fields: {} };
|
|
1878
|
+
if (feeMinor <= 0)
|
|
1879
|
+
return {
|
|
1880
|
+
row: {},
|
|
1881
|
+
fields: {
|
|
1882
|
+
refund: {
|
|
1883
|
+
reason: byGuest
|
|
1884
|
+
? 'cancelled within the free cancellation period'
|
|
1885
|
+
: 'cancelled at Huazhu within the free cancellation period',
|
|
1886
|
+
at: now(),
|
|
1887
|
+
tries: 0,
|
|
1888
|
+
},
|
|
1889
|
+
},
|
|
1890
|
+
};
|
|
1891
|
+
if (byGuest && feeMinor >= b.quote.total.minor)
|
|
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
|
+
};
|
|
1907
|
+
const fee = formatMoney(money(feeMinor, b.quote.total.currency));
|
|
1908
|
+
const why = byGuest
|
|
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`
|
|
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`;
|
|
1911
|
+
console.error(`[huazhu prepaid ${b.id}] ⚠️ NEEDS A PERSON: ${why}`);
|
|
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}`);
|
|
1719
1939
|
}
|
|
1720
1940
|
};
|
|
1721
1941
|
/**
|
|
@@ -1723,6 +1943,13 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1723
1943
|
* callback is seconds behind the guest. Later than this, it is never used.
|
|
1724
1944
|
*/
|
|
1725
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;
|
|
1726
1953
|
/**
|
|
1727
1954
|
* One step for a booking paid with UnionPay: confirmed on UnionPay's page →
|
|
1728
1955
|
* the one-time card, once, straight into a CCPN booking with Huazhu;
|
|
@@ -1733,13 +1960,13 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1733
1960
|
const p = row.prepaid;
|
|
1734
1961
|
if (row.status !== 'awaiting-payment' || !unionpay)
|
|
1735
1962
|
return;
|
|
1736
|
-
const lapse = () =>
|
|
1737
|
-
status: 'expired',
|
|
1738
|
-
|
|
1739
|
-
}
|
|
1963
|
+
const lapse = async () => {
|
|
1964
|
+
patch(row.id, { status: 'expired' }, { lapsedAt: now(), url: undefined });
|
|
1965
|
+
await discardCard(row.id);
|
|
1966
|
+
};
|
|
1740
1967
|
if (!p.sessionId) {
|
|
1741
1968
|
if (now() >= p.payBy)
|
|
1742
|
-
lapse();
|
|
1969
|
+
await lapse();
|
|
1743
1970
|
return;
|
|
1744
1971
|
}
|
|
1745
1972
|
let state;
|
|
@@ -1747,7 +1974,11 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1747
1974
|
state = await unionpay.state(p.sessionId);
|
|
1748
1975
|
}
|
|
1749
1976
|
catch {
|
|
1750
|
-
|
|
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;
|
|
1751
1982
|
}
|
|
1752
1983
|
if (state === 'issued' && now() < p.payBy + ISSUED_GRACE_MS) {
|
|
1753
1984
|
// ⚠️ ONCE: the gateway hands the card over a single time, then forgets it.
|
|
@@ -1763,20 +1994,21 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1763
1994
|
return;
|
|
1764
1995
|
}
|
|
1765
1996
|
if (!card) {
|
|
1766
|
-
//
|
|
1767
|
-
//
|
|
1768
|
-
//
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1997
|
+
// ⚠️ GONE FROM THE GATEWAY (410), OR NOT THIS MERCHANT'S (404) - NEVER
|
|
1998
|
+
// "NOT BOOKED" BY GUESS (2026-10-16 audit, P0-A). Handed over once, to
|
|
1999
|
+
// a step that may have booked it with Huazhu and then died: what
|
|
2000
|
+
// Huazhu has decides. The stay waits on the follow-up queries
|
|
2001
|
+
// (`Reconciler`) as any booking whose answer was lost does - booked
|
|
2002
|
+
// if Huazhu has it, failed (nothing charged) if Huazhu says late in
|
|
2003
|
+
// the eight minutes that it does not.
|
|
2004
|
+
const cur = store.get(row.id);
|
|
2005
|
+
if (!cur || cur.status !== 'awaiting-payment')
|
|
2006
|
+
return;
|
|
2007
|
+
patch(row.id, { status: 'requested', pendingSince: now() }, { issuedAt: cur.prepaid?.issuedAt ?? now(), url: undefined });
|
|
2008
|
+
console.error(`[huazhu prepaid ${row.id}] UnionPay's one-time card was not there to take (handed over already, or not this merchant's): asking Huazhu whether ${row.distributorResId} was booked`);
|
|
1775
2009
|
return;
|
|
1776
2010
|
}
|
|
1777
|
-
|
|
1778
|
-
prepaid: { ...p, issuedAt: now(), cardLastFour: lastFour(card), url: undefined },
|
|
1779
|
-
});
|
|
2011
|
+
patch(row.id, {}, { issuedAt: now(), cardLastFour: lastFour(card), url: undefined });
|
|
1780
2012
|
console.log(`[huazhu prepaid ${row.id}] UnionPay issued a one-time card ···· ${lastFour(card)}: booking it with Huazhu as CCPN`);
|
|
1781
2013
|
await bookPaid(row.id, card);
|
|
1782
2014
|
return;
|
|
@@ -1784,10 +2016,82 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1784
2016
|
// Unconfirmed in time: lapsed. A confirmation after this is never handed
|
|
1785
2017
|
// on, so nothing is ever charged for it.
|
|
1786
2018
|
if (state === 'closed' || now() >= p.payBy)
|
|
1787
|
-
lapse();
|
|
2019
|
+
await lapse();
|
|
2020
|
+
};
|
|
2021
|
+
/**
|
|
2022
|
+
* ⚠️ ONE STEP AT A TIME ON A BOOKING - every step that moves a payment on:
|
|
2023
|
+
* the pass, the landing page, paying with UnionPay instead, a cancellation
|
|
2024
|
+
* (2026-10-16 audit, P0-A). A single flag for the pass alone was walked
|
|
2025
|
+
* round by every lookup, which moved payments on unlocked: the one-time card
|
|
2026
|
+
* taken twice, a stay Huazhu had booked written off as "not booked", a
|
|
2027
|
+
* refund's record wiped and then reported as never opened.
|
|
2028
|
+
*/
|
|
2029
|
+
const busy = new Set();
|
|
2030
|
+
/** Every step under way - what a deploy's SIGTERM waits for (audit P1-3). */
|
|
2031
|
+
const steps = new Set();
|
|
2032
|
+
/** A step on this booking if nobody is taking one - undefined if somebody is, or the process is stopping. */
|
|
2033
|
+
const ifFree = (id, fn) => {
|
|
2034
|
+
if (busy.has(id) || closing)
|
|
2035
|
+
return Promise.resolve(undefined);
|
|
2036
|
+
busy.add(id);
|
|
2037
|
+
const step = (async () => {
|
|
2038
|
+
try {
|
|
2039
|
+
return await fn();
|
|
2040
|
+
}
|
|
2041
|
+
finally {
|
|
2042
|
+
busy.delete(id);
|
|
2043
|
+
}
|
|
2044
|
+
})();
|
|
2045
|
+
steps.add(step);
|
|
2046
|
+
void step.finally(() => steps.delete(step)).catch(() => { });
|
|
2047
|
+
return step;
|
|
2048
|
+
};
|
|
2049
|
+
/** A step on this booking once it is its turn - one under way is waited for, a few seconds at most. */
|
|
2050
|
+
const inTurnFor = async (id, fn) => {
|
|
2051
|
+
for (let i = 0; busy.has(id) && i < 400; i += 1)
|
|
2052
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
2053
|
+
if (closing)
|
|
2054
|
+
throw new Error('This connector is restarting. Nothing changed - ask again in a moment.');
|
|
2055
|
+
if (busy.has(id))
|
|
2056
|
+
throw new Error('A payment on this booking is being settled right now. Ask again in a moment.');
|
|
2057
|
+
return (await ifFree(id, fn));
|
|
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
|
+
};
|
|
2080
|
+
/**
|
|
2081
|
+
* Writes a booking as it is NOW, with `row` and the prepaid `fields` on top -
|
|
2082
|
+
* never a whole record read before an await (audit P0-A: each of two steps
|
|
2083
|
+
* wrote back what it had read, and the later one undid the earlier).
|
|
2084
|
+
*/
|
|
2085
|
+
const patch = (id, row, fields = {}) => {
|
|
2086
|
+
const cur = store.get(id);
|
|
2087
|
+
if (!cur)
|
|
2088
|
+
return undefined;
|
|
2089
|
+
return store.update(id, {
|
|
2090
|
+
...row,
|
|
2091
|
+
...(cur.prepaid ? { prepaid: { ...cur.prepaid, ...fields } } : {}),
|
|
2092
|
+
});
|
|
1788
2093
|
};
|
|
1789
2094
|
/** Whether a booking paid in advance still has money work to do. */
|
|
1790
|
-
let ticking = false;
|
|
1791
2095
|
const moneyPending = (b) => {
|
|
1792
2096
|
const p = b.prepaid;
|
|
1793
2097
|
if (!p)
|
|
@@ -1799,6 +2103,9 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1799
2103
|
return false;
|
|
1800
2104
|
if (!p.paidAt)
|
|
1801
2105
|
return false;
|
|
2106
|
+
// Cancelled - at Huazhu's end, say - with nothing decided about the money yet.
|
|
2107
|
+
if (b.status === 'cancelled' && !p.refund && !p.kept && !b.attention)
|
|
2108
|
+
return true;
|
|
1802
2109
|
// Booked: Huazhu's order number still to be written onto the payment, once.
|
|
1803
2110
|
if (b.status === 'confirmed' && b.resId && !p.noted && !p.refund && checkout?.annotate)
|
|
1804
2111
|
return true;
|
|
@@ -1822,7 +2129,7 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1822
2129
|
if (row.status === 'awaiting-payment') {
|
|
1823
2130
|
if (!p.sessionId) {
|
|
1824
2131
|
if (now() >= p.payBy)
|
|
1825
|
-
|
|
2132
|
+
patch(row.id, { status: 'expired' }, { lapsedAt: now() });
|
|
1826
2133
|
return;
|
|
1827
2134
|
}
|
|
1828
2135
|
let state;
|
|
@@ -1832,8 +2139,17 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1832
2139
|
state = await checkout.close(p.sessionId);
|
|
1833
2140
|
}
|
|
1834
2141
|
catch {
|
|
1835
|
-
|
|
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;
|
|
1836
2150
|
}
|
|
2151
|
+
if (p.unanswered)
|
|
2152
|
+
patch(row.id, {}, { unanswered: undefined });
|
|
1837
2153
|
if (state === 'paid') {
|
|
1838
2154
|
// ⚠️ THE MONEY HELD TO THE QUOTE BEFORE ANYTHING IS BOOKED (总线程,
|
|
1839
2155
|
// 2026-10-15): what the page took, to the fen and in the currency,
|
|
@@ -1849,40 +2165,88 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1849
2165
|
const want = row.quote.total;
|
|
1850
2166
|
if (!took || took.amountMinor !== want.minor || took.currency !== want.currency) {
|
|
1851
2167
|
const said = took ? `${took.amountMinor} ${took.currency}` : 'an amount it could not say';
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
},
|
|
2168
|
+
// Shown to the guest (`refundReason`): money as `formatMoney` writes
|
|
2169
|
+
// it, so the card can say it in the guest's own way (香格里拉,
|
|
2170
|
+
// 2026-10-16) - never minor units.
|
|
2171
|
+
const shown = took
|
|
2172
|
+
? formatMoney(money(took.amountMinor, took.currency))
|
|
2173
|
+
: 'an amount it could not say';
|
|
2174
|
+
patch(row.id, { status: 'failed' }, {
|
|
2175
|
+
paidAt: now(),
|
|
2176
|
+
url: undefined,
|
|
2177
|
+
refund: {
|
|
2178
|
+
reason: `the payment page took ${shown}, not the ${formatMoney(want)} quoted - nothing was booked`,
|
|
2179
|
+
at: now(),
|
|
2180
|
+
tries: 0,
|
|
1863
2181
|
},
|
|
1864
2182
|
});
|
|
1865
2183
|
console.error(`[huazhu prepaid ${row.id}] ⚠️ paid ${said} against ${want.minor} ${want.currency} quoted: not booked, refunding`);
|
|
1866
2184
|
return;
|
|
1867
2185
|
}
|
|
1868
|
-
|
|
2186
|
+
patch(row.id, {}, { paidAt: now(), url: undefined });
|
|
1869
2187
|
await bookPaid(row.id);
|
|
1870
2188
|
}
|
|
1871
2189
|
else if (state === 'closed') {
|
|
1872
|
-
|
|
1873
|
-
status: 'expired',
|
|
1874
|
-
prepaid: { ...p, lapsedAt: now(), url: undefined },
|
|
1875
|
-
});
|
|
2190
|
+
patch(row.id, { status: 'expired' }, { lapsedAt: now(), url: undefined });
|
|
1876
2191
|
}
|
|
1877
2192
|
return;
|
|
1878
2193
|
}
|
|
1879
2194
|
if (!p.paidAt)
|
|
1880
2195
|
return;
|
|
2196
|
+
// Cancelled with the money not decided - at Huazhu's end (a lookup or a
|
|
2197
|
+
// follow-up query saw HOTEL_CANCEL): free means all of it back, anything
|
|
2198
|
+
// else a person's to judge (audit P0-B).
|
|
2199
|
+
if (row.status === 'cancelled' && !p.refund && !p.kept) {
|
|
2200
|
+
if (row.attention)
|
|
2201
|
+
return;
|
|
2202
|
+
const at = row.cancelledAt ? Date.parse(row.cancelledAt) : now();
|
|
2203
|
+
const owed = owedOnCancel(row, row.cancellationFee?.minor ?? feeNow(row, at).fee, false);
|
|
2204
|
+
patch(row.id, owed.row, owed.fields);
|
|
2205
|
+
if (owed.fields.refund)
|
|
2206
|
+
await refundOf(row.id, owed.fields.refund.reason);
|
|
2207
|
+
return;
|
|
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
|
+
}
|
|
1881
2245
|
// ⚠️ HUAZHU'S ORDER NUMBER ON THE PAYMENT, ONCE (总线程, 2026-10-15), so
|
|
1882
2246
|
// Stripe's dashboard and Huazhu's order are one look apart. Tried once:
|
|
1883
2247
|
// a failure is said in the log and never touches the booking.
|
|
1884
2248
|
if (row.status === 'confirmed' && row.resId && !p.noted && !p.refund && checkout.annotate) {
|
|
1885
|
-
|
|
2249
|
+
patch(row.id, {}, { noted: now() });
|
|
1886
2250
|
try {
|
|
1887
2251
|
await checkout.annotate(p.sessionId ?? '', {
|
|
1888
2252
|
huazhu_res_id: row.resId,
|
|
@@ -1894,25 +2258,25 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1894
2258
|
}
|
|
1895
2259
|
return;
|
|
1896
2260
|
}
|
|
1897
|
-
if (!p.refund?.requestId &&
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
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');
|
|
1903
2269
|
return;
|
|
1904
2270
|
}
|
|
1905
2271
|
const refund = p.refund;
|
|
1906
2272
|
if (refund?.requestId && !['refunded', 'failed', 'rejected'].includes(refund.status ?? '')) {
|
|
1907
2273
|
try {
|
|
1908
2274
|
const status = await checkout.refundState(refund.requestId);
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
console.error(`[huazhu prepaid ${row.id}] ⚠️ NEEDS A PERSON: ${why}`);
|
|
1915
|
-
}
|
|
2275
|
+
const latest = store.get(row.id)?.prepaid?.refund;
|
|
2276
|
+
if (latest && status !== latest.status)
|
|
2277
|
+
patch(row.id, {}, { refund: { ...latest, status } });
|
|
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`);
|
|
1916
2280
|
}
|
|
1917
2281
|
catch {
|
|
1918
2282
|
// asked again on the next pass
|
|
@@ -2046,7 +2410,7 @@ export const createHuazhuHotels = (opts) => {
|
|
|
2046
2410
|
const record = {
|
|
2047
2411
|
method: checkout.method,
|
|
2048
2412
|
payBy: now() + payWithin,
|
|
2049
|
-
...(plan.token ? { preBookToken: plan.token } : {}),
|
|
2413
|
+
...(plan.token ? { preBookToken: plan.token, prebookAt: now() } : {}),
|
|
2050
2414
|
after: s.after,
|
|
2051
2415
|
...(s.before ? { before: s.before } : {}),
|
|
2052
2416
|
...(s.rack ? { rack: s.rack } : {}),
|
|
@@ -2103,6 +2467,8 @@ export const createHuazhuHotels = (opts) => {
|
|
|
2103
2467
|
amountMinor: allIn,
|
|
2104
2468
|
currency: cur,
|
|
2105
2469
|
email: guest.email,
|
|
2470
|
+
// A minute past the booking's own deadline - ours closes it first.
|
|
2471
|
+
expiresAt: record.payBy + 60_000,
|
|
2106
2472
|
});
|
|
2107
2473
|
}
|
|
2108
2474
|
catch (err) {
|
|
@@ -2239,7 +2605,14 @@ export const createHuazhuHotels = (opts) => {
|
|
|
2239
2605
|
let answer;
|
|
2240
2606
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
2241
2607
|
try {
|
|
2242
|
-
|
|
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
|
+
}
|
|
2243
2616
|
if (got && typeof got.resId === 'string' && got.resId)
|
|
2244
2617
|
answer = got;
|
|
2245
2618
|
}
|
|
@@ -2294,23 +2667,6 @@ export const createHuazhuHotels = (opts) => {
|
|
|
2294
2667
|
}
|
|
2295
2668
|
return { booked: made.map((b) => publicBooking(store.get(b.id) ?? b)), failed };
|
|
2296
2669
|
};
|
|
2297
|
-
/**
|
|
2298
|
-
* Waits its turn behind the pass and the landing page - the one at a time
|
|
2299
|
-
* every step on a booking paid in advance takes - then runs.
|
|
2300
|
-
*/
|
|
2301
|
-
const inTurn = async (fn) => {
|
|
2302
|
-
for (let i = 0; ticking && i < 400; i += 1)
|
|
2303
|
-
await new Promise((r) => setTimeout(r, 25));
|
|
2304
|
-
if (ticking)
|
|
2305
|
-
throw new Error('A payment here is being settled right now. Ask again in a moment.');
|
|
2306
|
-
ticking = true;
|
|
2307
|
-
try {
|
|
2308
|
-
return await fn();
|
|
2309
|
-
}
|
|
2310
|
-
finally {
|
|
2311
|
-
ticking = false;
|
|
2312
|
-
}
|
|
2313
|
-
};
|
|
2314
2670
|
/**
|
|
2315
2671
|
* Pay a booking waiting for payment with UnionPay instead of the card page
|
|
2316
2672
|
* (robin, 2026-10-15). The card page is closed first - paid on it in the
|
|
@@ -2341,6 +2697,20 @@ export const createHuazhuHotels = (opts) => {
|
|
|
2341
2697
|
const no = notWaiting(first);
|
|
2342
2698
|
if (no)
|
|
2343
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');
|
|
2704
|
+
// ⚠️ COUNTED BEFORE ANYTHING REACHES THE GATEWAY (audit P0-E).
|
|
2705
|
+
const keys = [
|
|
2706
|
+
[`b:${first.id}`, UNIONPAY_ASKS.booking],
|
|
2707
|
+
[`s:${conversationOf(who)}`, UNIONPAY_ASKS.conversation],
|
|
2708
|
+
...(who.ip ? [[`ip:${who.ip}`, UNIONPAY_ASKS.ip]] : []),
|
|
2709
|
+
];
|
|
2710
|
+
if (keys.some(([k, most]) => unionPayAsks.count(k) >= most))
|
|
2711
|
+
return refused(`Paying ${first.confirmationCode} with UnionPay was asked for too often this hour. Nothing changed - the card page still takes the payment, or ask again later.`, 'too-many-tries');
|
|
2712
|
+
for (const [k] of keys)
|
|
2713
|
+
unionPayAsks.add(k);
|
|
2344
2714
|
if (first.prepaid?.method === 'unionpay' && first.prepaid.url)
|
|
2345
2715
|
return { kind: 'confirm', url: first.prepaid.url, booking: publicBooking(first) };
|
|
2346
2716
|
let cards;
|
|
@@ -2352,15 +2722,26 @@ export const createHuazhuHotels = (opts) => {
|
|
|
2352
2722
|
}
|
|
2353
2723
|
const card = cards[0];
|
|
2354
2724
|
// No card yet: UnionPay's page to bind one - and nothing else changes.
|
|
2725
|
+
// The same page for half an hour (audit P0-E): asked again, it is handed
|
|
2726
|
+
// out again, not asked of the gateway and UnionPay again.
|
|
2355
2727
|
if (!card) {
|
|
2728
|
+
const p0 = first.prepaid;
|
|
2729
|
+
if (p0?.enrollUrl && p0.enrollAt !== undefined && now() - p0.enrollAt < ENROLL_REUSE_MS)
|
|
2730
|
+
return { kind: 'enroll', url: p0.enrollUrl };
|
|
2356
2731
|
try {
|
|
2357
|
-
|
|
2732
|
+
const { url } = await unionpay.enroll(first.guest.email);
|
|
2733
|
+
patch(first.id, {}, { enrollUrl: url, enrollAt: now() });
|
|
2734
|
+
return { kind: 'enroll', url };
|
|
2358
2735
|
}
|
|
2359
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');
|
|
2360
2741
|
return refused(`UnionPay's page for adding a card could not be opened (${err.message}). Nothing changed - ask again.`, 'unreachable');
|
|
2361
2742
|
}
|
|
2362
2743
|
}
|
|
2363
|
-
return
|
|
2744
|
+
return inTurnFor(first.id, async () => {
|
|
2364
2745
|
const row = store.get(first.id) ?? first;
|
|
2365
2746
|
const again = notWaiting(row);
|
|
2366
2747
|
if (again)
|
|
@@ -2368,21 +2749,6 @@ export const createHuazhuHotels = (opts) => {
|
|
|
2368
2749
|
const p = row.prepaid;
|
|
2369
2750
|
if (p.method === 'unionpay' && p.url)
|
|
2370
2751
|
return { kind: 'confirm', url: p.url, booking: publicBooking(row) };
|
|
2371
|
-
// The card page first: closed, it cannot be paid after this.
|
|
2372
|
-
if (p.method !== 'unionpay' && p.sessionId) {
|
|
2373
|
-
let state;
|
|
2374
|
-
try {
|
|
2375
|
-
state = await checkout.close(p.sessionId);
|
|
2376
|
-
}
|
|
2377
|
-
catch (err) {
|
|
2378
|
-
return refused(`The card page could not be closed (${err.message}). Nothing changed - ask again.`, 'unreachable');
|
|
2379
|
-
}
|
|
2380
|
-
if (state === 'paid') {
|
|
2381
|
-
await settlePrepaid(row);
|
|
2382
|
-
return { kind: 'paid-on-card', booking: publicBooking(store.get(row.id) ?? row) };
|
|
2383
|
-
}
|
|
2384
|
-
store.update(row.id, { prepaid: { ...p, sessionId: undefined, url: undefined } });
|
|
2385
|
-
}
|
|
2386
2752
|
const title = row.quote.lines[0]?.title ?? row.hotelName;
|
|
2387
2753
|
// Prebooked again the way it will be booked: Huazhu's token holds only
|
|
2388
2754
|
// for the same request (wire.ts).
|
|
@@ -2407,16 +2773,48 @@ export const createHuazhuHotels = (opts) => {
|
|
|
2407
2773
|
}))?.preBookToken;
|
|
2408
2774
|
}
|
|
2409
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.
|
|
2410
2778
|
if (err instanceof HuazhuRefusal) {
|
|
2411
|
-
store.update(row.id, {
|
|
2412
|
-
status: 'expired',
|
|
2413
|
-
prepaid: { ...p, sessionId: undefined, url: undefined, lapsedAt: now() },
|
|
2414
|
-
});
|
|
2415
2779
|
const said = refusalCode(err, row.rooms).code;
|
|
2416
|
-
|
|
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');
|
|
2417
2799
|
}
|
|
2418
2800
|
return refused(`Huazhu could not be reached to check the stay (${err.message}). Nothing was charged - ask again.`, 'unreachable');
|
|
2419
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
|
+
}
|
|
2420
2818
|
const names = hotelNamesOf(row.hotelId);
|
|
2421
2819
|
const nights = row.quote.lines[0]?.nights ?? 1;
|
|
2422
2820
|
let opened;
|
|
@@ -2437,16 +2835,12 @@ export const createHuazhuHotels = (opts) => {
|
|
|
2437
2835
|
catch (err) {
|
|
2438
2836
|
return refused(`UnionPay's page could not be opened (${err.message}). Nothing was charged - ask again.`, 'unreachable');
|
|
2439
2837
|
}
|
|
2440
|
-
const updated =
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
url: opened.url,
|
|
2447
|
-
...(token ? { preBookToken: token } : {}),
|
|
2448
|
-
payBy: now() + payWithin,
|
|
2449
|
-
},
|
|
2838
|
+
const updated = patch(row.id, { paymentType: 'CCPN' }, {
|
|
2839
|
+
method: 'unionpay',
|
|
2840
|
+
sessionId: opened.sessionId,
|
|
2841
|
+
url: opened.url,
|
|
2842
|
+
...(token ? { preBookToken: token, prebookAt: now() } : {}),
|
|
2843
|
+
payBy: now() + payWithin,
|
|
2450
2844
|
}) ?? row;
|
|
2451
2845
|
return { kind: 'confirm', url: opened.url, booking: publicBooking(updated) };
|
|
2452
2846
|
});
|
|
@@ -2468,14 +2862,27 @@ export const createHuazhuHotels = (opts) => {
|
|
|
2468
2862
|
: 'not-paid'
|
|
2469
2863
|
: row.status === 'expired'
|
|
2470
2864
|
? 'not-paid'
|
|
2471
|
-
:
|
|
2472
|
-
?
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
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';
|
|
2476
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;
|
|
2477
2880
|
return {
|
|
2478
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
|
+
: {}),
|
|
2479
2886
|
code: row.confirmationCode,
|
|
2480
2887
|
hotelName: row.hotelName,
|
|
2481
2888
|
...(names ? { hotelNames: names } : {}),
|
|
@@ -2500,25 +2907,40 @@ export const createHuazhuHotels = (opts) => {
|
|
|
2500
2907
|
return undefined;
|
|
2501
2908
|
const at = now();
|
|
2502
2909
|
if (at - (returned.get(sessionId) ?? Number.NEGATIVE_INFINITY) >= 10_000 &&
|
|
2503
|
-
!ticking &&
|
|
2504
2910
|
moneyPending(found)) {
|
|
2505
2911
|
returned.set(sessionId, at);
|
|
2506
2912
|
if (returned.size > 1_000)
|
|
2507
2913
|
for (const [k, v] of returned)
|
|
2508
2914
|
if (at - v > 10 * 60_000)
|
|
2509
2915
|
returned.delete(k);
|
|
2510
|
-
ticking = true;
|
|
2511
2916
|
try {
|
|
2512
|
-
|
|
2917
|
+
// Under the booking's own lock - taken by the pass meanwhile, this
|
|
2918
|
+
// step is the pass's to take (audit P0-A).
|
|
2919
|
+
await ifFree(found.id, () => settlePrepaid(found));
|
|
2513
2920
|
}
|
|
2514
2921
|
catch (err) {
|
|
2515
2922
|
console.error(`[huazhu prepaid ${found.id}] the landing page's step failed: ${err.message}`);
|
|
2516
2923
|
}
|
|
2517
|
-
finally {
|
|
2518
|
-
ticking = false;
|
|
2519
|
-
}
|
|
2520
2924
|
}
|
|
2521
|
-
|
|
2925
|
+
const row = store.get(found.id) ?? found;
|
|
2926
|
+
const told = returnOf(row);
|
|
2927
|
+
// ⚠️ "NOT PAID" ONLY WHEN STRIPE SAYS SO (audit P0-D): a pass busy on this
|
|
2928
|
+
// booking, or behind on others, left a guest who had paid told "nothing
|
|
2929
|
+
// was charged - book again". Not paid here yet, the page is asked - only
|
|
2930
|
+
// read, never moved on.
|
|
2931
|
+
if (told.state === 'not-paid' &&
|
|
2932
|
+
row.status === 'awaiting-payment' &&
|
|
2933
|
+
row.prepaid?.method !== 'unionpay' &&
|
|
2934
|
+
row.prepaid?.sessionId &&
|
|
2935
|
+
checkout) {
|
|
2936
|
+
const page = await checkout.state(row.prepaid.sessionId).catch(() => undefined);
|
|
2937
|
+
if (page === 'paid')
|
|
2938
|
+
return { ...told, state: 'booking' };
|
|
2939
|
+
// Cannot be read just now: the page looks again (香格里拉's `checking`).
|
|
2940
|
+
if (page === undefined)
|
|
2941
|
+
return { ...told, state: 'checking' };
|
|
2942
|
+
}
|
|
2943
|
+
return told;
|
|
2522
2944
|
};
|
|
2523
2945
|
return {
|
|
2524
2946
|
brand: opts.brand,
|
|
@@ -2536,18 +2958,20 @@ export const createHuazhuHotels = (opts) => {
|
|
|
2536
2958
|
...(checkout ? { paymentReturn } : {}),
|
|
2537
2959
|
...(checkout && unionpay ? { unionPay } : {}),
|
|
2538
2960
|
async prepaidTick() {
|
|
2539
|
-
// ⚠️
|
|
2540
|
-
//
|
|
2541
|
-
|
|
2961
|
+
// ⚠️ EACH BOOKING UNDER ITS OWN LOCK (audit P0-A): two passes, or a
|
|
2962
|
+
// pass and the landing page, never take a step on one booking at once.
|
|
2963
|
+
// One booking's failure holds up none of the others (audit P2).
|
|
2964
|
+
if (closing)
|
|
2542
2965
|
return;
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2966
|
+
for (const row of store.all()) {
|
|
2967
|
+
if (!moneyPending(row))
|
|
2968
|
+
continue;
|
|
2969
|
+
try {
|
|
2970
|
+
await ifFree(row.id, () => settlePrepaid(row));
|
|
2971
|
+
}
|
|
2972
|
+
catch (err) {
|
|
2973
|
+
console.error(`[huazhu prepaid ${row.id}] the pass's step failed: ${err.message}`);
|
|
2974
|
+
}
|
|
2551
2975
|
}
|
|
2552
2976
|
},
|
|
2553
2977
|
imageDomains() {
|
|
@@ -2612,7 +3036,9 @@ export const createHuazhuHotels = (opts) => {
|
|
|
2612
3036
|
*/
|
|
2613
3037
|
async drain() {
|
|
2614
3038
|
closing = true;
|
|
2615
|
-
|
|
3039
|
+
// The steps on payments too - a refund or a booking half taken when the
|
|
3040
|
+
// process stopped was lost (audit P1-3, P0-B).
|
|
3041
|
+
await Promise.allSettled([...new Set(flights.values()), ...steps]);
|
|
2616
3042
|
},
|
|
2617
3043
|
async searchHotels(q) {
|
|
2618
3044
|
const terms = (q.query ?? '')
|
|
@@ -3222,6 +3648,30 @@ export const createHuazhuHotels = (opts) => {
|
|
|
3222
3648
|
};
|
|
3223
3649
|
}
|
|
3224
3650
|
const { fee, forbidden, rule, freeUntil } = feeNow(b);
|
|
3651
|
+
// A test booking says it is one (2026-10-16 audit, P2): Huazhu's test
|
|
3652
|
+
// system charges nobody and books nothing anyone stays in.
|
|
3653
|
+
const testNote = opts.testEnvironment
|
|
3654
|
+
? " This is a test booking in Huazhu's test system: nothing is charged to anyone."
|
|
3655
|
+
: '';
|
|
3656
|
+
// Paid with UnionPay: Huazhu charged the one-time card, and gives back
|
|
3657
|
+
// to it what its terms allow - nothing passes through here (audit P0-C:
|
|
3658
|
+
// it was told as "paid at the hotel, nothing paid through here").
|
|
3659
|
+
if (b.prepaid?.method === 'unionpay' && b.prepaid.issuedAt) {
|
|
3660
|
+
const back = !forbidden && fee === 0;
|
|
3661
|
+
return {
|
|
3662
|
+
bookingId: b.id,
|
|
3663
|
+
confirmationCode: b.confirmationCode,
|
|
3664
|
+
fee: money(forbidden ? 0 : fee, cur),
|
|
3665
|
+
refund: back ? b.quote.total : zero,
|
|
3666
|
+
rule: forbidden
|
|
3667
|
+
? `This rate cannot be cancelled now. ${rule} Huazhu keeps the ${formatMoney(b.quote.total)} it charged to the UnionPay card.${testNote}`
|
|
3668
|
+
: back
|
|
3669
|
+
? `${rule} Paid with a UnionPay card that Huazhu charged: cancelling now, Huazhu gives all ${formatMoney(b.quote.total)} back to that same card - not through here.${testNote}`
|
|
3670
|
+
: `${rule} Paid with a UnionPay card that Huazhu charged: cancelling now gives nothing back - the fee is the whole stay, and Huazhu keeps it.${testNote}`,
|
|
3671
|
+
...(freeUntil ? { freeUntil: new Date(freeUntil).toISOString() } : {}),
|
|
3672
|
+
cancellable: !forbidden,
|
|
3673
|
+
};
|
|
3674
|
+
}
|
|
3225
3675
|
if (b.prepaid?.paidAt) {
|
|
3226
3676
|
// Paid here in advance: free means all of it back, anything else -
|
|
3227
3677
|
// the whole stay, on these rates - means none.
|
|
@@ -3234,8 +3684,8 @@ export const createHuazhuHotels = (opts) => {
|
|
|
3234
3684
|
rule: forbidden
|
|
3235
3685
|
? `This rate cannot be cancelled now. ${rule} The ${formatMoney(b.quote.total)} paid stays paid.`
|
|
3236
3686
|
: back
|
|
3237
|
-
? `${rule} Cancelling now gives back all ${formatMoney(b.quote.total)} paid, to the card it was paid with.${opts.sandbox ? ' This is a test booking in the simulated Huazhu, paid in the gateway’s test mode: no real money moves.' : ''}`
|
|
3238
|
-
: `${rule} Cancelling now gives nothing back: the fee is the whole stay
|
|
3687
|
+
? `${rule} Cancelling now gives back all ${formatMoney(b.quote.total)} paid, to the card it was paid with.${opts.sandbox ? ' This is a test booking in the simulated Huazhu, paid in the gateway’s test mode: no real money moves.' : opts.testEnvironment ? ' This is a test booking in Huazhu’s test system, paid in test mode: no real money moves.' : ''}`
|
|
3688
|
+
: `${rule} Cancelling now gives nothing back: the fee is the whole stay.${testNote}`,
|
|
3239
3689
|
...(freeUntil ? { freeUntil: new Date(freeUntil).toISOString() } : {}),
|
|
3240
3690
|
cancellable: !forbidden,
|
|
3241
3691
|
};
|
|
@@ -3250,7 +3700,9 @@ export const createHuazhuHotels = (opts) => {
|
|
|
3250
3700
|
? `This rate cannot be cancelled now. ${rule}`
|
|
3251
3701
|
: opts.sandbox
|
|
3252
3702
|
? `${rule} This is a test booking in the simulated Huazhu: the fee is worked out as a real booking's would be, and nothing is charged to anyone.`
|
|
3253
|
-
:
|
|
3703
|
+
: opts.testEnvironment
|
|
3704
|
+
? `${rule} This is a test booking in Huazhu's test system: the fee is worked out as a real booking's would be, and nothing is charged to anyone.`
|
|
3705
|
+
: `${rule} Nothing was paid through this connector - the booking is paid at the hotel - so any fee is charged under the hotel's terms, and Huazhu works out the exact amount when it cancels.`,
|
|
3254
3706
|
...(freeUntil ? { freeUntil: new Date(freeUntil).toISOString() } : {}),
|
|
3255
3707
|
cancellable: !forbidden,
|
|
3256
3708
|
};
|
|
@@ -3259,80 +3711,94 @@ export const createHuazhuHotels = (opts) => {
|
|
|
3259
3711
|
const found = findOwn(ref, who);
|
|
3260
3712
|
if (!found)
|
|
3261
3713
|
return null;
|
|
3262
|
-
const
|
|
3263
|
-
//
|
|
3264
|
-
// the
|
|
3265
|
-
|
|
3266
|
-
const
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
if (
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
|
|
3714
|
+
const first = await refresh(found);
|
|
3715
|
+
// A booking paid in advance is cancelled under its own lock - the pass,
|
|
3716
|
+
// the landing page and a switch to UnionPay wait (audit P0-A).
|
|
3717
|
+
const run = async () => {
|
|
3718
|
+
const b = store.get(first.id) ?? first;
|
|
3719
|
+
// Waiting on UnionPay: a confirmation already made is being booked by
|
|
3720
|
+
// the pass - which alone takes the card, once; one not made never will be.
|
|
3721
|
+
if (b.status === 'awaiting-payment' && b.prepaid?.method === 'unionpay') {
|
|
3722
|
+
const state = b.prepaid.sessionId && unionpay
|
|
3723
|
+
? await unionpay.state(b.prepaid.sessionId).catch(() => undefined)
|
|
3724
|
+
: 'closed';
|
|
3725
|
+
if (state !== 'issued' && state !== undefined) {
|
|
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);
|
|
3729
|
+
}
|
|
3730
|
+
throw new Error(state === 'issued'
|
|
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.'
|
|
3732
|
+
: 'UnionPay could not be reached to say whether the guest confirmed. Ask again in a moment - nothing is booked until it can.');
|
|
3274
3733
|
}
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
|
|
3282
|
-
|
|
3283
|
-
|
|
3284
|
-
|
|
3285
|
-
|
|
3286
|
-
|
|
3734
|
+
// Not paid yet: the page is closed, so it cannot be paid after this.
|
|
3735
|
+
if (b.status === 'awaiting-payment' && b.prepaid?.sessionId && checkout) {
|
|
3736
|
+
const state = await checkout.close(b.prepaid.sessionId).catch(() => undefined);
|
|
3737
|
+
if (state === 'closed') {
|
|
3738
|
+
return publicBooking(patch(b.id, { status: 'expired' }, { lapsedAt: now(), url: undefined }) ?? b);
|
|
3739
|
+
}
|
|
3740
|
+
// ⚠️ NO ANSWER IS NOT "PAID" (audit P0-C): the page may well still be
|
|
3741
|
+
// open and unpaid.
|
|
3742
|
+
if (state === undefined)
|
|
3743
|
+
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.');
|
|
3744
|
+
// Paid in the last second: it is booked now, and cancelled as one.
|
|
3745
|
+
await settlePrepaid(b);
|
|
3746
|
+
throw new Error('The guest has just paid, so the stay is being booked with Huazhu now. Ask again in a moment to cancel it - a cancellation in the free period gives all the money back.');
|
|
3287
3747
|
}
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
|
|
3297
|
-
|
|
3298
|
-
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
|
|
3302
|
-
|
|
3303
|
-
? money(toMinor(out.penaltyAmount, out.penaltyCurrencyCode || cur), out.penaltyCurrencyCode || cur)
|
|
3304
|
-
: money(feeNow(b).fee, cur);
|
|
3305
|
-
const cancelled = store.update(b.id, {
|
|
3306
|
-
status: 'cancelled',
|
|
3307
|
-
hotelStatus: 'HOTEL_CANCEL',
|
|
3308
|
-
cancelledAt: new Date(now()).toISOString(),
|
|
3309
|
-
cancellationFee: fee,
|
|
3310
|
-
}) ?? b;
|
|
3311
|
-
// Paid in advance and cancelled free: all of it back. A fee here is
|
|
3312
|
-
// the whole stay (only whole-or-nothing rates are sold this way), so
|
|
3313
|
-
// nothing comes back.
|
|
3314
|
-
if (cancelled.prepaid?.paidAt && fee.minor === 0)
|
|
3315
|
-
await refundOf(b.id, 'cancelled within the free cancellation period');
|
|
3316
|
-
return publicBooking(store.get(b.id) ?? cancelled);
|
|
3317
|
-
}
|
|
3318
|
-
catch (err) {
|
|
3319
|
-
if (err instanceof HuazhuRefusal && err.code === 5402) {
|
|
3320
|
-
return publicBooking(store.update(b.id, {
|
|
3748
|
+
if (b.pendingSince !== undefined || b.status === 'unconfirmed') {
|
|
3749
|
+
throw new Error(waitingWords(b));
|
|
3750
|
+
}
|
|
3751
|
+
if (!b.resId)
|
|
3752
|
+
return publicBooking(b);
|
|
3753
|
+
if (b.status !== 'confirmed')
|
|
3754
|
+
return publicBooking(b);
|
|
3755
|
+
const cur = b.quote.total.currency;
|
|
3756
|
+
// ⚠️ THE CANCELLATION AND WHAT IT OWES IN ONE WRITE, then the refund
|
|
3757
|
+
// (audit P0-B) - a process stopped in between leaves the refund to
|
|
3758
|
+
// open, which the pass opens.
|
|
3759
|
+
const cancelled = (fee, at) => {
|
|
3760
|
+
const latest = store.get(b.id) ?? b;
|
|
3761
|
+
const owed = owedOnCancel(latest, fee.minor, true);
|
|
3762
|
+
const done = patch(b.id, {
|
|
3321
3763
|
status: 'cancelled',
|
|
3322
3764
|
hotelStatus: 'HOTEL_CANCEL',
|
|
3323
|
-
cancelledAt:
|
|
3324
|
-
|
|
3325
|
-
|
|
3326
|
-
|
|
3327
|
-
|
|
3328
|
-
|
|
3329
|
-
|
|
3765
|
+
cancelledAt: at,
|
|
3766
|
+
cancellationFee: fee,
|
|
3767
|
+
...owed.row,
|
|
3768
|
+
}, owed.fields) ?? b;
|
|
3769
|
+
return publicBooking(done);
|
|
3770
|
+
};
|
|
3771
|
+
try {
|
|
3772
|
+
const out = await client.cancel(b.resId);
|
|
3773
|
+
const fee = typeof out.penaltyAmount === 'number'
|
|
3774
|
+
? money(toMinor(out.penaltyAmount, out.penaltyCurrencyCode || cur), out.penaltyCurrencyCode || cur)
|
|
3775
|
+
: money(feeNow(b).fee, cur);
|
|
3776
|
+
cancelled(fee, new Date(now()).toISOString());
|
|
3330
3777
|
}
|
|
3331
|
-
|
|
3332
|
-
|
|
3778
|
+
catch (err) {
|
|
3779
|
+
if (err instanceof HuazhuRefusal && err.code === 5402) {
|
|
3780
|
+
// Cancelled already - by this guest's own earlier try whose answer
|
|
3781
|
+
// was lost, most likely: owed as that cancellation was (audit P0-B).
|
|
3782
|
+
cancelled(b.cancellationFee ?? money(feeNow(b).fee, cur), b.cancelledAt ?? new Date(now()).toISOString());
|
|
3783
|
+
}
|
|
3784
|
+
else if (err instanceof HuazhuRefusal && (err.code === 5401 || err.code === 5405)) {
|
|
3785
|
+
throw new Error(err.code === 5405
|
|
3786
|
+
? 'Huazhu says this stay has already started, so it cannot be cancelled.'
|
|
3787
|
+
: 'Huazhu says this booking cannot be cancelled.');
|
|
3788
|
+
}
|
|
3789
|
+
else if (err instanceof HuazhuUnreachable) {
|
|
3790
|
+
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.');
|
|
3791
|
+
}
|
|
3792
|
+
else
|
|
3793
|
+
throw err;
|
|
3333
3794
|
}
|
|
3334
|
-
|
|
3335
|
-
|
|
3795
|
+
const after = store.get(b.id);
|
|
3796
|
+
const refund = after?.prepaid?.refund;
|
|
3797
|
+
if (refund && !refund.requestId)
|
|
3798
|
+
await refundOf(b.id, refund.reason);
|
|
3799
|
+
return publicBooking(store.get(b.id) ?? after ?? b);
|
|
3800
|
+
};
|
|
3801
|
+
return first.prepaid ? inTurnFor(first.id, run) : run();
|
|
3336
3802
|
},
|
|
3337
3803
|
async policies() {
|
|
3338
3804
|
// Huazhu publishes no policy text: each RATE carries its terms, and those
|