@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.
@@ -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.trim().toLowerCase() === surname.trim().toLowerCase());
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: `${hit.given} ${hit.surname}`,
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(subtotal + taxed, cur),
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(((subtotal + taxed) / minorFactor(cur) / earnRate(cur)) * member.tier.pointsPerUnit),
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
- const total = subtotal + taxes;
963
- const { dropped, member, ...rest } = was;
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
- return { fee: money(fee, currency), rules, freeUntil };
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
- return single([...locks, `e:${email}`], async () => {
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 = {