@curless/hotel-storefront 0.50.4 → 0.51.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/aggregate/hotels.d.ts.map +1 -1
- package/dist/aggregate/hotels.js +2 -1
- package/dist/aggregate/hotels.js.map +1 -1
- package/dist/backend.d.ts +6 -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/data.d.ts.map +1 -1
- package/dist/data.js +161 -0
- package/dist/data.js.map +1 -1
- package/dist/demo-hotels.d.ts +48 -2
- package/dist/demo-hotels.d.ts.map +1 -1
- package/dist/demo-hotels.js +364 -11
- package/dist/demo-hotels.js.map +1 -1
- package/dist/server.d.ts +2 -2
- package/dist/server.js +2 -2
- package/dist/surface.d.ts.map +1 -1
- package/dist/surface.js +9 -2
- package/dist/surface.js.map +1 -1
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +348 -20
- package/dist/tools.js.map +1 -1
- package/dist/types.d.ts +150 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/demo-hotels.js
CHANGED
|
@@ -103,6 +103,16 @@ const wholeBooking = (b) => {
|
|
|
103
103
|
x.quote.lines.every((l) => !!l && typeof l.hotelId === 'string' && isMoney(l.total)) &&
|
|
104
104
|
isMoney(x.quote?.total));
|
|
105
105
|
};
|
|
106
|
+
/**
|
|
107
|
+
* A member's whole name: given then surname - or, in a script that puts the
|
|
108
|
+
* surname first, that way round with no space. "小雨 林" was 林小雨 written
|
|
109
|
+
* backwards (10-30, the Atour member screenshots).
|
|
110
|
+
*/
|
|
111
|
+
/** "A, B or C" - for what a coupon is limited to. */
|
|
112
|
+
const listOr = (xs) => xs.length > 1 ? `${xs.slice(0, -1).join(', ')} or ${xs.at(-1)}` : (xs[0] ?? '');
|
|
113
|
+
export const fullName = (given, surname) => /[\p{Script=Han}\p{Script=Hangul}\p{Script=Hiragana}\p{Script=Katakana}]/u.test(given + surname)
|
|
114
|
+
? `${surname.trim()}${given.trim()}`
|
|
115
|
+
: `${given.trim()} ${surname.trim()}`;
|
|
106
116
|
const publicBooking = (b) => {
|
|
107
117
|
const { owners, askedAs, ...rest } = b;
|
|
108
118
|
void owners;
|
|
@@ -217,6 +227,13 @@ export const createDemoHotels = (opts) => {
|
|
|
217
227
|
const rng = opts.rng ?? (() => randomInt(0, 2 ** 32) / 2 ** 32);
|
|
218
228
|
const holdMs = opts.holdMs ?? HOLD_MS_DEFAULT;
|
|
219
229
|
const bookings = new Map();
|
|
230
|
+
/**
|
|
231
|
+
* The held coupons spent on a booking, by held id (upper case): the booking
|
|
232
|
+
* it went on. Kept apart from the bookings because a booking is forgotten
|
|
233
|
+
* some days after its stay, and a coupon spent on it must not come back then.
|
|
234
|
+
* Given back by `giveBack` when the booking is cancelled or lapses in time.
|
|
235
|
+
*/
|
|
236
|
+
const spent = new Map();
|
|
220
237
|
/** One booking request at a time per conversation and per email - see `singleFlight`. */
|
|
221
238
|
const single = singleFlight();
|
|
222
239
|
/** Saved room preferences, by membership number - see `preferencesFor`. */
|
|
@@ -270,9 +287,14 @@ export const createDemoHotels = (opts) => {
|
|
|
270
287
|
if (typeof number === 'string' && Array.isArray(kinds))
|
|
271
288
|
preferences.set(number, kinds);
|
|
272
289
|
}
|
|
290
|
+
for (const [held, booking] of saved?.couponsSpent ?? []) {
|
|
291
|
+
if (typeof held === 'string' && typeof booking === 'string')
|
|
292
|
+
spent.set(held.toUpperCase(), booking);
|
|
293
|
+
}
|
|
273
294
|
opts.state?.bind(() => ({
|
|
274
295
|
bookings: [...bookings.values()].map((b) => ({ ...b, owners: [...b.owners] })),
|
|
275
296
|
preferences: [...preferences],
|
|
297
|
+
couponsSpent: [...spent],
|
|
276
298
|
}));
|
|
277
299
|
const changed = () => opts.state?.changed();
|
|
278
300
|
/**
|
|
@@ -310,16 +332,22 @@ export const createDemoHotels = (opts) => {
|
|
|
310
332
|
if (!m)
|
|
311
333
|
return null;
|
|
312
334
|
const wanted = number.replace(/\s+/g, '').toUpperCase();
|
|
335
|
+
const said = surname.trim().toLowerCase();
|
|
336
|
+
// The surname as it is printed in any script the programme holds it in:
|
|
337
|
+
// 林 as well as Lin (10-30: a Chinese guest types 林).
|
|
313
338
|
const hit = m.members.find((x) => x.number.replace(/\s+/g, '').toUpperCase() === wanted &&
|
|
314
|
-
x.surname.
|
|
339
|
+
[x.surname, ...Object.values(x.names ?? {}).map((n) => n.surname)].some((n) => n.trim().toLowerCase() === said));
|
|
315
340
|
if (!hit)
|
|
316
341
|
return null;
|
|
317
342
|
const tier = m.tiers.find((t) => t.id === hit.tier);
|
|
318
343
|
if (!tier)
|
|
319
344
|
return null;
|
|
345
|
+
const names = Object.fromEntries(Object.entries(hit.names ?? {}).map(([lang, n]) => [lang, fullName(n.given, n.surname)]));
|
|
346
|
+
const coupons = heldCouponsOf(hit);
|
|
320
347
|
return {
|
|
321
348
|
number: hit.number,
|
|
322
|
-
name:
|
|
349
|
+
name: fullName(hit.given, hit.surname),
|
|
350
|
+
...(Object.keys(names).length ? { names } : {}),
|
|
323
351
|
surname: hit.surname,
|
|
324
352
|
programme: m.programme,
|
|
325
353
|
tier: {
|
|
@@ -329,8 +357,71 @@ export const createDemoHotels = (opts) => {
|
|
|
329
357
|
pointsPerUnit: tier.pointsPerUnit,
|
|
330
358
|
},
|
|
331
359
|
points: hit.points,
|
|
360
|
+
...(hit.pointsExpiring ? { pointsExpiring: { ...hit.pointsExpiring } } : {}),
|
|
361
|
+
// An empty list where the programme gives coupons out: "holds none" is
|
|
362
|
+
// not "takes none", and the card says the one, not the other.
|
|
363
|
+
...(couponKinds.size ? { coupons } : {}),
|
|
332
364
|
};
|
|
333
365
|
};
|
|
366
|
+
/** The programme's coupon kinds, by id. */
|
|
367
|
+
const couponKinds = new Map((opts.data.membership?.coupons ?? []).map((c) => [c.id, c]));
|
|
368
|
+
/** A held coupon as the member sees it, on `today` - see `HeldCoupon`. */
|
|
369
|
+
const heldCoupon = (held, todayIs) => {
|
|
370
|
+
const kind = couponKinds.get(held.coupon);
|
|
371
|
+
if (!kind)
|
|
372
|
+
return undefined;
|
|
373
|
+
const cur = kind.currency;
|
|
374
|
+
const amount = (x) => x === undefined ? undefined : money(toMinor(x, cur), cur);
|
|
375
|
+
const brandNames = (b) => hotels.find((h) => h.brand === b)?.names?.brand;
|
|
376
|
+
return {
|
|
377
|
+
id: held.id,
|
|
378
|
+
kind: kind.kind,
|
|
379
|
+
name: kind.name,
|
|
380
|
+
...(kind.names ? { names: kind.names } : {}),
|
|
381
|
+
...(kind.amountOff !== undefined ? { amountOff: amount(kind.amountOff) } : {}),
|
|
382
|
+
...(kind.percentOff !== undefined ? { percentOff: kind.percentOff } : {}),
|
|
383
|
+
...(kind.maxOff !== undefined ? { maxOff: amount(kind.maxOff) } : {}),
|
|
384
|
+
...(kind.minSpend ? { minSpend: amount(kind.minSpend) } : {}),
|
|
385
|
+
...(kind.brands
|
|
386
|
+
? {
|
|
387
|
+
brands: kind.brands.map((b) => ({
|
|
388
|
+
name: b,
|
|
389
|
+
...(brandNames(b) ? { names: brandNames(b) } : {}),
|
|
390
|
+
})),
|
|
391
|
+
}
|
|
392
|
+
: {}),
|
|
393
|
+
...(kind.hotels
|
|
394
|
+
? {
|
|
395
|
+
hotels: kind.hotels.flatMap((id) => {
|
|
396
|
+
const h = hotels.find((x) => x.id === id);
|
|
397
|
+
return h
|
|
398
|
+
? [{ id, name: h.name, ...(h.names?.name ? { names: h.names.name } : {}) }]
|
|
399
|
+
: [];
|
|
400
|
+
}),
|
|
401
|
+
}
|
|
402
|
+
: {}),
|
|
403
|
+
validFrom: held.validFrom,
|
|
404
|
+
validUntil: held.validUntil,
|
|
405
|
+
state: spent.has(held.id.toUpperCase())
|
|
406
|
+
? 'used'
|
|
407
|
+
: todayIs > held.validUntil
|
|
408
|
+
? 'expired'
|
|
409
|
+
: todayIs < held.validFrom
|
|
410
|
+
? 'not-yet'
|
|
411
|
+
: 'usable',
|
|
412
|
+
};
|
|
413
|
+
};
|
|
414
|
+
/** A member's coupons as they stand, usable first, then by when they run out. */
|
|
415
|
+
const heldCouponsOf = (raw) => {
|
|
416
|
+
const order = { usable: 0, 'not-yet': 1, used: 2, expired: 3 };
|
|
417
|
+
return (raw.coupons ?? [])
|
|
418
|
+
.flatMap((c) => heldCoupon(c, todayAt(now(), DEFAULT_OFFSET)) ?? [])
|
|
419
|
+
.sort((a, b) => order[a.state] - order[b.state] || a.validUntil.localeCompare(b.validUntil));
|
|
420
|
+
};
|
|
421
|
+
/** The member's own record - what their coupons are read from, live. */
|
|
422
|
+
const rawMember = (m) => m
|
|
423
|
+
? opts.data.membership?.members.find((x) => x.number.toUpperCase() === m.number.toUpperCase())
|
|
424
|
+
: undefined;
|
|
334
425
|
/** The tier's discount, or none. One place, so a rate cannot be discounted twice. */
|
|
335
426
|
const discountOf = (who) => who?.member?.tier.discount ?? 0;
|
|
336
427
|
/**
|
|
@@ -405,12 +496,29 @@ export const createDemoHotels = (opts) => {
|
|
|
405
496
|
}
|
|
406
497
|
else if (b.status === 'requested' && b.holdUntil && Date.parse(b.holdUntil) <= t) {
|
|
407
498
|
b.status = 'expired';
|
|
499
|
+
giveBack(b, Date.parse(b.holdUntil));
|
|
408
500
|
dirty = true;
|
|
409
501
|
}
|
|
410
502
|
}
|
|
411
503
|
if (dirty)
|
|
412
504
|
changed();
|
|
413
505
|
};
|
|
506
|
+
/**
|
|
507
|
+
* A booking that ended - cancelled, or a hold that lapsed - hands its coupon
|
|
508
|
+
* back while the coupon is in its dates on the hotel's calendar; once it has
|
|
509
|
+
* run out, it stays spent (§2). Said on the booking either way.
|
|
510
|
+
*/
|
|
511
|
+
const giveBack = (b, at = now()) => {
|
|
512
|
+
const c = b.quote.coupon;
|
|
513
|
+
if (!c || spent.get(c.id.toUpperCase()) !== b.id)
|
|
514
|
+
return;
|
|
515
|
+
// On the day it ended - a lapse noticed days later lapsed when its hold did.
|
|
516
|
+
const h = byId(b.hotelId);
|
|
517
|
+
const returned = todayAt(at, h ? offsetOf(h) : DEFAULT_OFFSET) <= c.validUntil;
|
|
518
|
+
if (returned)
|
|
519
|
+
spent.delete(c.id.toUpperCase());
|
|
520
|
+
b.couponReturned = returned;
|
|
521
|
+
};
|
|
414
522
|
/** Room-nights the live holds that `pick` chooses take, leaving out `ignore`. */
|
|
415
523
|
const heldBy = (pick, ignore) => [...bookings.values()]
|
|
416
524
|
.filter((b) => b.status === 'requested' && b.id !== ignore && pick(b))
|
|
@@ -880,11 +988,15 @@ export const createDemoHotels = (opts) => {
|
|
|
880
988
|
const member = who?.member;
|
|
881
989
|
// Every line's tax is already a whole unit - see `taxOn`.
|
|
882
990
|
const taxed = taxes;
|
|
991
|
+
const { coupons, coupon, couponRefused } = couponsFor(trip.coupon, member, lines, cur);
|
|
992
|
+
// The coupon comes off the all-in total, after the tier (§2): what is
|
|
993
|
+
// paid at the hotel, and what points are earned on.
|
|
994
|
+
const paid = subtotal + taxed - (coupon?.off.minor ?? 0);
|
|
883
995
|
return {
|
|
884
996
|
lines,
|
|
885
997
|
subtotal: money(subtotal, cur),
|
|
886
998
|
taxesAndFees: money(taxed, cur),
|
|
887
|
-
total: money(
|
|
999
|
+
total: money(paid, cur),
|
|
888
1000
|
stay,
|
|
889
1001
|
dropped: dropped.length ? dropped : undefined,
|
|
890
1002
|
...(member && lines.length
|
|
@@ -896,10 +1008,176 @@ export const createDemoHotels = (opts) => {
|
|
|
896
1008
|
// Points on what is actually paid, converted to the programme's
|
|
897
1009
|
// own currency first. See RawEarn: without the conversion this
|
|
898
1010
|
// line pays a Jakarta guest two thousand times a Paris one.
|
|
899
|
-
points: Math.round((
|
|
1011
|
+
points: Math.round((paid / minorFactor(cur) / earnRate(cur)) * member.tier.pointsPerUnit),
|
|
1012
|
+
publicTotal: money(subtotal + taxed + saved, cur),
|
|
900
1013
|
},
|
|
901
1014
|
}
|
|
902
1015
|
: {}),
|
|
1016
|
+
...(coupon ? { coupon } : {}),
|
|
1017
|
+
...(couponRefused ? { couponRefused } : {}),
|
|
1018
|
+
...(coupons ? { coupons } : {}),
|
|
1019
|
+
};
|
|
1020
|
+
};
|
|
1021
|
+
/**
|
|
1022
|
+
* What a coupon takes off a stay that comes to `base` (minor units, all in at
|
|
1023
|
+
* the member rate): an amount, or a fraction rounded DOWN to the whole unit
|
|
1024
|
+
* and capped - never more than the stay (§2).
|
|
1025
|
+
*/
|
|
1026
|
+
const offOf = (kind, base) => {
|
|
1027
|
+
const cur = kind.currency;
|
|
1028
|
+
const off = kind.kind === 'amount-off'
|
|
1029
|
+
? toMinor(kind.amountOff ?? 0, cur)
|
|
1030
|
+
: Math.min(Math.floor((base * (kind.percentOff ?? 0)) / minorFactor(cur)) * minorFactor(cur), kind.maxOff === undefined ? Number.POSITIVE_INFINITY : toMinor(kind.maxOff, cur));
|
|
1031
|
+
return Math.max(0, Math.min(off, base));
|
|
1032
|
+
};
|
|
1033
|
+
/**
|
|
1034
|
+
* The member's coupons against these priced lines, and the one asked for.
|
|
1035
|
+
*
|
|
1036
|
+
* ⚠️ ONE STAY TAKES IT. A stay - one hotel's rooms for the same nights - is
|
|
1037
|
+
* one booking, and a coupon is spent on one booking: the stay it takes off
|
|
1038
|
+
* most (the first, on a tie). Its minimum is that stay's rooms all in at the
|
|
1039
|
+
* member rate; add-ons are not rooms and do not count. Two or three rooms of
|
|
1040
|
+
* one stay are one stay: taken off once.
|
|
1041
|
+
*/
|
|
1042
|
+
const couponsFor = (asked, member, lines, cur) => {
|
|
1043
|
+
const raw = rawMember(member);
|
|
1044
|
+
if (!couponKinds.size) {
|
|
1045
|
+
return asked
|
|
1046
|
+
? {
|
|
1047
|
+
couponRefused: {
|
|
1048
|
+
id: asked,
|
|
1049
|
+
code: 'not-held',
|
|
1050
|
+
why: 'Coupons are not taken here.',
|
|
1051
|
+
},
|
|
1052
|
+
}
|
|
1053
|
+
: {};
|
|
1054
|
+
}
|
|
1055
|
+
const stays = [];
|
|
1056
|
+
for (const l of lines) {
|
|
1057
|
+
if (l.kind !== 'room' || !l.stay)
|
|
1058
|
+
continue;
|
|
1059
|
+
const h = byId(l.hotelId);
|
|
1060
|
+
if (!h)
|
|
1061
|
+
continue;
|
|
1062
|
+
const key = `${h.id}|${l.stay.checkIn}|${l.stay.checkOut}`;
|
|
1063
|
+
const at = stays.find((x) => x.key === key);
|
|
1064
|
+
const all = (l.totalWithTaxes ?? l.total).minor;
|
|
1065
|
+
if (at)
|
|
1066
|
+
at.base += all;
|
|
1067
|
+
else
|
|
1068
|
+
stays.push({
|
|
1069
|
+
key,
|
|
1070
|
+
hotel: h,
|
|
1071
|
+
checkIn: l.stay.checkIn,
|
|
1072
|
+
checkOut: l.stay.checkOut,
|
|
1073
|
+
base: all,
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
const said = (minor, c) => formatMoney(money(minor, c));
|
|
1077
|
+
const options = [];
|
|
1078
|
+
for (const held of raw?.coupons ?? []) {
|
|
1079
|
+
const kind = couponKinds.get(held.coupon);
|
|
1080
|
+
// Its dates on the calendar of the hotel it would go on - all of them
|
|
1081
|
+
// are on one clock in this demonstration, so the trip's first.
|
|
1082
|
+
const c = kind && heldCoupon(held, today(stays[0]?.hotel.id));
|
|
1083
|
+
if (!kind || !c)
|
|
1084
|
+
continue;
|
|
1085
|
+
const no = (code, why, more = {}) => options.push({ ...c, code, why, ...more });
|
|
1086
|
+
if (c.state === 'used') {
|
|
1087
|
+
no('used', 'It has already been used on a booking.');
|
|
1088
|
+
continue;
|
|
1089
|
+
}
|
|
1090
|
+
if (c.state === 'expired') {
|
|
1091
|
+
no('expired', `It ran out on ${c.validUntil}.`);
|
|
1092
|
+
continue;
|
|
1093
|
+
}
|
|
1094
|
+
if (c.state === 'not-yet') {
|
|
1095
|
+
no('not-yet', `It can be used from ${c.validFrom}.`);
|
|
1096
|
+
continue;
|
|
1097
|
+
}
|
|
1098
|
+
if (!stays.length) {
|
|
1099
|
+
no('no-room', 'The trip has no room for it to come off.');
|
|
1100
|
+
continue;
|
|
1101
|
+
}
|
|
1102
|
+
const inCur = stays.filter((x) => x.hotel.currency.toUpperCase() === kind.currency);
|
|
1103
|
+
if (!inCur.length) {
|
|
1104
|
+
no('currency', `It is in ${kind.currency}, and this trip is priced in ${cur}.`);
|
|
1105
|
+
continue;
|
|
1106
|
+
}
|
|
1107
|
+
const fits = inCur.filter((x) => (!kind.brands || kind.brands.includes(x.hotel.brand)) &&
|
|
1108
|
+
(!kind.hotels || kind.hotels.includes(x.hotel.id)));
|
|
1109
|
+
if (!fits.length) {
|
|
1110
|
+
no('scope', kind.hotels
|
|
1111
|
+
? `It is only for ${listOr(kind.hotels.map((id) => byId(id)?.name ?? id))}.`
|
|
1112
|
+
: `It is only for ${listOr(kind.brands ?? [])} hotels.`);
|
|
1113
|
+
continue;
|
|
1114
|
+
}
|
|
1115
|
+
const min = toMinor(kind.minSpend ?? 0, kind.currency);
|
|
1116
|
+
const big = fits.filter((x) => x.base >= min);
|
|
1117
|
+
if (!big.length) {
|
|
1118
|
+
const most = Math.max(...fits.map((x) => x.base));
|
|
1119
|
+
no('min-spend', `It needs a stay of ${said(min, kind.currency)} or more, and ${fits.length > 1 ? 'the largest stay it is for' : 'the stay'} comes to ${said(most, kind.currency)}.`, { minSpend: money(min, kind.currency), spend: money(most, kind.currency) });
|
|
1120
|
+
continue;
|
|
1121
|
+
}
|
|
1122
|
+
const best = big.reduce((a, b) => (offOf(kind, b.base) > offOf(kind, a.base) ? b : a));
|
|
1123
|
+
options.push({
|
|
1124
|
+
...c,
|
|
1125
|
+
off: money(offOf(kind, best.base), kind.currency),
|
|
1126
|
+
hotelId: best.hotel.id,
|
|
1127
|
+
hotelName: best.hotel.name,
|
|
1128
|
+
...(best.hotel.names?.name ? { hotelNames: best.hotel.names.name } : {}),
|
|
1129
|
+
stay: best,
|
|
1130
|
+
});
|
|
1131
|
+
}
|
|
1132
|
+
// Usable here first, the one that takes most off at the top - then the rest
|
|
1133
|
+
// in the member's own order.
|
|
1134
|
+
const ranked = [...options].sort((a, b) => (b.off?.minor ?? -1) - (a.off?.minor ?? -1));
|
|
1135
|
+
const coupons = raw ? ranked.map(({ stay: _s, ...o }) => o) : undefined;
|
|
1136
|
+
if (!asked)
|
|
1137
|
+
return coupons ? { coupons } : {};
|
|
1138
|
+
const wanted = asked.trim().toUpperCase();
|
|
1139
|
+
const o = options.find((x) => x.id.toUpperCase() === wanted);
|
|
1140
|
+
const refused = (r) => ({
|
|
1141
|
+
...(coupons ? { coupons } : {}),
|
|
1142
|
+
couponRefused: r,
|
|
1143
|
+
});
|
|
1144
|
+
if (!member)
|
|
1145
|
+
return refused({
|
|
1146
|
+
id: asked,
|
|
1147
|
+
code: 'signed-out',
|
|
1148
|
+
why: "Nobody is signed in, and a coupon is a member's: sign in first.",
|
|
1149
|
+
});
|
|
1150
|
+
if (!o)
|
|
1151
|
+
return refused({
|
|
1152
|
+
id: asked,
|
|
1153
|
+
code: 'not-held',
|
|
1154
|
+
why: `${member.name} holds no coupon ${asked}.`,
|
|
1155
|
+
});
|
|
1156
|
+
if (!o.off || !o.stay)
|
|
1157
|
+
return refused({
|
|
1158
|
+
id: o.id,
|
|
1159
|
+
name: o.name,
|
|
1160
|
+
...(o.names ? { names: o.names } : {}),
|
|
1161
|
+
code: o.code ?? 'scope',
|
|
1162
|
+
why: o.why ?? 'It cannot be used on this trip.',
|
|
1163
|
+
...(o.minSpend && o.code === 'min-spend' ? { minSpend: o.minSpend } : {}),
|
|
1164
|
+
...(o.spend ? { spend: o.spend } : {}),
|
|
1165
|
+
});
|
|
1166
|
+
return {
|
|
1167
|
+
...(coupons ? { coupons } : {}),
|
|
1168
|
+
coupon: {
|
|
1169
|
+
id: o.id,
|
|
1170
|
+
name: o.name,
|
|
1171
|
+
...(o.names ? { names: o.names } : {}),
|
|
1172
|
+
kind: o.kind,
|
|
1173
|
+
off: o.off,
|
|
1174
|
+
hotelId: o.stay.hotel.id,
|
|
1175
|
+
hotelName: o.stay.hotel.name,
|
|
1176
|
+
...(o.stay.hotel.names?.name ? { hotelNames: o.stay.hotel.names.name } : {}),
|
|
1177
|
+
checkIn: o.stay.checkIn,
|
|
1178
|
+
checkOut: o.stay.checkOut,
|
|
1179
|
+
validUntil: o.validUntil,
|
|
1180
|
+
},
|
|
903
1181
|
};
|
|
904
1182
|
};
|
|
905
1183
|
/**
|
|
@@ -959,9 +1237,14 @@ export const createDemoHotels = (opts) => {
|
|
|
959
1237
|
const subtotal = lines.reduce((n, l) => n + l.total.minor, 0);
|
|
960
1238
|
const taxes = rooms.reduce((n, l) => n + roomTaxOf(l, taxRate), 0) +
|
|
961
1239
|
extras.reduce((n, l) => n + wholeUnits(taxOn(l.total, taxRate)).minor, 0);
|
|
962
|
-
|
|
963
|
-
|
|
1240
|
+
// The coupon the rooms were booked with stays off: it went on the rooms,
|
|
1241
|
+
// and they are kept as booked.
|
|
1242
|
+
const off = was.coupon?.off.minor ?? 0;
|
|
1243
|
+
const total = subtotal + taxes - off;
|
|
1244
|
+
const { dropped, member, coupons, couponRefused, ...rest } = was;
|
|
964
1245
|
void dropped;
|
|
1246
|
+
void coupons;
|
|
1247
|
+
void couponRefused;
|
|
965
1248
|
return {
|
|
966
1249
|
...rest,
|
|
967
1250
|
lines,
|
|
@@ -976,6 +1259,7 @@ export const createDemoHotels = (opts) => {
|
|
|
976
1259
|
points: was.total.minor
|
|
977
1260
|
? Math.round((member.points * total) / was.total.minor)
|
|
978
1261
|
: member.points,
|
|
1262
|
+
publicTotal: money(total + off + member.saved.minor, cur),
|
|
979
1263
|
},
|
|
980
1264
|
}
|
|
981
1265
|
: {}),
|
|
@@ -1047,6 +1331,10 @@ export const createDemoHotels = (opts) => {
|
|
|
1047
1331
|
: undefined;
|
|
1048
1332
|
if (locked)
|
|
1049
1333
|
return no(`${locked.title} is a non-refundable rate: it cannot be cancelled or changed. Book the new nights as their own stay - this one stands.`);
|
|
1334
|
+
// A coupon was worked out on these nights and rooms (§2): moved, it would
|
|
1335
|
+
// have to be worked out again, and may no longer apply. Not in place yet.
|
|
1336
|
+
if (b.quote.coupon && movesRooms)
|
|
1337
|
+
return no(`This booking used the coupon ${b.quote.coupon.name}, and a booking made with a coupon cannot be moved in place yet: cancel it (the coupon comes back while it is in its dates) and book the new nights.`);
|
|
1050
1338
|
// "No rooms" is not a smaller booking. It became one room without a word.
|
|
1051
1339
|
if (change.rooms !== undefined && !(Math.floor(change.rooms) >= 1))
|
|
1052
1340
|
return no('A booking keeps at least one room. To give them all back, cancel it instead.');
|
|
@@ -1203,7 +1491,24 @@ export const createDemoHotels = (opts) => {
|
|
|
1203
1491
|
if (b.quote.lines.some((l) => l.kind === 'extra')) {
|
|
1204
1492
|
rules.push('Add-ons are dropped at no charge.');
|
|
1205
1493
|
}
|
|
1206
|
-
|
|
1494
|
+
const capped = couponCap(b, fee);
|
|
1495
|
+
if (capped < fee)
|
|
1496
|
+
rules.push(`The coupon took ${formatMoney(b.quote.coupon?.off ?? money(0, currency))} off this stay, so the fee is at most what the stay comes to.`);
|
|
1497
|
+
return { fee: money(capped, currency), rules, freeUntil };
|
|
1498
|
+
};
|
|
1499
|
+
/**
|
|
1500
|
+
* A fee never above what the stay came to: a coupon took money off the
|
|
1501
|
+
* rooms, and a whole non-refundable stay charged at its price before the
|
|
1502
|
+
* coupon was ¥604 on a booking of ¥554 (0.51.0).
|
|
1503
|
+
*/
|
|
1504
|
+
const couponCap = (b, fee) => {
|
|
1505
|
+
const off = b.quote.coupon?.off.minor;
|
|
1506
|
+
if (!off)
|
|
1507
|
+
return fee;
|
|
1508
|
+
const rooms = b.quote.lines
|
|
1509
|
+
.filter((l) => l.kind === 'room')
|
|
1510
|
+
.reduce((n, l) => n + (l.totalWithTaxes ?? l.total).minor, 0);
|
|
1511
|
+
return Math.min(fee, Math.max(0, rooms - off));
|
|
1207
1512
|
};
|
|
1208
1513
|
/**
|
|
1209
1514
|
* The same terms as `feeFor` works them out, as steps in time
|
|
@@ -1225,7 +1530,7 @@ export const createDemoHotels = (opts) => {
|
|
|
1225
1530
|
? Date.parse(line.freeCancellationUntil)
|
|
1226
1531
|
: Number.NaN;
|
|
1227
1532
|
const inFree = !Number.isNaN(freeUntil) && at <= freeUntil;
|
|
1228
|
-
const after = money(line.refundable ? oneNightOf(line, taxRate) : wholeStayOf(line, taxRate), currency);
|
|
1533
|
+
const after = money(couponCap(b, line.refundable ? oneNightOf(line, taxRate) : wholeStayOf(line, taxRate)), currency);
|
|
1229
1534
|
const terms = [
|
|
1230
1535
|
...(Number.isNaN(freeUntil)
|
|
1231
1536
|
? []
|
|
@@ -1261,6 +1566,8 @@ export const createDemoHotels = (opts) => {
|
|
|
1261
1566
|
...FULL_CAPABILITIES,
|
|
1262
1567
|
...opts.capabilities,
|
|
1263
1568
|
...(opts.data.membership ? { programme: opts.data.membership.programme } : {}),
|
|
1569
|
+
// Coupons where the programme gives some out, and members can sign in.
|
|
1570
|
+
...(couponKinds.size && opts.capabilities?.members !== false ? { coupons: true } : {}),
|
|
1264
1571
|
codePrefix,
|
|
1265
1572
|
},
|
|
1266
1573
|
today,
|
|
@@ -1726,6 +2033,8 @@ export const createDemoHotels = (opts) => {
|
|
|
1726
2033
|
},
|
|
1727
2034
|
quote: quoteTrip,
|
|
1728
2035
|
async signInMember(number, surname) {
|
|
2036
|
+
// A hold that lapsed hands its coupon back - before the coupons are read.
|
|
2037
|
+
sweep();
|
|
1729
2038
|
const member = memberFor(number, surname);
|
|
1730
2039
|
if (!member)
|
|
1731
2040
|
return null;
|
|
@@ -1788,7 +2097,10 @@ export const createDemoHotels = (opts) => {
|
|
|
1788
2097
|
const email = guest.email.trim().toLowerCase();
|
|
1789
2098
|
const conversation = conversationOf(who);
|
|
1790
2099
|
const locks = [...new Set([who.sessionId, conversation])].map((k) => `s:${k}`);
|
|
1791
|
-
|
|
2100
|
+
// ⚠️ AND ONE AT A TIME PER MEMBER, when a coupon is spent: two
|
|
2101
|
+
// conversations signed in as one member would each find it unspent.
|
|
2102
|
+
const holder = trip.coupon && who.member ? [`m:${who.member.number.toUpperCase()}`] : [];
|
|
2103
|
+
return single([...locks, `e:${email}`, ...holder], async () => {
|
|
1792
2104
|
// ⚠️ THE SAME STAY ASKED FOR AGAIN IS ANSWERED WITH THE FIRST. A stay is
|
|
1793
2105
|
// the same when its rooms, nights, party, hours and add-ons are, and the
|
|
1794
2106
|
// guest is - name, phone, requests and arrival, NOT the note (free text,
|
|
@@ -1819,6 +2131,28 @@ export const createDemoHotels = (opts) => {
|
|
|
1819
2131
|
if (reused.length && !fresh.length) {
|
|
1820
2132
|
return { booked: reused.map(publicBooking), failed: [], repeat: true };
|
|
1821
2133
|
}
|
|
2134
|
+
// ⚠️ THE COUPON ASKED FOR, OR NOTHING. The guest agreed to a price with
|
|
2135
|
+
// it; booked without it they would pay more than they were told. Not
|
|
2136
|
+
// when it already went on a stay asked for minutes before (`reused`).
|
|
2137
|
+
const wantCoupon = trip.coupon?.trim().toUpperCase();
|
|
2138
|
+
const couponDone = reused.some((b) => b.quote.coupon?.id.toUpperCase() === wantCoupon);
|
|
2139
|
+
let couponAt;
|
|
2140
|
+
if (wantCoupon && !couponDone) {
|
|
2141
|
+
const whole = await quoteTrip({ lines: fresh.flatMap((f) => f.lines), coupon: trip.coupon }, who);
|
|
2142
|
+
if (whole.couponRefused) {
|
|
2143
|
+
throw demoError('coupon', `Nothing was booked. The coupon ${whole.couponRefused.name ?? whole.couponRefused.id} cannot be used: ${whole.couponRefused.why} Book without it only if the guest says so.`);
|
|
2144
|
+
}
|
|
2145
|
+
const c = whole.coupon;
|
|
2146
|
+
couponAt = c ? `${c.hotelId}|${c.checkIn}|${c.checkOut}` : undefined;
|
|
2147
|
+
}
|
|
2148
|
+
/** The coupon for this stay's own quote - the one stay it goes on. */
|
|
2149
|
+
const couponFor = (g) => {
|
|
2150
|
+
const r = g.find((l) => l.kind === 'room');
|
|
2151
|
+
const h = r && byId(r.hotelId);
|
|
2152
|
+
return couponAt && r && h && `${h.id}|${r.stay.checkIn}|${r.stay.checkOut}` === couponAt
|
|
2153
|
+
? { coupon: trip.coupon }
|
|
2154
|
+
: {};
|
|
2155
|
+
};
|
|
1822
2156
|
// ⚠️ EACH STAY ON ITS OWN, AND WHAT WAS BOOKED IS KEPT (robin,
|
|
1823
2157
|
// 2026-10-14 - see the seam). A stay that cannot be booked is said, with
|
|
1824
2158
|
// why, and stays in the trip; the others are booked. It used to be all or
|
|
@@ -1845,7 +2179,7 @@ export const createDemoHotels = (opts) => {
|
|
|
1845
2179
|
const priced = await Promise.all(fresh.map(async ({ lines: g, key }) => ({
|
|
1846
2180
|
lines: g,
|
|
1847
2181
|
key,
|
|
1848
|
-
quote: await quoteTrip({ lines: g }, who),
|
|
2182
|
+
quote: await quoteTrip({ lines: g, ...couponFor(g) }, who),
|
|
1849
2183
|
})));
|
|
1850
2184
|
const bookable = priced.filter(({ lines, quote }) => {
|
|
1851
2185
|
const ok = !quote.dropped?.length && quote.lines.some((l) => l.kind === 'room');
|
|
@@ -1867,13 +2201,18 @@ export const createDemoHotels = (opts) => {
|
|
|
1867
2201
|
// One at a time, in date order: each is held as it is made, so a later
|
|
1868
2202
|
// stay of this same trip sees the rooms an earlier one took.
|
|
1869
2203
|
for (const { lines: g, key } of bookable) {
|
|
1870
|
-
const quote = await quoteTrip({ lines: g }, who);
|
|
2204
|
+
const quote = await quoteTrip({ lines: g, ...couponFor(g) }, who);
|
|
1871
2205
|
const [firstRoom] = quote.lines.filter((l) => l.kind === 'room');
|
|
1872
2206
|
const h = firstRoom ? byId(firstRoom.hotelId) : undefined;
|
|
1873
2207
|
if (!firstRoom || !h || !quote.stay || quote.dropped?.length) {
|
|
1874
2208
|
failed.push(failedFor(g, quote));
|
|
1875
2209
|
continue;
|
|
1876
2210
|
}
|
|
2211
|
+
// Never booked without the coupon it was meant to carry.
|
|
2212
|
+
if (quote.couponRefused) {
|
|
2213
|
+
failed.push(failedFor(g, quote, `The coupon could not be used: ${quote.couponRefused.why}`));
|
|
2214
|
+
continue;
|
|
2215
|
+
}
|
|
1877
2216
|
const arrival = arrivalInstant(quote.stay.checkIn, h.checkInFrom ?? '15:00', offsetOf(h));
|
|
1878
2217
|
const holdUntil = new Date(Math.max(t + 3_600_000, Math.min(t + holdMs, arrival - HOLD_STOPS_BEFORE_ARRIVAL_MS))).toISOString();
|
|
1879
2218
|
const booking = {
|
|
@@ -1896,6 +2235,8 @@ export const createDemoHotels = (opts) => {
|
|
|
1896
2235
|
askedAs: key,
|
|
1897
2236
|
};
|
|
1898
2237
|
bookings.set(booking.id, booking);
|
|
2238
|
+
if (quote.coupon)
|
|
2239
|
+
spent.set(quote.coupon.id.toUpperCase(), booking.id);
|
|
1899
2240
|
made.push(booking);
|
|
1900
2241
|
}
|
|
1901
2242
|
if (made.length)
|
|
@@ -1996,6 +2337,17 @@ export const createDemoHotels = (opts) => {
|
|
|
1996
2337
|
freeUntil,
|
|
1997
2338
|
cancellable,
|
|
1998
2339
|
...(steps ? { steps } : {}),
|
|
2340
|
+
...(cancellable && b.quote.coupon
|
|
2341
|
+
? {
|
|
2342
|
+
coupon: {
|
|
2343
|
+
id: b.quote.coupon.id,
|
|
2344
|
+
name: b.quote.coupon.name,
|
|
2345
|
+
...(b.quote.coupon.names ? { names: b.quote.coupon.names } : {}),
|
|
2346
|
+
validUntil: b.quote.coupon.validUntil,
|
|
2347
|
+
returned: today(b.hotelId) <= b.quote.coupon.validUntil,
|
|
2348
|
+
},
|
|
2349
|
+
}
|
|
2350
|
+
: {}),
|
|
1999
2351
|
};
|
|
2000
2352
|
},
|
|
2001
2353
|
async cancelBooking(bookingRef, who, reason) {
|
|
@@ -2009,6 +2361,7 @@ export const createDemoHotels = (opts) => {
|
|
|
2009
2361
|
b.status = 'cancelled';
|
|
2010
2362
|
b.cancelledAt = new Date(now()).toISOString();
|
|
2011
2363
|
b.cancellationFee = fee;
|
|
2364
|
+
giveBack(b);
|
|
2012
2365
|
const why = reason?.replace(/\s+/g, ' ').trim().slice(0, REASON_MAX);
|
|
2013
2366
|
if (why)
|
|
2014
2367
|
b.guest = {
|