@curless/hotel-storefront 0.26.2 → 0.27.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/audit-p0.test.d.ts +2 -0
- package/dist/__tests__/audit-p0.test.d.ts.map +1 -0
- package/dist/__tests__/audit-p0.test.js +274 -0
- package/dist/__tests__/audit-p0.test.js.map +1 -0
- package/dist/__tests__/card-contract.test.js +15 -0
- package/dist/__tests__/card-contract.test.js.map +1 -1
- package/dist/__tests__/demo-hotels.test.js +2 -1
- package/dist/__tests__/demo-hotels.test.js.map +1 -1
- package/dist/__tests__/huazhu-audit.test.js +19 -0
- package/dist/__tests__/huazhu-audit.test.js.map +1 -1
- package/dist/__tests__/huazhu-backend.test.js +510 -14
- package/dist/__tests__/huazhu-backend.test.js.map +1 -1
- package/dist/__tests__/huazhu-sandbox.test.js +1 -1
- package/dist/__tests__/huazhu-sandbox.test.js.map +1 -1
- package/dist/__tests__/server.test.js +1 -1
- package/dist/__tests__/server.test.js.map +1 -1
- package/dist/__tests__/tools.test.js +13 -7
- package/dist/__tests__/tools.test.js.map +1 -1
- package/dist/backend.d.ts +30 -0
- package/dist/backend.d.ts.map +1 -1
- package/dist/backend.js +37 -0
- package/dist/backend.js.map +1 -1
- package/dist/card.generated.d.ts +1 -1
- package/dist/card.generated.js +2 -2
- package/dist/card.generated.js.map +1 -1
- package/dist/demo-hotels.d.ts +14 -0
- package/dist/demo-hotels.d.ts.map +1 -1
- package/dist/demo-hotels.js +100 -12
- package/dist/demo-hotels.js.map +1 -1
- package/dist/huazhu/backend.d.ts +11 -2
- package/dist/huazhu/backend.d.ts.map +1 -1
- package/dist/huazhu/backend.js +524 -271
- package/dist/huazhu/backend.js.map +1 -1
- package/dist/huazhu/bookings.d.ts +119 -9
- package/dist/huazhu/bookings.d.ts.map +1 -1
- package/dist/huazhu/bookings.js +355 -66
- package/dist/huazhu/bookings.js.map +1 -1
- package/dist/huazhu/catalog.d.ts.map +1 -1
- package/dist/huazhu/catalog.js +11 -1
- package/dist/huazhu/catalog.js.map +1 -1
- package/dist/huazhu/check.d.ts.map +1 -1
- package/dist/huazhu/check.js +19 -4
- package/dist/huazhu/check.js.map +1 -1
- package/dist/huazhu/client.d.ts +26 -0
- package/dist/huazhu/client.d.ts.map +1 -1
- package/dist/huazhu/client.js +77 -2
- package/dist/huazhu/client.js.map +1 -1
- package/dist/huazhu/fake.d.ts +12 -2
- package/dist/huazhu/fake.d.ts.map +1 -1
- package/dist/huazhu/fake.js +17 -4
- package/dist/huazhu/fake.js.map +1 -1
- package/dist/huazhu/origin.d.ts +29 -0
- package/dist/huazhu/origin.d.ts.map +1 -0
- package/dist/huazhu/origin.js +69 -0
- package/dist/huazhu/origin.js.map +1 -0
- package/dist/huazhu/sandbox.d.ts.map +1 -1
- package/dist/huazhu/sandbox.js +5 -1
- package/dist/huazhu/sandbox.js.map +1 -1
- package/dist/huazhu/sellable.d.ts +6 -1
- package/dist/huazhu/sellable.d.ts.map +1 -1
- package/dist/huazhu/sellable.js +14 -3
- package/dist/huazhu/sellable.js.map +1 -1
- package/dist/huazhu/wire.d.ts +28 -4
- package/dist/huazhu/wire.d.ts.map +1 -1
- package/dist/huazhu/wire.js +42 -3
- package/dist/huazhu/wire.js.map +1 -1
- package/dist/index.js +13 -5
- package/dist/index.js.map +1 -1
- package/dist/limits.d.ts +10 -0
- package/dist/limits.d.ts.map +1 -1
- package/dist/limits.js +25 -2
- package/dist/limits.js.map +1 -1
- package/dist/server.d.ts +25 -8
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +114 -9
- package/dist/server.js.map +1 -1
- package/dist/sessions.d.ts +31 -4
- package/dist/sessions.d.ts.map +1 -1
- package/dist/sessions.js +66 -15
- package/dist/sessions.js.map +1 -1
- package/dist/state.d.ts +16 -0
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js +33 -3
- package/dist/state.js.map +1 -1
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +63 -18
- package/dist/tools.js.map +1 -1
- package/dist/types.d.ts +16 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +9 -11
package/dist/huazhu/backend.js
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import { randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
-
import { DEFAULT_PAGE, MAX_PAGE, REQUEST_KINDS, REQUEST_NOTE_MAX, } from '../backend.js';
|
|
2
|
+
import { DEFAULT_PAGE, MAX_PAGE, REQUEST_KINDS, REQUEST_NOTE_MAX, guestTooLong, partyNeeds, } from '../backend.js';
|
|
3
3
|
import { addDays, atHotelTime, nights as nightsBetween, stayProblem, todayAt } from '../dates.js';
|
|
4
4
|
import { byWords, splitByStay } from '../demo-hotels.js';
|
|
5
5
|
import { FACILITIES, hasFacility } from '../facilities.js';
|
|
6
6
|
import { BOOKING_LIMITS, DAY_MS, HOUR_MS, WindowCounter } from '../limits.js';
|
|
7
7
|
import { formatMoney, minorFactor, money, multiplyMoney, toMinor } from '../money.js';
|
|
8
8
|
import { MAX_ROOMS_PER_LINE } from '../trip.js';
|
|
9
|
-
import { statusOf } from './bookings.js';
|
|
9
|
+
import { IN_REQUEST_AT_MS, StoreWriteError, nextCheckAt, statusOf, } from './bookings.js';
|
|
10
10
|
import { describePolicy, feeFor, freeUntilOf, nightAuditOf, parseCancelPolicy, penaltyAt, refundableFrom, } from './cancel-policy.js';
|
|
11
11
|
import { haystackOf, inPlace, roomPhoto, toHotel } from './catalog.js';
|
|
12
12
|
import { HuazhuRefusal, HuazhuUnreachable } from './client.js';
|
|
13
13
|
import { ariRanges, bookableNow, clockAt, offeredAtAll, sell, } from './sellable.js';
|
|
14
|
+
import { BOOK_EXISTS, BOOK_REFUSALS, CODES, } from './wire.js';
|
|
14
15
|
export const HUAZHU_CAPABILITIES = {
|
|
15
16
|
members: false,
|
|
16
17
|
extras: false,
|
|
@@ -29,6 +30,26 @@ export const isPhone = (raw) => {
|
|
|
29
30
|
const p = (raw ?? '').trim();
|
|
30
31
|
return /^\+?[\d\s()-]+$/.test(p) && p.replace(/\D/g, '').length >= 6;
|
|
31
32
|
};
|
|
33
|
+
/**
|
|
34
|
+
* The guest's code for a booking: the connector's own, random, and the same
|
|
35
|
+
* from the moment it is asked for to the day it is forgotten.
|
|
36
|
+
*
|
|
37
|
+
* ⚠️ NEVER HUAZHU'S ORDER NUMBER. Huazhu numbers its orders in sequence
|
|
38
|
+
* (R + hotel + counter, on the sandbox 44 seconds apart: R10011014,
|
|
39
|
+
* R10021015): a code next to someone's gives their booking away to anyone
|
|
40
|
+
* who knows their email, and the count of everyone's (2026-10-05 audit, P1-6).
|
|
41
|
+
* 32 letters and digits that cannot be misread, eight of them: 2^40.
|
|
42
|
+
*/
|
|
43
|
+
const CODE_LETTERS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
|
44
|
+
export const newPublicCode = (taken) => {
|
|
45
|
+
for (;;) {
|
|
46
|
+
const code = `HZ-${[...randomBytes(8)].map((b) => CODE_LETTERS[b % 32]).join('')}`;
|
|
47
|
+
if (!taken(code))
|
|
48
|
+
return code;
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
/** The same trip asked for again this soon, by the same conversation and email, is answered with the first. */
|
|
52
|
+
const SAME_TRIP_MS = 10 * 60_000;
|
|
32
53
|
/**
|
|
33
54
|
* The most hotels priced for one search. A city can hold hundreds of Huazhu
|
|
34
55
|
* hotels, and every one priced is two calls to Huazhu; past this, the page is
|
|
@@ -156,8 +177,53 @@ export const createHuazhuHotels = (opts) => {
|
|
|
156
177
|
const limits = { ...BOOKING_LIMITS, ...(opts.limits ?? {}) };
|
|
157
178
|
const requestsByEmail = new WindowCounter(DAY_MS, now);
|
|
158
179
|
const requestsByIp = new WindowCounter(HOUR_MS, now);
|
|
159
|
-
/**
|
|
160
|
-
|
|
180
|
+
/**
|
|
181
|
+
* Bookings a request has passed the limits for and not yet written down.
|
|
182
|
+
*
|
|
183
|
+
* ⚠️ TAKEN THE MOMENT THE CHECK PASSES. The rows are written after prebook,
|
|
184
|
+
* seconds later - and five requests at once all passed a limit of three
|
|
185
|
+
* before any was written (2026-10-05 audit, P1-5).
|
|
186
|
+
*/
|
|
187
|
+
const slots = new Set();
|
|
188
|
+
const slotsWhere = (pred) => [...slots].filter(pred).reduce((a, x) => a + x.n, 0);
|
|
189
|
+
/** Requests in flight, by conversation and by email - see `single`. */
|
|
190
|
+
const flights = new Map();
|
|
191
|
+
/** Set by `drain`: a stopping process starts no booking. */
|
|
192
|
+
let closing = false;
|
|
193
|
+
/**
|
|
194
|
+
* One booking request at a time per conversation and per email.
|
|
195
|
+
*
|
|
196
|
+
* ⚠️ TWO AT ONCE BOOKED TWICE. The trip is emptied only when a request
|
|
197
|
+
* succeeds, and every request has its own distributorResId: a guest who
|
|
198
|
+
* pressed twice, or a host that retried, had two real bookings (2026-10-05
|
|
199
|
+
* audit, P1-2). The second waits for the first, then finds its trip
|
|
200
|
+
* already booked.
|
|
201
|
+
*/
|
|
202
|
+
const single = async (keys, run) => {
|
|
203
|
+
for (;;) {
|
|
204
|
+
const busy = keys.flatMap((k) => {
|
|
205
|
+
const p = flights.get(k);
|
|
206
|
+
return p ? [p] : [];
|
|
207
|
+
});
|
|
208
|
+
if (busy.length === 0)
|
|
209
|
+
break;
|
|
210
|
+
await Promise.allSettled(busy);
|
|
211
|
+
}
|
|
212
|
+
// Nothing awaited between the check above and these lines: no other
|
|
213
|
+
// request can slip in.
|
|
214
|
+
const p = run();
|
|
215
|
+
const done = p.then(() => undefined, () => undefined);
|
|
216
|
+
for (const k of keys)
|
|
217
|
+
flights.set(k, done);
|
|
218
|
+
try {
|
|
219
|
+
return await p;
|
|
220
|
+
}
|
|
221
|
+
finally {
|
|
222
|
+
for (const k of keys)
|
|
223
|
+
if (flights.get(k) === done)
|
|
224
|
+
flights.delete(k);
|
|
225
|
+
}
|
|
226
|
+
};
|
|
161
227
|
// ------------------------------------------------------------- lookups ----
|
|
162
228
|
/**
|
|
163
229
|
* Every hotel, as the connector shows it - worked out again only when the
|
|
@@ -342,7 +408,7 @@ export const createHuazhuHotels = (opts) => {
|
|
|
342
408
|
const only = (k) => kinds.size === 1 && kinds.has(k);
|
|
343
409
|
if (only('party')) {
|
|
344
410
|
const largest = Math.max(...fails.map((f) => Number(/\d+/.exec(f.why)?.[0] ?? 0)));
|
|
345
|
-
return
|
|
411
|
+
return partyNeeds(stay.guests, largest);
|
|
346
412
|
}
|
|
347
413
|
if (only('prepaid'))
|
|
348
414
|
return 'Every rate here for those nights is paid in advance, and this connector books rates paid at the hotel.';
|
|
@@ -505,24 +571,31 @@ export const createHuazhuHotels = (opts) => {
|
|
|
505
571
|
...(b.cancellationFee ? { cancellationFee: b.cancellationFee } : {}),
|
|
506
572
|
...(b.requests ? { requests: b.requests } : {}),
|
|
507
573
|
});
|
|
574
|
+
/**
|
|
575
|
+
* The codes a guest may have been given for a booking: ours, and the
|
|
576
|
+
* distributorResId a booking still waiting on Huazhu showed before 0.27.
|
|
577
|
+
* ⚠️ NEVER Huazhu's order number - see `newPublicCode`. A booking made
|
|
578
|
+
* before 0.27 showed it as its code, and is opened by it only in the
|
|
579
|
+
* conversation that made it.
|
|
580
|
+
*/
|
|
581
|
+
const codesOf = (b) => [b.confirmationCode !== b.resId ? b.confirmationCode : undefined, b.distributorResId]
|
|
582
|
+
.filter((x) => !!x)
|
|
583
|
+
.map((x) => x.toLowerCase());
|
|
508
584
|
const findOwn = (ref, who) => {
|
|
509
585
|
const wanted = ref.trim().toLowerCase();
|
|
510
586
|
if (!wanted)
|
|
511
587
|
return undefined;
|
|
512
|
-
const
|
|
513
|
-
.
|
|
514
|
-
.
|
|
515
|
-
|
|
588
|
+
const own = store
|
|
589
|
+
.all()
|
|
590
|
+
.find((b) => b.owners.includes(who.sessionId) &&
|
|
591
|
+
[b.id.toLowerCase(), b.confirmationCode.toLowerCase(), ...codesOf(b)].includes(wanted));
|
|
516
592
|
if (own || !who.email)
|
|
517
593
|
return own;
|
|
518
594
|
// ⚠️ A CODE AND ITS EMAIL, never an id and never an email alone - see the seam.
|
|
519
595
|
const email = who.email.trim().toLowerCase();
|
|
520
596
|
const there = store
|
|
521
597
|
.all()
|
|
522
|
-
.find((b) =>
|
|
523
|
-
.filter(Boolean)
|
|
524
|
-
.some((x) => String(x).toLowerCase() === wanted) &&
|
|
525
|
-
b.guest.email.trim().toLowerCase() === email);
|
|
598
|
+
.find((b) => codesOf(b).includes(wanted) && b.guest.email.trim().toLowerCase() === email);
|
|
526
599
|
if (there) {
|
|
527
600
|
const owners = [...new Set([...there.owners, who.sessionId])];
|
|
528
601
|
// The conversation that made it stays; the oldest finder goes.
|
|
@@ -557,6 +630,10 @@ export const createHuazhuHotels = (opts) => {
|
|
|
557
630
|
return b;
|
|
558
631
|
}
|
|
559
632
|
};
|
|
633
|
+
/** Why a booking cannot be cancelled yet: Huazhu has not said whether it exists. */
|
|
634
|
+
const waitingWords = (b) => b.status === 'unconfirmed'
|
|
635
|
+
? `${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.`
|
|
636
|
+
: `${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.`;
|
|
560
637
|
const feeNow = (b) => {
|
|
561
638
|
const policy = policyOf(b.cancelCode);
|
|
562
639
|
const amounts = {
|
|
@@ -582,18 +659,380 @@ export const createHuazhuHotels = (opts) => {
|
|
|
582
659
|
freeUntil: freeUntilOf(policy, b.stay.checkIn, b.tz, now()),
|
|
583
660
|
};
|
|
584
661
|
};
|
|
585
|
-
/**
|
|
586
|
-
|
|
662
|
+
/**
|
|
663
|
+
* Chase one unanswered booking call inside the guest's request - the first
|
|
664
|
+
* of its checks (`IN_REQUEST_AT_MS`), on the reconciler's own schedule; the
|
|
665
|
+
* rest are the reconciler's, spread over the eight minutes.
|
|
666
|
+
*/
|
|
667
|
+
const settle = async (id) => {
|
|
587
668
|
const until = now() + (opts.settleWithinMs ?? 25_000);
|
|
588
|
-
let
|
|
589
|
-
while (
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
669
|
+
let row = store.get(id);
|
|
670
|
+
while (row?.pendingSince !== undefined && (row.checks ?? 0) < IN_REQUEST_AT_MS.length) {
|
|
671
|
+
const due = nextCheckAt(row);
|
|
672
|
+
if (due > until)
|
|
673
|
+
break;
|
|
674
|
+
if (due > now())
|
|
675
|
+
await sleep(due - now());
|
|
676
|
+
await reconciler.check(row);
|
|
677
|
+
row = store.get(id);
|
|
593
678
|
}
|
|
594
|
-
return
|
|
679
|
+
return row;
|
|
595
680
|
};
|
|
596
681
|
const distributorResId = () => `CUR${now().toString(36).toUpperCase()}${randomBytes(6).toString('hex').toUpperCase()}`;
|
|
682
|
+
/**
|
|
683
|
+
* The conversation a booking is asked for in: the trip's owner when the
|
|
684
|
+
* card asks on a connection of its own, else the caller - what "this
|
|
685
|
+
* conversation's bookings" counts and what the same trip asked twice is
|
|
686
|
+
* matched by.
|
|
687
|
+
*/
|
|
688
|
+
const conversationOf = (who) => who.tripOwner ?? who.sessionId;
|
|
689
|
+
/** A name for a booking in what the guest is told. */
|
|
690
|
+
const named = (b) => `${b.hotelName} (${b.confirmationCode})`;
|
|
691
|
+
const isLive = (b) => (b.status === 'confirmed' || b.status === 'requested' || b.status === 'unconfirmed') &&
|
|
692
|
+
!b.rollback &&
|
|
693
|
+
b.stay.checkOut >= today();
|
|
694
|
+
/** The booking itself, one request at a time per conversation and email - see `single`. */
|
|
695
|
+
const book = async (lines, tripKey, email, guest, contact, who, requests) => {
|
|
696
|
+
// ⚠️ THE SAME TRIP ASKED FOR AGAIN IS ANSWERED WITH THE FIRST. A second
|
|
697
|
+
// press, or a host retrying a call whose answer it lost, waited for the
|
|
698
|
+
// first request (`single`) and finds it here - rather than booking every
|
|
699
|
+
// stay a second time.
|
|
700
|
+
const earlier = store
|
|
701
|
+
.all()
|
|
702
|
+
.filter((b) => b.tripKey === tripKey &&
|
|
703
|
+
b.owners.includes(conversationOf(who)) &&
|
|
704
|
+
b.guest.email.trim().toLowerCase() === email &&
|
|
705
|
+
now() - Date.parse(b.createdAt) < SAME_TRIP_MS &&
|
|
706
|
+
isLive(b));
|
|
707
|
+
const ref = earlier[0]?.tripRef;
|
|
708
|
+
if (ref &&
|
|
709
|
+
earlier.length === lines.length &&
|
|
710
|
+
earlier.every((b) => b.tripRef === ref) &&
|
|
711
|
+
(store.intent(ref)?.done ?? true)) {
|
|
712
|
+
return earlier.map(publicBooking);
|
|
713
|
+
}
|
|
714
|
+
// ⚠️ THE LIMITS - see `BookingLimits`. Each is said as what it is, and
|
|
715
|
+
// none suggests a way round itself (another email, another chat).
|
|
716
|
+
const plural = (n, one) => `${n} ${one}${n === 1 ? '' : 's'}`;
|
|
717
|
+
const liveHere = store.all().filter((b) => b.owners.includes(conversationOf(who)) && isLive(b)).length +
|
|
718
|
+
slotsWhere((x) => x.sessionId === conversationOf(who));
|
|
719
|
+
if (liveHere + lines.length > limits.perConversationLive) {
|
|
720
|
+
throw new Error(`Nothing was booked. This conversation already has ${plural(liveHere, 'booking')} still to come, and ${limits.perConversationLive} at once is the most - cancel one that is not needed, then ask again.`);
|
|
721
|
+
}
|
|
722
|
+
const liveForEmail = store.all().filter((b) => b.guest.email.trim().toLowerCase() === email && isLive(b)).length +
|
|
723
|
+
slotsWhere((x) => x.email === email);
|
|
724
|
+
if (liveForEmail + lines.length > limits.perEmailLive) {
|
|
725
|
+
throw new Error(`Nothing was booked. ${guest.email.trim()} already has ${plural(liveForEmail, 'booking')} still to come through this connector, and ${limits.perEmailLive} at once is the most - cancel one that is not needed, then ask again.`);
|
|
726
|
+
}
|
|
727
|
+
if (requestsByEmail.count(email) >= limits.perEmailPerDay) {
|
|
728
|
+
throw new Error(`Nothing was booked. ${guest.email.trim()} has asked for ${plural(limits.perEmailPerDay, 'booking')} in the last 24 hours, which is the most here - try again later.`);
|
|
729
|
+
}
|
|
730
|
+
if (who.ip && requestsByIp.count(who.ip) >= limits.perIpPerHour) {
|
|
731
|
+
throw new Error('Nothing was booked. Too many bookings have been asked for from this network in the last hour - try again later.');
|
|
732
|
+
}
|
|
733
|
+
const madeToday = store
|
|
734
|
+
.all()
|
|
735
|
+
.filter((b) => now() - Date.parse(b.createdAt) < DAY_MS &&
|
|
736
|
+
(b.status === 'confirmed' ||
|
|
737
|
+
b.status === 'requested' ||
|
|
738
|
+
b.status === 'unconfirmed' ||
|
|
739
|
+
b.status === 'cancelled')).length + slotsWhere(() => true);
|
|
740
|
+
if (madeToday + lines.length > limits.perDeploymentPerDay) {
|
|
741
|
+
// ⚠️ A CEILING HIT IS SOMETHING A PERSON SHOULD KNOW: either business
|
|
742
|
+
// is booming or somebody is filling hotels with names. Logged, and on
|
|
743
|
+
// /healthz/detail as needsAPerson for a day - across a restart.
|
|
744
|
+
const last = store.ceilingHitAt;
|
|
745
|
+
if (last === undefined || now() - last > HOUR_MS) {
|
|
746
|
+
console.error(`[huazhu bookings] ⚠️ NEEDS A PERSON: ${madeToday} bookings in the last 24 hours reached the ceiling of ${limits.perDeploymentPerDay}; new bookings are refused until the count falls`);
|
|
747
|
+
}
|
|
748
|
+
store.ceilingHitAt = now();
|
|
749
|
+
throw new Error(`Nothing was booked: bookings through this connector are paused for now. Try again later, or book with ${opts.brand} directly.`);
|
|
750
|
+
}
|
|
751
|
+
// The network counted once it has passed every limit - a request turned
|
|
752
|
+
// away is not one more against the guest, or a guest at a limit could
|
|
753
|
+
// never leave it. The email is counted when a booking is asked of Huazhu
|
|
754
|
+
// (`bookHeld`): a price that moved is not one of the guest's five.
|
|
755
|
+
if (who.ip)
|
|
756
|
+
requestsByIp.add(who.ip);
|
|
757
|
+
// ⚠️ AND ITS PLACES TAKEN NOW, before anything is awaited - see `slots`.
|
|
758
|
+
const slot = { sessionId: conversationOf(who), email, n: lines.length };
|
|
759
|
+
slots.add(slot);
|
|
760
|
+
try {
|
|
761
|
+
return await bookHeld(lines, tripKey, email, guest, contact, who, requests, slot);
|
|
762
|
+
}
|
|
763
|
+
finally {
|
|
764
|
+
slots.delete(slot);
|
|
765
|
+
}
|
|
766
|
+
};
|
|
767
|
+
const bookHeld = async (lines, tripKey, email, guest, contact, who, requests, slot) => {
|
|
768
|
+
// Priced afresh, not from the cache: this is the price that will be sent.
|
|
769
|
+
const priced = await quoteLines({ lines }, true);
|
|
770
|
+
if (priced.dropped?.length || priced.sold.size !== lines.length) {
|
|
771
|
+
throw new Error(`Nothing was booked. ${(priced.dropped ?? []).map((d) => `${d.title}: ${d.why}`).join(' ')} The trip is as it was - take that out or change it, then ask again.`);
|
|
772
|
+
}
|
|
773
|
+
const clean = cleanRequests(requests);
|
|
774
|
+
const comment = commentOf(clean, guest.notes);
|
|
775
|
+
const plans = [];
|
|
776
|
+
for (const [i, line] of lines.entries()) {
|
|
777
|
+
const sold = priced.sold.get(i);
|
|
778
|
+
const qline = priced.lines[i];
|
|
779
|
+
if (line.kind !== 'room' || !sold || !qline)
|
|
780
|
+
throw new Error('Nothing was booked: the trip changed while it was being priced.');
|
|
781
|
+
plans.push({ i, line, sold, qline });
|
|
782
|
+
}
|
|
783
|
+
for (const plan of plans) {
|
|
784
|
+
const s = plan.sold.res;
|
|
785
|
+
const title = `${plan.qline.hotelName} - ${plan.qline.title}`;
|
|
786
|
+
try {
|
|
787
|
+
const out = await client.prebook({
|
|
788
|
+
hotelId: plan.line.hotelId,
|
|
789
|
+
checkinType: 'DAY',
|
|
790
|
+
paymentType: opts.paymentType,
|
|
791
|
+
reservations: [
|
|
792
|
+
{
|
|
793
|
+
roomTypeCode: plan.sold.product.roomTypeCode,
|
|
794
|
+
rateCode: plan.sold.product.rateCode,
|
|
795
|
+
checkin: plan.line.stay.checkIn,
|
|
796
|
+
checkout: plan.line.stay.checkOut,
|
|
797
|
+
amountAfterTax: s.after,
|
|
798
|
+
...(s.before ? { amountBeforeTax: s.before } : {}),
|
|
799
|
+
currencyCode: s.currency,
|
|
800
|
+
roomCount: plan.qline.quantity ?? 1,
|
|
801
|
+
adultCount: plan.line.stay.guests,
|
|
802
|
+
},
|
|
803
|
+
],
|
|
804
|
+
});
|
|
805
|
+
plan.token = out?.preBookToken;
|
|
806
|
+
}
|
|
807
|
+
catch (err) {
|
|
808
|
+
if (err instanceof HuazhuRefusal)
|
|
809
|
+
throw new Error(`Nothing was booked. ${refusalWords(err, title, s.currency)}`);
|
|
810
|
+
throw new Error(`Nothing was booked: Huazhu could not be reached to check ${title} (${err.message}). Asking again is safe.`);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
requestsByEmail.add(email);
|
|
814
|
+
const tripRef = randomUUID();
|
|
815
|
+
const made = [];
|
|
816
|
+
/**
|
|
817
|
+
* Undo what this trip booked, when a later stay of it failed - and say
|
|
818
|
+
* what actually became of each, not what was meant to (2026-10-05 audit,
|
|
819
|
+
* e7: "flagged for a person" of a stay nobody had flagged).
|
|
820
|
+
*/
|
|
821
|
+
const takeBackAll = async () => {
|
|
822
|
+
const waiting = [];
|
|
823
|
+
const charged = [];
|
|
824
|
+
const flagged = [];
|
|
825
|
+
const retrying = [];
|
|
826
|
+
for (const b of made) {
|
|
827
|
+
const row = store.update(b.id, { rollback: true });
|
|
828
|
+
if (!row || row.status === 'failed' || row.status === 'cancelled')
|
|
829
|
+
continue;
|
|
830
|
+
if (row.pendingSince !== undefined || row.status !== 'confirmed') {
|
|
831
|
+
// The reconciler cancels it if it turns up.
|
|
832
|
+
waiting.push(named(row));
|
|
833
|
+
continue;
|
|
834
|
+
}
|
|
835
|
+
await reconciler.takeBack(row);
|
|
836
|
+
const after = store.get(b.id) ?? row;
|
|
837
|
+
if (after.status === 'cancelled') {
|
|
838
|
+
if (after.cancellationFee && after.cancellationFee.minor > 0)
|
|
839
|
+
charged.push(`${named(after)}: ${formatMoney(after.cancellationFee)}`);
|
|
840
|
+
}
|
|
841
|
+
else if (after.attention && !after.resolvedAt)
|
|
842
|
+
flagged.push(named(after));
|
|
843
|
+
else
|
|
844
|
+
retrying.push(named(after));
|
|
845
|
+
}
|
|
846
|
+
const and = (xs) => xs.join(' and ');
|
|
847
|
+
return [
|
|
848
|
+
charged.length
|
|
849
|
+
? ` Cancelling again cost a fee under the rate's terms - ${charged.join('; ')}. A person here has been told and will follow it up.`
|
|
850
|
+
: '',
|
|
851
|
+
flagged.length
|
|
852
|
+
? ` ${and(flagged)} could not be cancelled automatically and has been flagged for a person to cancel with the hotel.`
|
|
853
|
+
: '',
|
|
854
|
+
retrying.length
|
|
855
|
+
? ` ${and(retrying)} could not be cancelled yet: it is tried again automatically, and a person is told if that keeps failing.`
|
|
856
|
+
: '',
|
|
857
|
+
waiting.length
|
|
858
|
+
? ` Whether ${and(waiting)} was booked is not known yet; if it turns up in the next few minutes it is cancelled automatically.`
|
|
859
|
+
: '',
|
|
860
|
+
].join('');
|
|
861
|
+
};
|
|
862
|
+
const notWritten = async (title) => {
|
|
863
|
+
const back = await takeBackAll();
|
|
864
|
+
throw new Error(`Nothing was booked: ${title} could not be written down here before asking Huazhu, so it was not asked for. A person has been told.${back}`);
|
|
865
|
+
};
|
|
866
|
+
// ⚠️ THE REQUEST, WRITTEN DOWN BEFORE ITS FIRST STAY IS BOOKED. A
|
|
867
|
+
// process that dies half way through a trip leaves this open, and the
|
|
868
|
+
// next one takes back what it booked (`Reconciler.recover`).
|
|
869
|
+
try {
|
|
870
|
+
store.open({
|
|
871
|
+
tripRef,
|
|
872
|
+
key: tripKey,
|
|
873
|
+
sessionId: conversationOf(who),
|
|
874
|
+
email,
|
|
875
|
+
stays: plans.length,
|
|
876
|
+
startedAt: now(),
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
catch (err) {
|
|
880
|
+
if (err instanceof StoreWriteError)
|
|
881
|
+
return notWritten('the trip');
|
|
882
|
+
throw err;
|
|
883
|
+
}
|
|
884
|
+
try {
|
|
885
|
+
for (const plan of plans) {
|
|
886
|
+
const s = plan.sold.res;
|
|
887
|
+
const rooms = plan.qline.quantity ?? 1;
|
|
888
|
+
const qTotal = plan.qline.total.minor;
|
|
889
|
+
const nightlyMinor = s.after.map((x) => toMinor(x, s.currency));
|
|
890
|
+
const allIn = nightlyMinor.reduce((a, b) => a + b, 0) * rooms;
|
|
891
|
+
const cur = s.currency;
|
|
892
|
+
const title = `${plan.qline.hotelName} - ${plan.qline.title}`;
|
|
893
|
+
const id = `hz_${randomUUID()}`;
|
|
894
|
+
const dres = distributorResId();
|
|
895
|
+
const row = {
|
|
896
|
+
id,
|
|
897
|
+
confirmationCode: newPublicCode((c) => store.all().some((b) => b.confirmationCode === c)),
|
|
898
|
+
status: 'requested',
|
|
899
|
+
createdAt: new Date(now()).toISOString(),
|
|
900
|
+
hotelId: plan.line.hotelId,
|
|
901
|
+
hotelName: plan.qline.hotelName,
|
|
902
|
+
stay: plan.line.stay,
|
|
903
|
+
guest,
|
|
904
|
+
quote: {
|
|
905
|
+
lines: [plan.qline],
|
|
906
|
+
subtotal: money(qTotal, cur),
|
|
907
|
+
taxesAndFees: money(allIn - qTotal, cur),
|
|
908
|
+
total: money(allIn, cur),
|
|
909
|
+
stay: plan.line.stay,
|
|
910
|
+
},
|
|
911
|
+
...(clean ? { requests: clean } : {}),
|
|
912
|
+
// The caller, and the conversation whose trip it was - see `Identity.tripOwner`.
|
|
913
|
+
owners: [...new Set([who.sessionId, conversationOf(who)])],
|
|
914
|
+
distributorResId: dres,
|
|
915
|
+
tripRef,
|
|
916
|
+
tripKey,
|
|
917
|
+
paymentType: opts.paymentType,
|
|
918
|
+
guaranteeCode: s.guaranteeCode,
|
|
919
|
+
cancelCode: s.cancelCode,
|
|
920
|
+
nightlyMinor,
|
|
921
|
+
rooms,
|
|
922
|
+
tz: opts.tz,
|
|
923
|
+
roomTypeCode: plan.sold.product.roomTypeCode,
|
|
924
|
+
rateCode: plan.sold.product.rateCode,
|
|
925
|
+
// ⚠️ SET BEFORE THE CALL: a process that dies mid-call leaves a
|
|
926
|
+
// record the reconciler follows up on restart.
|
|
927
|
+
pendingSince: now(),
|
|
928
|
+
};
|
|
929
|
+
// ⚠️ NOT WRITTEN DOWN, NOT SENT. A booking Huazhu made that nobody
|
|
930
|
+
// here knows of is one nobody can find, follow up or cancel - and a
|
|
931
|
+
// write that failed used to skip taking back the trip's earlier stays
|
|
932
|
+
// too (2026-10-05 audit, P1-8).
|
|
933
|
+
try {
|
|
934
|
+
store.put(row);
|
|
935
|
+
}
|
|
936
|
+
catch (err) {
|
|
937
|
+
if (err instanceof StoreWriteError)
|
|
938
|
+
return notWritten(title);
|
|
939
|
+
throw err;
|
|
940
|
+
}
|
|
941
|
+
made.push(row);
|
|
942
|
+
slot.n -= 1;
|
|
943
|
+
const reservation = {
|
|
944
|
+
roomTypeCode: plan.sold.product.roomTypeCode,
|
|
945
|
+
rateCode: plan.sold.product.rateCode,
|
|
946
|
+
checkin: plan.line.stay.checkIn,
|
|
947
|
+
checkout: plan.line.stay.checkOut,
|
|
948
|
+
amountAfterTax: s.after,
|
|
949
|
+
...(s.before ? { amountBeforeTax: s.before } : {}),
|
|
950
|
+
...(s.rack ? { rackRate: s.rack } : {}),
|
|
951
|
+
currencyCode: cur,
|
|
952
|
+
roomCount: rooms,
|
|
953
|
+
adultCount: plan.line.stay.guests,
|
|
954
|
+
guaranteePolicyCode: s.guaranteeCode,
|
|
955
|
+
...(clean?.arrivingAt ? { estimatedArrivalTime: clean.arrivingAt } : {}),
|
|
956
|
+
resGuest: [contact],
|
|
957
|
+
};
|
|
958
|
+
const request = {
|
|
959
|
+
hotelId: plan.line.hotelId,
|
|
960
|
+
checkinType: 'DAY',
|
|
961
|
+
contactPerson: contact,
|
|
962
|
+
distributorResId: dres,
|
|
963
|
+
paymentType: opts.paymentType,
|
|
964
|
+
reservations: [reservation],
|
|
965
|
+
...(comment ? { guestComment: comment } : {}),
|
|
966
|
+
...(plan.token ? { preBookToken: plan.token } : {}),
|
|
967
|
+
};
|
|
968
|
+
// ⚠️ ONLY HUAZHU'S OWN "NO" MEANS "NOT BOOKED" - a code
|
|
969
|
+
// /reservation/book documents as one (`BOOK_REFUSALS`). Anything else
|
|
970
|
+
// - no answer, an answer that did not parse, a code nobody
|
|
971
|
+
// documented, a "yes" without an order number - means it may exist,
|
|
972
|
+
// and is followed up by the consistency query, never by sending the
|
|
973
|
+
// booking again.
|
|
974
|
+
let refusal;
|
|
975
|
+
let answer;
|
|
976
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
977
|
+
try {
|
|
978
|
+
const got = await client.book(request);
|
|
979
|
+
if (got && typeof got.resId === 'string' && got.resId)
|
|
980
|
+
answer = got;
|
|
981
|
+
}
|
|
982
|
+
catch (err) {
|
|
983
|
+
if (err instanceof HuazhuRefusal && err.code === CODES.systemError && attempt === 0) {
|
|
984
|
+
// The documentation's own advice: retry a system error, same id.
|
|
985
|
+
await sleep(1_000);
|
|
986
|
+
continue;
|
|
987
|
+
}
|
|
988
|
+
if (err instanceof HuazhuRefusal &&
|
|
989
|
+
BOOK_REFUSALS.has(err.code) &&
|
|
990
|
+
!BOOK_EXISTS.has(err.code))
|
|
991
|
+
refusal = err;
|
|
992
|
+
}
|
|
993
|
+
break;
|
|
994
|
+
}
|
|
995
|
+
if (answer) {
|
|
996
|
+
// Outside the try: a failure to WRITE this down is not Huazhu
|
|
997
|
+
// refusing it, and must not be recorded as one.
|
|
998
|
+
store.update(id, {
|
|
999
|
+
resId: answer.resId,
|
|
1000
|
+
...(answer.resCode ? { resCode: answer.resCode } : {}),
|
|
1001
|
+
status: 'confirmed',
|
|
1002
|
+
hotelStatus: 'HOTEL_RESERVATION',
|
|
1003
|
+
pendingSince: undefined,
|
|
1004
|
+
});
|
|
1005
|
+
continue;
|
|
1006
|
+
}
|
|
1007
|
+
if (refusal) {
|
|
1008
|
+
// Never made at Huazhu: nothing to follow up, nothing to show.
|
|
1009
|
+
store.update(id, { pendingSince: undefined, status: 'failed' });
|
|
1010
|
+
made.pop();
|
|
1011
|
+
const before = made.length;
|
|
1012
|
+
const back = await takeBackAll();
|
|
1013
|
+
throw new Error(`Nothing was booked. ${refusalWords(refusal, title, cur)}${before && !back ? ' The stays booked before it in this trip were cancelled again.' : ''}${back}`);
|
|
1014
|
+
}
|
|
1015
|
+
// Unknown: the eight minutes run from the moment that was known.
|
|
1016
|
+
store.update(id, { pendingSince: now() });
|
|
1017
|
+
const settled = await settle(id);
|
|
1018
|
+
if (settled?.status === 'confirmed' && settled.pendingSince === undefined)
|
|
1019
|
+
continue;
|
|
1020
|
+
if (plans.length > 1 || settled?.status === 'failed') {
|
|
1021
|
+
const back = await takeBackAll();
|
|
1022
|
+
if (settled?.status === 'failed') {
|
|
1023
|
+
throw new Error(`Nothing was booked: Huazhu did not confirm ${plan.qline.hotelName}.${back}`);
|
|
1024
|
+
}
|
|
1025
|
+
throw new Error(`Nothing was booked: Huazhu did not answer in time for ${plan.qline.hotelName}, so the trip was stopped.${back}`);
|
|
1026
|
+
}
|
|
1027
|
+
// One stay, outcome still unknown: it is the guest's booking, marked
|
|
1028
|
+
// as waiting, and the reconciler settles it within eight minutes.
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
finally {
|
|
1032
|
+
store.close(tripRef);
|
|
1033
|
+
}
|
|
1034
|
+
return made.map((b) => publicBooking(store.get(b.id) ?? b));
|
|
1035
|
+
};
|
|
597
1036
|
return {
|
|
598
1037
|
brand: opts.brand,
|
|
599
1038
|
capabilities: HUAZHU_CAPABILITIES,
|
|
@@ -616,17 +1055,49 @@ export const createHuazhuHotels = (opts) => {
|
|
|
616
1055
|
},
|
|
617
1056
|
health() {
|
|
618
1057
|
const rows = store.all();
|
|
1058
|
+
const open = store.unresolved();
|
|
1059
|
+
const ceilingHitAt = store.ceilingHitAt;
|
|
1060
|
+
const ceiling = ceilingHitAt !== undefined && now() - ceilingHitAt < DAY_MS;
|
|
619
1061
|
return {
|
|
620
1062
|
catalog: catalog.state(),
|
|
621
1063
|
bookings: rows.length,
|
|
622
1064
|
pending: rows.filter((r) => r.pendingSince !== undefined).length,
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
?
|
|
1065
|
+
// ⚠️ ONLY WHAT IS STILL OPEN. A booking a person has dealt with is
|
|
1066
|
+
// marked resolved, and stops counting (2026-10-05 audit, P1-9).
|
|
1067
|
+
needsAPerson: open.length +
|
|
1068
|
+
(ceiling ? 1 : 0) +
|
|
1069
|
+
(store.writeFailing ? 1 : 0) +
|
|
1070
|
+
(client.misconfigured ? 1 : 0),
|
|
1071
|
+
...(open.length
|
|
1072
|
+
? {
|
|
1073
|
+
attention: open.slice(0, 20).map((r) => ({
|
|
1074
|
+
id: r.id,
|
|
1075
|
+
since: r.attentionAt,
|
|
1076
|
+
why: r.attention,
|
|
1077
|
+
})),
|
|
1078
|
+
}
|
|
627
1079
|
: {}),
|
|
1080
|
+
...(ceiling ? { ceilingHitAt: new Date(ceilingHitAt).toISOString() } : {}),
|
|
1081
|
+
...(store.writeFailing ? { writeFailing: store.writeFailing } : {}),
|
|
1082
|
+
...(client.misconfigured ? { misconfigured: client.misconfigured } : {}),
|
|
628
1083
|
};
|
|
629
1084
|
},
|
|
1085
|
+
/** A person dealt with a booking that needed one - see `BookingStore.resolve`. */
|
|
1086
|
+
resolve(id, resolution) {
|
|
1087
|
+
const row = store.get(id);
|
|
1088
|
+
if (!row?.attention)
|
|
1089
|
+
return undefined;
|
|
1090
|
+
return store.resolve(id, resolution);
|
|
1091
|
+
},
|
|
1092
|
+
/**
|
|
1093
|
+
* Stop taking bookings, and wait for the ones in flight - a deploy's
|
|
1094
|
+
* SIGTERM. A process stopped in the middle of a trip leaves its first
|
|
1095
|
+
* stays booked and the rest not (2026-10-05 audit, P1-2).
|
|
1096
|
+
*/
|
|
1097
|
+
async drain() {
|
|
1098
|
+
closing = true;
|
|
1099
|
+
await Promise.allSettled([...new Set(flights.values())]);
|
|
1100
|
+
},
|
|
630
1101
|
async searchHotels(q) {
|
|
631
1102
|
const terms = (q.query ?? '')
|
|
632
1103
|
.toLowerCase()
|
|
@@ -962,9 +1433,18 @@ export const createHuazhuHotels = (opts) => {
|
|
|
962
1433
|
},
|
|
963
1434
|
quote,
|
|
964
1435
|
async requestBooking(trip, guest, who, requests) {
|
|
1436
|
+
if (closing) {
|
|
1437
|
+
throw new Error('Nothing was booked: this connector is restarting. Ask again in a minute - the trip is kept.');
|
|
1438
|
+
}
|
|
965
1439
|
if (!guest.name.trim() || !guest.email.includes('@')) {
|
|
966
|
-
|
|
1440
|
+
// Nothing is emailed from here (2026-10-05 audit): the email is how the
|
|
1441
|
+
// booking is found again, with its code.
|
|
1442
|
+
throw new Error("A booking needs the guest's name and email - the email is how the booking is found again, with its code.");
|
|
967
1443
|
}
|
|
1444
|
+
// Bounded at the seam too - see GUEST_LIMITS.
|
|
1445
|
+
const tooLong = guestTooLong(guest);
|
|
1446
|
+
if (tooLong.length)
|
|
1447
|
+
throw new Error(`Nothing was booked: a booking takes ${tooLong.join(', ')}.`);
|
|
968
1448
|
const contact = contactOf(guest);
|
|
969
1449
|
if (typeof contact === 'string')
|
|
970
1450
|
throw new Error(contact);
|
|
@@ -979,247 +1459,16 @@ export const createHuazhuHotels = (opts) => {
|
|
|
979
1459
|
}
|
|
980
1460
|
const { groups } = splitByStay(trip.lines);
|
|
981
1461
|
const lines = groups.flat();
|
|
982
|
-
// ⚠️ THE LIMITS - see `BookingLimits`. Each is said as what it is, and
|
|
983
|
-
// none suggests a way round itself (another email, another chat).
|
|
984
|
-
const isLive = (b) => (b.status === 'confirmed' || b.status === 'requested') && b.stay.checkOut >= today();
|
|
985
1462
|
const email = guest.email.trim().toLowerCase();
|
|
986
|
-
const
|
|
987
|
-
|
|
988
|
-
.
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
const
|
|
994
|
-
|
|
995
|
-
.filter((b) => b.guest.email.trim().toLowerCase() === email && isLive(b)).length;
|
|
996
|
-
if (liveForEmail + lines.length > limits.perEmailLive) {
|
|
997
|
-
throw new Error(`Nothing was booked. ${guest.email.trim()} already has ${plural(liveForEmail, 'booking')} still to come through this connector, and ${limits.perEmailLive} at once is the most - cancel one that is not needed, then ask again.`);
|
|
998
|
-
}
|
|
999
|
-
if (requestsByEmail.count(email) >= limits.perEmailPerDay) {
|
|
1000
|
-
throw new Error(`Nothing was booked. ${guest.email.trim()} has asked for ${plural(limits.perEmailPerDay, 'booking')} in the last 24 hours, which is the most here - try again later.`);
|
|
1001
|
-
}
|
|
1002
|
-
if (who.ip && requestsByIp.count(who.ip) >= limits.perIpPerHour) {
|
|
1003
|
-
throw new Error('Nothing was booked. Too many bookings have been asked for from this network in the last hour - try again later.');
|
|
1004
|
-
}
|
|
1005
|
-
const madeToday = store
|
|
1006
|
-
.all()
|
|
1007
|
-
.filter((b) => now() - Date.parse(b.createdAt) < DAY_MS &&
|
|
1008
|
-
(b.status === 'confirmed' || b.status === 'requested' || b.status === 'cancelled')).length;
|
|
1009
|
-
if (madeToday + lines.length > limits.perDeploymentPerDay) {
|
|
1010
|
-
// ⚠️ A CEILING HIT IS SOMETHING A PERSON SHOULD KNOW: either business
|
|
1011
|
-
// is booming or somebody is filling hotels with names. Logged, and on
|
|
1012
|
-
// /healthz/detail as needsAPerson for a day.
|
|
1013
|
-
if (ceilingHitAt === undefined || now() - ceilingHitAt > HOUR_MS) {
|
|
1014
|
-
console.error(`[huazhu bookings] ⚠️ NEEDS A PERSON: ${madeToday} bookings in the last 24 hours reached the ceiling of ${limits.perDeploymentPerDay}; new bookings are refused until the count falls`);
|
|
1015
|
-
}
|
|
1016
|
-
ceilingHitAt = now();
|
|
1017
|
-
throw new Error(`Nothing was booked: bookings through this connector are paused for now. Try again later, or book with ${opts.brand} directly.`);
|
|
1018
|
-
}
|
|
1019
|
-
// Counted once it has passed every limit - a request turned away is not
|
|
1020
|
-
// one more against the guest, or a guest at a limit could never leave it.
|
|
1021
|
-
requestsByEmail.add(email);
|
|
1022
|
-
if (who.ip)
|
|
1023
|
-
requestsByIp.add(who.ip);
|
|
1024
|
-
// Priced afresh, not from the cache: this is the price that will be sent.
|
|
1025
|
-
const priced = await quoteLines({ lines }, true);
|
|
1026
|
-
if (priced.dropped?.length || priced.sold.size !== lines.length) {
|
|
1027
|
-
throw new Error(`Nothing was booked. ${(priced.dropped ?? []).map((d) => `${d.title}: ${d.why}`).join(' ')} The trip is as it was - take that out or change it, then ask again.`);
|
|
1028
|
-
}
|
|
1029
|
-
const clean = cleanRequests(requests);
|
|
1030
|
-
const comment = commentOf(clean, guest.notes);
|
|
1031
|
-
const plans = [];
|
|
1032
|
-
for (const [i, line] of lines.entries()) {
|
|
1033
|
-
const sold = priced.sold.get(i);
|
|
1034
|
-
const qline = priced.lines[i];
|
|
1035
|
-
if (line.kind !== 'room' || !sold || !qline)
|
|
1036
|
-
throw new Error('Nothing was booked: the trip changed while it was being priced.');
|
|
1037
|
-
plans.push({ i, line, sold, qline });
|
|
1038
|
-
}
|
|
1039
|
-
for (const plan of plans) {
|
|
1040
|
-
const s = plan.sold.res;
|
|
1041
|
-
const title = `${plan.qline.hotelName} - ${plan.qline.title}`;
|
|
1042
|
-
try {
|
|
1043
|
-
const out = await client.prebook({
|
|
1044
|
-
hotelId: plan.line.hotelId,
|
|
1045
|
-
checkinType: 'DAY',
|
|
1046
|
-
paymentType: opts.paymentType,
|
|
1047
|
-
reservations: [
|
|
1048
|
-
{
|
|
1049
|
-
roomTypeCode: plan.sold.product.roomTypeCode,
|
|
1050
|
-
rateCode: plan.sold.product.rateCode,
|
|
1051
|
-
checkin: plan.line.stay.checkIn,
|
|
1052
|
-
checkout: plan.line.stay.checkOut,
|
|
1053
|
-
amountAfterTax: s.after,
|
|
1054
|
-
...(s.before ? { amountBeforeTax: s.before } : {}),
|
|
1055
|
-
currencyCode: s.currency,
|
|
1056
|
-
roomCount: plan.qline.quantity ?? 1,
|
|
1057
|
-
adultCount: plan.line.stay.guests,
|
|
1058
|
-
},
|
|
1059
|
-
],
|
|
1060
|
-
});
|
|
1061
|
-
plan.token = out.preBookToken;
|
|
1062
|
-
}
|
|
1063
|
-
catch (err) {
|
|
1064
|
-
if (err instanceof HuazhuRefusal)
|
|
1065
|
-
throw new Error(`Nothing was booked. ${refusalWords(err, title, s.currency)}`);
|
|
1066
|
-
throw new Error(`Nothing was booked: Huazhu could not be reached to check ${title} (${err.message}). Asking again is safe.`);
|
|
1067
|
-
}
|
|
1068
|
-
}
|
|
1069
|
-
const tripRef = randomUUID();
|
|
1070
|
-
const made = [];
|
|
1071
|
-
/** Undo what this trip booked, when a later stay of it failed. */
|
|
1072
|
-
const takeBackAll = async () => {
|
|
1073
|
-
const stuck = [];
|
|
1074
|
-
for (const b of made) {
|
|
1075
|
-
store.update(b.id, { rollback: true });
|
|
1076
|
-
const row = store.get(b.id);
|
|
1077
|
-
if (!row)
|
|
1078
|
-
continue;
|
|
1079
|
-
if (row.pendingSince !== undefined)
|
|
1080
|
-
continue; // the reconciler cancels it if it turns up
|
|
1081
|
-
await reconciler.takeBack(row);
|
|
1082
|
-
if (store.get(b.id)?.status !== 'cancelled')
|
|
1083
|
-
stuck.push(`${row.hotelName} (${row.confirmationCode})`);
|
|
1084
|
-
}
|
|
1085
|
-
return stuck.length
|
|
1086
|
-
? ` ${stuck.join(' and ')} could not be cancelled automatically and has been flagged for a person to cancel with the hotel.`
|
|
1087
|
-
: '';
|
|
1088
|
-
};
|
|
1089
|
-
for (const plan of plans) {
|
|
1090
|
-
const s = plan.sold.res;
|
|
1091
|
-
const rooms = plan.qline.quantity ?? 1;
|
|
1092
|
-
const qTotal = plan.qline.total.minor;
|
|
1093
|
-
const nightlyMinor = s.after.map((x) => toMinor(x, s.currency));
|
|
1094
|
-
const allIn = nightlyMinor.reduce((a, b) => a + b, 0) * rooms;
|
|
1095
|
-
const cur = s.currency;
|
|
1096
|
-
const id = `hz_${randomUUID()}`;
|
|
1097
|
-
const dres = distributorResId();
|
|
1098
|
-
const row = {
|
|
1099
|
-
id,
|
|
1100
|
-
confirmationCode: dres,
|
|
1101
|
-
status: 'requested',
|
|
1102
|
-
createdAt: new Date(now()).toISOString(),
|
|
1103
|
-
hotelId: plan.line.hotelId,
|
|
1104
|
-
hotelName: plan.qline.hotelName,
|
|
1105
|
-
stay: plan.line.stay,
|
|
1106
|
-
guest,
|
|
1107
|
-
quote: {
|
|
1108
|
-
lines: [plan.qline],
|
|
1109
|
-
subtotal: money(qTotal, cur),
|
|
1110
|
-
taxesAndFees: money(allIn - qTotal, cur),
|
|
1111
|
-
total: money(allIn, cur),
|
|
1112
|
-
stay: plan.line.stay,
|
|
1113
|
-
},
|
|
1114
|
-
...(clean ? { requests: clean } : {}),
|
|
1115
|
-
owners: [who.sessionId],
|
|
1116
|
-
distributorResId: dres,
|
|
1117
|
-
tripRef,
|
|
1118
|
-
paymentType: opts.paymentType,
|
|
1119
|
-
guaranteeCode: s.guaranteeCode,
|
|
1120
|
-
cancelCode: s.cancelCode,
|
|
1121
|
-
nightlyMinor,
|
|
1122
|
-
rooms,
|
|
1123
|
-
tz: opts.tz,
|
|
1124
|
-
roomTypeCode: plan.sold.product.roomTypeCode,
|
|
1125
|
-
rateCode: plan.sold.product.rateCode,
|
|
1126
|
-
// ⚠️ SET BEFORE THE CALL: a process that dies mid-call leaves a
|
|
1127
|
-
// record the reconciler follows up on restart.
|
|
1128
|
-
pendingSince: now(),
|
|
1129
|
-
};
|
|
1130
|
-
store.put(row);
|
|
1131
|
-
made.push(row);
|
|
1132
|
-
const reservation = {
|
|
1133
|
-
roomTypeCode: plan.sold.product.roomTypeCode,
|
|
1134
|
-
rateCode: plan.sold.product.rateCode,
|
|
1135
|
-
checkin: plan.line.stay.checkIn,
|
|
1136
|
-
checkout: plan.line.stay.checkOut,
|
|
1137
|
-
amountAfterTax: s.after,
|
|
1138
|
-
...(s.before ? { amountBeforeTax: s.before } : {}),
|
|
1139
|
-
...(s.rack ? { rackRate: s.rack } : {}),
|
|
1140
|
-
currencyCode: cur,
|
|
1141
|
-
roomCount: rooms,
|
|
1142
|
-
adultCount: plan.line.stay.guests,
|
|
1143
|
-
guaranteePolicyCode: s.guaranteeCode,
|
|
1144
|
-
...(clean?.arrivingAt ? { estimatedArrivalTime: clean.arrivingAt } : {}),
|
|
1145
|
-
resGuest: [contact],
|
|
1146
|
-
};
|
|
1147
|
-
const request = {
|
|
1148
|
-
hotelId: plan.line.hotelId,
|
|
1149
|
-
checkinType: 'DAY',
|
|
1150
|
-
contactPerson: contact,
|
|
1151
|
-
distributorResId: dres,
|
|
1152
|
-
paymentType: opts.paymentType,
|
|
1153
|
-
reservations: [reservation],
|
|
1154
|
-
...(comment ? { guestComment: comment } : {}),
|
|
1155
|
-
...(plan.token ? { preBookToken: plan.token } : {}),
|
|
1156
|
-
};
|
|
1157
|
-
// ⚠️ ONLY A REFUSAL FROM HUAZHU MEANS "NOT BOOKED". Anything else -
|
|
1158
|
-
// no answer, an answer that did not parse, our own code failing after
|
|
1159
|
-
// the call - means it may exist, and is followed up by the consistency
|
|
1160
|
-
// query, never written off.
|
|
1161
|
-
let outcome = 'unknown';
|
|
1162
|
-
let refusal;
|
|
1163
|
-
let answer;
|
|
1164
|
-
for (let attempt = 0; attempt < 2 && !answer && !refusal; attempt += 1) {
|
|
1165
|
-
try {
|
|
1166
|
-
answer = await client.book(request);
|
|
1167
|
-
}
|
|
1168
|
-
catch (err) {
|
|
1169
|
-
if (!(err instanceof HuazhuRefusal))
|
|
1170
|
-
break;
|
|
1171
|
-
// "Already exists" for OUR id: an earlier try got through.
|
|
1172
|
-
if (err.code === 5218 || err.code === 5221 || err.code === 5231)
|
|
1173
|
-
break;
|
|
1174
|
-
if (err.code === 2001 && attempt === 0) {
|
|
1175
|
-
// The documentation's own advice: retry a system error, same id.
|
|
1176
|
-
await sleep(1_000);
|
|
1177
|
-
continue;
|
|
1178
|
-
}
|
|
1179
|
-
refusal = err;
|
|
1180
|
-
}
|
|
1181
|
-
}
|
|
1182
|
-
if (answer) {
|
|
1183
|
-
// Outside the try: a failure to WRITE this down is not Huazhu
|
|
1184
|
-
// refusing it, and must not be recorded as one.
|
|
1185
|
-
store.update(id, {
|
|
1186
|
-
resId: answer.resId,
|
|
1187
|
-
...(answer.resCode ? { resCode: answer.resCode } : {}),
|
|
1188
|
-
confirmationCode: answer.resId,
|
|
1189
|
-
status: 'confirmed',
|
|
1190
|
-
hotelStatus: 'HOTEL_RESERVATION',
|
|
1191
|
-
pendingSince: undefined,
|
|
1192
|
-
});
|
|
1193
|
-
outcome = 'booked';
|
|
1194
|
-
}
|
|
1195
|
-
else if (refusal) {
|
|
1196
|
-
outcome = 'refused';
|
|
1197
|
-
}
|
|
1198
|
-
if (outcome === 'refused') {
|
|
1199
|
-
// Never made at Huazhu: nothing to follow up, nothing to show.
|
|
1200
|
-
store.update(id, { pendingSince: undefined, status: 'failed' });
|
|
1201
|
-
made.pop();
|
|
1202
|
-
const before = made.length;
|
|
1203
|
-
const back = await takeBackAll();
|
|
1204
|
-
const title = `${plan.qline.hotelName} - ${plan.qline.title}`;
|
|
1205
|
-
throw new Error(`Nothing was booked. ${refusal ? refusalWords(refusal, title, cur) : `Huazhu did not accept ${title}.`}${before && !back ? ' The stays booked before it in this trip were cancelled again.' : ''}${back}`);
|
|
1206
|
-
}
|
|
1207
|
-
if (outcome === 'unknown') {
|
|
1208
|
-
const settled = await settle(store.get(id) ?? row);
|
|
1209
|
-
if (settled.pendingSince !== undefined || settled.status !== 'confirmed') {
|
|
1210
|
-
if (plans.length > 1 || settled.status === 'failed') {
|
|
1211
|
-
const back = await takeBackAll();
|
|
1212
|
-
if (settled.status === 'failed') {
|
|
1213
|
-
throw new Error(`Nothing was booked: Huazhu did not confirm ${plan.qline.hotelName}.${back}`);
|
|
1214
|
-
}
|
|
1215
|
-
throw new Error(`Nothing was booked: Huazhu did not answer in time for ${plan.qline.hotelName}, so the trip was stopped. If that booking turns up in the next few minutes it is cancelled automatically.${back}`);
|
|
1216
|
-
}
|
|
1217
|
-
// One stay, outcome still unknown: it is the guest's booking, marked
|
|
1218
|
-
// as waiting, and the reconciler settles it within eight minutes.
|
|
1219
|
-
}
|
|
1220
|
-
}
|
|
1221
|
-
}
|
|
1222
|
-
return made.map((b) => publicBooking(store.get(b.id) ?? b));
|
|
1463
|
+
const tripKey = lines
|
|
1464
|
+
.map((l) => l.kind === 'room'
|
|
1465
|
+
? `${l.hotelId}|${l.roomId}|${l.stay.checkIn}|${l.stay.checkOut}|${l.stay.guests}|${l.rooms ?? 1}`
|
|
1466
|
+
: '')
|
|
1467
|
+
.sort()
|
|
1468
|
+
.join(';');
|
|
1469
|
+
// The caller and, from the card, the conversation whose trip it is.
|
|
1470
|
+
const keys = [...new Set([who.sessionId, who.tripOwner ?? who.sessionId])].map((k) => `s:${k}`);
|
|
1471
|
+
return single([...keys, `e:${email}`], () => book(lines, tripKey, email, guest, contact, who, requests));
|
|
1223
1472
|
},
|
|
1224
1473
|
async listBookings(who, limit = 10) {
|
|
1225
1474
|
const most = Math.max(1, Math.min(MAX_PAGE, Math.floor(Number(limit) > 0 ? limit : 10)));
|
|
@@ -1266,13 +1515,13 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1266
1515
|
const cur = b.quote.total.currency;
|
|
1267
1516
|
const zero = money(0, cur);
|
|
1268
1517
|
const past = now() >= nightAuditOf(b.stay.checkIn, b.tz);
|
|
1269
|
-
if (b.pendingSince !== undefined) {
|
|
1518
|
+
if (b.pendingSince !== undefined || b.status === 'unconfirmed') {
|
|
1270
1519
|
return {
|
|
1271
1520
|
bookingId: b.id,
|
|
1272
1521
|
confirmationCode: b.confirmationCode,
|
|
1273
1522
|
fee: zero,
|
|
1274
1523
|
refund: zero,
|
|
1275
|
-
rule:
|
|
1524
|
+
rule: waitingWords(b),
|
|
1276
1525
|
cancellable: false,
|
|
1277
1526
|
};
|
|
1278
1527
|
}
|
|
@@ -1297,7 +1546,9 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1297
1546
|
refund: zero,
|
|
1298
1547
|
rule: forbidden
|
|
1299
1548
|
? `This rate cannot be cancelled now. ${rule}`
|
|
1300
|
-
:
|
|
1549
|
+
: opts.sandbox
|
|
1550
|
+
? `${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.`
|
|
1551
|
+
: `${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.`,
|
|
1301
1552
|
...(freeUntil ? { freeUntil: new Date(freeUntil).toISOString() } : {}),
|
|
1302
1553
|
cancellable: !forbidden,
|
|
1303
1554
|
};
|
|
@@ -1307,9 +1558,11 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1307
1558
|
if (!found)
|
|
1308
1559
|
return null;
|
|
1309
1560
|
const b = await refresh(found);
|
|
1310
|
-
if (b.pendingSince !== undefined ||
|
|
1311
|
-
throw new Error(
|
|
1561
|
+
if (b.pendingSince !== undefined || b.status === 'unconfirmed') {
|
|
1562
|
+
throw new Error(waitingWords(b));
|
|
1312
1563
|
}
|
|
1564
|
+
if (!b.resId)
|
|
1565
|
+
return publicBooking(b);
|
|
1313
1566
|
if (b.status !== 'confirmed')
|
|
1314
1567
|
return publicBooking(b);
|
|
1315
1568
|
const cur = b.quote.total.currency;
|