@curless/hotel-storefront 0.23.1 → 0.24.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/README.md +28 -11
- package/dist/__tests__/__fixtures__/huazhu-fake.d.ts +54 -0
- package/dist/__tests__/__fixtures__/huazhu-fake.d.ts.map +1 -0
- package/dist/__tests__/__fixtures__/huazhu-fake.js +340 -0
- package/dist/__tests__/__fixtures__/huazhu-fake.js.map +1 -0
- package/dist/__tests__/huazhu-backend.test.d.ts +2 -0
- package/dist/__tests__/huazhu-backend.test.d.ts.map +1 -0
- package/dist/__tests__/huazhu-backend.test.js +434 -0
- package/dist/__tests__/huazhu-backend.test.js.map +1 -0
- package/dist/__tests__/huazhu-cancel-policy.test.d.ts +2 -0
- package/dist/__tests__/huazhu-cancel-policy.test.d.ts.map +1 -0
- package/dist/__tests__/huazhu-cancel-policy.test.js +82 -0
- package/dist/__tests__/huazhu-cancel-policy.test.js.map +1 -0
- package/dist/__tests__/huazhu-sellable.test.d.ts +2 -0
- package/dist/__tests__/huazhu-sellable.test.d.ts.map +1 -0
- package/dist/__tests__/huazhu-sellable.test.js +192 -0
- package/dist/__tests__/huazhu-sellable.test.js.map +1 -0
- package/dist/__tests__/server.test.js +129 -4
- package/dist/__tests__/server.test.js.map +1 -1
- package/dist/__tests__/tool-descriptions.test.js +57 -4
- package/dist/__tests__/tool-descriptions.test.js.map +1 -1
- package/dist/__tests__/tools.test.js +2 -0
- package/dist/__tests__/tools.test.js.map +1 -1
- package/dist/backend.d.ts +27 -0
- package/dist/backend.d.ts.map +1 -1
- package/dist/backend.js +7 -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/config.d.ts +52 -4
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js.map +1 -1
- package/dist/facilities.d.ts.map +1 -1
- package/dist/facilities.js +7 -5
- package/dist/facilities.js.map +1 -1
- package/dist/huazhu/backend.d.ts +40 -0
- package/dist/huazhu/backend.d.ts.map +1 -0
- package/dist/huazhu/backend.js +1318 -0
- package/dist/huazhu/backend.js.map +1 -0
- package/dist/huazhu/bookings.d.ts +81 -0
- package/dist/huazhu/bookings.d.ts.map +1 -0
- package/dist/huazhu/bookings.js +189 -0
- package/dist/huazhu/bookings.js.map +1 -0
- package/dist/huazhu/cancel-policy.d.ts +69 -0
- package/dist/huazhu/cancel-policy.d.ts.map +1 -0
- package/dist/huazhu/cancel-policy.js +201 -0
- package/dist/huazhu/cancel-policy.js.map +1 -0
- package/dist/huazhu/catalog.d.ts +75 -0
- package/dist/huazhu/catalog.d.ts.map +1 -0
- package/dist/huazhu/catalog.js +251 -0
- package/dist/huazhu/catalog.js.map +1 -0
- package/dist/huazhu/check.d.ts +5 -0
- package/dist/huazhu/check.d.ts.map +1 -0
- package/dist/huazhu/check.js +170 -0
- package/dist/huazhu/check.js.map +1 -0
- package/dist/huazhu/client.d.ts +100 -0
- package/dist/huazhu/client.d.ts.map +1 -0
- package/dist/huazhu/client.js +232 -0
- package/dist/huazhu/client.js.map +1 -0
- package/dist/huazhu/sellable.d.ts +81 -0
- package/dist/huazhu/sellable.d.ts.map +1 -0
- package/dist/huazhu/sellable.js +219 -0
- package/dist/huazhu/sellable.js.map +1 -0
- package/dist/huazhu/wire.d.ts +389 -0
- package/dist/huazhu/wire.d.ts.map +1 -0
- package/dist/huazhu/wire.js +72 -0
- package/dist/huazhu/wire.js.map +1 -0
- package/dist/index.js +18 -4
- package/dist/index.js.map +1 -1
- package/dist/legal.d.ts +4 -3
- package/dist/legal.d.ts.map +1 -1
- package/dist/legal.js +33 -18
- package/dist/legal.js.map +1 -1
- package/dist/lib.d.ts +8 -3
- package/dist/lib.d.ts.map +1 -1
- package/dist/lib.js +8 -1
- package/dist/lib.js.map +1 -1
- package/dist/server.d.ts +20 -8
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +132 -28
- package/dist/server.js.map +1 -1
- package/dist/surface.d.ts +2 -1
- package/dist/surface.d.ts.map +1 -1
- package/dist/surface.js +37 -10
- package/dist/surface.js.map +1 -1
- package/dist/tools.d.ts +33 -20
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +130 -11
- package/dist/tools.js.map +1 -1
- package/dist/types.d.ts +21 -2
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,1318 @@
|
|
|
1
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
+
import { DEFAULT_PAGE, MAX_PAGE, REQUEST_KINDS, REQUEST_NOTE_MAX, } from '../backend.js';
|
|
3
|
+
import { addDays, nights as nightsBetween, stayProblem, todayAt } from '../dates.js';
|
|
4
|
+
import { MAX_HOLDS_PER_CONVERSATION, byWords, splitByStay } from '../demo-hotels.js';
|
|
5
|
+
import { FACILITIES, hasFacility } from '../facilities.js';
|
|
6
|
+
import { formatMoney, minorFactor, money, multiplyMoney, toMinor } from '../money.js';
|
|
7
|
+
import { MAX_ROOMS_PER_LINE } from '../trip.js';
|
|
8
|
+
import { statusOf } from './bookings.js';
|
|
9
|
+
import { describePolicy, feeFor, freeUntilOf, nightAuditOf, parseCancelPolicy, penaltyAt, refundableFrom, } from './cancel-policy.js';
|
|
10
|
+
import { haystackOf, inPlace, roomPhoto, toHotel } from './catalog.js';
|
|
11
|
+
import { HuazhuRefusal, HuazhuUnreachable } from './client.js';
|
|
12
|
+
import { ariRanges, bookableNow, clockAt, offeredAtAll, sell, } from './sellable.js';
|
|
13
|
+
export const HUAZHU_CAPABILITIES = {
|
|
14
|
+
members: false,
|
|
15
|
+
extras: false,
|
|
16
|
+
changes: false,
|
|
17
|
+
booking: 'pay-at-hotel',
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* The most hotels priced for one search. A city can hold hundreds of Huazhu
|
|
21
|
+
* hotels, and every one priced is two calls to Huazhu; past this, the page is
|
|
22
|
+
* priced and a price ORDER or CEILING is not applied - and the answer says so,
|
|
23
|
+
* rather than sorting the twenty it happened to price.
|
|
24
|
+
*/
|
|
25
|
+
export const PRICE_ALL = 20;
|
|
26
|
+
const PRICE_CONCURRENCY = 4;
|
|
27
|
+
/** What a room is called here: the room type, then the rate when there is more than one. */
|
|
28
|
+
const ROOM_SEP = '~';
|
|
29
|
+
const roomIdOf = (p) => `${p.roomTypeCode}${ROOM_SEP}${p.rateCode}`;
|
|
30
|
+
/** 客人备注 for the preferences the desk understands, in the hotel's language. */
|
|
31
|
+
const REQUEST_ZH = {
|
|
32
|
+
'king-bed': '大床',
|
|
33
|
+
'twin-beds': '双床',
|
|
34
|
+
'high-floor': '高楼层',
|
|
35
|
+
'low-floor': '低楼层',
|
|
36
|
+
'quiet-room': '安静房间',
|
|
37
|
+
'connecting-rooms': '连通房',
|
|
38
|
+
'early-check-in': '提前入住',
|
|
39
|
+
'late-arrival': '晚到',
|
|
40
|
+
'accessible-room': '无障碍房',
|
|
41
|
+
};
|
|
42
|
+
const WINDOW = {
|
|
43
|
+
'1': 'Window',
|
|
44
|
+
'2': 'Some rooms without a window',
|
|
45
|
+
'3': 'No window',
|
|
46
|
+
'4': 'Window onto an inner courtyard',
|
|
47
|
+
};
|
|
48
|
+
const pool = async (items, n, fn) => {
|
|
49
|
+
const out = new Array(items.length);
|
|
50
|
+
let next = 0;
|
|
51
|
+
await Promise.all(Array.from({ length: Math.min(n, items.length) }, async () => {
|
|
52
|
+
for (let i = next++; i < items.length; i = next++)
|
|
53
|
+
out[i] = await fn(items[i]);
|
|
54
|
+
}));
|
|
55
|
+
return out;
|
|
56
|
+
};
|
|
57
|
+
/** 16 characters a name part, `|` and `'` never - Huazhu's limits on the contact. */
|
|
58
|
+
const contactOf = (g) => {
|
|
59
|
+
const name = g.name.replace(/\s+/g, ' ').trim();
|
|
60
|
+
const cut = name.lastIndexOf(' ');
|
|
61
|
+
const last = cut > 0 ? name.slice(cut + 1) : name;
|
|
62
|
+
const first = cut > 0 ? name.slice(0, cut) : '';
|
|
63
|
+
if (last.length > 16 || first.length > 16)
|
|
64
|
+
return "Huazhu's booking system takes a name of up to 16 characters for the family name and 16 for the given names. Ask the guest how the name should be written within that.";
|
|
65
|
+
const email = g.email.trim();
|
|
66
|
+
const phone = (g.phone ?? '').replace(/[\s()-]/g, '');
|
|
67
|
+
return {
|
|
68
|
+
lastName: last,
|
|
69
|
+
...(first ? { firstName: first } : {}),
|
|
70
|
+
// The contact's email is capped at 32 characters. A longer one is kept
|
|
71
|
+
// here - it is how the booking is found - and not sent, rather than cut.
|
|
72
|
+
...(email.length <= 32 ? { email } : {}),
|
|
73
|
+
...(phone && phone.length <= 32 ? { phone } : {}),
|
|
74
|
+
};
|
|
75
|
+
};
|
|
76
|
+
const commentOf = (r, note) => {
|
|
77
|
+
const kinds = (r?.kinds ?? []).filter((k) => REQUEST_KINDS.includes(k));
|
|
78
|
+
const words = [
|
|
79
|
+
...kinds.map((k) => REQUEST_ZH[k]),
|
|
80
|
+
...(r?.arrivingAt ? [`预计${r.arrivingAt}到店`] : []),
|
|
81
|
+
];
|
|
82
|
+
const free = (r?.note ?? note ?? '').replace(/\s+/g, ' ').trim().slice(0, REQUEST_NOTE_MAX);
|
|
83
|
+
const text = [words.join(','), free].filter(Boolean).join('。').replace(/[|']/g, ' ');
|
|
84
|
+
return text ? text.slice(0, 512) : undefined;
|
|
85
|
+
};
|
|
86
|
+
const cleanRequests = (r) => {
|
|
87
|
+
if (!r)
|
|
88
|
+
return undefined;
|
|
89
|
+
const kinds = [...new Set(r.kinds ?? [])].filter((k) => REQUEST_KINDS.includes(k));
|
|
90
|
+
const arrivingAt = /^([01]\d|2[0-3]):[0-5]\d$/.test(r.arrivingAt ?? '')
|
|
91
|
+
? r.arrivingAt
|
|
92
|
+
: undefined;
|
|
93
|
+
const note = (r.note ?? '').replace(/\s+/g, ' ').trim().slice(0, REQUEST_NOTE_MAX);
|
|
94
|
+
if (kinds.length === 0 && !arrivingAt && !note)
|
|
95
|
+
return undefined;
|
|
96
|
+
return { kinds, ...(arrivingAt ? { arrivingAt } : {}), ...(note ? { note } : {}) };
|
|
97
|
+
};
|
|
98
|
+
/** A refusal from prebook or book, in words a guest can act on. */
|
|
99
|
+
const refusalWords = (err, room, currency) => {
|
|
100
|
+
const detail = err.detail ? ` (${err.detail})` : '';
|
|
101
|
+
switch (err.code) {
|
|
102
|
+
case 5108:
|
|
103
|
+
case 5209: {
|
|
104
|
+
const ari = err.content?.reservationsAri?.[0];
|
|
105
|
+
const now = ari?.rates?.[0]?.amountAfterTax?.reduce((a, b) => a + (Number(b) || 0), 0);
|
|
106
|
+
return `The price of ${room} changed since it was shown${now ? `: it is now ${formatMoney(money(toMinor(now, currency), currency))} a room for the stay` : ''}. Open the hotel again for today's price.`;
|
|
107
|
+
}
|
|
108
|
+
case 5109:
|
|
109
|
+
case 5210:
|
|
110
|
+
return `${room} has no rooms left for those nights.`;
|
|
111
|
+
case 5110:
|
|
112
|
+
case 5214:
|
|
113
|
+
return `${room} cannot be booked for that many rooms at once.`;
|
|
114
|
+
case 5111:
|
|
115
|
+
case 5215:
|
|
116
|
+
return `${room} does not take that many guests in a room.`;
|
|
117
|
+
case 5120:
|
|
118
|
+
case 5233:
|
|
119
|
+
return `${room} cannot be booked for those nights${detail}.`;
|
|
120
|
+
case 5114:
|
|
121
|
+
case 5211:
|
|
122
|
+
case 5116:
|
|
123
|
+
case 5213:
|
|
124
|
+
return `${room} cannot be booked to be paid at the hotel.`;
|
|
125
|
+
case 5107:
|
|
126
|
+
case 5207:
|
|
127
|
+
case 5128:
|
|
128
|
+
case 5238:
|
|
129
|
+
return `${room} is not on sale that far ahead.`;
|
|
130
|
+
default:
|
|
131
|
+
return `Huazhu did not accept ${room}: ${err.huazhuMessage}${detail}.`;
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
export const createHuazhuHotels = (opts) => {
|
|
135
|
+
const { client, catalog, store, reconciler } = opts;
|
|
136
|
+
const now = opts.now ?? (() => Date.now());
|
|
137
|
+
const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
138
|
+
const ariTtl = opts.ariTtlMs ?? 60_000;
|
|
139
|
+
const productsTtl = opts.productsTtlMs ?? 10 * 60_000;
|
|
140
|
+
const allowTags = new Set(opts.allowTags ?? []);
|
|
141
|
+
const view = { language: opts.language, tz: opts.tz };
|
|
142
|
+
const today = () => todayAt(now(), opts.tz);
|
|
143
|
+
// ------------------------------------------------------------- lookups ----
|
|
144
|
+
const hotelsNow = () => catalog.all().map((h) => ({ raw: h, hotel: toHotel(h, view, now()) }));
|
|
145
|
+
/** A hotel by id, by its name as written, or unambiguously by part of it. */
|
|
146
|
+
const findHotel = (ref) => {
|
|
147
|
+
const wanted = ref.trim();
|
|
148
|
+
if (!wanted)
|
|
149
|
+
return undefined;
|
|
150
|
+
const byId = catalog.get(wanted);
|
|
151
|
+
if (byId)
|
|
152
|
+
return byId;
|
|
153
|
+
const all = catalog.all();
|
|
154
|
+
const low = wanted.toLowerCase();
|
|
155
|
+
const exact = all.find((h) => h.hotelName.toLowerCase() === low || (h.hotelNameEn ?? '').toLowerCase() === low);
|
|
156
|
+
if (exact)
|
|
157
|
+
return exact;
|
|
158
|
+
if (wanted.length >= 4) {
|
|
159
|
+
const part = all.filter((h) => h.hotelName.toLowerCase().includes(low) ||
|
|
160
|
+
(h.hotelNameEn ?? '').toLowerCase().includes(low));
|
|
161
|
+
if (part.length === 1)
|
|
162
|
+
return part[0];
|
|
163
|
+
}
|
|
164
|
+
const en = all.filter((h) => h.hotelNameEn).map((h) => ({ name: h.hotelNameEn, h }));
|
|
165
|
+
return byWords(en, wanted)?.h;
|
|
166
|
+
};
|
|
167
|
+
const productsCache = new Map();
|
|
168
|
+
const productsOf = async (hotelId) => {
|
|
169
|
+
const hit = productsCache.get(hotelId);
|
|
170
|
+
if (hit && now() - hit.at < productsTtl)
|
|
171
|
+
return hit.value;
|
|
172
|
+
const value = await client
|
|
173
|
+
.products(hotelId)
|
|
174
|
+
.then((c) => (c.products ?? []).filter((p) => offeredAtAll(p, allowTags)))
|
|
175
|
+
.catch((err) => {
|
|
176
|
+
if (err instanceof HuazhuRefusal && [4101, 4102, 4103, 2007].includes(err.code))
|
|
177
|
+
return [];
|
|
178
|
+
throw err;
|
|
179
|
+
});
|
|
180
|
+
productsCache.set(hotelId, { at: now(), value });
|
|
181
|
+
return value;
|
|
182
|
+
};
|
|
183
|
+
const ariCache = new Map();
|
|
184
|
+
/** Every product's prices, rules and rooms left, one element per date from `start` to `end`. */
|
|
185
|
+
const ariFor = async (hotelId, start, end, fresh = false) => {
|
|
186
|
+
const key = `${hotelId}|${start}|${end}`;
|
|
187
|
+
const hit = ariCache.get(key);
|
|
188
|
+
if (!fresh && hit && now() - hit.at < ariTtl)
|
|
189
|
+
return hit.value;
|
|
190
|
+
const ranges = ariRanges(start, end);
|
|
191
|
+
const parts = await Promise.all(ranges.map(async (r) => {
|
|
192
|
+
const [offers, inventory] = await Promise.all([
|
|
193
|
+
client.offers(hotelId, r.start, r.end).catch((err) => {
|
|
194
|
+
if (err instanceof HuazhuRefusal && [4206, 2008].includes(err.code))
|
|
195
|
+
return { dailyOffers: [] };
|
|
196
|
+
throw err;
|
|
197
|
+
}),
|
|
198
|
+
client.inventory(hotelId, r.start, r.end).catch((err) => {
|
|
199
|
+
if (err instanceof HuazhuRefusal && [4306, 2008].includes(err.code))
|
|
200
|
+
return {
|
|
201
|
+
dailyInventories: [],
|
|
202
|
+
};
|
|
203
|
+
throw err;
|
|
204
|
+
}),
|
|
205
|
+
]);
|
|
206
|
+
return {
|
|
207
|
+
range: r,
|
|
208
|
+
offers: offers.dailyOffers ?? [],
|
|
209
|
+
inventory: inventory.dailyInventories ?? [],
|
|
210
|
+
};
|
|
211
|
+
}));
|
|
212
|
+
const out = new Map();
|
|
213
|
+
const keys = new Set(parts.flatMap((p) => p.offers.map((o) => roomIdOf(o))));
|
|
214
|
+
for (const k of keys) {
|
|
215
|
+
const merged = mergeOffers(parts.map((p) => ({
|
|
216
|
+
days: nightsBetween(p.range.start, p.range.end) + 1,
|
|
217
|
+
offer: p.offers.find((o) => roomIdOf(o) === k),
|
|
218
|
+
})));
|
|
219
|
+
if (!merged)
|
|
220
|
+
continue;
|
|
221
|
+
const inv = parts.flatMap((p) => {
|
|
222
|
+
const days = nightsBetween(p.range.start, p.range.end) + 1;
|
|
223
|
+
const row = p.inventory.find((i) => roomIdOf(i) === k)?.inventories ?? [];
|
|
224
|
+
return Array.from({ length: days }, (_, i) => row[i] ?? null);
|
|
225
|
+
});
|
|
226
|
+
out.set(k, { start, offer: merged, inventory: inv });
|
|
227
|
+
}
|
|
228
|
+
ariCache.set(key, { at: now(), value: out });
|
|
229
|
+
if (ariCache.size > 500)
|
|
230
|
+
ariCache.delete(ariCache.keys().next().value);
|
|
231
|
+
return out;
|
|
232
|
+
};
|
|
233
|
+
/** Every sellable-or-not product of one hotel for one stay. */
|
|
234
|
+
const priceStay = async (h, stay, rooms, fresh = false) => {
|
|
235
|
+
const products = await productsOf(h.hotelId);
|
|
236
|
+
if (products.length === 0)
|
|
237
|
+
return [];
|
|
238
|
+
const ari = await ariFor(h.hotelId, stay.checkIn, stay.checkOut, fresh);
|
|
239
|
+
const context = {
|
|
240
|
+
checkIn: stay.checkIn,
|
|
241
|
+
checkOut: stay.checkOut,
|
|
242
|
+
adults: stay.guests,
|
|
243
|
+
rooms,
|
|
244
|
+
today: today(),
|
|
245
|
+
clock: clockAt(now(), opts.tz),
|
|
246
|
+
paymentType: opts.paymentType,
|
|
247
|
+
allowTags,
|
|
248
|
+
};
|
|
249
|
+
return products
|
|
250
|
+
.filter((p) => bookableNow(p.bookWindow, stay.checkIn, context.clock))
|
|
251
|
+
.map((product) => {
|
|
252
|
+
const roomType = (h.hotelRoomTypeList ?? []).find((r) => r.roomTypeCode === product.roomTypeCode);
|
|
253
|
+
const w = ari.get(roomIdOf(product));
|
|
254
|
+
const res = w
|
|
255
|
+
? sell(product, w, roomType, context)
|
|
256
|
+
: { ok: false, kind: 'no-price', why: 'no price for those nights' };
|
|
257
|
+
return { product, roomType, res };
|
|
258
|
+
})
|
|
259
|
+
.filter((x) => !(x.roomType && x.roomType.status === 0));
|
|
260
|
+
};
|
|
261
|
+
const policyOf = (code) => parseCancelPolicy(code);
|
|
262
|
+
const toRoom = (h, p, stay) => {
|
|
263
|
+
const s = p.res;
|
|
264
|
+
const cur = s.currency;
|
|
265
|
+
const n = s.after.length;
|
|
266
|
+
const after = s.after.map((x) => toMinor(x, cur));
|
|
267
|
+
const total = after.reduce((a, b) => a + b, 0);
|
|
268
|
+
const pre = s.before ? s.before.map((x) => toMinor(x, cur)).reduce((a, b) => a + b, 0) : total;
|
|
269
|
+
const policy = policyOf(s.cancelCode);
|
|
270
|
+
const freeUntil = policy ? freeUntilOf(policy, stay.checkIn, opts.tz, now()) : undefined;
|
|
271
|
+
const rt = p.roomType;
|
|
272
|
+
const beds = (rt?.bedTypeList ?? [])
|
|
273
|
+
.map((b) => [b.bedTypeName, b.bedSpec].filter(Boolean).join(' '))
|
|
274
|
+
.filter(Boolean);
|
|
275
|
+
return {
|
|
276
|
+
id: roomIdOf(p.product),
|
|
277
|
+
name: [p.product.roomTypeName, p.product.rateCodeName].filter(Boolean).join(' · '),
|
|
278
|
+
sleeps: s.sleeps,
|
|
279
|
+
...(rt?.hotelArea ? { sizeSqm: rt.hotelArea } : {}),
|
|
280
|
+
...(beds.length ? { beds: beds.join(' / ') } : {}),
|
|
281
|
+
...(rt?.isHasWindow && WINDOW[rt.isHasWindow] ? { view: WINDOW[rt.isHasWindow] } : {}),
|
|
282
|
+
breakfast: !!s.meal,
|
|
283
|
+
...(roomPhoto(h, p.product.roomTypeCode)
|
|
284
|
+
? { imageUrl: roomPhoto(h, p.product.roomTypeCode) }
|
|
285
|
+
: {}),
|
|
286
|
+
nightly: money(Math.round(pre / n), cur),
|
|
287
|
+
stayTotal: money(total, cur),
|
|
288
|
+
taxesAndFees: money(total - pre, cur),
|
|
289
|
+
nights: n,
|
|
290
|
+
refundable: policy ? refundableFrom(policy, stay.checkIn, opts.tz, now()) : false,
|
|
291
|
+
...(freeUntil ? { freeCancellationUntil: new Date(freeUntil).toISOString() } : {}),
|
|
292
|
+
roomsLeft: s.roomsLeft,
|
|
293
|
+
};
|
|
294
|
+
};
|
|
295
|
+
/** Why nothing here can be sold for these nights - the most useful reason first. */
|
|
296
|
+
const noRoomsWhy = (priced, stay, rooms) => {
|
|
297
|
+
if (priced.length === 0)
|
|
298
|
+
return 'This hotel has no rooms on sale through this connector.';
|
|
299
|
+
const fails = priced.map((p) => p.res).filter((r) => !r.ok);
|
|
300
|
+
const kinds = new Set(fails.map((f) => f.kind));
|
|
301
|
+
const only = (k) => kinds.size === 1 && kinds.has(k);
|
|
302
|
+
if (only('party')) {
|
|
303
|
+
const largest = Math.max(...fails.map((f) => Number(/\d+/.exec(f.why)?.[0] ?? 0)));
|
|
304
|
+
return `No room here sleeps ${stay.guests}; the largest sleeps ${largest}, so two rooms would work.`;
|
|
305
|
+
}
|
|
306
|
+
if (only('prepaid'))
|
|
307
|
+
return 'Every rate here for those nights is paid in advance, and this connector books rates paid at the hotel.';
|
|
308
|
+
if (only('sold-out'))
|
|
309
|
+
return rooms > 1
|
|
310
|
+
? `No room type here has ${rooms} left for those nights.`
|
|
311
|
+
: 'Every room type here is taken for those nights.';
|
|
312
|
+
if (only('too-many-rooms'))
|
|
313
|
+
return `No rate here books ${rooms} rooms at once; ask for fewer.`;
|
|
314
|
+
const rule = fails.find((f) => f.kind === 'stay-rule');
|
|
315
|
+
if (rule)
|
|
316
|
+
return `Not for those nights: ${rule.why}.`;
|
|
317
|
+
if (kinds.has('closed'))
|
|
318
|
+
return 'Not on sale for those nights.';
|
|
319
|
+
return 'No room here can be booked for those nights.';
|
|
320
|
+
};
|
|
321
|
+
// --------------------------------------------------------------- quote ----
|
|
322
|
+
const quoteLines = async (trip, fresh = false) => {
|
|
323
|
+
const lines = [];
|
|
324
|
+
const dropped = [];
|
|
325
|
+
const sold = new Map();
|
|
326
|
+
let currency;
|
|
327
|
+
let subtotal = 0;
|
|
328
|
+
let taxes = 0;
|
|
329
|
+
const taken = new Map();
|
|
330
|
+
for (const [i, line] of trip.lines.entries()) {
|
|
331
|
+
const h = catalog.get(line.hotelId);
|
|
332
|
+
if (!h) {
|
|
333
|
+
dropped.push({ title: line.hotelId, why: 'That hotel is not in this connector any more.' });
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
const hotelName = toHotel(h, view, now()).name;
|
|
337
|
+
if (line.kind !== 'room') {
|
|
338
|
+
dropped.push({
|
|
339
|
+
title: hotelName,
|
|
340
|
+
why: 'Huazhu hotels sell rooms only here; there are no add-ons.',
|
|
341
|
+
});
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
const problem = stayProblem(line.stay, today());
|
|
345
|
+
if (problem) {
|
|
346
|
+
dropped.push({ title: hotelName, why: problem });
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
const wanted = Math.max(1, Math.floor(line.rooms ?? 1));
|
|
350
|
+
let priced;
|
|
351
|
+
try {
|
|
352
|
+
priced = await priceStay(h, line.stay, wanted, fresh);
|
|
353
|
+
}
|
|
354
|
+
catch (err) {
|
|
355
|
+
dropped.push({
|
|
356
|
+
title: hotelName,
|
|
357
|
+
why: `Huazhu could not be asked for today's price just now (${err.message}).`,
|
|
358
|
+
});
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
const p = priced.find((x) => roomIdOf(x.product) === line.roomId);
|
|
362
|
+
if (!p) {
|
|
363
|
+
dropped.push({
|
|
364
|
+
title: `${hotelName} - ${line.roomId}`,
|
|
365
|
+
why: 'That rate is no longer sold.',
|
|
366
|
+
});
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
if (!p.res.ok) {
|
|
370
|
+
dropped.push({ title: `${hotelName} - ${p.product.roomTypeName}`, why: `${p.res.why}.` });
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
const held = `${h.hotelId}|${line.roomId}|${line.stay.checkIn}|${line.stay.checkOut}`;
|
|
374
|
+
const already = taken.get(held) ?? 0;
|
|
375
|
+
if (p.res.roomsLeft - already < wanted) {
|
|
376
|
+
dropped.push({
|
|
377
|
+
title: `${hotelName} - ${p.product.roomTypeName}`,
|
|
378
|
+
why: `Only ${Math.max(0, p.res.roomsLeft - already)} left.`,
|
|
379
|
+
});
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
taken.set(held, already + wanted);
|
|
383
|
+
const room = toRoom(h, p, line.stay);
|
|
384
|
+
if (currency === undefined)
|
|
385
|
+
currency = room.stayTotal.currency;
|
|
386
|
+
if (room.stayTotal.currency !== currency) {
|
|
387
|
+
dropped.push({
|
|
388
|
+
title: hotelName,
|
|
389
|
+
why: `This trip is priced in ${currency} and ${hotelName} charges in ${room.stayTotal.currency}. No exchange rate is invented here - book those nights as their own trip.`,
|
|
390
|
+
});
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
const preTax = (room.stayTotal.minor - room.taxesAndFees.minor) * wanted;
|
|
394
|
+
subtotal += preTax;
|
|
395
|
+
taxes += room.taxesAndFees.minor * wanted;
|
|
396
|
+
sold.set(i, p);
|
|
397
|
+
lines.push({
|
|
398
|
+
kind: 'room',
|
|
399
|
+
hotelId: h.hotelId,
|
|
400
|
+
hotelName,
|
|
401
|
+
roomRef: room.id,
|
|
402
|
+
title: room.name,
|
|
403
|
+
detail: [
|
|
404
|
+
room.beds,
|
|
405
|
+
room.view,
|
|
406
|
+
p.res.meal
|
|
407
|
+
? `${p.res.meal}${p.res.mealCount > 1 ? ` × ${p.res.mealCount}` : ''}`
|
|
408
|
+
: 'Room only',
|
|
409
|
+
]
|
|
410
|
+
.filter(Boolean)
|
|
411
|
+
.join(' · '),
|
|
412
|
+
stay: line.stay,
|
|
413
|
+
nights: room.nights,
|
|
414
|
+
quantity: wanted,
|
|
415
|
+
unit: room.nightly,
|
|
416
|
+
total: money(preTax, room.stayTotal.currency),
|
|
417
|
+
refundable: room.refundable,
|
|
418
|
+
freeCancellationUntil: room.freeCancellationUntil,
|
|
419
|
+
day: line.stay.checkIn,
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
const cur = currency ?? 'CNY';
|
|
423
|
+
const stays = lines.flatMap((l) => (l.stay ? [l.stay] : []));
|
|
424
|
+
const first = stays.map((s) => s.checkIn).sort()[0];
|
|
425
|
+
const last = stays
|
|
426
|
+
.map((s) => s.checkOut)
|
|
427
|
+
.sort()
|
|
428
|
+
.at(-1);
|
|
429
|
+
return {
|
|
430
|
+
lines,
|
|
431
|
+
subtotal: money(subtotal, cur),
|
|
432
|
+
taxesAndFees: money(taxes, cur),
|
|
433
|
+
total: money(subtotal + taxes, cur),
|
|
434
|
+
...(first && last
|
|
435
|
+
? {
|
|
436
|
+
stay: {
|
|
437
|
+
checkIn: first,
|
|
438
|
+
checkOut: last,
|
|
439
|
+
guests: Math.max(...stays.map((s) => s.guests)),
|
|
440
|
+
},
|
|
441
|
+
}
|
|
442
|
+
: {}),
|
|
443
|
+
...(dropped.length ? { dropped } : {}),
|
|
444
|
+
sold,
|
|
445
|
+
};
|
|
446
|
+
};
|
|
447
|
+
const quote = async (trip) => {
|
|
448
|
+
const { sold, ...q } = await quoteLines(trip);
|
|
449
|
+
void sold;
|
|
450
|
+
return q;
|
|
451
|
+
};
|
|
452
|
+
// ------------------------------------------------------------ bookings ----
|
|
453
|
+
const publicBooking = (b) => ({
|
|
454
|
+
id: b.id,
|
|
455
|
+
confirmationCode: b.confirmationCode,
|
|
456
|
+
status: b.status,
|
|
457
|
+
createdAt: b.createdAt,
|
|
458
|
+
hotelId: b.hotelId,
|
|
459
|
+
hotelName: b.hotelName,
|
|
460
|
+
stay: b.stay,
|
|
461
|
+
guest: b.guest,
|
|
462
|
+
quote: b.quote,
|
|
463
|
+
...(b.cancelledAt ? { cancelledAt: b.cancelledAt } : {}),
|
|
464
|
+
...(b.cancellationFee ? { cancellationFee: b.cancellationFee } : {}),
|
|
465
|
+
...(b.requests ? { requests: b.requests } : {}),
|
|
466
|
+
});
|
|
467
|
+
const findOwn = (ref, who) => {
|
|
468
|
+
const wanted = ref.trim().toLowerCase();
|
|
469
|
+
if (!wanted)
|
|
470
|
+
return undefined;
|
|
471
|
+
const matches = (b) => [b.id, b.confirmationCode, b.resId, b.distributorResId]
|
|
472
|
+
.filter(Boolean)
|
|
473
|
+
.some((x) => String(x).toLowerCase() === wanted);
|
|
474
|
+
const own = store.all().find((b) => b.owners.includes(who.sessionId) && matches(b));
|
|
475
|
+
if (own || !who.email)
|
|
476
|
+
return own;
|
|
477
|
+
// ⚠️ A CODE AND ITS EMAIL, never an id and never an email alone - see the seam.
|
|
478
|
+
const email = who.email.trim().toLowerCase();
|
|
479
|
+
const there = store
|
|
480
|
+
.all()
|
|
481
|
+
.find((b) => [b.confirmationCode, b.resId]
|
|
482
|
+
.filter(Boolean)
|
|
483
|
+
.some((x) => String(x).toLowerCase() === wanted) &&
|
|
484
|
+
b.guest.email.trim().toLowerCase() === email);
|
|
485
|
+
if (there) {
|
|
486
|
+
const owners = [...new Set([...there.owners, who.sessionId])];
|
|
487
|
+
// The conversation that made it stays; the oldest finder goes.
|
|
488
|
+
while (owners.length > 10)
|
|
489
|
+
owners.splice(1, 1);
|
|
490
|
+
store.update(there.id, { owners });
|
|
491
|
+
}
|
|
492
|
+
return there;
|
|
493
|
+
};
|
|
494
|
+
/** Huazhu's own word on a booking, at most once a minute each. */
|
|
495
|
+
const refresh = async (b) => {
|
|
496
|
+
if (!b.resId || b.pendingSince !== undefined)
|
|
497
|
+
return b;
|
|
498
|
+
if (b.refreshedAt && now() - b.refreshedAt < 60_000)
|
|
499
|
+
return b;
|
|
500
|
+
try {
|
|
501
|
+
const d = await client.detail(b.resId);
|
|
502
|
+
const status = statusOf(d.status);
|
|
503
|
+
const line = d.reservations?.[0];
|
|
504
|
+
return (store.update(b.id, {
|
|
505
|
+
hotelStatus: d.status,
|
|
506
|
+
status,
|
|
507
|
+
refreshedAt: now(),
|
|
508
|
+
...(line?.cancelPolicyCode ? { cancelCode: line.cancelPolicyCode } : {}),
|
|
509
|
+
...(status === 'cancelled' && !b.cancelledAt
|
|
510
|
+
? { cancelledAt: new Date(now()).toISOString() }
|
|
511
|
+
: {}),
|
|
512
|
+
}) ?? b);
|
|
513
|
+
}
|
|
514
|
+
catch {
|
|
515
|
+
// Unreachable or refused: what we last knew stands, and says so by its age.
|
|
516
|
+
return b;
|
|
517
|
+
}
|
|
518
|
+
};
|
|
519
|
+
const feeNow = (b) => {
|
|
520
|
+
const policy = policyOf(b.cancelCode);
|
|
521
|
+
const amounts = {
|
|
522
|
+
currency: b.quote.total.currency,
|
|
523
|
+
nightlyMinor: b.nightlyMinor,
|
|
524
|
+
rooms: b.rooms,
|
|
525
|
+
};
|
|
526
|
+
const total = b.nightlyMinor.reduce((a, c) => a + c, 0) * b.rooms;
|
|
527
|
+
if (!policy) {
|
|
528
|
+
return {
|
|
529
|
+
fee: total,
|
|
530
|
+
forbidden: false,
|
|
531
|
+
rule: `This rate's terms (${b.cancelCode || 'none given'}) could not be read here, so the fee shown is the most it could be. Huazhu works out the actual fee when it cancels.`,
|
|
532
|
+
freeUntil: undefined,
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
const penalty = penaltyAt(policy, b.stay.checkIn, b.tz, now());
|
|
536
|
+
const fee = feeFor(penalty, amounts);
|
|
537
|
+
return {
|
|
538
|
+
fee: fee ?? total,
|
|
539
|
+
forbidden: fee === null,
|
|
540
|
+
rule: describePolicy(policy, b.stay.checkIn, b.tz, amounts),
|
|
541
|
+
freeUntil: freeUntilOf(policy, b.stay.checkIn, b.tz, now()),
|
|
542
|
+
};
|
|
543
|
+
};
|
|
544
|
+
/** Chase one unanswered booking call for a little while, inside the guest's request. */
|
|
545
|
+
const settle = async (row) => {
|
|
546
|
+
const until = now() + (opts.settleWithinMs ?? 25_000);
|
|
547
|
+
let current = row;
|
|
548
|
+
while (current.pendingSince !== undefined && now() < until) {
|
|
549
|
+
await sleep(5_000);
|
|
550
|
+
await reconciler.check(current);
|
|
551
|
+
current = store.get(row.id) ?? current;
|
|
552
|
+
}
|
|
553
|
+
return current;
|
|
554
|
+
};
|
|
555
|
+
const distributorResId = () => `CUR${now().toString(36).toUpperCase()}${randomBytes(6).toString('hex').toUpperCase()}`;
|
|
556
|
+
return {
|
|
557
|
+
brand: opts.brand,
|
|
558
|
+
capabilities: HUAZHU_CAPABILITIES,
|
|
559
|
+
imageDomains() {
|
|
560
|
+
const hosts = new Set();
|
|
561
|
+
for (const h of catalog.all()) {
|
|
562
|
+
for (const p of h.hotelPhotoList ?? []) {
|
|
563
|
+
try {
|
|
564
|
+
const u = p.fileUrl &&
|
|
565
|
+
new URL(p.fileUrl.replace(/^http:\/\//, 'https://').replace(/^\/\//, 'https://'));
|
|
566
|
+
if (u)
|
|
567
|
+
hosts.add(u.origin);
|
|
568
|
+
}
|
|
569
|
+
catch {
|
|
570
|
+
// not a URL
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
return [...hosts];
|
|
575
|
+
},
|
|
576
|
+
health() {
|
|
577
|
+
const rows = store.all();
|
|
578
|
+
return {
|
|
579
|
+
catalog: catalog.state(),
|
|
580
|
+
bookings: rows.length,
|
|
581
|
+
pending: rows.filter((r) => r.pendingSince !== undefined).length,
|
|
582
|
+
needsAPerson: rows.filter((r) => r.attention).length,
|
|
583
|
+
};
|
|
584
|
+
},
|
|
585
|
+
async searchHotels(q) {
|
|
586
|
+
const terms = (q.query ?? '')
|
|
587
|
+
.toLowerCase()
|
|
588
|
+
.split(/\s+/)
|
|
589
|
+
.filter((t) => t.length > 0 && !(t.length === 1 && /[a-z0-9]/.test(t)));
|
|
590
|
+
const place = q.city?.trim();
|
|
591
|
+
const all = hotelsNow();
|
|
592
|
+
const placed = all.filter(({ raw }) => {
|
|
593
|
+
if (place && !inPlace(raw, place))
|
|
594
|
+
return false;
|
|
595
|
+
if (terms.length === 0)
|
|
596
|
+
return true;
|
|
597
|
+
const hay = haystackOf(raw);
|
|
598
|
+
return terms.every((t) => hay.includes(t));
|
|
599
|
+
});
|
|
600
|
+
const f = q.filters ?? {};
|
|
601
|
+
const stay = q.stay;
|
|
602
|
+
const wantRooms = Math.max(1, Math.min(MAX_ROOMS_PER_LINE, Math.floor(q.rooms ?? 1)));
|
|
603
|
+
const unapplied = [];
|
|
604
|
+
const ignored = {};
|
|
605
|
+
const roomTerms = !!(f.freeCancellation || f.breakfast);
|
|
606
|
+
const passesStatic = (h, over) => (!over.brand || h.brand.toLowerCase() === over.brand.toLowerCase()) &&
|
|
607
|
+
(over.facilities ?? []).every((k) => hasFacility(h.amenities, k));
|
|
608
|
+
let matches = placed.filter(({ hotel }) => passesStatic(hotel, f));
|
|
609
|
+
// ⚠️ PRICED WHEN THE NIGHTS ARE NAMED - but a city can hold hundreds of
|
|
610
|
+
// these hotels and each one is two calls to Huazhu. Everything is priced
|
|
611
|
+
// when there are few enough; past that, only the page, and the price
|
|
612
|
+
// order, the ceiling and the room terms are not applied to a list they
|
|
613
|
+
// could not see all of. Said, not approximated.
|
|
614
|
+
const priceEverything = !!stay && matches.length <= PRICE_ALL;
|
|
615
|
+
const wantsPriceKnob = q.sort === 'price-low' || q.sort === 'price-high' || Number(q.maxNightly) > 0;
|
|
616
|
+
if (!priceEverything && (wantsPriceKnob || roomTerms)) {
|
|
617
|
+
if (q.sort === 'price-low' || q.sort === 'price-high')
|
|
618
|
+
ignored.sort = true;
|
|
619
|
+
if (Number(q.maxNightly) > 0)
|
|
620
|
+
ignored.ceiling = true;
|
|
621
|
+
if (roomTerms)
|
|
622
|
+
ignored.roomTerms = true;
|
|
623
|
+
}
|
|
624
|
+
if (stay && !priceEverything && (wantsPriceKnob || roomTerms)) {
|
|
625
|
+
unapplied.push(`${matches.length} hotels matched - too many to price at once, so ${[
|
|
626
|
+
wantsPriceKnob ? 'the price order and ceiling' : '',
|
|
627
|
+
roomTerms ? 'the free-cancellation and breakfast filters' : '',
|
|
628
|
+
]
|
|
629
|
+
.filter(Boolean)
|
|
630
|
+
.join(' and ')} were not applied. A district, a brand or a landmark narrows it.`);
|
|
631
|
+
}
|
|
632
|
+
if (!stay && (wantsPriceKnob || roomTerms)) {
|
|
633
|
+
unapplied.push('Prices, free cancellation and breakfast belong to a room on given nights, and Huazhu publishes no price without them - with check-in and check-out they apply.');
|
|
634
|
+
}
|
|
635
|
+
const best = new Map();
|
|
636
|
+
const why = new Map();
|
|
637
|
+
const bestKey = (id, over) => `${id}|${!!over.freeCancellation}|${!!over.breakfast}`;
|
|
638
|
+
const pricedRooms = new Map();
|
|
639
|
+
const priceHotel = async (raw) => {
|
|
640
|
+
if (!stay || pricedRooms.has(raw.hotelId))
|
|
641
|
+
return;
|
|
642
|
+
const problem = stayProblem(stay, today());
|
|
643
|
+
if (problem) {
|
|
644
|
+
pricedRooms.set(raw.hotelId, []);
|
|
645
|
+
why.set(raw.hotelId, problem);
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
try {
|
|
649
|
+
const priced = await priceStay(raw, stay, wantRooms);
|
|
650
|
+
pricedRooms.set(raw.hotelId, priced);
|
|
651
|
+
if (!priced.some((p) => p.res.ok))
|
|
652
|
+
why.set(raw.hotelId, noRoomsWhy(priced, stay, wantRooms));
|
|
653
|
+
}
|
|
654
|
+
catch (err) {
|
|
655
|
+
pricedRooms.set(raw.hotelId, []);
|
|
656
|
+
why.set(raw.hotelId, err instanceof HuazhuRefusal && err.code === 2009
|
|
657
|
+
? 'Huazhu is answering slowly just now; open the hotel for its rooms.'
|
|
658
|
+
: 'Prices could not be read from Huazhu just now; open the hotel for its rooms.');
|
|
659
|
+
}
|
|
660
|
+
};
|
|
661
|
+
const bestFor = (raw, over) => {
|
|
662
|
+
const k = bestKey(raw.hotelId, over);
|
|
663
|
+
const known = best.get(k);
|
|
664
|
+
if (known !== undefined)
|
|
665
|
+
return known;
|
|
666
|
+
let found = null;
|
|
667
|
+
for (const p of pricedRooms.get(raw.hotelId) ?? []) {
|
|
668
|
+
if (!p.res.ok || !stay)
|
|
669
|
+
continue;
|
|
670
|
+
const room = toRoom(raw, p, stay);
|
|
671
|
+
if (over.freeCancellation && !room.freeCancellationUntil)
|
|
672
|
+
continue;
|
|
673
|
+
if (over.breakfast && !room.breakfast)
|
|
674
|
+
continue;
|
|
675
|
+
if (!found || room.stayTotal.minor < found.room.stayTotal.minor)
|
|
676
|
+
found = { room, name: room.name };
|
|
677
|
+
}
|
|
678
|
+
best.set(k, found);
|
|
679
|
+
return found;
|
|
680
|
+
};
|
|
681
|
+
if (priceEverything)
|
|
682
|
+
await pool(matches.map((m) => m.raw), PRICE_CONCURRENCY, priceHotel);
|
|
683
|
+
const canRoomFilter = priceEverything;
|
|
684
|
+
if (canRoomFilter && roomTerms)
|
|
685
|
+
matches = matches.filter(({ raw }) => bestFor(raw, f) !== null);
|
|
686
|
+
const currencies = [...new Set(matches.map((m) => m.hotel.currency))].sort();
|
|
687
|
+
const onePrice = currencies.length === 1;
|
|
688
|
+
const ceiling = priceEverything && onePrice && Number(q.maxNightly) > 0 ? Number(q.maxNightly) : undefined;
|
|
689
|
+
const nightlyOf = (raw) => {
|
|
690
|
+
const b = bestFor(raw, f);
|
|
691
|
+
return b ? b.room.nightly.minor / minorFactor(b.room.nightly.currency) : undefined;
|
|
692
|
+
};
|
|
693
|
+
const under = (raw) => {
|
|
694
|
+
if (ceiling === undefined)
|
|
695
|
+
return true;
|
|
696
|
+
const n = nightlyOf(raw);
|
|
697
|
+
return n !== undefined && n <= ceiling;
|
|
698
|
+
};
|
|
699
|
+
// Group order: the cities with the most hotels first - the same rule as
|
|
700
|
+
// every other deployment, and a fact about the data rather than a pick.
|
|
701
|
+
const citySize = new Map();
|
|
702
|
+
for (const m of matches)
|
|
703
|
+
citySize.set(m.hotel.cityId, (citySize.get(m.hotel.cityId) ?? 0) + 1);
|
|
704
|
+
const manyCities = citySize.size > 1;
|
|
705
|
+
const ranked = manyCities
|
|
706
|
+
? [...matches].sort((a, b) => (citySize.get(b.hotel.cityId) ?? 0) - (citySize.get(a.hotel.cityId) ?? 0) ||
|
|
707
|
+
a.hotel.cityId.localeCompare(b.hotel.cityId))
|
|
708
|
+
: matches;
|
|
709
|
+
const passed = ranked.filter(({ raw }) => under(raw));
|
|
710
|
+
const over = ceiling === undefined
|
|
711
|
+
? []
|
|
712
|
+
: ranked.filter(({ raw }) => !under(raw) && nightlyOf(raw) !== undefined);
|
|
713
|
+
const priceKey = (raw) => {
|
|
714
|
+
const b = bestFor(raw, f);
|
|
715
|
+
return b ? b.room.stayTotal.minor * wantRooms : undefined;
|
|
716
|
+
};
|
|
717
|
+
const byPrice = (a, b, dir) => {
|
|
718
|
+
const x = priceKey(a);
|
|
719
|
+
const y = priceKey(b);
|
|
720
|
+
if (x === undefined)
|
|
721
|
+
return y === undefined ? 0 : 1;
|
|
722
|
+
if (y === undefined)
|
|
723
|
+
return -1;
|
|
724
|
+
return (x - y) * dir;
|
|
725
|
+
};
|
|
726
|
+
const sort = q.sort ?? 'group';
|
|
727
|
+
const ordered = (sort === 'price-low' || sort === 'price-high') && priceEverything && onePrice
|
|
728
|
+
? [...passed].sort((a, b) => byPrice(a.raw, b.raw, sort === 'price-low' ? 1 : -1))
|
|
729
|
+
: passed;
|
|
730
|
+
if (sort === 'rating') {
|
|
731
|
+
unapplied.push('Huazhu publishes no guest ratings, so there is no rating order.');
|
|
732
|
+
ignored.sort = true;
|
|
733
|
+
}
|
|
734
|
+
const page = Math.max(1, Math.min(MAX_PAGE, Math.floor(Number(q.limit) > 0 ? Number(q.limit) : DEFAULT_PAGE)));
|
|
735
|
+
const from = Math.max(0, Math.floor(Number(q.offset) > 0 ? Number(q.offset) : 0));
|
|
736
|
+
const shown = ordered.slice(from, from + page);
|
|
737
|
+
// The page is priced whatever else was: it is what the guest sees.
|
|
738
|
+
if (stay && !priceEverything)
|
|
739
|
+
await pool(shown.map((m) => m.raw), PRICE_CONCURRENCY, priceHotel);
|
|
740
|
+
const priced = (m) => {
|
|
741
|
+
if (!stay)
|
|
742
|
+
return m.hotel;
|
|
743
|
+
const b = bestFor(m.raw, f);
|
|
744
|
+
if (!b)
|
|
745
|
+
return {
|
|
746
|
+
...m.hotel,
|
|
747
|
+
noRoomsForStay: why.get(m.raw.hotelId) ?? 'No room here can be booked for those nights.',
|
|
748
|
+
};
|
|
749
|
+
return {
|
|
750
|
+
...m.hotel,
|
|
751
|
+
forStay: {
|
|
752
|
+
total: multiplyMoney(b.room.stayTotal, wantRooms),
|
|
753
|
+
nightly: b.room.nightly,
|
|
754
|
+
room: b.name,
|
|
755
|
+
rooms: wantRooms,
|
|
756
|
+
nights: b.room.nights,
|
|
757
|
+
roomsLeft: b.room.roomsLeft,
|
|
758
|
+
freeCancellation: !!b.room.freeCancellationUntil,
|
|
759
|
+
},
|
|
760
|
+
};
|
|
761
|
+
};
|
|
762
|
+
const tally = (key) => {
|
|
763
|
+
const counts = new Map();
|
|
764
|
+
for (const { hotel } of passed)
|
|
765
|
+
counts.set(key(hotel), (counts.get(key(hotel)) ?? 0) + 1);
|
|
766
|
+
return [...counts.entries()]
|
|
767
|
+
.map(([name, count]) => ({ name, count }))
|
|
768
|
+
.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
|
|
769
|
+
};
|
|
770
|
+
const count = (o) => placed.filter(({ hotel, raw }) => passesStatic(hotel, o) &&
|
|
771
|
+
(!(o.freeCancellation || o.breakfast) || (canRoomFilter && bestFor(raw, o) !== null)) &&
|
|
772
|
+
under(raw)).length;
|
|
773
|
+
const result = {
|
|
774
|
+
hotels: shown.map(priced),
|
|
775
|
+
total: passed.length,
|
|
776
|
+
currencies,
|
|
777
|
+
refine: {
|
|
778
|
+
brands: [...new Set(placed.map((m) => m.hotel.brand))]
|
|
779
|
+
.map((name) => ({ name, count: count({ ...f, brand: name }) }))
|
|
780
|
+
.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)),
|
|
781
|
+
facilities: FACILITIES.map((x) => ({
|
|
782
|
+
key: x.key,
|
|
783
|
+
label: x.label,
|
|
784
|
+
count: count({ ...f, facilities: [...new Set([...(f.facilities ?? []), x.key])] }),
|
|
785
|
+
})),
|
|
786
|
+
// Only when every match was priced: a count over part of a list is
|
|
787
|
+
// a count of nothing in particular.
|
|
788
|
+
freeCancellation: canRoomFilter ? count({ ...f, freeCancellation: true }) : 0,
|
|
789
|
+
breakfast: canRoomFilter ? count({ ...f, breakfast: true }) : 0,
|
|
790
|
+
},
|
|
791
|
+
...(q.filters ? { beforeFilters: placed.length } : {}),
|
|
792
|
+
...(ceiling !== undefined ? { beforeCeiling: matches.length } : {}),
|
|
793
|
+
facets: {
|
|
794
|
+
countries: tally((h) => h.country ?? h.city),
|
|
795
|
+
cities: tally((h) => h.city).map((c) => ({
|
|
796
|
+
...c,
|
|
797
|
+
country: passed.find((m) => m.hotel.city === c.name)?.hotel.country,
|
|
798
|
+
})),
|
|
799
|
+
},
|
|
800
|
+
...(over.length ? { overBudget: over.length } : {}),
|
|
801
|
+
...(passed.length === 0 && over.length
|
|
802
|
+
? {
|
|
803
|
+
nearest: [...over]
|
|
804
|
+
.sort((a, b) => byPrice(a.raw, b.raw, 1))
|
|
805
|
+
.slice(0, 3)
|
|
806
|
+
.map(priced),
|
|
807
|
+
}
|
|
808
|
+
: {}),
|
|
809
|
+
...(unapplied.length ? { unapplied, ignored } : {}),
|
|
810
|
+
};
|
|
811
|
+
return result;
|
|
812
|
+
},
|
|
813
|
+
async getHotel(id, stay) {
|
|
814
|
+
const raw = findHotel(id);
|
|
815
|
+
if (!raw)
|
|
816
|
+
return null;
|
|
817
|
+
const detail = { hotel: toHotel(raw, view, now()) };
|
|
818
|
+
if (!stay)
|
|
819
|
+
return detail;
|
|
820
|
+
const problem = stayProblem(stay, today());
|
|
821
|
+
if (problem)
|
|
822
|
+
return { ...detail, noRooms: problem };
|
|
823
|
+
let priced;
|
|
824
|
+
try {
|
|
825
|
+
priced = await priceStay(raw, stay, 1);
|
|
826
|
+
}
|
|
827
|
+
catch (err) {
|
|
828
|
+
return {
|
|
829
|
+
...detail,
|
|
830
|
+
stay,
|
|
831
|
+
noRooms: `Huazhu could not be asked for this hotel's rooms just now (${err instanceof HuazhuRefusal ? err.huazhuMessage : 'no answer'}). Try again in a moment.`,
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
const rooms = priced
|
|
835
|
+
.filter((p) => p.res.ok)
|
|
836
|
+
.map((p) => toRoom(raw, p, stay))
|
|
837
|
+
.sort((a, b) => a.stayTotal.minor - b.stayTotal.minor);
|
|
838
|
+
return {
|
|
839
|
+
...detail,
|
|
840
|
+
stay,
|
|
841
|
+
rooms,
|
|
842
|
+
...(rooms.length === 0 ? { noRooms: noRoomsWhy(priced, stay, 1) } : {}),
|
|
843
|
+
};
|
|
844
|
+
},
|
|
845
|
+
async datesAround(id, stay, o) {
|
|
846
|
+
const raw = findHotel(id);
|
|
847
|
+
if (!raw)
|
|
848
|
+
return [];
|
|
849
|
+
const span = Math.max(0, Math.min(7, Math.floor(o.span)));
|
|
850
|
+
const rooms = Math.max(1, Math.floor(o.rooms));
|
|
851
|
+
const n = nightsBetween(stay.checkIn, stay.checkOut);
|
|
852
|
+
const from = today();
|
|
853
|
+
const first = addDays(stay.checkIn, -span) < from ? from : addDays(stay.checkIn, -span);
|
|
854
|
+
const last = addDays(stay.checkOut, span);
|
|
855
|
+
let ari;
|
|
856
|
+
let products;
|
|
857
|
+
try {
|
|
858
|
+
[products, ari] = await Promise.all([
|
|
859
|
+
productsOf(raw.hotelId),
|
|
860
|
+
ariFor(raw.hotelId, first, last),
|
|
861
|
+
]);
|
|
862
|
+
}
|
|
863
|
+
catch {
|
|
864
|
+
return [];
|
|
865
|
+
}
|
|
866
|
+
const out = [];
|
|
867
|
+
for (let shift = -span; shift <= span; shift += 1) {
|
|
868
|
+
const checkIn = addDays(stay.checkIn, shift);
|
|
869
|
+
const checkOut = addDays(checkIn, n);
|
|
870
|
+
if (checkIn < from)
|
|
871
|
+
continue;
|
|
872
|
+
const candidate = { ...stay, checkIn, checkOut };
|
|
873
|
+
if (stayProblem(candidate, from))
|
|
874
|
+
continue;
|
|
875
|
+
const context = {
|
|
876
|
+
checkIn,
|
|
877
|
+
checkOut,
|
|
878
|
+
adults: stay.guests,
|
|
879
|
+
rooms,
|
|
880
|
+
today: from,
|
|
881
|
+
clock: clockAt(now(), opts.tz),
|
|
882
|
+
paymentType: opts.paymentType,
|
|
883
|
+
allowTags,
|
|
884
|
+
};
|
|
885
|
+
let cheapest;
|
|
886
|
+
for (const product of products) {
|
|
887
|
+
if (!bookableNow(product.bookWindow, checkIn, context.clock))
|
|
888
|
+
continue;
|
|
889
|
+
const w = ari.get(roomIdOf(product));
|
|
890
|
+
if (!w)
|
|
891
|
+
continue;
|
|
892
|
+
const roomType = (raw.hotelRoomTypeList ?? []).find((r) => r.roomTypeCode === product.roomTypeCode);
|
|
893
|
+
const res = sell(product, w, roomType, context);
|
|
894
|
+
if (!res.ok)
|
|
895
|
+
continue;
|
|
896
|
+
const total = money(res.after.reduce((a, b) => a + toMinor(b, res.currency), 0) * rooms, res.currency);
|
|
897
|
+
if (!cheapest || total.minor < cheapest.minor)
|
|
898
|
+
cheapest = total;
|
|
899
|
+
}
|
|
900
|
+
out.push({
|
|
901
|
+
checkIn,
|
|
902
|
+
checkOut,
|
|
903
|
+
...(cheapest ? { from: cheapest } : {}),
|
|
904
|
+
...(shift === 0 ? { chosen: true } : {}),
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
return out;
|
|
908
|
+
},
|
|
909
|
+
async signInMember() {
|
|
910
|
+
return null;
|
|
911
|
+
},
|
|
912
|
+
async rememberPreferences() {
|
|
913
|
+
return [];
|
|
914
|
+
},
|
|
915
|
+
async extrasFor() {
|
|
916
|
+
return [];
|
|
917
|
+
},
|
|
918
|
+
quote,
|
|
919
|
+
async requestBooking(trip, guest, who, requests) {
|
|
920
|
+
if (!guest.name.trim() || !guest.email.includes('@')) {
|
|
921
|
+
throw new Error("A booking needs the guest's name and an email to send it to.");
|
|
922
|
+
}
|
|
923
|
+
const contact = contactOf(guest);
|
|
924
|
+
if (typeof contact === 'string')
|
|
925
|
+
throw new Error(contact);
|
|
926
|
+
if (trip.lines.some((l) => l.kind !== 'room')) {
|
|
927
|
+
throw new Error('Nothing was booked. Huazhu hotels have no add-ons here - take them out of the trip and ask again.');
|
|
928
|
+
}
|
|
929
|
+
const live = store
|
|
930
|
+
.all()
|
|
931
|
+
.filter((b) => b.owners.includes(who.sessionId) &&
|
|
932
|
+
(b.status === 'confirmed' || b.status === 'requested') &&
|
|
933
|
+
b.stay.checkOut >= today()).length;
|
|
934
|
+
const { groups } = splitByStay(trip.lines);
|
|
935
|
+
const lines = groups.flat();
|
|
936
|
+
if (live + lines.length > MAX_HOLDS_PER_CONVERSATION) {
|
|
937
|
+
throw new Error(`Nothing was booked. This conversation already has ${live} live booking${live === 1 ? '' : 's'}, and ${MAX_HOLDS_PER_CONVERSATION} at once is the most - cancel one that is not needed, then ask again.`);
|
|
938
|
+
}
|
|
939
|
+
// Priced afresh, not from the cache: this is the price that will be sent.
|
|
940
|
+
const priced = await quoteLines({ lines }, true);
|
|
941
|
+
if (priced.dropped?.length || priced.sold.size !== lines.length) {
|
|
942
|
+
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.`);
|
|
943
|
+
}
|
|
944
|
+
const clean = cleanRequests(requests);
|
|
945
|
+
const comment = commentOf(clean, guest.notes);
|
|
946
|
+
const plans = [];
|
|
947
|
+
for (const [i, line] of lines.entries()) {
|
|
948
|
+
const sold = priced.sold.get(i);
|
|
949
|
+
const qline = priced.lines[i];
|
|
950
|
+
if (line.kind !== 'room' || !sold || !qline)
|
|
951
|
+
throw new Error('Nothing was booked: the trip changed while it was being priced.');
|
|
952
|
+
plans.push({ i, line, sold, qline });
|
|
953
|
+
}
|
|
954
|
+
for (const plan of plans) {
|
|
955
|
+
const s = plan.sold.res;
|
|
956
|
+
const title = `${plan.qline.hotelName} - ${plan.qline.title}`;
|
|
957
|
+
try {
|
|
958
|
+
const out = await client.prebook({
|
|
959
|
+
hotelId: plan.line.hotelId,
|
|
960
|
+
checkinType: 'DAY',
|
|
961
|
+
paymentType: opts.paymentType,
|
|
962
|
+
reservations: [
|
|
963
|
+
{
|
|
964
|
+
roomTypeCode: plan.sold.product.roomTypeCode,
|
|
965
|
+
rateCode: plan.sold.product.rateCode,
|
|
966
|
+
checkin: plan.line.stay.checkIn,
|
|
967
|
+
checkout: plan.line.stay.checkOut,
|
|
968
|
+
amountAfterTax: s.after,
|
|
969
|
+
...(s.before ? { amountBeforeTax: s.before } : {}),
|
|
970
|
+
currencyCode: s.currency,
|
|
971
|
+
roomCount: plan.qline.quantity ?? 1,
|
|
972
|
+
adultCount: plan.line.stay.guests,
|
|
973
|
+
},
|
|
974
|
+
],
|
|
975
|
+
});
|
|
976
|
+
plan.token = out.preBookToken;
|
|
977
|
+
}
|
|
978
|
+
catch (err) {
|
|
979
|
+
if (err instanceof HuazhuRefusal)
|
|
980
|
+
throw new Error(`Nothing was booked. ${refusalWords(err, title, s.currency)}`);
|
|
981
|
+
throw new Error(`Nothing was booked: Huazhu could not be reached to check ${title} (${err.message}). Asking again is safe.`);
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
const tripRef = randomUUID();
|
|
985
|
+
const made = [];
|
|
986
|
+
/** Undo what this trip booked, when a later stay of it failed. */
|
|
987
|
+
const takeBackAll = async () => {
|
|
988
|
+
const stuck = [];
|
|
989
|
+
for (const b of made) {
|
|
990
|
+
store.update(b.id, { rollback: true });
|
|
991
|
+
const row = store.get(b.id);
|
|
992
|
+
if (!row)
|
|
993
|
+
continue;
|
|
994
|
+
if (row.pendingSince !== undefined)
|
|
995
|
+
continue; // the reconciler cancels it if it turns up
|
|
996
|
+
await reconciler.takeBack(row);
|
|
997
|
+
if (store.get(b.id)?.status !== 'cancelled')
|
|
998
|
+
stuck.push(`${row.hotelName} (${row.confirmationCode})`);
|
|
999
|
+
}
|
|
1000
|
+
return stuck.length
|
|
1001
|
+
? ` ${stuck.join(' and ')} could not be cancelled automatically and has been flagged for a person to cancel with the hotel.`
|
|
1002
|
+
: '';
|
|
1003
|
+
};
|
|
1004
|
+
for (const plan of plans) {
|
|
1005
|
+
const s = plan.sold.res;
|
|
1006
|
+
const rooms = plan.qline.quantity ?? 1;
|
|
1007
|
+
const qTotal = plan.qline.total.minor;
|
|
1008
|
+
const nightlyMinor = s.after.map((x) => toMinor(x, s.currency));
|
|
1009
|
+
const allIn = nightlyMinor.reduce((a, b) => a + b, 0) * rooms;
|
|
1010
|
+
const cur = s.currency;
|
|
1011
|
+
const id = `hz_${randomUUID()}`;
|
|
1012
|
+
const dres = distributorResId();
|
|
1013
|
+
const row = {
|
|
1014
|
+
id,
|
|
1015
|
+
confirmationCode: dres,
|
|
1016
|
+
status: 'requested',
|
|
1017
|
+
createdAt: new Date(now()).toISOString(),
|
|
1018
|
+
hotelId: plan.line.hotelId,
|
|
1019
|
+
hotelName: plan.qline.hotelName,
|
|
1020
|
+
stay: plan.line.stay,
|
|
1021
|
+
guest,
|
|
1022
|
+
quote: {
|
|
1023
|
+
lines: [plan.qline],
|
|
1024
|
+
subtotal: money(qTotal, cur),
|
|
1025
|
+
taxesAndFees: money(allIn - qTotal, cur),
|
|
1026
|
+
total: money(allIn, cur),
|
|
1027
|
+
stay: plan.line.stay,
|
|
1028
|
+
},
|
|
1029
|
+
...(clean ? { requests: clean } : {}),
|
|
1030
|
+
owners: [who.sessionId],
|
|
1031
|
+
distributorResId: dres,
|
|
1032
|
+
tripRef,
|
|
1033
|
+
paymentType: opts.paymentType,
|
|
1034
|
+
guaranteeCode: s.guaranteeCode,
|
|
1035
|
+
cancelCode: s.cancelCode,
|
|
1036
|
+
nightlyMinor,
|
|
1037
|
+
rooms,
|
|
1038
|
+
tz: opts.tz,
|
|
1039
|
+
roomTypeCode: plan.sold.product.roomTypeCode,
|
|
1040
|
+
rateCode: plan.sold.product.rateCode,
|
|
1041
|
+
// ⚠️ SET BEFORE THE CALL: a process that dies mid-call leaves a
|
|
1042
|
+
// record the reconciler follows up on restart.
|
|
1043
|
+
pendingSince: now(),
|
|
1044
|
+
};
|
|
1045
|
+
store.put(row);
|
|
1046
|
+
made.push(row);
|
|
1047
|
+
const reservation = {
|
|
1048
|
+
roomTypeCode: plan.sold.product.roomTypeCode,
|
|
1049
|
+
rateCode: plan.sold.product.rateCode,
|
|
1050
|
+
checkin: plan.line.stay.checkIn,
|
|
1051
|
+
checkout: plan.line.stay.checkOut,
|
|
1052
|
+
amountAfterTax: s.after,
|
|
1053
|
+
...(s.before ? { amountBeforeTax: s.before } : {}),
|
|
1054
|
+
...(s.rack ? { rackRate: s.rack } : {}),
|
|
1055
|
+
currencyCode: cur,
|
|
1056
|
+
roomCount: rooms,
|
|
1057
|
+
adultCount: plan.line.stay.guests,
|
|
1058
|
+
guaranteePolicyCode: s.guaranteeCode,
|
|
1059
|
+
...(clean?.arrivingAt ? { estimatedArrivalTime: clean.arrivingAt } : {}),
|
|
1060
|
+
resGuest: [contact],
|
|
1061
|
+
};
|
|
1062
|
+
const request = {
|
|
1063
|
+
hotelId: plan.line.hotelId,
|
|
1064
|
+
checkinType: 'DAY',
|
|
1065
|
+
contactPerson: contact,
|
|
1066
|
+
distributorResId: dres,
|
|
1067
|
+
paymentType: opts.paymentType,
|
|
1068
|
+
reservations: [reservation],
|
|
1069
|
+
...(comment ? { guestComment: comment } : {}),
|
|
1070
|
+
...(plan.token ? { preBookToken: plan.token } : {}),
|
|
1071
|
+
};
|
|
1072
|
+
// ⚠️ ONLY A REFUSAL FROM HUAZHU MEANS "NOT BOOKED". Anything else -
|
|
1073
|
+
// no answer, an answer that did not parse, our own code failing after
|
|
1074
|
+
// the call - means it may exist, and is followed up by the consistency
|
|
1075
|
+
// query, never written off.
|
|
1076
|
+
let outcome = 'unknown';
|
|
1077
|
+
let refusal;
|
|
1078
|
+
let answer;
|
|
1079
|
+
for (let attempt = 0; attempt < 2 && !answer && !refusal; attempt += 1) {
|
|
1080
|
+
try {
|
|
1081
|
+
answer = await client.book(request);
|
|
1082
|
+
}
|
|
1083
|
+
catch (err) {
|
|
1084
|
+
if (!(err instanceof HuazhuRefusal))
|
|
1085
|
+
break;
|
|
1086
|
+
// "Already exists" for OUR id: an earlier try got through.
|
|
1087
|
+
if (err.code === 5218 || err.code === 5221 || err.code === 5231)
|
|
1088
|
+
break;
|
|
1089
|
+
if (err.code === 2001 && attempt === 0) {
|
|
1090
|
+
// The documentation's own advice: retry a system error, same id.
|
|
1091
|
+
await sleep(1_000);
|
|
1092
|
+
continue;
|
|
1093
|
+
}
|
|
1094
|
+
refusal = err;
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
if (answer) {
|
|
1098
|
+
// Outside the try: a failure to WRITE this down is not Huazhu
|
|
1099
|
+
// refusing it, and must not be recorded as one.
|
|
1100
|
+
store.update(id, {
|
|
1101
|
+
resId: answer.resId,
|
|
1102
|
+
...(answer.resCode ? { resCode: answer.resCode } : {}),
|
|
1103
|
+
confirmationCode: answer.resId,
|
|
1104
|
+
status: 'confirmed',
|
|
1105
|
+
hotelStatus: 'HOTEL_RESERVATION',
|
|
1106
|
+
pendingSince: undefined,
|
|
1107
|
+
});
|
|
1108
|
+
outcome = 'booked';
|
|
1109
|
+
}
|
|
1110
|
+
else if (refusal) {
|
|
1111
|
+
outcome = 'refused';
|
|
1112
|
+
}
|
|
1113
|
+
if (outcome === 'refused') {
|
|
1114
|
+
// Never made at Huazhu: nothing to follow up, nothing to show.
|
|
1115
|
+
store.update(id, { pendingSince: undefined, status: 'failed' });
|
|
1116
|
+
made.pop();
|
|
1117
|
+
const before = made.length;
|
|
1118
|
+
const back = await takeBackAll();
|
|
1119
|
+
const title = `${plan.qline.hotelName} - ${plan.qline.title}`;
|
|
1120
|
+
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}`);
|
|
1121
|
+
}
|
|
1122
|
+
if (outcome === 'unknown') {
|
|
1123
|
+
const settled = await settle(store.get(id) ?? row);
|
|
1124
|
+
if (settled.pendingSince !== undefined || settled.status !== 'confirmed') {
|
|
1125
|
+
if (plans.length > 1 || settled.status === 'failed') {
|
|
1126
|
+
const back = await takeBackAll();
|
|
1127
|
+
if (settled.status === 'failed') {
|
|
1128
|
+
throw new Error(`Nothing was booked: Huazhu did not confirm ${plan.qline.hotelName}.${back}`);
|
|
1129
|
+
}
|
|
1130
|
+
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}`);
|
|
1131
|
+
}
|
|
1132
|
+
// One stay, outcome still unknown: it is the guest's booking, marked
|
|
1133
|
+
// as waiting, and the reconciler settles it within eight minutes.
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
return made.map((b) => publicBooking(store.get(b.id) ?? b));
|
|
1138
|
+
},
|
|
1139
|
+
async listBookings(who, limit = 10) {
|
|
1140
|
+
const most = Math.max(1, Math.min(MAX_PAGE, Math.floor(Number(limit) > 0 ? limit : 10)));
|
|
1141
|
+
const mine = store
|
|
1142
|
+
.all()
|
|
1143
|
+
.filter((b) => b.owners.includes(who.sessionId) &&
|
|
1144
|
+
!(b.status === 'failed' && !b.resId && b.pendingSince === undefined && !b.attention))
|
|
1145
|
+
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
|
|
1146
|
+
.slice(0, most);
|
|
1147
|
+
const fresh = await pool(mine, 3, (b) => refresh(b));
|
|
1148
|
+
return fresh.map(publicBooking);
|
|
1149
|
+
},
|
|
1150
|
+
async findBooking(ref, who) {
|
|
1151
|
+
const b = findOwn(ref, who);
|
|
1152
|
+
return b ? publicBooking(await refresh(b)) : null;
|
|
1153
|
+
},
|
|
1154
|
+
async previewChange(ref, _change, who) {
|
|
1155
|
+
const b = findOwn(ref, who);
|
|
1156
|
+
if (!b)
|
|
1157
|
+
return null;
|
|
1158
|
+
const rooms = b.rooms;
|
|
1159
|
+
return {
|
|
1160
|
+
bookingId: b.id,
|
|
1161
|
+
confirmationCode: b.confirmationCode,
|
|
1162
|
+
hotelName: b.hotelName,
|
|
1163
|
+
from: {
|
|
1164
|
+
stay: b.stay,
|
|
1165
|
+
rooms,
|
|
1166
|
+
roomName: b.quote.lines[0]?.title ?? '',
|
|
1167
|
+
total: b.quote.total,
|
|
1168
|
+
},
|
|
1169
|
+
changeable: false,
|
|
1170
|
+
rule: "Huazhu's booking system does not move a booking to other nights or another room. To change it, cancel it - under its cancellation terms - and book the new nights.",
|
|
1171
|
+
};
|
|
1172
|
+
},
|
|
1173
|
+
async changeBooking() {
|
|
1174
|
+
throw new Error("Huazhu's booking system does not move a booking to other nights or another room. To change it, cancel it - under its cancellation terms - and book the new nights.");
|
|
1175
|
+
},
|
|
1176
|
+
async previewCancellation(ref, who) {
|
|
1177
|
+
const found = findOwn(ref, who);
|
|
1178
|
+
if (!found)
|
|
1179
|
+
return null;
|
|
1180
|
+
const b = await refresh(found);
|
|
1181
|
+
const cur = b.quote.total.currency;
|
|
1182
|
+
const zero = money(0, cur);
|
|
1183
|
+
const past = now() >= nightAuditOf(b.stay.checkIn, b.tz);
|
|
1184
|
+
if (b.pendingSince !== undefined) {
|
|
1185
|
+
return {
|
|
1186
|
+
bookingId: b.id,
|
|
1187
|
+
confirmationCode: b.confirmationCode,
|
|
1188
|
+
fee: zero,
|
|
1189
|
+
refund: zero,
|
|
1190
|
+
rule: 'Huazhu has not confirmed this booking yet - that takes up to eight minutes. It can be cancelled once it is confirmed.',
|
|
1191
|
+
cancellable: false,
|
|
1192
|
+
};
|
|
1193
|
+
}
|
|
1194
|
+
if (b.status !== 'confirmed' || past) {
|
|
1195
|
+
return {
|
|
1196
|
+
bookingId: b.id,
|
|
1197
|
+
confirmationCode: b.confirmationCode,
|
|
1198
|
+
fee: zero,
|
|
1199
|
+
refund: zero,
|
|
1200
|
+
rule: past && b.status === 'confirmed'
|
|
1201
|
+
? 'The arrival day has passed; a booking cannot be cancelled after it.'
|
|
1202
|
+
: `This booking is ${b.status}; there is nothing left to cancel.`,
|
|
1203
|
+
cancellable: false,
|
|
1204
|
+
};
|
|
1205
|
+
}
|
|
1206
|
+
const { fee, forbidden, rule, freeUntil } = feeNow(b);
|
|
1207
|
+
return {
|
|
1208
|
+
bookingId: b.id,
|
|
1209
|
+
confirmationCode: b.confirmationCode,
|
|
1210
|
+
fee: money(forbidden ? 0 : fee, cur),
|
|
1211
|
+
// Nothing was paid through this connector: there is nothing to refund.
|
|
1212
|
+
refund: zero,
|
|
1213
|
+
rule: forbidden
|
|
1214
|
+
? `This rate cannot be cancelled now. ${rule}`
|
|
1215
|
+
: `${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.`,
|
|
1216
|
+
...(freeUntil ? { freeUntil: new Date(freeUntil).toISOString() } : {}),
|
|
1217
|
+
cancellable: !forbidden,
|
|
1218
|
+
};
|
|
1219
|
+
},
|
|
1220
|
+
async cancelBooking(ref, who) {
|
|
1221
|
+
const found = findOwn(ref, who);
|
|
1222
|
+
if (!found)
|
|
1223
|
+
return null;
|
|
1224
|
+
const b = await refresh(found);
|
|
1225
|
+
if (b.status !== 'confirmed')
|
|
1226
|
+
return publicBooking(b);
|
|
1227
|
+
if (b.pendingSince !== undefined || !b.resId) {
|
|
1228
|
+
throw new Error('Huazhu has not confirmed this booking yet - that takes up to eight minutes. It can be cancelled once it is confirmed.');
|
|
1229
|
+
}
|
|
1230
|
+
const cur = b.quote.total.currency;
|
|
1231
|
+
try {
|
|
1232
|
+
const out = await client.cancel(b.resId);
|
|
1233
|
+
const fee = typeof out.penaltyAmount === 'number'
|
|
1234
|
+
? money(toMinor(out.penaltyAmount, out.penaltyCurrencyCode || cur), out.penaltyCurrencyCode || cur)
|
|
1235
|
+
: money(feeNow(b).fee, cur);
|
|
1236
|
+
return publicBooking(store.update(b.id, {
|
|
1237
|
+
status: 'cancelled',
|
|
1238
|
+
hotelStatus: 'HOTEL_CANCEL',
|
|
1239
|
+
cancelledAt: new Date(now()).toISOString(),
|
|
1240
|
+
cancellationFee: fee,
|
|
1241
|
+
}) ?? b);
|
|
1242
|
+
}
|
|
1243
|
+
catch (err) {
|
|
1244
|
+
if (err instanceof HuazhuRefusal && err.code === 5402) {
|
|
1245
|
+
return publicBooking(store.update(b.id, {
|
|
1246
|
+
status: 'cancelled',
|
|
1247
|
+
hotelStatus: 'HOTEL_CANCEL',
|
|
1248
|
+
cancelledAt: b.cancelledAt ?? new Date(now()).toISOString(),
|
|
1249
|
+
}) ?? b);
|
|
1250
|
+
}
|
|
1251
|
+
if (err instanceof HuazhuRefusal && (err.code === 5401 || err.code === 5405)) {
|
|
1252
|
+
throw new Error(err.code === 5405
|
|
1253
|
+
? 'Huazhu says this stay has already started, so it cannot be cancelled.'
|
|
1254
|
+
: 'Huazhu says this booking cannot be cancelled.');
|
|
1255
|
+
}
|
|
1256
|
+
if (err instanceof HuazhuUnreachable) {
|
|
1257
|
+
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.');
|
|
1258
|
+
}
|
|
1259
|
+
throw err;
|
|
1260
|
+
}
|
|
1261
|
+
},
|
|
1262
|
+
async policies() {
|
|
1263
|
+
// Huazhu publishes no policy text: each RATE carries its terms, and those
|
|
1264
|
+
// are shown with the room and on the cancellation preview. Nothing here
|
|
1265
|
+
// is written in the hotel's name.
|
|
1266
|
+
return [];
|
|
1267
|
+
},
|
|
1268
|
+
};
|
|
1269
|
+
};
|
|
1270
|
+
/**
|
|
1271
|
+
* Offers for one product from consecutive ARI answers, as one run of dates.
|
|
1272
|
+
* A part with no offer for the product is a run of nulls, not a gap.
|
|
1273
|
+
*/
|
|
1274
|
+
const mergeOffers = (parts) => {
|
|
1275
|
+
const base = parts.find((p) => p.offer)?.offer;
|
|
1276
|
+
if (!base)
|
|
1277
|
+
return undefined;
|
|
1278
|
+
const run = (pick) => parts.flatMap((p) => {
|
|
1279
|
+
const arr = p.offer ? pick(p.offer) : undefined;
|
|
1280
|
+
return Array.from({ length: p.days }, (_, i) => (arr?.[i] ?? null));
|
|
1281
|
+
});
|
|
1282
|
+
const adults = [
|
|
1283
|
+
...new Set(parts.flatMap((p) => (p.offer?.rates ?? []).map((r) => r.maxAdultCount))),
|
|
1284
|
+
];
|
|
1285
|
+
const rates = adults.map((a) => {
|
|
1286
|
+
const of = (o) => o.rates.find((r) => r.maxAdultCount === a);
|
|
1287
|
+
return {
|
|
1288
|
+
maxAdultCount: a,
|
|
1289
|
+
amountAfterTax: run((o) => of(o)?.amountAfterTax),
|
|
1290
|
+
amountBeforeTax: run((o) => of(o)?.amountBeforeTax),
|
|
1291
|
+
rackRate: run((o) => of(o)?.rackRate),
|
|
1292
|
+
};
|
|
1293
|
+
});
|
|
1294
|
+
const r = (k) => run((o) => o.restriction?.[k]);
|
|
1295
|
+
return {
|
|
1296
|
+
roomTypeCode: base.roomTypeCode,
|
|
1297
|
+
rateCode: base.rateCode,
|
|
1298
|
+
currencyCode: base.currencyCode,
|
|
1299
|
+
mealPlanType: base.mealPlanType,
|
|
1300
|
+
mealPlanCounts: run((o) => o.mealPlanCounts),
|
|
1301
|
+
rates,
|
|
1302
|
+
restriction: {
|
|
1303
|
+
close: r('close'),
|
|
1304
|
+
minStayArrival: r('minStayArrival'),
|
|
1305
|
+
maxStayArrival: r('maxStayArrival'),
|
|
1306
|
+
minStayThrough: r('minStayThrough'),
|
|
1307
|
+
maxStayThrough: r('maxStayThrough'),
|
|
1308
|
+
minAdvanceDay: r('minAdvanceDay'),
|
|
1309
|
+
maxAdvanceDay: r('maxAdvanceDay'),
|
|
1310
|
+
cta: r('cta'),
|
|
1311
|
+
ctd: r('ctd'),
|
|
1312
|
+
fplos: r('fplos'),
|
|
1313
|
+
},
|
|
1314
|
+
guaranteePolicies: run((o) => o.guaranteePolicies),
|
|
1315
|
+
cancelPolicies: run((o) => o.cancelPolicies),
|
|
1316
|
+
};
|
|
1317
|
+
};
|
|
1318
|
+
//# sourceMappingURL=backend.js.map
|