@curless/hotel-storefront 0.27.28 → 0.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/backend.d.ts +19 -0
- package/dist/backend.d.ts.map +1 -1
- package/dist/backend.js.map +1 -1
- package/dist/card.generated.d.ts +1 -1
- package/dist/card.generated.js +2 -2
- package/dist/card.generated.js.map +1 -1
- package/dist/config.d.ts +45 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js.map +1 -1
- package/dist/demo-hotels.d.ts.map +1 -1
- package/dist/demo-hotels.js +7 -1
- package/dist/demo-hotels.js.map +1 -1
- package/dist/hourly.d.ts +31 -0
- package/dist/hourly.d.ts.map +1 -0
- package/dist/hourly.js +61 -0
- package/dist/hourly.js.map +1 -0
- package/dist/huazhu/backend.d.ts +13 -1
- package/dist/huazhu/backend.d.ts.map +1 -1
- package/dist/huazhu/backend.js +712 -39
- package/dist/huazhu/backend.js.map +1 -1
- package/dist/huazhu/bookings.d.ts +37 -1
- package/dist/huazhu/bookings.d.ts.map +1 -1
- package/dist/huazhu/bookings.js.map +1 -1
- package/dist/huazhu/check.js +2 -2
- package/dist/huazhu/check.js.map +1 -1
- package/dist/huazhu/client.d.ts +3 -3
- package/dist/huazhu/client.d.ts.map +1 -1
- package/dist/huazhu/client.js +4 -4
- package/dist/huazhu/client.js.map +1 -1
- package/dist/huazhu/english.d.ts.map +1 -1
- package/dist/huazhu/english.js +15 -0
- package/dist/huazhu/english.js.map +1 -1
- package/dist/huazhu/fake.d.ts +3 -1
- package/dist/huazhu/fake.d.ts.map +1 -1
- package/dist/huazhu/fake.js +69 -7
- package/dist/huazhu/fake.js.map +1 -1
- package/dist/huazhu/labels.d.ts +9 -0
- package/dist/huazhu/labels.d.ts.map +1 -0
- package/dist/huazhu/labels.js +28 -0
- package/dist/huazhu/labels.js.map +1 -0
- package/dist/huazhu/make.d.ts.map +1 -1
- package/dist/huazhu/make.js +26 -0
- package/dist/huazhu/make.js.map +1 -1
- package/dist/huazhu/sandbox-photos.d.ts +7 -0
- package/dist/huazhu/sandbox-photos.d.ts.map +1 -1
- package/dist/huazhu/sandbox-photos.js +7 -0
- package/dist/huazhu/sandbox-photos.js.map +1 -1
- package/dist/huazhu/sandbox.d.ts +2 -0
- package/dist/huazhu/sandbox.d.ts.map +1 -1
- package/dist/huazhu/sandbox.js +125 -6
- package/dist/huazhu/sandbox.js.map +1 -1
- package/dist/huazhu/sellable.d.ts +46 -3
- package/dist/huazhu/sellable.d.ts.map +1 -1
- package/dist/huazhu/sellable.js +105 -12
- package/dist/huazhu/sellable.js.map +1 -1
- package/dist/huazhu/wire.d.ts +11 -0
- package/dist/huazhu/wire.d.ts.map +1 -1
- package/dist/huazhu/wire.js.map +1 -1
- package/dist/legal.d.ts.map +1 -1
- package/dist/legal.js +19 -8
- package/dist/legal.js.map +1 -1
- package/dist/payments/checkout.d.ts +71 -0
- package/dist/payments/checkout.d.ts.map +1 -0
- package/dist/payments/checkout.js +133 -0
- package/dist/payments/checkout.js.map +1 -0
- package/dist/payments/fake-gateway.d.ts +43 -0
- package/dist/payments/fake-gateway.d.ts.map +1 -0
- package/dist/payments/fake-gateway.js +135 -0
- package/dist/payments/fake-gateway.js.map +1 -0
- package/dist/present.d.ts.map +1 -1
- package/dist/present.js +8 -4
- package/dist/present.js.map +1 -1
- package/dist/server.d.ts +4 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +19 -4
- package/dist/server.js.map +1 -1
- package/dist/supplier.d.ts +2 -0
- package/dist/supplier.d.ts.map +1 -1
- package/dist/supplier.js.map +1 -1
- package/dist/surface.d.ts.map +1 -1
- package/dist/surface.js +7 -2
- package/dist/surface.js.map +1 -1
- package/dist/tools.d.ts +15 -1
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +298 -30
- package/dist/tools.js.map +1 -1
- package/dist/trip.d.ts +6 -0
- package/dist/trip.d.ts.map +1 -1
- package/dist/trip.js +17 -3
- package/dist/trip.js.map +1 -1
- package/dist/types.d.ts +109 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/huazhu/backend.js
CHANGED
|
@@ -3,6 +3,7 @@ import { DEFAULT_PAGE, MAX_PAGE, REQUEST_KINDS, REQUEST_NOTE_MAX, guestTooLong,
|
|
|
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
|
+
import { endOf, startProblem } from '../hourly.js';
|
|
6
7
|
import { BOOKING_LIMITS, DAY_MS, HOUR_MS, WindowCounter } from '../limits.js';
|
|
7
8
|
import { formatMoney, minorFactor, money, multiplyMoney, toMinor } from '../money.js';
|
|
8
9
|
import { MAX_ROOMS_PER_LINE, quoteLineKey } from '../trip.js';
|
|
@@ -10,8 +11,9 @@ import { IN_REQUEST_AT_MS, StoreWriteError, nextCheckAt, statusOf, } from './boo
|
|
|
10
11
|
import { describePolicy, feeFor, freeUntilOf, nightAuditOf, parseCancelPolicy, penaltyAt, refundableFrom, } from './cancel-policy.js';
|
|
11
12
|
import { haystackOf, inPlace, roomPhoto, toHotel } from './catalog.js';
|
|
12
13
|
import { HuazhuRefusal, HuazhuUnreachable } from './client.js';
|
|
13
|
-
import { bedNameEn, offerNameEn, roomNameEn } from './english.js';
|
|
14
|
-
import {
|
|
14
|
+
import { bedNameEn, offerNameEn, rateNameEn, roomNameEn } from './english.js';
|
|
15
|
+
import { rateLabels } from './labels.js';
|
|
16
|
+
import { ariRanges, bookableNow, clockAt, hourlyWindow, inStayWindow, offeredAtAll, sell, } from './sellable.js';
|
|
15
17
|
import { BOOK_EXISTS, BOOK_REFUSALS, CODES, } from './wire.js';
|
|
16
18
|
export const HUAZHU_CAPABILITIES = {
|
|
17
19
|
members: false,
|
|
@@ -84,6 +86,21 @@ const WINDOW = {
|
|
|
84
86
|
'3': 'No window',
|
|
85
87
|
'4': 'Window onto an inner courtyard',
|
|
86
88
|
};
|
|
89
|
+
/** The same, as Huazhu's own dictionary says it - for a connector that speaks Chinese. */
|
|
90
|
+
const WINDOW_ZH = {
|
|
91
|
+
'1': '有窗',
|
|
92
|
+
'2': '部分有窗',
|
|
93
|
+
'3': '无窗',
|
|
94
|
+
'4': '内窗(朝天井)',
|
|
95
|
+
};
|
|
96
|
+
/** A rate's meal in Chinese, by its code; room only is 不含早. */
|
|
97
|
+
const MEAL_ZH = {
|
|
98
|
+
BB: '含早',
|
|
99
|
+
BF: '含自助早',
|
|
100
|
+
HB: '含半餐',
|
|
101
|
+
FB: '含全餐',
|
|
102
|
+
AI: '全包',
|
|
103
|
+
};
|
|
87
104
|
const pool = async (items, n, fn) => {
|
|
88
105
|
const out = new Array(items.length);
|
|
89
106
|
let next = 0;
|
|
@@ -266,12 +283,43 @@ export const englishFits = (hotels, ref) => {
|
|
|
266
283
|
* "Sun 11 Oct, 00:00" for a stay arriving on Saturday the 10th - read as a
|
|
267
284
|
* day more than there is (2026-10-05 audit, P3). Said as the 10th, 24:00.
|
|
268
285
|
*/
|
|
269
|
-
export const deadlineAt = (instant, tz) => {
|
|
286
|
+
export const deadlineAt = (instant, tz, language = 'en') => {
|
|
287
|
+
if (language === 'zh')
|
|
288
|
+
return deadlineZh(instant, tz);
|
|
270
289
|
const shown = atHotelTime(instant, tz);
|
|
271
290
|
if (!/, 00:00\b/.test(shown))
|
|
272
291
|
return shown;
|
|
273
292
|
return atHotelTime(instant - 60_000, tz).replace(/, 23:59\b/, ', 24:00');
|
|
274
293
|
};
|
|
294
|
+
/**
|
|
295
|
+
* The same deadline in Chinese - "10月25日 周日 18:00", midnight still the
|
|
296
|
+
* day before's 24:00. A connector that speaks Chinese had its card say
|
|
297
|
+
* "Sun 25 Oct, 18:00" beside Chinese words (香格里拉, 2026-10-12).
|
|
298
|
+
*/
|
|
299
|
+
const deadlineZh = (instant, tz) => {
|
|
300
|
+
const parts = (at) => {
|
|
301
|
+
const p = Object.fromEntries(new Intl.DateTimeFormat('en-GB', {
|
|
302
|
+
timeZone: tz,
|
|
303
|
+
month: 'numeric',
|
|
304
|
+
day: 'numeric',
|
|
305
|
+
weekday: 'short',
|
|
306
|
+
hour: '2-digit',
|
|
307
|
+
minute: '2-digit',
|
|
308
|
+
hourCycle: 'h23',
|
|
309
|
+
})
|
|
310
|
+
.formatToParts(at)
|
|
311
|
+
.map((x) => [x.type, x.value]));
|
|
312
|
+
const week = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].indexOf(String(p.weekday));
|
|
313
|
+
return {
|
|
314
|
+
day: `${Number(p.month)}月${Number(p.day)}日 周${'日一二三四五六'[week] ?? ''}`,
|
|
315
|
+
time: `${p.hour}:${p.minute}`,
|
|
316
|
+
};
|
|
317
|
+
};
|
|
318
|
+
const now = parts(instant);
|
|
319
|
+
if (now.time !== '00:00')
|
|
320
|
+
return `${now.day} ${now.time}`;
|
|
321
|
+
return `${parts(instant - 60_000).day} 24:00`;
|
|
322
|
+
};
|
|
275
323
|
/**
|
|
276
324
|
* How a fresh price differs from what the guest was shown - or nothing, when
|
|
277
325
|
* it does not. See `ShownQuote`.
|
|
@@ -300,6 +348,15 @@ export const movedSince = (shown, q) => {
|
|
|
300
348
|
};
|
|
301
349
|
export const createHuazhuHotels = (opts) => {
|
|
302
350
|
const { client, catalog, store, reconciler } = opts;
|
|
351
|
+
// ⚠️ NO MONEY TAKEN FOR A REAL HUAZHU YET: who takes it, the licence, a
|
|
352
|
+
// partial refund, mixed trips, invoices and VCC are robin's to decide
|
|
353
|
+
// (总线程, 2026-10-12). Prepaid runs against the simulated Huazhu only.
|
|
354
|
+
if (opts.prepaid && !opts.sandbox)
|
|
355
|
+
throw new Error('prepaid rates are sold against the simulated Huazhu only, until robin decides who takes the money (docs/huazhu-products-plan.md)');
|
|
356
|
+
const checkout = opts.prepaid?.checkout;
|
|
357
|
+
/** Unpaid this long, the page is closed and the booking lapses - inside the card page's own ~31 minutes. */
|
|
358
|
+
const payWithin = opts.prepaid?.payWithinMs ?? 30 * 60_000;
|
|
359
|
+
const prepaidType = checkout ? 'OPN' : undefined;
|
|
303
360
|
const now = opts.now ?? (() => Date.now());
|
|
304
361
|
const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
305
362
|
const ariTtl = opts.ariTtlMs ?? 60_000;
|
|
@@ -312,6 +369,16 @@ export const createHuazhuHotels = (opts) => {
|
|
|
312
369
|
/** A room as offered - type and rate - in the connector's language. */
|
|
313
370
|
const offerOf = (p) => (en ? offerNameEn(p.roomTypeName, p.rateCodeName) : undefined) ??
|
|
314
371
|
[p.roomTypeName, p.rateCodeName].filter(Boolean).join(' · ');
|
|
372
|
+
/** A rate's meal as the trip line says it, in the connector's language. */
|
|
373
|
+
const mealWords = (r) => en
|
|
374
|
+
? r.meal
|
|
375
|
+
? `${r.meal}${r.mealCount > 1 ? ` × ${r.mealCount}` : ''}`
|
|
376
|
+
: 'Room only'
|
|
377
|
+
: r.meal
|
|
378
|
+
? `${MEAL_ZH[r.mealType] ?? '含餐'}${r.mealCount > 1 ? ` × ${r.mealCount}` : ''}`
|
|
379
|
+
: '不含早';
|
|
380
|
+
/** A rate's own name in the connector's language, or Huazhu's words as they are. */
|
|
381
|
+
const rateNameOf = (p) => (en ? rateNameEn(p.rateCodeName) : undefined) ?? p.rateCodeName ?? p.rateCode;
|
|
315
382
|
const today = () => todayAt(now(), opts.tz);
|
|
316
383
|
const limits = { ...BOOKING_LIMITS, ...(opts.limits ?? {}) };
|
|
317
384
|
const requestsByEmail = new WindowCounter(DAY_MS, now);
|
|
@@ -494,7 +561,9 @@ export const createHuazhuHotels = (opts) => {
|
|
|
494
561
|
};
|
|
495
562
|
/** Every sellable-or-not product of one hotel for one stay. */
|
|
496
563
|
const priceStay = async (h, stay, rooms, fresh = false) => {
|
|
497
|
-
const { value:
|
|
564
|
+
const { value: all, sellDays } = await productsWithDays(h.hotelId);
|
|
565
|
+
// Nights only: rooms by the hour are priced for today apart - `priceHourly`.
|
|
566
|
+
const products = all.filter((p) => p.checkinType === 'DAY');
|
|
498
567
|
if (products.length === 0)
|
|
499
568
|
return [];
|
|
500
569
|
const roomTypeOf = (p) => (h.hotelRoomTypeList ?? []).find((r) => r.roomTypeCode === p.roomTypeCode);
|
|
@@ -523,6 +592,7 @@ export const createHuazhuHotels = (opts) => {
|
|
|
523
592
|
today: today(),
|
|
524
593
|
clock: clockAt(now(), opts.tz),
|
|
525
594
|
paymentType: opts.paymentType,
|
|
595
|
+
...(prepaidType ? { prepaidType } : {}),
|
|
526
596
|
allowTags,
|
|
527
597
|
};
|
|
528
598
|
return products
|
|
@@ -537,6 +607,85 @@ export const createHuazhuHotels = (opts) => {
|
|
|
537
607
|
})
|
|
538
608
|
.filter((x) => !(x.roomType && x.roomType.status === 0));
|
|
539
609
|
};
|
|
610
|
+
const hourlyAriCache = new Map();
|
|
611
|
+
/**
|
|
612
|
+
* Today's prices and rooms for a hotel's rooms by the hour. Huazhu answers
|
|
613
|
+
* these for today only ("时租房 … 只能查 startDate 为当天的 ARI"), asked apart
|
|
614
|
+
* from the nights with `checkinType: HOUR`.
|
|
615
|
+
*/
|
|
616
|
+
const hourlyAriFor = async (hotelId, date, fresh = false) => {
|
|
617
|
+
const key = `${hotelId}|${date}`;
|
|
618
|
+
const hit = hourlyAriCache.get(key);
|
|
619
|
+
if (!fresh && hit && now() - hit.at < ariTtl)
|
|
620
|
+
return hit.value;
|
|
621
|
+
const [offers, inventory] = await Promise.all([
|
|
622
|
+
client.offers(hotelId, date, date, undefined, 'HOUR').catch((err) => {
|
|
623
|
+
if (err instanceof HuazhuRefusal && [4206, 2008].includes(err.code))
|
|
624
|
+
return { dailyOffers: [] };
|
|
625
|
+
throw err;
|
|
626
|
+
}),
|
|
627
|
+
client.inventory(hotelId, date, date, undefined, 'HOUR').catch((err) => {
|
|
628
|
+
if (err instanceof HuazhuRefusal && [4306, 2008].includes(err.code))
|
|
629
|
+
return {
|
|
630
|
+
dailyInventories: [],
|
|
631
|
+
};
|
|
632
|
+
throw err;
|
|
633
|
+
}),
|
|
634
|
+
]);
|
|
635
|
+
const out = new Map();
|
|
636
|
+
for (const offer of offers.dailyOffers ?? []) {
|
|
637
|
+
const row = (inventory.dailyInventories ?? []).find((i) => roomIdOf(i) === roomIdOf(offer));
|
|
638
|
+
out.set(roomIdOf(offer), { start: date, offer, inventory: row?.inventories ?? [] });
|
|
639
|
+
}
|
|
640
|
+
hourlyAriCache.set(key, { at: now(), value: out });
|
|
641
|
+
if (hourlyAriCache.size > 500)
|
|
642
|
+
hourlyAriCache.delete(hourlyAriCache.keys().next().value);
|
|
643
|
+
return out;
|
|
644
|
+
};
|
|
645
|
+
/**
|
|
646
|
+
* A hotel's rooms by the hour, for today on the hotel's own date and clock:
|
|
647
|
+
* each sellable one with the starts it takes, or why not.
|
|
648
|
+
*
|
|
649
|
+
* ⚠️ TODAY IS THE HOTEL'S, NOT THE SERVER'S (总线程, 2026-10-12): at 01:00 in
|
|
650
|
+
* Singapore it is still yesterday nowhere that matters, and a start that has
|
|
651
|
+
* already gone by on the hotel's clock is not offered.
|
|
652
|
+
*/
|
|
653
|
+
const priceHourly = async (h, guests, rooms, fresh = false) => {
|
|
654
|
+
const products = (await productsOf(h.hotelId)).filter((p) => p.checkinType === 'HOUR');
|
|
655
|
+
if (products.length === 0)
|
|
656
|
+
return [];
|
|
657
|
+
const date = today();
|
|
658
|
+
const clock = clockAt(now(), opts.tz);
|
|
659
|
+
const live = products.filter((p) => bookableNow(p.bookWindow, date, clock) && inStayWindow(p.stayWindow, date));
|
|
660
|
+
if (live.length === 0)
|
|
661
|
+
return [];
|
|
662
|
+
const ari = await hourlyAriFor(h.hotelId, date, fresh);
|
|
663
|
+
// One day, priced once: `sell` reads it as a single night from today.
|
|
664
|
+
const context = {
|
|
665
|
+
checkIn: date,
|
|
666
|
+
checkOut: addDays(date, 1),
|
|
667
|
+
adults: guests,
|
|
668
|
+
rooms,
|
|
669
|
+
today: date,
|
|
670
|
+
clock,
|
|
671
|
+
paymentType: opts.paymentType,
|
|
672
|
+
...(prepaidType ? { prepaidType } : {}),
|
|
673
|
+
allowTags,
|
|
674
|
+
};
|
|
675
|
+
return live
|
|
676
|
+
.map((product) => {
|
|
677
|
+
const roomType = (h.hotelRoomTypeList ?? []).find((r) => r.roomTypeCode === product.roomTypeCode);
|
|
678
|
+
const w = ari.get(roomIdOf(product));
|
|
679
|
+
const hourly = hourlyWindow(product, date, clock);
|
|
680
|
+
let res = w
|
|
681
|
+
? sell(product, w, roomType, context)
|
|
682
|
+
: { ok: false, kind: 'no-price', why: 'no price for today' };
|
|
683
|
+
if (res.ok && !hourly)
|
|
684
|
+
res = { ok: false, kind: 'closed', why: 'no start time is left today' };
|
|
685
|
+
return { product, roomType, res, ...(hourly ? { hourly } : {}) };
|
|
686
|
+
})
|
|
687
|
+
.filter((x) => !(x.roomType && x.roomType.status === 0));
|
|
688
|
+
};
|
|
540
689
|
const policyOf = (code) => parseCancelPolicy(code);
|
|
541
690
|
const toRoom = (h, p, stay) => {
|
|
542
691
|
const s = p.res;
|
|
@@ -553,23 +702,30 @@ export const createHuazhuHotels = (opts) => {
|
|
|
553
702
|
.filter(Boolean)
|
|
554
703
|
.join(' '))
|
|
555
704
|
.filter(Boolean);
|
|
705
|
+
const hourly = p.hourly;
|
|
706
|
+
const maxChildren = Number(p.product.occupancy?.maxChild ?? rt?.maxChild);
|
|
556
707
|
return {
|
|
557
708
|
id: roomIdOf(p.product),
|
|
558
709
|
name: offerOf(p.product),
|
|
559
710
|
sleeps: s.sleeps,
|
|
560
711
|
...(rt?.hotelArea ? { sizeSqm: rt.hotelArea } : {}),
|
|
561
712
|
...(beds.length ? { beds: beds.join(' / ') } : {}),
|
|
562
|
-
...(rt?.isHasWindow &&
|
|
713
|
+
...(rt?.isHasWindow && (en ? WINDOW : WINDOW_ZH)[rt.isHasWindow]
|
|
714
|
+
? { view: (en ? WINDOW : WINDOW_ZH)[rt.isHasWindow] }
|
|
715
|
+
: {}),
|
|
563
716
|
breakfast: !!s.meal,
|
|
564
717
|
...(roomPhoto(h, p.product.roomTypeCode)
|
|
565
718
|
? { imageUrl: roomPhoto(h, p.product.roomTypeCode) }
|
|
566
719
|
: {}),
|
|
567
720
|
// A whole yuan, as a hotel quotes a nightly rate - not ¥343.50 (2026-10-05
|
|
568
|
-
// audit, P3). The stay total stays exact.
|
|
569
|
-
|
|
721
|
+
// audit, P3). The stay total stays exact. A room by the hour is the
|
|
722
|
+
// price of its hours, and no nights.
|
|
723
|
+
nightly: hourly
|
|
724
|
+
? money(total, cur)
|
|
725
|
+
: money(Math.round(pre / n / minorFactor(cur)) * minorFactor(cur), cur),
|
|
570
726
|
stayTotal: money(total, cur),
|
|
571
727
|
taxesAndFees: money(total - pre, cur),
|
|
572
|
-
nights: n,
|
|
728
|
+
nights: hourly ? 0 : n,
|
|
573
729
|
refundable: policy ? refundableFrom(policy, stay.checkIn, opts.tz, now()) : false,
|
|
574
730
|
...(freeUntil
|
|
575
731
|
? {
|
|
@@ -577,12 +733,41 @@ export const createHuazhuHotels = (opts) => {
|
|
|
577
733
|
// ⚠️ ON THE HOTEL'S CLOCK. Without it the guest read "10:00 (UTC)"
|
|
578
734
|
// for what a Shanghai desk calls 18:00 - found by the Huazhu evals
|
|
579
735
|
// (2026-10-05).
|
|
580
|
-
freeCancellationLocal: deadlineAt(freeUntil, opts.tz),
|
|
736
|
+
freeCancellationLocal: deadlineAt(freeUntil, opts.tz, en ? 'en' : 'zh'),
|
|
581
737
|
}
|
|
582
738
|
: {}),
|
|
583
739
|
roomsLeft: s.roomsLeft,
|
|
740
|
+
roomType: { id: p.product.roomTypeCode, name: roomTypeOf(p.product) },
|
|
741
|
+
rate: { id: p.product.rateCode, name: rateNameOf(p.product) },
|
|
742
|
+
labels: rateLabels(p.product, { type: s.mealType, count: s.mealCount }),
|
|
743
|
+
// ⚠️ PREPAID ONLY WHERE THIS DEPLOYMENT TAKES PAYMENT - a sandbox's test
|
|
744
|
+
// mode (`opts.prepaid`); everywhere else `sell` leaves prepaid rates out
|
|
745
|
+
// and every room is paid at the hotel (总线程, 2026-10-12).
|
|
746
|
+
payment: s.prepaid ? 'prepaid' : 'at-hotel',
|
|
747
|
+
...(hourly ? { hourly } : {}),
|
|
748
|
+
...(maxChildren > 0 && maxChildren < 99 ? { maxChildren } : {}),
|
|
584
749
|
};
|
|
585
750
|
};
|
|
751
|
+
/** The most a room here sleeps, from rates refused for the party ("sleeps 2"). */
|
|
752
|
+
const largestOf = (fails) => Math.max(...fails.map((f) => Number(/\d+/.exec(f.why)?.[0] ?? 0)));
|
|
753
|
+
/**
|
|
754
|
+
* Why no room by the hour can be sold here today.
|
|
755
|
+
*
|
|
756
|
+
* ⚠️ A PARTY TOO BIG IS SAID AS partyNeeds SAYS IT, as for nights: the card
|
|
757
|
+
* reads "the largest sleeps N" from that sentence to offer more rooms with
|
|
758
|
+
* fewer in each (香格里拉, 2026-10-12). Three guests at a hotel whose hourly
|
|
759
|
+
* rooms sleep two were told none was left.
|
|
760
|
+
*/
|
|
761
|
+
const hourlyNoRoomsWhy = (priced, guests) => {
|
|
762
|
+
if (priced.length === 0)
|
|
763
|
+
return 'This hotel sells no rooms by the hour today.';
|
|
764
|
+
const fails = priced.map((p) => p.res).filter((r) => !r.ok);
|
|
765
|
+
if (fails.length > 0 && fails.every((f) => f.kind === 'party'))
|
|
766
|
+
return partyNeeds(guests, largestOf(fails));
|
|
767
|
+
if (fails.some((f) => f.kind === 'prepaid'))
|
|
768
|
+
return "This hotel's rooms by the hour are paid in advance, and this connector books rates paid at the hotel.";
|
|
769
|
+
return 'No room by the hour is left here today.';
|
|
770
|
+
};
|
|
586
771
|
/** Why nothing here can be sold for these nights - the most useful reason first. */
|
|
587
772
|
const noRoomsWhy = (priced, stay, rooms) => {
|
|
588
773
|
if (priced.length === 0)
|
|
@@ -590,10 +775,8 @@ export const createHuazhuHotels = (opts) => {
|
|
|
590
775
|
const fails = priced.map((p) => p.res).filter((r) => !r.ok);
|
|
591
776
|
const kinds = new Set(fails.map((f) => f.kind));
|
|
592
777
|
const only = (k) => kinds.size === 1 && kinds.has(k);
|
|
593
|
-
if (only('party'))
|
|
594
|
-
|
|
595
|
-
return partyNeeds(stay.guests, largest);
|
|
596
|
-
}
|
|
778
|
+
if (only('party'))
|
|
779
|
+
return partyNeeds(stay.guests, largestOf(fails));
|
|
597
780
|
if (only('prepaid'))
|
|
598
781
|
return 'Every rate here for those nights is paid in advance, and this connector books rates paid at the hotel.';
|
|
599
782
|
if (only('sold-out'))
|
|
@@ -636,7 +819,13 @@ export const createHuazhuHotels = (opts) => {
|
|
|
636
819
|
});
|
|
637
820
|
continue;
|
|
638
821
|
}
|
|
639
|
-
const
|
|
822
|
+
const hourly = line.hourly;
|
|
823
|
+
// A room by the hour is today, on the hotel's date, in and out the same day.
|
|
824
|
+
const problem = hourly
|
|
825
|
+
? line.stay.checkIn !== today() || line.stay.checkOut !== line.stay.checkIn
|
|
826
|
+
? `Rooms by the hour are sold for today (${today()}) only, in and out the same day.`
|
|
827
|
+
: undefined
|
|
828
|
+
: stayProblem(line.stay, today());
|
|
640
829
|
if (problem) {
|
|
641
830
|
dropped.push({ title: hotelName, why: problem });
|
|
642
831
|
continue;
|
|
@@ -644,7 +833,9 @@ export const createHuazhuHotels = (opts) => {
|
|
|
644
833
|
const wanted = Math.max(1, Math.floor(line.rooms ?? 1));
|
|
645
834
|
let priced;
|
|
646
835
|
try {
|
|
647
|
-
priced =
|
|
836
|
+
priced = hourly
|
|
837
|
+
? await priceHourly(h, line.stay.guests, wanted, fresh)
|
|
838
|
+
: await priceStay(h, line.stay, wanted, fresh);
|
|
648
839
|
}
|
|
649
840
|
catch (err) {
|
|
650
841
|
dropped.push({
|
|
@@ -657,7 +848,7 @@ export const createHuazhuHotels = (opts) => {
|
|
|
657
848
|
if (!p) {
|
|
658
849
|
dropped.push({
|
|
659
850
|
title: `${hotelName} - ${line.roomId}`,
|
|
660
|
-
why: 'That rate is no longer sold.',
|
|
851
|
+
why: hourly ? 'That room by the hour is not sold today.' : 'That rate is no longer sold.',
|
|
661
852
|
});
|
|
662
853
|
continue;
|
|
663
854
|
}
|
|
@@ -665,7 +856,20 @@ export const createHuazhuHotels = (opts) => {
|
|
|
665
856
|
dropped.push({ title: `${hotelName} - ${roomTypeOf(p.product)}`, why: `${p.res.why}.` });
|
|
666
857
|
continue;
|
|
667
858
|
}
|
|
668
|
-
|
|
859
|
+
if (hourly) {
|
|
860
|
+
// ⚠️ CHECKED AGAIN AT EVERY PRICING: the starts a room takes shrink as
|
|
861
|
+
// the day goes on, and a start that was fine at noon is gone by four.
|
|
862
|
+
const bad = !p.hourly
|
|
863
|
+
? 'No start time is left today.'
|
|
864
|
+
: hourly.hours !== p.hourly.hours
|
|
865
|
+
? `This room is ${p.hourly.hours} hours, not ${hourly.hours}.`
|
|
866
|
+
: startProblem(p.hourly, hourly.start);
|
|
867
|
+
if (bad) {
|
|
868
|
+
dropped.push({ title: `${hotelName} - ${offerOf(p.product)}`, why: bad });
|
|
869
|
+
continue;
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
const held = `${h.hotelId}|${line.roomId}|${line.stay.checkIn}|${line.stay.checkOut}${hourly ? `|${hourly.start}` : ''}`;
|
|
669
873
|
const already = taken.get(held) ?? 0;
|
|
670
874
|
if (p.res.roomsLeft - already < wanted) {
|
|
671
875
|
dropped.push({
|
|
@@ -700,19 +904,23 @@ export const createHuazhuHotels = (opts) => {
|
|
|
700
904
|
detail: [
|
|
701
905
|
room.beds,
|
|
702
906
|
room.view,
|
|
703
|
-
p.res
|
|
704
|
-
|
|
705
|
-
: 'Room only',
|
|
907
|
+
mealWords(p.res),
|
|
908
|
+
hourly ? `${hourly.start}–${endOf(hourly.start, hourly.hours)}` : undefined,
|
|
706
909
|
]
|
|
707
910
|
.filter(Boolean)
|
|
708
911
|
.join(' · '),
|
|
709
912
|
stay: line.stay,
|
|
710
913
|
nights: room.nights,
|
|
914
|
+
...(hourly ? { hourly } : {}),
|
|
915
|
+
...(room.payment === 'prepaid' ? { payment: 'prepaid' } : {}),
|
|
711
916
|
quantity: wanted,
|
|
712
917
|
unit: room.nightly,
|
|
713
918
|
total: money(preTax, room.stayTotal.currency),
|
|
714
919
|
refundable: room.refundable,
|
|
715
920
|
freeCancellationUntil: room.freeCancellationUntil,
|
|
921
|
+
...(room.freeCancellationLocal
|
|
922
|
+
? { freeCancellationLocal: room.freeCancellationLocal }
|
|
923
|
+
: {}),
|
|
716
924
|
day: line.stay.checkIn,
|
|
717
925
|
});
|
|
718
926
|
}
|
|
@@ -748,6 +956,33 @@ export const createHuazhuHotels = (opts) => {
|
|
|
748
956
|
return q;
|
|
749
957
|
};
|
|
750
958
|
// ------------------------------------------------------------ bookings ----
|
|
959
|
+
/** The money's side of a booking paid in advance, as the guest is shown it. */
|
|
960
|
+
const paymentOf = (b) => {
|
|
961
|
+
const p = b.prepaid;
|
|
962
|
+
const refund = p.refund;
|
|
963
|
+
const status = refund
|
|
964
|
+
? refund.status === 'refunded'
|
|
965
|
+
? 'refunded'
|
|
966
|
+
: 'refunding'
|
|
967
|
+
: p.lapsedAt
|
|
968
|
+
? 'lapsed'
|
|
969
|
+
: p.paidAt
|
|
970
|
+
? 'paid'
|
|
971
|
+
: 'awaiting';
|
|
972
|
+
return {
|
|
973
|
+
method: p.method,
|
|
974
|
+
amount: b.quote.total,
|
|
975
|
+
status,
|
|
976
|
+
...(status === 'awaiting' && p.url ? { url: p.url } : {}),
|
|
977
|
+
...(status === 'awaiting'
|
|
978
|
+
? {
|
|
979
|
+
payBy: new Date(p.payBy).toISOString(),
|
|
980
|
+
payByLocal: deadlineAt(p.payBy, b.tz, en ? 'en' : 'zh'),
|
|
981
|
+
}
|
|
982
|
+
: {}),
|
|
983
|
+
...(refund ? { refundReason: refund.reason } : {}),
|
|
984
|
+
};
|
|
985
|
+
};
|
|
751
986
|
const publicBooking = (b) => ({
|
|
752
987
|
id: b.id,
|
|
753
988
|
confirmationCode: b.confirmationCode,
|
|
@@ -756,11 +991,13 @@ export const createHuazhuHotels = (opts) => {
|
|
|
756
991
|
hotelId: b.hotelId,
|
|
757
992
|
hotelName: b.hotelName,
|
|
758
993
|
stay: b.stay,
|
|
994
|
+
...(b.hourly ? { hourly: b.hourly } : {}),
|
|
759
995
|
guest: b.guest,
|
|
760
996
|
quote: b.quote,
|
|
761
997
|
...(b.cancelledAt ? { cancelledAt: b.cancelledAt } : {}),
|
|
762
998
|
...(b.cancellationFee ? { cancellationFee: b.cancellationFee } : {}),
|
|
763
999
|
...(b.requests ? { requests: b.requests } : {}),
|
|
1000
|
+
...(b.prepaid ? { payment: paymentOf(b) } : {}),
|
|
764
1001
|
});
|
|
765
1002
|
/**
|
|
766
1003
|
* The codes a guest may have been given for a booking: ours, and the
|
|
@@ -797,7 +1034,15 @@ export const createHuazhuHotels = (opts) => {
|
|
|
797
1034
|
return there;
|
|
798
1035
|
};
|
|
799
1036
|
/** Huazhu's own word on a booking, at most once a minute each. */
|
|
800
|
-
const refresh = async (
|
|
1037
|
+
const refresh = async (found) => {
|
|
1038
|
+
// A page open for payment, or money going back, is looked at as it is
|
|
1039
|
+
// asked about - the guest who just paid sees it booked without waiting
|
|
1040
|
+
// for the next pass.
|
|
1041
|
+
let b = found;
|
|
1042
|
+
if (b.prepaid && moneyPending(b)) {
|
|
1043
|
+
await settlePrepaid(b);
|
|
1044
|
+
b = store.get(b.id) ?? b;
|
|
1045
|
+
}
|
|
801
1046
|
if (!b.resId || b.pendingSince !== undefined)
|
|
802
1047
|
return b;
|
|
803
1048
|
if (b.refreshedAt && now() - b.refreshedAt < 60_000)
|
|
@@ -881,7 +1126,11 @@ export const createHuazhuHotels = (opts) => {
|
|
|
881
1126
|
const conversationOf = (who) => who.tripOwner ?? who.sessionId;
|
|
882
1127
|
/** A name for a booking in what the guest is told. */
|
|
883
1128
|
const named = (b) => `${b.hotelName} (${b.confirmationCode})`;
|
|
884
|
-
const isLive = (b) => (b.status === 'confirmed' ||
|
|
1129
|
+
const isLive = (b) => (b.status === 'confirmed' ||
|
|
1130
|
+
b.status === 'requested' ||
|
|
1131
|
+
b.status === 'unconfirmed' ||
|
|
1132
|
+
// A page open for payment holds its place in the limits until it closes.
|
|
1133
|
+
b.status === 'awaiting-payment') &&
|
|
885
1134
|
!b.rollback &&
|
|
886
1135
|
b.stay.checkOut >= today();
|
|
887
1136
|
/** The booking itself, one request at a time per conversation and email - see `single`. */
|
|
@@ -961,6 +1210,211 @@ export const createHuazhuHotels = (opts) => {
|
|
|
961
1210
|
slots.delete(slot);
|
|
962
1211
|
}
|
|
963
1212
|
};
|
|
1213
|
+
/**
|
|
1214
|
+
* A stay's dates as Huazhu takes them: check-in and check-out, and for a room
|
|
1215
|
+
* by the hour the same day with its hours ("时租入离同一天").
|
|
1216
|
+
*/
|
|
1217
|
+
const stayDatesOf = (line) => line.hourly
|
|
1218
|
+
? {
|
|
1219
|
+
checkin: line.stay.checkIn,
|
|
1220
|
+
checkout: line.stay.checkIn,
|
|
1221
|
+
hourlyRoom: {
|
|
1222
|
+
hourlyRateStartTime: line.hourly.start,
|
|
1223
|
+
hourlyRateEndTime: endOf(line.hourly.start, line.hourly.hours),
|
|
1224
|
+
},
|
|
1225
|
+
}
|
|
1226
|
+
: { checkin: line.stay.checkIn, checkout: line.stay.checkOut };
|
|
1227
|
+
/**
|
|
1228
|
+
* Books a stay the guest has paid for - the request made from what was
|
|
1229
|
+
* prebooked and written down, at the price Huazhu's prebook vouched for.
|
|
1230
|
+
* Huazhu's "no" refunds the money in full; no answer leaves it to the
|
|
1231
|
+
* reconciler, whose write-off or "nobody could confirm it" refunds it too
|
|
1232
|
+
* (`settlePrepaid`).
|
|
1233
|
+
*/
|
|
1234
|
+
const bookPaid = async (id) => {
|
|
1235
|
+
const row = store.get(id);
|
|
1236
|
+
const p = row?.prepaid;
|
|
1237
|
+
if (!row || !p?.paidAt || row.status !== 'awaiting-payment')
|
|
1238
|
+
return;
|
|
1239
|
+
const contact = contactOf(row.guest);
|
|
1240
|
+
if (typeof contact === 'string') {
|
|
1241
|
+
store.update(id, { status: 'failed' });
|
|
1242
|
+
await refundOf(id, "the guest's name could not be sent to Huazhu as written");
|
|
1243
|
+
return;
|
|
1244
|
+
}
|
|
1245
|
+
const comment = commentOf(row.requests, row.guest.notes);
|
|
1246
|
+
const request = {
|
|
1247
|
+
hotelId: row.hotelId,
|
|
1248
|
+
checkinType: row.hourly ? 'HOUR' : 'DAY',
|
|
1249
|
+
contactPerson: contact,
|
|
1250
|
+
distributorResId: row.distributorResId,
|
|
1251
|
+
paymentType: row.paymentType,
|
|
1252
|
+
reservations: [
|
|
1253
|
+
{
|
|
1254
|
+
roomTypeCode: row.roomTypeCode,
|
|
1255
|
+
rateCode: row.rateCode,
|
|
1256
|
+
...stayDatesOf(row),
|
|
1257
|
+
amountAfterTax: p.after,
|
|
1258
|
+
...(p.before ? { amountBeforeTax: p.before } : {}),
|
|
1259
|
+
...(p.rack ? { rackRate: p.rack } : {}),
|
|
1260
|
+
currencyCode: p.currency,
|
|
1261
|
+
roomCount: row.rooms,
|
|
1262
|
+
adultCount: row.stay.guests,
|
|
1263
|
+
guaranteePolicyCode: row.guaranteeCode,
|
|
1264
|
+
...(row.requests?.arrivingAt ? { estimatedArrivalTime: row.requests.arrivingAt } : {}),
|
|
1265
|
+
resGuest: [contact],
|
|
1266
|
+
},
|
|
1267
|
+
],
|
|
1268
|
+
...(comment ? { guestComment: comment } : {}),
|
|
1269
|
+
...(p.preBookToken ? { preBookToken: p.preBookToken } : {}),
|
|
1270
|
+
};
|
|
1271
|
+
// ⚠️ SET BEFORE THE CALL, as every booking is: a process that dies in it
|
|
1272
|
+
// leaves a row the reconciler follows up.
|
|
1273
|
+
store.update(id, { status: 'requested', pendingSince: now() });
|
|
1274
|
+
let refusal;
|
|
1275
|
+
let answer;
|
|
1276
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
1277
|
+
try {
|
|
1278
|
+
const got = await client.book(request);
|
|
1279
|
+
if (got && typeof got.resId === 'string' && got.resId)
|
|
1280
|
+
answer = got;
|
|
1281
|
+
}
|
|
1282
|
+
catch (err) {
|
|
1283
|
+
if (err instanceof HuazhuRefusal && err.code === CODES.systemError && attempt === 0) {
|
|
1284
|
+
await sleep(1_000);
|
|
1285
|
+
continue;
|
|
1286
|
+
}
|
|
1287
|
+
if (err instanceof HuazhuRefusal &&
|
|
1288
|
+
BOOK_REFUSALS.has(err.code) &&
|
|
1289
|
+
!BOOK_EXISTS.has(err.code))
|
|
1290
|
+
refusal = err;
|
|
1291
|
+
}
|
|
1292
|
+
break;
|
|
1293
|
+
}
|
|
1294
|
+
if (answer) {
|
|
1295
|
+
store.update(id, {
|
|
1296
|
+
resId: answer.resId,
|
|
1297
|
+
...(answer.resCode ? { resCode: answer.resCode } : {}),
|
|
1298
|
+
status: 'confirmed',
|
|
1299
|
+
hotelStatus: 'HOTEL_RESERVATION',
|
|
1300
|
+
pendingSince: undefined,
|
|
1301
|
+
});
|
|
1302
|
+
return;
|
|
1303
|
+
}
|
|
1304
|
+
if (refusal) {
|
|
1305
|
+
store.update(id, { status: 'failed', pendingSince: undefined });
|
|
1306
|
+
// A price that moved since the prebook is one of these (5209): the
|
|
1307
|
+
// guest paid for a price Huazhu no longer gives, and gets it all back.
|
|
1308
|
+
await refundOf(id, `Huazhu did not take the booking: ${refusal.huazhuMessage} (${refusal.code})`);
|
|
1309
|
+
}
|
|
1310
|
+
// No answer: the reconciler's eight minutes decide it.
|
|
1311
|
+
};
|
|
1312
|
+
/**
|
|
1313
|
+
* Gives a guest's money back, in full - the only refund the gateway makes.
|
|
1314
|
+
* Written down with the gateway's checkout session (its payment intent)
|
|
1315
|
+
* and, once open, its refund request: the two ids the money is found by.
|
|
1316
|
+
*/
|
|
1317
|
+
const refundOf = async (id, reason) => {
|
|
1318
|
+
const row = store.get(id);
|
|
1319
|
+
const p = row?.prepaid;
|
|
1320
|
+
if (!row || !p?.sessionId || !p.paidAt || !checkout || p.refund?.requestId)
|
|
1321
|
+
return;
|
|
1322
|
+
const tries = (p.refund?.tries ?? 0) + 1;
|
|
1323
|
+
const at = p.refund?.at ?? now();
|
|
1324
|
+
try {
|
|
1325
|
+
const r = await checkout.refund(p.sessionId, reason);
|
|
1326
|
+
store.update(id, {
|
|
1327
|
+
prepaid: { ...p, refund: { reason, at, requestId: r.requestId, status: r.status, tries } },
|
|
1328
|
+
});
|
|
1329
|
+
console.error(`[huazhu prepaid ${id}] refund ${r.requestId} (${r.status}) of session ${p.sessionId}: ${reason}`);
|
|
1330
|
+
}
|
|
1331
|
+
catch (err) {
|
|
1332
|
+
store.update(id, { prepaid: { ...p, refund: { reason, at, tries } } });
|
|
1333
|
+
if (tries >= 8 && !row.attention) {
|
|
1334
|
+
const why = `the guest paid (gateway session ${p.sessionId}) and the refund could not be opened: ${err.message}`;
|
|
1335
|
+
store.update(id, { attention: why, attentionAt: new Date(now()).toISOString() });
|
|
1336
|
+
console.error(`[huazhu prepaid ${id}] ⚠️ NEEDS A PERSON: ${why}`);
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
};
|
|
1340
|
+
/** Whether a booking paid in advance still has money work to do. */
|
|
1341
|
+
const moneyPending = (b) => {
|
|
1342
|
+
const p = b.prepaid;
|
|
1343
|
+
if (!p)
|
|
1344
|
+
return false;
|
|
1345
|
+
if (b.status === 'awaiting-payment')
|
|
1346
|
+
return true;
|
|
1347
|
+
if (!p.paidAt)
|
|
1348
|
+
return false;
|
|
1349
|
+
if (!p.refund?.requestId)
|
|
1350
|
+
return !!p.refund || b.status === 'failed' || b.status === 'unconfirmed';
|
|
1351
|
+
return !['refunded', 'failed', 'rejected'].includes(p.refund.status ?? '');
|
|
1352
|
+
};
|
|
1353
|
+
/**
|
|
1354
|
+
* One step for a booking paid in advance: a page paid → booked; a page
|
|
1355
|
+
* unpaid past its time → closed (so it cannot be paid later) and lapsed; a
|
|
1356
|
+
* paid booking Huazhu did not take, or nobody could confirm → refunded in
|
|
1357
|
+
* full; a refund under way → followed to its end.
|
|
1358
|
+
*/
|
|
1359
|
+
const settlePrepaid = async (found) => {
|
|
1360
|
+
const row = store.get(found.id) ?? found;
|
|
1361
|
+
const p = row.prepaid;
|
|
1362
|
+
if (!p || !checkout)
|
|
1363
|
+
return;
|
|
1364
|
+
if (row.status === 'awaiting-payment') {
|
|
1365
|
+
if (!p.sessionId) {
|
|
1366
|
+
if (now() >= p.payBy)
|
|
1367
|
+
store.update(row.id, { status: 'expired', prepaid: { ...p, lapsedAt: now() } });
|
|
1368
|
+
return;
|
|
1369
|
+
}
|
|
1370
|
+
let state;
|
|
1371
|
+
try {
|
|
1372
|
+
state = await checkout.state(p.sessionId);
|
|
1373
|
+
if (state === 'open' && now() >= p.payBy)
|
|
1374
|
+
state = await checkout.close(p.sessionId);
|
|
1375
|
+
}
|
|
1376
|
+
catch {
|
|
1377
|
+
return; // asked again on the next pass
|
|
1378
|
+
}
|
|
1379
|
+
if (state === 'paid') {
|
|
1380
|
+
store.update(row.id, { prepaid: { ...p, paidAt: now(), url: undefined } });
|
|
1381
|
+
await bookPaid(row.id);
|
|
1382
|
+
}
|
|
1383
|
+
else if (state === 'closed') {
|
|
1384
|
+
store.update(row.id, {
|
|
1385
|
+
status: 'expired',
|
|
1386
|
+
prepaid: { ...p, lapsedAt: now(), url: undefined },
|
|
1387
|
+
});
|
|
1388
|
+
}
|
|
1389
|
+
return;
|
|
1390
|
+
}
|
|
1391
|
+
if (!p.paidAt)
|
|
1392
|
+
return;
|
|
1393
|
+
if (!p.refund?.requestId &&
|
|
1394
|
+
(p.refund || row.status === 'failed' || row.status === 'unconfirmed')) {
|
|
1395
|
+
await refundOf(row.id, p.refund?.reason ??
|
|
1396
|
+
(row.status === 'failed'
|
|
1397
|
+
? 'Huazhu did not take the booking'
|
|
1398
|
+
: 'nobody could confirm the booking with Huazhu - if it turns up there, a person cancels it'));
|
|
1399
|
+
return;
|
|
1400
|
+
}
|
|
1401
|
+
const refund = p.refund;
|
|
1402
|
+
if (refund?.requestId && !['refunded', 'failed', 'rejected'].includes(refund.status ?? '')) {
|
|
1403
|
+
try {
|
|
1404
|
+
const status = await checkout.refundState(refund.requestId);
|
|
1405
|
+
if (status !== refund.status)
|
|
1406
|
+
store.update(row.id, { prepaid: { ...p, refund: { ...refund, status } } });
|
|
1407
|
+
if ((status === 'failed' || status === 'rejected') && !row.attention) {
|
|
1408
|
+
const why = `refund ${refund.requestId} of gateway session ${p.sessionId} came back ${status}: the guest's money is not back`;
|
|
1409
|
+
store.update(row.id, { attention: why, attentionAt: new Date(now()).toISOString() });
|
|
1410
|
+
console.error(`[huazhu prepaid ${row.id}] ⚠️ NEEDS A PERSON: ${why}`);
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
catch {
|
|
1414
|
+
// asked again on the next pass
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
};
|
|
964
1418
|
const bookHeld = async (lines, shown, tripKey, email, guest, contact, who, requests, slot) => {
|
|
965
1419
|
// Priced afresh, not from the cache: this is the price that will be sent.
|
|
966
1420
|
const priced = await quoteLines({ lines }, true);
|
|
@@ -982,20 +1436,24 @@ export const createHuazhuHotels = (opts) => {
|
|
|
982
1436
|
throw new Error('Nothing was booked: the trip changed while it was being priced.');
|
|
983
1437
|
plans.push({ i, line, sold, qline });
|
|
984
1438
|
}
|
|
1439
|
+
// ⚠️ A STAY PAID IN ADVANCE IS BOOKED ON ITS OWN (v1): its money is taken
|
|
1440
|
+
// before Huazhu is asked, and a trip half paid and half not - or two paid
|
|
1441
|
+
// stays, one refused - has no whole refund to give (总线程, 2026-10-12).
|
|
1442
|
+
if (plans.length > 1 && plans.some((p) => p.sold.res.prepaid))
|
|
1443
|
+
throw new Error('Nothing was booked: a stay paid in advance is booked on its own here. Take the other stays out of the trip, book this one, then book the rest.');
|
|
985
1444
|
for (const plan of plans) {
|
|
986
1445
|
const s = plan.sold.res;
|
|
987
1446
|
const title = `${plan.qline.hotelName} - ${plan.qline.title}`;
|
|
988
1447
|
try {
|
|
989
1448
|
const out = await client.prebook({
|
|
990
1449
|
hotelId: plan.line.hotelId,
|
|
991
|
-
checkinType: 'DAY',
|
|
992
|
-
paymentType:
|
|
1450
|
+
checkinType: plan.line.hourly ? 'HOUR' : 'DAY',
|
|
1451
|
+
paymentType: plan.sold.res.paymentType,
|
|
993
1452
|
reservations: [
|
|
994
1453
|
{
|
|
995
1454
|
roomTypeCode: plan.sold.product.roomTypeCode,
|
|
996
1455
|
rateCode: plan.sold.product.rateCode,
|
|
997
|
-
|
|
998
|
-
checkout: plan.line.stay.checkOut,
|
|
1456
|
+
...stayDatesOf(plan.line),
|
|
999
1457
|
amountAfterTax: s.after,
|
|
1000
1458
|
...(s.before ? { amountBeforeTax: s.before } : {}),
|
|
1001
1459
|
currencyCode: s.currency,
|
|
@@ -1015,6 +1473,92 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1015
1473
|
requestsByEmail.add(email);
|
|
1016
1474
|
requestsByConversation.add(conversationOf(who));
|
|
1017
1475
|
const tripRef = randomUUID();
|
|
1476
|
+
// ⚠️ PAID FIRST, BOOKED AFTER. A stay paid in advance is not asked of
|
|
1477
|
+
// Huazhu until the guest has paid on the gateway's card page - then
|
|
1478
|
+
// `settlePrepaid` books it, and gives the money back whole if it cannot.
|
|
1479
|
+
const prepaidPlan = plans.find((p) => p.sold.res.prepaid);
|
|
1480
|
+
if (prepaidPlan) {
|
|
1481
|
+
const plan = prepaidPlan;
|
|
1482
|
+
const s = plan.sold.res;
|
|
1483
|
+
if (!checkout)
|
|
1484
|
+
throw new Error('Nothing was booked: this deployment takes no payment.');
|
|
1485
|
+
const rooms = plan.qline.quantity ?? 1;
|
|
1486
|
+
const qTotal = plan.qline.total.minor;
|
|
1487
|
+
const nightlyMinor = s.after.map((x) => toMinor(x, s.currency));
|
|
1488
|
+
const allIn = nightlyMinor.reduce((a, b) => a + b, 0) * rooms;
|
|
1489
|
+
const cur = s.currency;
|
|
1490
|
+
const id = `hz_${randomUUID()}`;
|
|
1491
|
+
const dres = distributorResId();
|
|
1492
|
+
const record = {
|
|
1493
|
+
method: checkout.method,
|
|
1494
|
+
payBy: now() + payWithin,
|
|
1495
|
+
...(plan.token ? { preBookToken: plan.token } : {}),
|
|
1496
|
+
after: s.after,
|
|
1497
|
+
...(s.before ? { before: s.before } : {}),
|
|
1498
|
+
...(s.rack ? { rack: s.rack } : {}),
|
|
1499
|
+
currency: cur,
|
|
1500
|
+
};
|
|
1501
|
+
const row = {
|
|
1502
|
+
id,
|
|
1503
|
+
confirmationCode: newPublicCode((c) => store.all().some((b) => b.confirmationCode === c)),
|
|
1504
|
+
status: 'awaiting-payment',
|
|
1505
|
+
createdAt: new Date(now()).toISOString(),
|
|
1506
|
+
hotelId: plan.line.hotelId,
|
|
1507
|
+
hotelName: plan.qline.hotelName,
|
|
1508
|
+
stay: plan.line.stay,
|
|
1509
|
+
...(plan.line.hourly ? { hourly: plan.line.hourly } : {}),
|
|
1510
|
+
guest,
|
|
1511
|
+
quote: {
|
|
1512
|
+
lines: [plan.qline],
|
|
1513
|
+
subtotal: money(qTotal, cur),
|
|
1514
|
+
taxesAndFees: money(allIn - qTotal, cur),
|
|
1515
|
+
...(s.before ? {} : { taxesIncluded: true }),
|
|
1516
|
+
total: money(allIn, cur),
|
|
1517
|
+
stay: plan.line.stay,
|
|
1518
|
+
},
|
|
1519
|
+
...(clean ? { requests: clean } : {}),
|
|
1520
|
+
owners: [...new Set([who.sessionId, conversationOf(who)])],
|
|
1521
|
+
distributorResId: dres,
|
|
1522
|
+
tripRef,
|
|
1523
|
+
tripKey,
|
|
1524
|
+
paymentType: s.paymentType,
|
|
1525
|
+
guaranteeCode: s.guaranteeCode,
|
|
1526
|
+
cancelCode: s.cancelCode,
|
|
1527
|
+
nightlyMinor,
|
|
1528
|
+
rooms,
|
|
1529
|
+
tz: opts.tz,
|
|
1530
|
+
roomTypeCode: plan.sold.product.roomTypeCode,
|
|
1531
|
+
rateCode: plan.sold.product.rateCode,
|
|
1532
|
+
prepaid: record,
|
|
1533
|
+
};
|
|
1534
|
+
try {
|
|
1535
|
+
store.put(row);
|
|
1536
|
+
}
|
|
1537
|
+
catch (err) {
|
|
1538
|
+
if (err instanceof StoreWriteError)
|
|
1539
|
+
throw new Error('Nothing was booked or charged: the booking could not be written down here, so no payment was asked for. A person has been told.');
|
|
1540
|
+
throw err;
|
|
1541
|
+
}
|
|
1542
|
+
slot.n -= 1;
|
|
1543
|
+
let opened;
|
|
1544
|
+
try {
|
|
1545
|
+
opened = await checkout.open({
|
|
1546
|
+
ref: dres,
|
|
1547
|
+
title: `${plan.qline.hotelName} · ${plan.qline.title}`.slice(0, 200),
|
|
1548
|
+
amountMinor: allIn,
|
|
1549
|
+
currency: cur,
|
|
1550
|
+
});
|
|
1551
|
+
}
|
|
1552
|
+
catch (err) {
|
|
1553
|
+
store.update(id, { status: 'failed' });
|
|
1554
|
+
throw new Error(`Nothing was booked or charged: the payment page could not be opened (${err.message}). Asking again is safe.`);
|
|
1555
|
+
}
|
|
1556
|
+
return [
|
|
1557
|
+
publicBooking(store.update(id, {
|
|
1558
|
+
prepaid: { ...record, sessionId: opened.sessionId, url: opened.url },
|
|
1559
|
+
}) ?? row),
|
|
1560
|
+
];
|
|
1561
|
+
}
|
|
1018
1562
|
const made = [];
|
|
1019
1563
|
/**
|
|
1020
1564
|
* Undo what this trip booked, when a later stay of it failed - and say
|
|
@@ -1103,6 +1647,7 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1103
1647
|
hotelId: plan.line.hotelId,
|
|
1104
1648
|
hotelName: plan.qline.hotelName,
|
|
1105
1649
|
stay: plan.line.stay,
|
|
1650
|
+
...(plan.line.hourly ? { hourly: plan.line.hourly } : {}),
|
|
1106
1651
|
guest,
|
|
1107
1652
|
quote: {
|
|
1108
1653
|
lines: [plan.qline],
|
|
@@ -1118,7 +1663,7 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1118
1663
|
distributorResId: dres,
|
|
1119
1664
|
tripRef,
|
|
1120
1665
|
tripKey,
|
|
1121
|
-
paymentType:
|
|
1666
|
+
paymentType: s.paymentType,
|
|
1122
1667
|
guaranteeCode: s.guaranteeCode,
|
|
1123
1668
|
cancelCode: s.cancelCode,
|
|
1124
1669
|
nightlyMinor,
|
|
@@ -1147,8 +1692,7 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1147
1692
|
const reservation = {
|
|
1148
1693
|
roomTypeCode: plan.sold.product.roomTypeCode,
|
|
1149
1694
|
rateCode: plan.sold.product.rateCode,
|
|
1150
|
-
|
|
1151
|
-
checkout: plan.line.stay.checkOut,
|
|
1695
|
+
...stayDatesOf(plan.line),
|
|
1152
1696
|
amountAfterTax: s.after,
|
|
1153
1697
|
...(s.before ? { amountBeforeTax: s.before } : {}),
|
|
1154
1698
|
...(s.rack ? { rackRate: s.rack } : {}),
|
|
@@ -1161,10 +1705,10 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1161
1705
|
};
|
|
1162
1706
|
const request = {
|
|
1163
1707
|
hotelId: plan.line.hotelId,
|
|
1164
|
-
checkinType: 'DAY',
|
|
1708
|
+
checkinType: plan.line.hourly ? 'HOUR' : 'DAY',
|
|
1165
1709
|
contactPerson: contact,
|
|
1166
1710
|
distributorResId: dres,
|
|
1167
|
-
paymentType:
|
|
1711
|
+
paymentType: s.paymentType,
|
|
1168
1712
|
reservations: [reservation],
|
|
1169
1713
|
...(comment ? { guestComment: comment } : {}),
|
|
1170
1714
|
...(plan.token ? { preBookToken: plan.token } : {}),
|
|
@@ -1239,7 +1783,21 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1239
1783
|
};
|
|
1240
1784
|
return {
|
|
1241
1785
|
brand: opts.brand,
|
|
1242
|
-
capabilities:
|
|
1786
|
+
capabilities: {
|
|
1787
|
+
...HUAZHU_CAPABILITIES,
|
|
1788
|
+
...(opts.hourly ? { hourly: true } : {}),
|
|
1789
|
+
...(checkout ? { prepaid: true } : {}),
|
|
1790
|
+
},
|
|
1791
|
+
/**
|
|
1792
|
+
* One pass over the bookings paid in advance - every 10 seconds while the
|
|
1793
|
+
* deployment takes payment (make.ts): pages paid or past their time, and
|
|
1794
|
+
* money going back.
|
|
1795
|
+
*/
|
|
1796
|
+
async prepaidTick() {
|
|
1797
|
+
for (const row of store.all())
|
|
1798
|
+
if (moneyPending(row))
|
|
1799
|
+
await settlePrepaid(row);
|
|
1800
|
+
},
|
|
1243
1801
|
imageDomains() {
|
|
1244
1802
|
const hosts = new Set();
|
|
1245
1803
|
for (const h of catalog.all()) {
|
|
@@ -1332,10 +1890,17 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1332
1890
|
return terms.every((t) => hay.includes(t));
|
|
1333
1891
|
});
|
|
1334
1892
|
const f = q.filters ?? {};
|
|
1335
|
-
|
|
1893
|
+
// Rooms by the hour are today's, on the hotel's date: the nights asked
|
|
1894
|
+
// for, if any, do not apply to them.
|
|
1895
|
+
const hourlyOnly = !!f.hourly;
|
|
1896
|
+
const stay = hourlyOnly
|
|
1897
|
+
? { checkIn: today(), checkOut: today(), guests: q.stay?.guests ?? 2 }
|
|
1898
|
+
: q.stay;
|
|
1336
1899
|
const wantRooms = Math.max(1, Math.min(MAX_ROOMS_PER_LINE, Math.floor(q.rooms ?? 1)));
|
|
1337
1900
|
const unapplied = [];
|
|
1338
1901
|
const ignored = {};
|
|
1902
|
+
if (hourlyOnly && q.stay)
|
|
1903
|
+
unapplied.push('Rooms by the hour are sold for today only, so the nights asked for were not used.');
|
|
1339
1904
|
const roomTerms = !!(f.freeCancellation || f.breakfast);
|
|
1340
1905
|
// A brand by the name shown or by Huazhu's own: "Hanting" and "汉庭" are one brand.
|
|
1341
1906
|
const passesStatic = (h, over, raw) => (!over.brand ||
|
|
@@ -1357,6 +1922,8 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1357
1922
|
if (roomTerms)
|
|
1358
1923
|
ignored.roomTerms = true;
|
|
1359
1924
|
}
|
|
1925
|
+
if (hourlyOnly && !priceEverything)
|
|
1926
|
+
unapplied.push(`${matches.length} hotels matched - too many to ask each for rooms by the hour at once, so only this page was asked. A district, a brand or a landmark narrows it.`);
|
|
1360
1927
|
if (stay && !priceEverything && (wantsPriceKnob || roomTerms)) {
|
|
1361
1928
|
unapplied.push(`${matches.length} hotels matched - too many to price at once, so ${[
|
|
1362
1929
|
wantsPriceKnob ? 'the price order and ceiling' : '',
|
|
@@ -1365,7 +1932,7 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1365
1932
|
.filter(Boolean)
|
|
1366
1933
|
.join(' and ')} were not applied. A district, a brand or a landmark narrows it.`);
|
|
1367
1934
|
}
|
|
1368
|
-
if (!stay && (wantsPriceKnob || roomTerms)) {
|
|
1935
|
+
if (!stay && !hourlyOnly && (wantsPriceKnob || roomTerms)) {
|
|
1369
1936
|
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.');
|
|
1370
1937
|
}
|
|
1371
1938
|
const best = new Map();
|
|
@@ -1375,6 +1942,19 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1375
1942
|
const priceHotel = async (raw) => {
|
|
1376
1943
|
if (!stay || pricedRooms.has(raw.hotelId))
|
|
1377
1944
|
return;
|
|
1945
|
+
if (hourlyOnly) {
|
|
1946
|
+
try {
|
|
1947
|
+
const priced = await priceHourly(raw, stay.guests, wantRooms);
|
|
1948
|
+
pricedRooms.set(raw.hotelId, priced);
|
|
1949
|
+
if (!priced.some((p) => p.res.ok))
|
|
1950
|
+
why.set(raw.hotelId, hourlyNoRoomsWhy(priced, stay.guests));
|
|
1951
|
+
}
|
|
1952
|
+
catch {
|
|
1953
|
+
pricedRooms.set(raw.hotelId, []);
|
|
1954
|
+
why.set(raw.hotelId, 'Rooms by the hour could not be read from Huazhu just now; open the hotel for them.');
|
|
1955
|
+
}
|
|
1956
|
+
return;
|
|
1957
|
+
}
|
|
1378
1958
|
const problem = stayProblem(stay, today());
|
|
1379
1959
|
if (problem) {
|
|
1380
1960
|
pricedRooms.set(raw.hotelId, []);
|
|
@@ -1417,8 +1997,16 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1417
1997
|
if (priceEverything)
|
|
1418
1998
|
await pool(matches.map((m) => m.raw), PRICE_CONCURRENCY, priceHotel);
|
|
1419
1999
|
const canRoomFilter = priceEverything;
|
|
1420
|
-
|
|
1421
|
-
|
|
2000
|
+
// ⚠️ A HOTEL WHOSE ROOMS BY THE HOUR ARE ONLY TOO SMALL STILL SELLS
|
|
2001
|
+
// THEM: it stays, with partyNeeds as its reason, so the card can offer
|
|
2002
|
+
// more rooms with fewer in each (香格里拉, 2026-10-12) - three guests
|
|
2003
|
+
// were shown no hotel at all, as if none sold rooms by the hour.
|
|
2004
|
+
const tooSmallOnly = (raw) => {
|
|
2005
|
+
const priced = pricedRooms.get(raw.hotelId) ?? [];
|
|
2006
|
+
return priced.length > 0 && priced.every((p) => !p.res.ok && p.res.kind === 'party');
|
|
2007
|
+
};
|
|
2008
|
+
if (canRoomFilter && (roomTerms || hourlyOnly))
|
|
2009
|
+
matches = matches.filter(({ raw }) => bestFor(raw, f) !== null || (hourlyOnly && !roomTerms && tooSmallOnly(raw)));
|
|
1422
2010
|
const currencies = [...new Set(matches.map((m) => m.hotel.currency))].sort();
|
|
1423
2011
|
const onePrice = currencies.length === 1;
|
|
1424
2012
|
const ceiling = priceEverything && onePrice && Number(q.maxNightly) > 0 ? Number(q.maxNightly) : undefined;
|
|
@@ -1492,6 +2080,8 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1492
2080
|
nights: b.room.nights,
|
|
1493
2081
|
roomsLeft: b.room.roomsLeft,
|
|
1494
2082
|
freeCancellation: !!b.room.freeCancellationUntil,
|
|
2083
|
+
...(b.room.hourly ? { hours: b.room.hourly.hours } : {}),
|
|
2084
|
+
...(b.room.labels?.length ? { labels: b.room.labels } : {}),
|
|
1495
2085
|
},
|
|
1496
2086
|
};
|
|
1497
2087
|
};
|
|
@@ -1593,6 +2183,35 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1593
2183
|
...(rooms.length === 0 ? { noRooms: noRoomsWhy(priced, stay, 1) } : {}),
|
|
1594
2184
|
};
|
|
1595
2185
|
},
|
|
2186
|
+
async hourlyRooms(id, guests) {
|
|
2187
|
+
const raw = findHotel(id);
|
|
2188
|
+
if (!raw)
|
|
2189
|
+
return null;
|
|
2190
|
+
const hotel = toHotel(raw, view, now());
|
|
2191
|
+
const date = today();
|
|
2192
|
+
const stay = { checkIn: date, checkOut: date, guests };
|
|
2193
|
+
let priced;
|
|
2194
|
+
try {
|
|
2195
|
+
priced = await priceHourly(raw, guests, 1);
|
|
2196
|
+
}
|
|
2197
|
+
catch (err) {
|
|
2198
|
+
return {
|
|
2199
|
+
hotel,
|
|
2200
|
+
stay,
|
|
2201
|
+
noRooms: `Huazhu could not be asked for this hotel's rooms by the hour just now (${err instanceof HuazhuRefusal ? err.huazhuMessage : 'no answer'}). Try again in a moment.`,
|
|
2202
|
+
};
|
|
2203
|
+
}
|
|
2204
|
+
const rooms = priced
|
|
2205
|
+
.filter((p) => p.res.ok)
|
|
2206
|
+
.map((p) => toRoom(raw, p, stay))
|
|
2207
|
+
.sort((a, b) => a.stayTotal.minor - b.stayTotal.minor);
|
|
2208
|
+
return {
|
|
2209
|
+
hotel,
|
|
2210
|
+
stay,
|
|
2211
|
+
rooms,
|
|
2212
|
+
...(rooms.length === 0 ? { noRooms: hourlyNoRoomsWhy(priced, guests) } : {}),
|
|
2213
|
+
};
|
|
2214
|
+
},
|
|
1596
2215
|
async datesAround(id, stay, o) {
|
|
1597
2216
|
const raw = findHotel(id);
|
|
1598
2217
|
if (!raw)
|
|
@@ -1631,6 +2250,7 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1631
2250
|
today: from,
|
|
1632
2251
|
clock: clockAt(now(), opts.tz),
|
|
1633
2252
|
paymentType: opts.paymentType,
|
|
2253
|
+
...(prepaidType ? { prepaidType } : {}),
|
|
1634
2254
|
allowTags,
|
|
1635
2255
|
};
|
|
1636
2256
|
let cheapest;
|
|
@@ -1710,7 +2330,13 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1710
2330
|
const mine = store
|
|
1711
2331
|
.all()
|
|
1712
2332
|
.filter((b) => b.owners.includes(who.sessionId) &&
|
|
1713
|
-
|
|
2333
|
+
// A refused booking is nothing to show - unless the guest paid for
|
|
2334
|
+
// it: their money going back is.
|
|
2335
|
+
!(b.status === 'failed' &&
|
|
2336
|
+
!b.resId &&
|
|
2337
|
+
b.pendingSince === undefined &&
|
|
2338
|
+
!b.attention &&
|
|
2339
|
+
!b.prepaid?.paidAt))
|
|
1714
2340
|
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
|
|
1715
2341
|
.slice(0, most);
|
|
1716
2342
|
const fresh = await pool(mine, 3, (b) => refresh(b));
|
|
@@ -1750,6 +2376,16 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1750
2376
|
const cur = b.quote.total.currency;
|
|
1751
2377
|
const zero = money(0, cur);
|
|
1752
2378
|
const past = now() >= nightAuditOf(b.stay.checkIn, b.tz);
|
|
2379
|
+
if (b.status === 'awaiting-payment') {
|
|
2380
|
+
return {
|
|
2381
|
+
bookingId: b.id,
|
|
2382
|
+
confirmationCode: b.confirmationCode,
|
|
2383
|
+
fee: zero,
|
|
2384
|
+
refund: zero,
|
|
2385
|
+
rule: 'Not paid yet, and nothing is booked with Huazhu: cancelling closes the payment page, and nothing is charged.',
|
|
2386
|
+
cancellable: true,
|
|
2387
|
+
};
|
|
2388
|
+
}
|
|
1753
2389
|
if (b.pendingSince !== undefined || b.status === 'unconfirmed') {
|
|
1754
2390
|
return {
|
|
1755
2391
|
bookingId: b.id,
|
|
@@ -1773,6 +2409,24 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1773
2409
|
};
|
|
1774
2410
|
}
|
|
1775
2411
|
const { fee, forbidden, rule, freeUntil } = feeNow(b);
|
|
2412
|
+
if (b.prepaid?.paidAt) {
|
|
2413
|
+
// Paid here in advance: free means all of it back, anything else -
|
|
2414
|
+
// the whole stay, on these rates - means none.
|
|
2415
|
+
const back = !forbidden && fee === 0;
|
|
2416
|
+
return {
|
|
2417
|
+
bookingId: b.id,
|
|
2418
|
+
confirmationCode: b.confirmationCode,
|
|
2419
|
+
fee: money(forbidden ? 0 : fee, cur),
|
|
2420
|
+
refund: back ? b.quote.total : zero,
|
|
2421
|
+
rule: forbidden
|
|
2422
|
+
? `This rate cannot be cancelled now. ${rule} The ${formatMoney(b.quote.total)} paid stays paid.`
|
|
2423
|
+
: back
|
|
2424
|
+
? `${rule} Cancelling now gives back all ${formatMoney(b.quote.total)} paid, to the card it was paid with.${opts.sandbox ? ' This is a test booking in the simulated Huazhu, paid in the gateway’s test mode: no real money moves.' : ''}`
|
|
2425
|
+
: `${rule} Cancelling now gives nothing back: the fee is the whole stay.`,
|
|
2426
|
+
...(freeUntil ? { freeUntil: new Date(freeUntil).toISOString() } : {}),
|
|
2427
|
+
cancellable: !forbidden,
|
|
2428
|
+
};
|
|
2429
|
+
}
|
|
1776
2430
|
return {
|
|
1777
2431
|
bookingId: b.id,
|
|
1778
2432
|
confirmationCode: b.confirmationCode,
|
|
@@ -1793,6 +2447,19 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1793
2447
|
if (!found)
|
|
1794
2448
|
return null;
|
|
1795
2449
|
const b = await refresh(found);
|
|
2450
|
+
// Not paid yet: the page is closed, so it cannot be paid after this.
|
|
2451
|
+
if (b.status === 'awaiting-payment' && b.prepaid?.sessionId && checkout) {
|
|
2452
|
+
const state = await checkout.close(b.prepaid.sessionId).catch(() => undefined);
|
|
2453
|
+
if (state === 'closed') {
|
|
2454
|
+
return publicBooking(store.update(b.id, {
|
|
2455
|
+
status: 'expired',
|
|
2456
|
+
prepaid: { ...b.prepaid, lapsedAt: now(), url: undefined },
|
|
2457
|
+
}) ?? b);
|
|
2458
|
+
}
|
|
2459
|
+
// Paid in the last second: it is booked now, and cancelled as one.
|
|
2460
|
+
await settlePrepaid(b);
|
|
2461
|
+
throw new Error('The guest has just paid, so the stay is being booked with Huazhu now. Ask again in a moment to cancel it - a cancellation in the free period gives all the money back.');
|
|
2462
|
+
}
|
|
1796
2463
|
if (b.pendingSince !== undefined || b.status === 'unconfirmed') {
|
|
1797
2464
|
throw new Error(waitingWords(b));
|
|
1798
2465
|
}
|
|
@@ -1806,12 +2473,18 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1806
2473
|
const fee = typeof out.penaltyAmount === 'number'
|
|
1807
2474
|
? money(toMinor(out.penaltyAmount, out.penaltyCurrencyCode || cur), out.penaltyCurrencyCode || cur)
|
|
1808
2475
|
: money(feeNow(b).fee, cur);
|
|
1809
|
-
|
|
2476
|
+
const cancelled = store.update(b.id, {
|
|
1810
2477
|
status: 'cancelled',
|
|
1811
2478
|
hotelStatus: 'HOTEL_CANCEL',
|
|
1812
2479
|
cancelledAt: new Date(now()).toISOString(),
|
|
1813
2480
|
cancellationFee: fee,
|
|
1814
|
-
}) ?? b
|
|
2481
|
+
}) ?? b;
|
|
2482
|
+
// Paid in advance and cancelled free: all of it back. A fee here is
|
|
2483
|
+
// the whole stay (only whole-or-nothing rates are sold this way), so
|
|
2484
|
+
// nothing comes back.
|
|
2485
|
+
if (cancelled.prepaid?.paidAt && fee.minor === 0)
|
|
2486
|
+
await refundOf(b.id, 'cancelled within the free cancellation period');
|
|
2487
|
+
return publicBooking(store.get(b.id) ?? cancelled);
|
|
1815
2488
|
}
|
|
1816
2489
|
catch (err) {
|
|
1817
2490
|
if (err instanceof HuazhuRefusal && err.code === 5402) {
|