@curless/hotel-storefront 0.28.0 → 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 +7 -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 +38 -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/huazhu/backend.d.ts +11 -1
- package/dist/huazhu/backend.d.ts.map +1 -1
- package/dist/huazhu/backend.js +516 -32
- 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/make.d.ts.map +1 -1
- package/dist/huazhu/make.js +25 -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 +14 -6
- package/dist/huazhu/sandbox.js.map +1 -1
- package/dist/huazhu/sellable.d.ts +17 -0
- package/dist/huazhu/sellable.d.ts.map +1 -1
- package/dist/huazhu/sellable.js +37 -8
- package/dist/huazhu/sellable.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/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 +127 -16
- package/dist/tools.js.map +1 -1
- package/dist/types.d.ts +41 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/huazhu/backend.js
CHANGED
|
@@ -86,6 +86,21 @@ const WINDOW = {
|
|
|
86
86
|
'3': 'No window',
|
|
87
87
|
'4': 'Window onto an inner courtyard',
|
|
88
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
|
+
};
|
|
89
104
|
const pool = async (items, n, fn) => {
|
|
90
105
|
const out = new Array(items.length);
|
|
91
106
|
let next = 0;
|
|
@@ -268,12 +283,43 @@ export const englishFits = (hotels, ref) => {
|
|
|
268
283
|
* "Sun 11 Oct, 00:00" for a stay arriving on Saturday the 10th - read as a
|
|
269
284
|
* day more than there is (2026-10-05 audit, P3). Said as the 10th, 24:00.
|
|
270
285
|
*/
|
|
271
|
-
export const deadlineAt = (instant, tz) => {
|
|
286
|
+
export const deadlineAt = (instant, tz, language = 'en') => {
|
|
287
|
+
if (language === 'zh')
|
|
288
|
+
return deadlineZh(instant, tz);
|
|
272
289
|
const shown = atHotelTime(instant, tz);
|
|
273
290
|
if (!/, 00:00\b/.test(shown))
|
|
274
291
|
return shown;
|
|
275
292
|
return atHotelTime(instant - 60_000, tz).replace(/, 23:59\b/, ', 24:00');
|
|
276
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
|
+
};
|
|
277
323
|
/**
|
|
278
324
|
* How a fresh price differs from what the guest was shown - or nothing, when
|
|
279
325
|
* it does not. See `ShownQuote`.
|
|
@@ -302,6 +348,15 @@ export const movedSince = (shown, q) => {
|
|
|
302
348
|
};
|
|
303
349
|
export const createHuazhuHotels = (opts) => {
|
|
304
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;
|
|
305
360
|
const now = opts.now ?? (() => Date.now());
|
|
306
361
|
const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
307
362
|
const ariTtl = opts.ariTtlMs ?? 60_000;
|
|
@@ -314,6 +369,14 @@ export const createHuazhuHotels = (opts) => {
|
|
|
314
369
|
/** A room as offered - type and rate - in the connector's language. */
|
|
315
370
|
const offerOf = (p) => (en ? offerNameEn(p.roomTypeName, p.rateCodeName) : undefined) ??
|
|
316
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
|
+
: '不含早';
|
|
317
380
|
/** A rate's own name in the connector's language, or Huazhu's words as they are. */
|
|
318
381
|
const rateNameOf = (p) => (en ? rateNameEn(p.rateCodeName) : undefined) ?? p.rateCodeName ?? p.rateCode;
|
|
319
382
|
const today = () => todayAt(now(), opts.tz);
|
|
@@ -529,6 +592,7 @@ export const createHuazhuHotels = (opts) => {
|
|
|
529
592
|
today: today(),
|
|
530
593
|
clock: clockAt(now(), opts.tz),
|
|
531
594
|
paymentType: opts.paymentType,
|
|
595
|
+
...(prepaidType ? { prepaidType } : {}),
|
|
532
596
|
allowTags,
|
|
533
597
|
};
|
|
534
598
|
return products
|
|
@@ -605,6 +669,7 @@ export const createHuazhuHotels = (opts) => {
|
|
|
605
669
|
today: date,
|
|
606
670
|
clock,
|
|
607
671
|
paymentType: opts.paymentType,
|
|
672
|
+
...(prepaidType ? { prepaidType } : {}),
|
|
608
673
|
allowTags,
|
|
609
674
|
};
|
|
610
675
|
return live
|
|
@@ -645,7 +710,9 @@ export const createHuazhuHotels = (opts) => {
|
|
|
645
710
|
sleeps: s.sleeps,
|
|
646
711
|
...(rt?.hotelArea ? { sizeSqm: rt.hotelArea } : {}),
|
|
647
712
|
...(beds.length ? { beds: beds.join(' / ') } : {}),
|
|
648
|
-
...(rt?.isHasWindow &&
|
|
713
|
+
...(rt?.isHasWindow && (en ? WINDOW : WINDOW_ZH)[rt.isHasWindow]
|
|
714
|
+
? { view: (en ? WINDOW : WINDOW_ZH)[rt.isHasWindow] }
|
|
715
|
+
: {}),
|
|
649
716
|
breakfast: !!s.meal,
|
|
650
717
|
...(roomPhoto(h, p.product.roomTypeCode)
|
|
651
718
|
? { imageUrl: roomPhoto(h, p.product.roomTypeCode) }
|
|
@@ -666,21 +733,41 @@ export const createHuazhuHotels = (opts) => {
|
|
|
666
733
|
// ⚠️ ON THE HOTEL'S CLOCK. Without it the guest read "10:00 (UTC)"
|
|
667
734
|
// for what a Shanghai desk calls 18:00 - found by the Huazhu evals
|
|
668
735
|
// (2026-10-05).
|
|
669
|
-
freeCancellationLocal: deadlineAt(freeUntil, opts.tz),
|
|
736
|
+
freeCancellationLocal: deadlineAt(freeUntil, opts.tz, en ? 'en' : 'zh'),
|
|
670
737
|
}
|
|
671
738
|
: {}),
|
|
672
739
|
roomsLeft: s.roomsLeft,
|
|
673
740
|
roomType: { id: p.product.roomTypeCode, name: roomTypeOf(p.product) },
|
|
674
741
|
rate: { id: p.product.rateCode, name: rateNameOf(p.product) },
|
|
675
742
|
labels: rateLabels(p.product, { type: s.mealType, count: s.mealCount }),
|
|
676
|
-
// ⚠️
|
|
677
|
-
//
|
|
678
|
-
//
|
|
679
|
-
payment: 'at-hotel',
|
|
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',
|
|
680
747
|
...(hourly ? { hourly } : {}),
|
|
681
748
|
...(maxChildren > 0 && maxChildren < 99 ? { maxChildren } : {}),
|
|
682
749
|
};
|
|
683
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
|
+
};
|
|
684
771
|
/** Why nothing here can be sold for these nights - the most useful reason first. */
|
|
685
772
|
const noRoomsWhy = (priced, stay, rooms) => {
|
|
686
773
|
if (priced.length === 0)
|
|
@@ -688,10 +775,8 @@ export const createHuazhuHotels = (opts) => {
|
|
|
688
775
|
const fails = priced.map((p) => p.res).filter((r) => !r.ok);
|
|
689
776
|
const kinds = new Set(fails.map((f) => f.kind));
|
|
690
777
|
const only = (k) => kinds.size === 1 && kinds.has(k);
|
|
691
|
-
if (only('party'))
|
|
692
|
-
|
|
693
|
-
return partyNeeds(stay.guests, largest);
|
|
694
|
-
}
|
|
778
|
+
if (only('party'))
|
|
779
|
+
return partyNeeds(stay.guests, largestOf(fails));
|
|
695
780
|
if (only('prepaid'))
|
|
696
781
|
return 'Every rate here for those nights is paid in advance, and this connector books rates paid at the hotel.';
|
|
697
782
|
if (only('sold-out'))
|
|
@@ -819,9 +904,7 @@ export const createHuazhuHotels = (opts) => {
|
|
|
819
904
|
detail: [
|
|
820
905
|
room.beds,
|
|
821
906
|
room.view,
|
|
822
|
-
p.res
|
|
823
|
-
? `${p.res.meal}${p.res.mealCount > 1 ? ` × ${p.res.mealCount}` : ''}`
|
|
824
|
-
: 'Room only',
|
|
907
|
+
mealWords(p.res),
|
|
825
908
|
hourly ? `${hourly.start}–${endOf(hourly.start, hourly.hours)}` : undefined,
|
|
826
909
|
]
|
|
827
910
|
.filter(Boolean)
|
|
@@ -829,11 +912,15 @@ export const createHuazhuHotels = (opts) => {
|
|
|
829
912
|
stay: line.stay,
|
|
830
913
|
nights: room.nights,
|
|
831
914
|
...(hourly ? { hourly } : {}),
|
|
915
|
+
...(room.payment === 'prepaid' ? { payment: 'prepaid' } : {}),
|
|
832
916
|
quantity: wanted,
|
|
833
917
|
unit: room.nightly,
|
|
834
918
|
total: money(preTax, room.stayTotal.currency),
|
|
835
919
|
refundable: room.refundable,
|
|
836
920
|
freeCancellationUntil: room.freeCancellationUntil,
|
|
921
|
+
...(room.freeCancellationLocal
|
|
922
|
+
? { freeCancellationLocal: room.freeCancellationLocal }
|
|
923
|
+
: {}),
|
|
837
924
|
day: line.stay.checkIn,
|
|
838
925
|
});
|
|
839
926
|
}
|
|
@@ -869,6 +956,33 @@ export const createHuazhuHotels = (opts) => {
|
|
|
869
956
|
return q;
|
|
870
957
|
};
|
|
871
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
|
+
};
|
|
872
986
|
const publicBooking = (b) => ({
|
|
873
987
|
id: b.id,
|
|
874
988
|
confirmationCode: b.confirmationCode,
|
|
@@ -883,6 +997,7 @@ export const createHuazhuHotels = (opts) => {
|
|
|
883
997
|
...(b.cancelledAt ? { cancelledAt: b.cancelledAt } : {}),
|
|
884
998
|
...(b.cancellationFee ? { cancellationFee: b.cancellationFee } : {}),
|
|
885
999
|
...(b.requests ? { requests: b.requests } : {}),
|
|
1000
|
+
...(b.prepaid ? { payment: paymentOf(b) } : {}),
|
|
886
1001
|
});
|
|
887
1002
|
/**
|
|
888
1003
|
* The codes a guest may have been given for a booking: ours, and the
|
|
@@ -919,7 +1034,15 @@ export const createHuazhuHotels = (opts) => {
|
|
|
919
1034
|
return there;
|
|
920
1035
|
};
|
|
921
1036
|
/** Huazhu's own word on a booking, at most once a minute each. */
|
|
922
|
-
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
|
+
}
|
|
923
1046
|
if (!b.resId || b.pendingSince !== undefined)
|
|
924
1047
|
return b;
|
|
925
1048
|
if (b.refreshedAt && now() - b.refreshedAt < 60_000)
|
|
@@ -1003,7 +1126,11 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1003
1126
|
const conversationOf = (who) => who.tripOwner ?? who.sessionId;
|
|
1004
1127
|
/** A name for a booking in what the guest is told. */
|
|
1005
1128
|
const named = (b) => `${b.hotelName} (${b.confirmationCode})`;
|
|
1006
|
-
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') &&
|
|
1007
1134
|
!b.rollback &&
|
|
1008
1135
|
b.stay.checkOut >= today();
|
|
1009
1136
|
/** The booking itself, one request at a time per conversation and email - see `single`. */
|
|
@@ -1097,6 +1224,197 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1097
1224
|
},
|
|
1098
1225
|
}
|
|
1099
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
|
+
};
|
|
1100
1418
|
const bookHeld = async (lines, shown, tripKey, email, guest, contact, who, requests, slot) => {
|
|
1101
1419
|
// Priced afresh, not from the cache: this is the price that will be sent.
|
|
1102
1420
|
const priced = await quoteLines({ lines }, true);
|
|
@@ -1118,6 +1436,11 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1118
1436
|
throw new Error('Nothing was booked: the trip changed while it was being priced.');
|
|
1119
1437
|
plans.push({ i, line, sold, qline });
|
|
1120
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.');
|
|
1121
1444
|
for (const plan of plans) {
|
|
1122
1445
|
const s = plan.sold.res;
|
|
1123
1446
|
const title = `${plan.qline.hotelName} - ${plan.qline.title}`;
|
|
@@ -1125,7 +1448,7 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1125
1448
|
const out = await client.prebook({
|
|
1126
1449
|
hotelId: plan.line.hotelId,
|
|
1127
1450
|
checkinType: plan.line.hourly ? 'HOUR' : 'DAY',
|
|
1128
|
-
paymentType:
|
|
1451
|
+
paymentType: plan.sold.res.paymentType,
|
|
1129
1452
|
reservations: [
|
|
1130
1453
|
{
|
|
1131
1454
|
roomTypeCode: plan.sold.product.roomTypeCode,
|
|
@@ -1150,6 +1473,92 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1150
1473
|
requestsByEmail.add(email);
|
|
1151
1474
|
requestsByConversation.add(conversationOf(who));
|
|
1152
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
|
+
}
|
|
1153
1562
|
const made = [];
|
|
1154
1563
|
/**
|
|
1155
1564
|
* Undo what this trip booked, when a later stay of it failed - and say
|
|
@@ -1254,7 +1663,7 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1254
1663
|
distributorResId: dres,
|
|
1255
1664
|
tripRef,
|
|
1256
1665
|
tripKey,
|
|
1257
|
-
paymentType:
|
|
1666
|
+
paymentType: s.paymentType,
|
|
1258
1667
|
guaranteeCode: s.guaranteeCode,
|
|
1259
1668
|
cancelCode: s.cancelCode,
|
|
1260
1669
|
nightlyMinor,
|
|
@@ -1299,7 +1708,7 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1299
1708
|
checkinType: plan.line.hourly ? 'HOUR' : 'DAY',
|
|
1300
1709
|
contactPerson: contact,
|
|
1301
1710
|
distributorResId: dres,
|
|
1302
|
-
paymentType:
|
|
1711
|
+
paymentType: s.paymentType,
|
|
1303
1712
|
reservations: [reservation],
|
|
1304
1713
|
...(comment ? { guestComment: comment } : {}),
|
|
1305
1714
|
...(plan.token ? { preBookToken: plan.token } : {}),
|
|
@@ -1374,7 +1783,21 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1374
1783
|
};
|
|
1375
1784
|
return {
|
|
1376
1785
|
brand: opts.brand,
|
|
1377
|
-
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
|
+
},
|
|
1378
1801
|
imageDomains() {
|
|
1379
1802
|
const hosts = new Set();
|
|
1380
1803
|
for (const h of catalog.all()) {
|
|
@@ -1524,7 +1947,7 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1524
1947
|
const priced = await priceHourly(raw, stay.guests, wantRooms);
|
|
1525
1948
|
pricedRooms.set(raw.hotelId, priced);
|
|
1526
1949
|
if (!priced.some((p) => p.res.ok))
|
|
1527
|
-
why.set(raw.hotelId,
|
|
1950
|
+
why.set(raw.hotelId, hourlyNoRoomsWhy(priced, stay.guests));
|
|
1528
1951
|
}
|
|
1529
1952
|
catch {
|
|
1530
1953
|
pricedRooms.set(raw.hotelId, []);
|
|
@@ -1574,8 +1997,16 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1574
1997
|
if (priceEverything)
|
|
1575
1998
|
await pool(matches.map((m) => m.raw), PRICE_CONCURRENCY, priceHotel);
|
|
1576
1999
|
const canRoomFilter = priceEverything;
|
|
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
|
+
};
|
|
1577
2008
|
if (canRoomFilter && (roomTerms || hourlyOnly))
|
|
1578
|
-
matches = matches.filter(({ raw }) => bestFor(raw, f) !== null);
|
|
2009
|
+
matches = matches.filter(({ raw }) => bestFor(raw, f) !== null || (hourlyOnly && !roomTerms && tooSmallOnly(raw)));
|
|
1579
2010
|
const currencies = [...new Set(matches.map((m) => m.hotel.currency))].sort();
|
|
1580
2011
|
const onePrice = currencies.length === 1;
|
|
1581
2012
|
const ceiling = priceEverything && onePrice && Number(q.maxNightly) > 0 ? Number(q.maxNightly) : undefined;
|
|
@@ -1774,13 +2205,12 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1774
2205
|
.filter((p) => p.res.ok)
|
|
1775
2206
|
.map((p) => toRoom(raw, p, stay))
|
|
1776
2207
|
.sort((a, b) => a.stayTotal.minor - b.stayTotal.minor);
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
return { hotel, stay, rooms, ...(rooms.length === 0 ? { noRooms: why } : {}) };
|
|
2208
|
+
return {
|
|
2209
|
+
hotel,
|
|
2210
|
+
stay,
|
|
2211
|
+
rooms,
|
|
2212
|
+
...(rooms.length === 0 ? { noRooms: hourlyNoRoomsWhy(priced, guests) } : {}),
|
|
2213
|
+
};
|
|
1784
2214
|
},
|
|
1785
2215
|
async datesAround(id, stay, o) {
|
|
1786
2216
|
const raw = findHotel(id);
|
|
@@ -1820,6 +2250,7 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1820
2250
|
today: from,
|
|
1821
2251
|
clock: clockAt(now(), opts.tz),
|
|
1822
2252
|
paymentType: opts.paymentType,
|
|
2253
|
+
...(prepaidType ? { prepaidType } : {}),
|
|
1823
2254
|
allowTags,
|
|
1824
2255
|
};
|
|
1825
2256
|
let cheapest;
|
|
@@ -1899,7 +2330,13 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1899
2330
|
const mine = store
|
|
1900
2331
|
.all()
|
|
1901
2332
|
.filter((b) => b.owners.includes(who.sessionId) &&
|
|
1902
|
-
|
|
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))
|
|
1903
2340
|
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
|
|
1904
2341
|
.slice(0, most);
|
|
1905
2342
|
const fresh = await pool(mine, 3, (b) => refresh(b));
|
|
@@ -1939,6 +2376,16 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1939
2376
|
const cur = b.quote.total.currency;
|
|
1940
2377
|
const zero = money(0, cur);
|
|
1941
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
|
+
}
|
|
1942
2389
|
if (b.pendingSince !== undefined || b.status === 'unconfirmed') {
|
|
1943
2390
|
return {
|
|
1944
2391
|
bookingId: b.id,
|
|
@@ -1962,6 +2409,24 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1962
2409
|
};
|
|
1963
2410
|
}
|
|
1964
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
|
+
}
|
|
1965
2430
|
return {
|
|
1966
2431
|
bookingId: b.id,
|
|
1967
2432
|
confirmationCode: b.confirmationCode,
|
|
@@ -1982,6 +2447,19 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1982
2447
|
if (!found)
|
|
1983
2448
|
return null;
|
|
1984
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
|
+
}
|
|
1985
2463
|
if (b.pendingSince !== undefined || b.status === 'unconfirmed') {
|
|
1986
2464
|
throw new Error(waitingWords(b));
|
|
1987
2465
|
}
|
|
@@ -1995,12 +2473,18 @@ export const createHuazhuHotels = (opts) => {
|
|
|
1995
2473
|
const fee = typeof out.penaltyAmount === 'number'
|
|
1996
2474
|
? money(toMinor(out.penaltyAmount, out.penaltyCurrencyCode || cur), out.penaltyCurrencyCode || cur)
|
|
1997
2475
|
: money(feeNow(b).fee, cur);
|
|
1998
|
-
|
|
2476
|
+
const cancelled = store.update(b.id, {
|
|
1999
2477
|
status: 'cancelled',
|
|
2000
2478
|
hotelStatus: 'HOTEL_CANCEL',
|
|
2001
2479
|
cancelledAt: new Date(now()).toISOString(),
|
|
2002
2480
|
cancellationFee: fee,
|
|
2003
|
-
}) ?? 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);
|
|
2004
2488
|
}
|
|
2005
2489
|
catch (err) {
|
|
2006
2490
|
if (err instanceof HuazhuRefusal && err.code === 5402) {
|