@curless/hotel-storefront 0.50.5 → 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/tools.js CHANGED
@@ -200,6 +200,44 @@ const forCapabilities = (tools, n, v, caps) => {
200
200
  }),
201
201
  };
202
202
  }
203
+ // ⚠️ ONLY WHERE COUPONS ARE TAKEN (`capabilities.coupons`): a group that
204
+ // takes none is offered no parameter for one.
205
+ if (caps.coupons) {
206
+ const withCoupon = (schema, description) => {
207
+ const s = schema;
208
+ return {
209
+ ...s,
210
+ properties: { ...(s.properties ?? {}), coupon: { type: 'string', description } },
211
+ };
212
+ };
213
+ if (t.name === n.viewTrip)
214
+ out = {
215
+ ...out,
216
+ description: `${out.description} With \`coupon\`, priced with that coupon taken off, after the member rate - nothing is spent by looking.`,
217
+ inputSchema: withCoupon(out.inputSchema, 'A coupon the signed-in member holds, by the id the sign-in answer gave it. Only one the guest named or agreed to - never one picked for them'),
218
+ };
219
+ if (t.name === n.requestBooking)
220
+ out = {
221
+ ...out,
222
+ inputSchema: withCoupon(out.inputSchema, 'A coupon the signed-in member holds, by its id: used on this booking, which spends it. Only when the guest named it or agreed to it - the one the price they agreed to was shown with. If it cannot be used, nothing is booked'),
223
+ };
224
+ // The card's stepper and Remove on a trip priced with a coupon keep it.
225
+ if (t.name === n.addToTrip)
226
+ out = {
227
+ ...out,
228
+ inputSchema: withCoupon(out.inputSchema, 'The coupon this trip is already priced with, by its id, to keep it on the new price - nothing is spent. Only one the guest named or agreed to'),
229
+ };
230
+ if (t.name === n.pickRoom || t.name === n.bookPicked)
231
+ out = {
232
+ ...out,
233
+ inputSchema: withCoupon(out.inputSchema, 'The coupon the guest chose on the card, by its id'),
234
+ };
235
+ if (t.name === n.signIn)
236
+ out = {
237
+ ...out,
238
+ description: out.description.replace('and answers with their tier, their point balance', 'and answers with their tier, their point balance, the coupons they hold'),
239
+ };
240
+ }
203
241
  // ⚠️ THE PICKED ROOM'S BOOKING TAKES THE SAME GUEST FIELDS as the trip's:
204
242
  // a phone where the hotel needs one, and no `rememberRequests` without
205
243
  // members. Its description is its own (below, only request_booking's).
@@ -995,6 +1033,10 @@ const TRIP_SCHEMA = {
995
1033
  member: OBJ,
996
1034
  /** What the tier took off THIS trip, and what it earns. */
997
1035
  memberSaving: OBJ,
1036
+ /** The coupon taken off, the one asked for and refused, and every one the member holds. */
1037
+ coupon: OBJ,
1038
+ couponRefused: OBJ,
1039
+ coupons: ARR,
998
1040
  days: ARR,
999
1041
  dropped: ARR,
1000
1042
  /** A name that fits more than one hotel: the ones it fits (`namesLike`). */
@@ -1008,6 +1050,8 @@ const CANCEL_SCHEMA = {
1008
1050
  type: 'object',
1009
1051
  properties: {
1010
1052
  view: { enum: ['cancellation-preview', 'cancelled', 'booking-not-found'] },
1053
+ /** The coupon the booking used and whether cancelling gives it back. */
1054
+ coupon: OBJ,
1011
1055
  brand: STR,
1012
1056
  toolNames: OBJ,
1013
1057
  bookingMode: STR,
@@ -1140,6 +1184,8 @@ const HOTEL_PAGE = {
1140
1184
  needsDates: BOOL,
1141
1185
  /** How many rooms of one type the list was filtered to. */
1142
1186
  rooms_wanted: { type: 'number' },
1187
+ /** Signed in: whose rate the prices are, said as the text says it (`said`). */
1188
+ memberRates: OBJ,
1143
1189
  noRooms: STR,
1144
1190
  /** `noRooms` as a code (`NoRoomsCode`) and what it holds, for a card to say in the guest's language. */
1145
1191
  noRoomsCode: STR,
@@ -1183,6 +1229,11 @@ const BOOKING_SCHEMA = {
1183
1229
  // `trip`: nothing was booked and the trip is shown again - its price
1184
1230
  // moved, or (in a market) it holds two groups' stays.
1185
1231
  view: { enum: ['booking', 'needs-guest', 'empty-trip', 'error', 'trip'] },
1232
+ /** `coupon`: nothing was booked because the coupon asked for cannot be used (`couponRefused`). */
1233
+ notBooked: STR,
1234
+ coupon: OBJ,
1235
+ couponRefused: OBJ,
1236
+ coupons: ARR,
1186
1237
  brand: STR,
1187
1238
  toolNames: OBJ,
1188
1239
  bookingMode: STR,
@@ -1519,10 +1570,25 @@ const memberBadge = (deps, key) => {
1519
1570
  programme: m.programme,
1520
1571
  tier: { name: m.tier.name, discount: m.tier.discount },
1521
1572
  ...(m.preferences?.length ? { preferences: m.preferences } : {}),
1573
+ // As they stand now - spent or handed back since signing in - so
1574
+ // the member page the badge opens is never a booking behind.
1575
+ points: m.points,
1576
+ ...(m.pointsExpiring ? { pointsExpiring: m.pointsExpiring } : {}),
1577
+ ...(m.coupons ? { coupons: m.coupons.map(couponForCard) } : {}),
1522
1578
  },
1523
1579
  }
1524
1580
  : { signedIn: false };
1525
1581
  };
1582
+ /** A member for the card: every amount as text, like every amount the card is given. */
1583
+ const memberForCard = (m, brand) => ({
1584
+ ...m,
1585
+ // Each with its terms in the sign-in text's own words (\`said\`): the model
1586
+ // reads this, and a coupon for every hotel had no scope here at all - so
1587
+ // "全部门店" went unsaid (0.51.0 eval).
1588
+ ...(m.coupons
1589
+ ? { coupons: m.coupons.map((c) => ({ ...couponForCard(c), said: couponTerms(c, brand) })) }
1590
+ : {}),
1591
+ });
1526
1592
  /** A market's group by its id or its name, as a guest or a model would say it. */
1527
1593
  /** How many stays that failed are each given alternatives - each is a search. */
1528
1594
  const MAX_FAILED_WITH_ALTERNATIVES = 3;
@@ -1779,7 +1845,22 @@ const madeForCard = (deps, b) => {
1779
1845
  const { confirmationCode: _unconfirmed, ...rest } = card;
1780
1846
  return { ...rest, pending: true };
1781
1847
  };
1782
- const bookedLine = (deps, b) => {
1848
+ const bookedLine = (deps, b) => [heldLine(deps, b), couponPointsLine(b)].filter(Boolean).join(' ');
1849
+ /**
1850
+ * The coupon a booking used and the points it should earn - on the booking
1851
+ * itself, so the confirmation says what the review said (§4, §5).
1852
+ */
1853
+ const couponPointsLine = (b) => [
1854
+ b.quote.coupon
1855
+ ? `Coupon ${couponName(b.quote.coupon)} used: −${formatMoney(b.quote.coupon.off)}, and ${formatMoney(b.quote.total)} is the booking's total after it.`
1856
+ : '',
1857
+ b.quote.member
1858
+ ? `About ${b.quote.member.points.toLocaleString('en-GB')} ${b.quote.member.programme} points after the stay - not added to the balance here.`
1859
+ : '',
1860
+ ]
1861
+ .filter(Boolean)
1862
+ .join(' ');
1863
+ const heldLine = (deps, b) => {
1783
1864
  const paid = paidLine(deps, b);
1784
1865
  if (paid)
1785
1866
  return paid;
@@ -1875,6 +1956,35 @@ const langOf = (deps, _key) => deps.lang;
1875
1956
  */
1876
1957
  const freeUntilText = (r) => r.freeCancellationLocal ??
1877
1958
  `${(r.freeCancellationUntil ?? '').slice(0, 16).replace('T', ' ')} UTC`;
1959
+ /**
1960
+ * What a signed-in member's prices are, said once at the top of a hotel's
1961
+ * rooms - with the cheapest one's public and member figures, so "member rate"
1962
+ * is a number the guest can check (10-30: the rooms were at the member rate
1963
+ * and nothing told the model so).
1964
+ */
1965
+ const memberRatesOf = (rooms, roomsWanted) => {
1966
+ const priced = rooms.filter((r) => r.memberRate && r.publicStayTotal);
1967
+ const cheapest = priced.reduce((a, r) => (!a || r.stayTotal.minor < a.stayTotal.minor ? r : a), undefined);
1968
+ const m = cheapest?.memberRate;
1969
+ if (!cheapest?.publicStayTotal || !m)
1970
+ return undefined;
1971
+ const n = Math.max(1, roomsWanted);
1972
+ return {
1973
+ programme: m.programme,
1974
+ tier: m.tier,
1975
+ discount: m.discount,
1976
+ said: `Prices here are the ${m.programme} ${m.tier} member rate, ${Math.round(m.discount * 100)}% below the public rate - the cheapest, ${cheapest.name}, is ${formatMoney(multiplyMoney(cheapest.publicStayTotal, n))} at the public rate and ${formatMoney(multiplyMoney(cheapest.stayTotal, n))} at the member rate for the stay${n > 1 ? ` (${n} rooms)` : ''}.`,
1977
+ };
1978
+ };
1979
+ /** A held coupon for the card: its amounts as text, like every amount the card is given. */
1980
+ const couponForCard = (c) => ({
1981
+ ...c,
1982
+ ...(c.amountOff ? { amountOff: formatMoney(c.amountOff) } : {}),
1983
+ ...(c.maxOff ? { maxOff: formatMoney(c.maxOff) } : {}),
1984
+ ...(c.minSpend ? { minSpend: formatMoney(c.minSpend) } : {}),
1985
+ ...(c.off ? { off: formatMoney(c.off) } : {}),
1986
+ ...(c.spend ? { spend: formatMoney(c.spend) } : {}),
1987
+ });
1878
1988
  /** One line of a trip, flattened for the card. Prices are strings: the card never does arithmetic. */
1879
1989
  const quoteForCard = (quote, groupOf) => ({
1880
1990
  lines: quote.lines.map((l) => ({
@@ -1925,11 +2035,114 @@ const quoteForCard = (quote, groupOf) => ({
1925
2035
  // the quote's block is a different shape (a tier NAME, a saving, points).
1926
2036
  // Two shapes on one key is how a card reads `member.tier.name` off a string.
1927
2037
  ...(quote.member
1928
- ? { memberSaving: { ...quote.member, saved: formatMoney(quote.member.saved) } }
2038
+ ? {
2039
+ memberSaving: {
2040
+ ...quote.member,
2041
+ saved: formatMoney(quote.member.saved),
2042
+ // The review's own order - public, member, coupon, total - each
2043
+ // figure the server's, so the card adds nothing up (10-30, ①).
2044
+ publicTotal: formatMoney(quote.member.publicTotal),
2045
+ memberTotal: formatMoney(money(quote.member.publicTotal.minor - quote.member.saved.minor, quote.total.currency)),
2046
+ },
2047
+ }
2048
+ : {}),
2049
+ ...(quote.coupon ? { coupon: { ...quote.coupon, off: formatMoney(quote.coupon.off) } } : {}),
2050
+ ...(quote.couponRefused
2051
+ ? {
2052
+ couponRefused: {
2053
+ ...quote.couponRefused,
2054
+ ...(quote.couponRefused.minSpend
2055
+ ? { minSpend: formatMoney(quote.couponRefused.minSpend) }
2056
+ : {}),
2057
+ ...(quote.couponRefused.spend ? { spend: formatMoney(quote.couponRefused.spend) } : {}),
2058
+ },
2059
+ }
1929
2060
  : {}),
2061
+ ...(quote.coupons ? { coupons: quote.coupons.map(couponForCard) } : {}),
1930
2062
  ...(quote.stay ? { stay: quote.stay } : {}),
1931
2063
  ...(quote.dropped ? { dropped: quote.dropped } : {}),
1932
2064
  });
2065
+ /**
2066
+ * A coupon's name as a guest of either language reads it: the programme's
2067
+ * Chinese beside its English - "满500减50 (¥50 off a stay of ¥500)".
2068
+ */
2069
+ const couponName = (c) => c.names?.zh && c.names.zh !== c.name ? `${c.names.zh} (${c.name})` : c.name;
2070
+ /** "A, B or C". */
2071
+ const listOrWords = (xs) => xs.length > 1 ? `${xs.slice(0, -1).join(', ')} or ${xs.at(-1)}` : (xs[0] ?? '');
2072
+ /** What a held coupon is - what it takes off, where, until when - and where it stands. */
2073
+ const couponTerms = (c, brand) => [
2074
+ c.kind === 'amount-off'
2075
+ ? `${c.amountOff ? formatMoney(c.amountOff) : ''} off${c.minSpend ? ` a stay of ${formatMoney(c.minSpend)} or more` : ', no minimum'}`
2076
+ : `${Math.round((c.percentOff ?? 0) * 100)}% off${c.maxOff ? `, at most ${formatMoney(c.maxOff)}` : ''}${c.minSpend ? `, on a stay of ${formatMoney(c.minSpend)} or more` : ''}`,
2077
+ c.hotels?.length
2078
+ ? `only at ${listOrWords(c.hotels.map((h) => h.name))}`
2079
+ : c.brands?.length
2080
+ ? `only at ${listOrWords(c.brands.map((b) => b.name))} hotels`
2081
+ : `at every ${brand} hotel`,
2082
+ c.state === 'expired'
2083
+ ? `ran out on ${c.validUntil} - cannot be used`
2084
+ : c.state === 'used'
2085
+ ? 'already used on a booking'
2086
+ : c.state === 'not-yet'
2087
+ ? `usable from ${c.validFrom} to ${c.validUntil}`
2088
+ : `until ${c.validUntil}`,
2089
+ ].join(' · ');
2090
+ /** What cancelling does to the coupon a booking used. */
2091
+ const couponBackWords = (c) => c.returned
2092
+ ? `The coupon ${couponName(c)} it used comes back to the member on cancelling (usable until ${c.validUntil}).`
2093
+ : `The coupon ${couponName(c)} it used ran out on ${c.validUntil}, so cancelling does not give it back.`;
2094
+ /**
2095
+ * Said wherever points are: they are shown and earned, never spent here -
2096
+ * nobody has published what a point is worth (hotel-connectors.md §5).
2097
+ */
2098
+ const POINTS_DO_NOT_PAY = 'Points cannot pay for a room here, not even in part: what a point is worth has not been published, so give no rate for them.';
2099
+ /** The rule a coupon is used by, said once where coupons are listed. */
2100
+ const COUPON_RULE = 'One coupon per booking, taken off after the member rate. Use one only when the guest names it or agrees to it - using it spends it - by passing its id as `coupon` to the trip view (to price it) and to the booking.';
2101
+ /** A signed-in member's coupons and points, for the sign-in answer - see `memberCouponsSaid`. */
2102
+ const memberSaid = (m, brand) => [
2103
+ `Points: ${m.points.toLocaleString('en-GB')}.${m.pointsExpiring ? ` ${m.pointsExpiring.points.toLocaleString('en-GB')} of them lapse on ${m.pointsExpiring.on}.` : ''} ${POINTS_DO_NOT_PAY}`,
2104
+ ...(m.coupons
2105
+ ? m.coupons.length
2106
+ ? [
2107
+ `Coupons held (${m.coupons.length}): ${COUPON_RULE}`,
2108
+ // The terms say it in English; the name is the programme's own.
2109
+ ...m.coupons.map((c) => `- ${c.names?.zh ?? c.name} · ${couponTerms(c, brand)} · id \`${c.id}\``),
2110
+ ]
2111
+ : ['No coupons are held on this membership.']
2112
+ : []),
2113
+ ];
2114
+ /**
2115
+ * What membership did to this trip, in the review's own order: the public
2116
+ * rate, the member rate, the coupon, what is paid - the same three figures the
2117
+ * card shows (`memberSaving`), so text and structure agree.
2118
+ */
2119
+ const memberTripLines = (q) => {
2120
+ const m = q.member;
2121
+ if (!m)
2122
+ return [];
2123
+ const cur = q.total.currency;
2124
+ const memberTotal = money(m.publicTotal.minor - m.saved.minor, cur);
2125
+ const atHotel = !q.lines.some((l) => l.payment === 'prepaid');
2126
+ const stays = new Set(q.lines.flatMap((l) => (l.kind === 'room' ? [`${l.hotelId}|${l.stay?.checkIn}`] : []))).size;
2127
+ return [
2128
+ `${m.programme} ${m.tier} member rate: public rate ${formatMoney(m.publicTotal)} → member rate ${formatMoney(memberTotal)} (${formatMoney(m.saved)} less)${q.coupon
2129
+ ? ` → coupon ${couponName(q.coupon)} −${formatMoney(q.coupon.off)}${stays > 1 ? ` (on the stay at ${q.coupon.hotelName})` : ''}`
2130
+ : ''} → **${atHotel ? 'to pay at the hotel' : 'to pay'} ${formatMoney(q.total)}**. Earns about ${m.points.toLocaleString('en-GB')} points${q.coupon ? ', on the price after the coupon' : ''}.`,
2131
+ q.couponRefused
2132
+ ? `The coupon ${q.couponRefused.name ? couponName({ name: q.couponRefused.name, names: q.couponRefused.names }) : q.couponRefused.id} was NOT taken off: ${q.couponRefused.why} The total is without it.`
2133
+ : '',
2134
+ ...(q.coupons?.length
2135
+ ? [
2136
+ `The member's coupons for this trip - ${COUPON_RULE}`,
2137
+ ...q.coupons.map((c) => `- ${couponName(c)} (\`${c.id}\`): ${q.coupon?.id === c.id
2138
+ ? 'taken off above'
2139
+ : c.off
2140
+ ? `would take ${formatMoney(c.off)} off${stays > 1 && c.hotelName ? ` (the stay at ${c.hotelName})` : ''}`
2141
+ : `cannot be used here: ${c.why ?? 'it does not apply.'}`}`),
2142
+ ]
2143
+ : []),
2144
+ ].filter(Boolean);
2145
+ };
1933
2146
  const tripText = (quote, demo) => {
1934
2147
  if (quote.lines.length === 0)
1935
2148
  return 'The trip is empty.';
@@ -1938,7 +2151,8 @@ const tripText = (quote, demo) => {
1938
2151
  : `- ${l.ordered ?? l.quantity}× ${l.title}${l.ordered !== undefined && l.quantity !== undefined && l.quantity !== l.ordered ? ` (${l.quantity} in all, over the stay)` : ''} — ${formatMoney(l.total)}`);
1939
2152
  return [
1940
2153
  ...lines,
1941
- `Taxes and fees ${quote.taxesIncluded ? 'included in the prices' : formatMoney(quote.taxesAndFees)} · **Total ${formatMoney(quote.total)}**`,
2154
+ `Taxes and fees ${quote.taxesIncluded ? 'included in the prices' : formatMoney(quote.taxesAndFees)}${quote.coupon ? ` · coupon −${formatMoney(quote.coupon.off)}` : ''} · **Total ${formatMoney(quote.total)}**`,
2155
+ ...memberTripLines(quote),
1942
2156
  ...(quote.dropped ?? []).map((d) => `Left out: ${d.title} — ${d.why}`),
1943
2157
  demo ? DEMO_NOTE : '',
1944
2158
  ]
@@ -2344,6 +2558,13 @@ const bookingLine = (b) => {
2344
2558
  ? 'unconfirmed: whether it was made is not known yet, and a person is confirming it'
2345
2559
  : b.status}`,
2346
2560
  kinds.length ? ` · asked for ${kinds.map(bare).join(', ')}` : '',
2561
+ b.quote.coupon
2562
+ ? ` · coupon ${couponName(b.quote.coupon)} −${formatMoney(b.quote.coupon.off)}${b.couponReturned === true
2563
+ ? ' (given back)'
2564
+ : b.couponReturned === false
2565
+ ? ' (ran out - not given back)'
2566
+ : ''}`
2567
+ : '',
2347
2568
  b.payment
2348
2569
  ? ` · ${paymentWords(b)}${b.payment.status === 'awaiting' && b.payment.url ? ` by ${b.payment.payByLocal ?? b.payment.payBy}: ${b.payment.url}` : ''}`
2349
2570
  : '',
@@ -2639,15 +2860,38 @@ const guarded = async (deps, ctx, who, code, run) => {
2639
2860
  * read as the hotel's. Now it is two quotes, drawn side by side and never
2640
2861
  * added up; each hotel is booked, and paid, in its own.
2641
2862
  */
2642
- const quoteParts = async (deps, lines, who) => {
2863
+ const quoteParts = async (deps, lines, who, coupon) => {
2643
2864
  const byCurrency = new Map();
2644
2865
  for (const l of lines) {
2645
2866
  const c = (await deps.backend.getHotel(l.hotelId))?.hotel.currency ?? '';
2646
2867
  byCurrency.set(c, [...(byCurrency.get(c) ?? []), l]);
2647
2868
  }
2869
+ const asked = coupon ? { coupon } : {};
2648
2870
  if (byCurrency.size === 0)
2649
- return [await deps.backend.quote({ lines: [] }, who)];
2650
- return Promise.all([...byCurrency.values()].map((g) => deps.backend.quote({ lines: g }, who)));
2871
+ return [await deps.backend.quote({ lines: [], ...asked }, who)];
2872
+ const parts = await Promise.all([...byCurrency.values()].map((g) => deps.backend.quote({ lines: g, ...asked }, who)));
2873
+ // A coupon goes on one part of a trip in two currencies: the others need
2874
+ // not say it is in the wrong one.
2875
+ return parts.some((p) => p.coupon) ? parts.map(({ couponRefused: _no, ...p }) => p) : parts;
2876
+ };
2877
+ /** The coupon a call names - only where the backend takes coupons (`capabilities.coupons`). */
2878
+ const couponOf = (deps, args) => (deps.backend.capabilities ?? FULL_CAPABILITIES).coupons
2879
+ ? str(args.coupon) || undefined
2880
+ : undefined;
2881
+ /**
2882
+ * The signed-in member as the backend has them now - their coupons spent or
2883
+ * handed back since they signed in. Every answer's badge reads the store, so
2884
+ * the card's member page is never a booking behind.
2885
+ */
2886
+ const refreshMember = async (deps, key) => {
2887
+ if (!(deps.backend.capabilities ?? FULL_CAPABILITIES).coupons)
2888
+ return;
2889
+ const m = deps.members.read(key);
2890
+ if (!m)
2891
+ return;
2892
+ const now = await deps.backend.signInMember(m.number, m.surname);
2893
+ if (now)
2894
+ deps.members.set(key, { ...now, ...(m.preferences ? { preferences: m.preferences } : {}) });
2651
2895
  };
2652
2896
  /** The trip, priced, as both views need it. */
2653
2897
  /**
@@ -2688,9 +2932,11 @@ const needsRoomIn = (t, lines, parts) => {
2688
2932
  * `store`) the picked room's slot. `key` stays the conversation's trip key: it
2689
2933
  * is the one `common` reads, so a header's trip count never counts a pick.
2690
2934
  */
2691
- const currentTrip = async (deps, key, who, store = key) => {
2935
+ const currentTrip = async (deps, key, who, store = key, coupon) => {
2936
+ await refreshMember(deps, key);
2937
+ const member = deps.members.read(key);
2692
2938
  const lines = deps.trips.read(store);
2693
- const parts = await quoteParts(deps, lines, who);
2939
+ const parts = await quoteParts(deps, lines, who && member ? { ...who, member } : who, coupon);
2694
2940
  // ⚠️ WHAT THE GUEST IS SHOWN IS WHAT IS BOOKED - see `ShownQuote`. Every
2695
2941
  // priced trip answer goes through here: add_to_trip, view_trip, and the
2696
2942
  // card's own trip view.
@@ -3449,7 +3695,7 @@ const putRoomLine = async (deps, t, ctx, key, store, args, detail) => {
3449
3695
  : !!asked && l.stay.checkIn === asked.checkIn && l.stay.checkOut === asked.checkOut));
3450
3696
  if (held) {
3451
3697
  deps.trips.set(store, unset(held));
3452
- return currentTrip(deps, key, who, store);
3698
+ return currentTrip(deps, key, who, store, couponOf(deps, args));
3453
3699
  }
3454
3700
  }
3455
3701
  const today = (startTime || !asStay(args)
@@ -3509,7 +3755,7 @@ const putRoomLine = async (deps, t, ctx, key, store, args, detail) => {
3509
3755
  hours: hourlyRoom.hourly.hours,
3510
3756
  },
3511
3757
  });
3512
- return currentTrip(deps, key, who, store);
3758
+ return currentTrip(deps, key, who, store, couponOf(deps, args));
3513
3759
  }
3514
3760
  const stay = asStay(args);
3515
3761
  // ⚠️ NO DATES, NO LINE. Guessing "tonight" or "this weekend" here is how a
@@ -3655,7 +3901,7 @@ const putRoomLine = async (deps, t, ctx, key, store, args, detail) => {
3655
3901
  stay,
3656
3902
  rooms: clampCount(args.rooms ?? 1, MAX_ROOMS_PER_LINE),
3657
3903
  });
3658
- const now = await currentTrip(deps, key, identity(deps, ctx, key), store);
3904
+ const now = await currentTrip(deps, key, identity(deps, ctx, key), store, couponOf(deps, args));
3659
3905
  // Which rate was taken, in the structure too and in the same words - see
3660
3906
  // the itinerary's `nudges` (2026-10-27 audit, F11).
3661
3907
  return took
@@ -3742,7 +3988,14 @@ const bookTripLines = async (deps, t, ctx, key, store, args, nothingThere) => {
3742
3988
  const { requests, dropped } = readRequests(args);
3743
3989
  try {
3744
3990
  const shown = deps.trips.shown(store);
3745
- const { booked: made, failed, repeat, reused, } = await deps.backend.requestBooking({ lines, ...(shown ? { shown } : {}) }, {
3991
+ const { booked: made, failed, repeat, reused, } = await deps.backend.requestBooking(
3992
+ // The coupon the guest named, spent on the one stay it goes on - or
3993
+ // nothing is booked (see `TripDraft.coupon`).
3994
+ {
3995
+ lines,
3996
+ ...(shown ? { shown } : {}),
3997
+ ...(couponOf(deps, args) ? { coupon: couponOf(deps, args) } : {}),
3998
+ }, {
3746
3999
  ...guest,
3747
4000
  ...(str(args.phone) ? { phone: str(args.phone) } : {}),
3748
4001
  ...(requests?.note ? { notes: requests.note } : {}),
@@ -3756,6 +4009,8 @@ const bookTripLines = async (deps, t, ctx, key, store, args, nothingThere) => {
3756
4009
  // only place an Identity is built; the guest's email still overrides,
3757
4010
  // because a booking is found by the address it was made with.
3758
4011
  { ...identity(deps, ctx, key), email: guest.email }, requests);
4012
+ // A coupon spent is spent on the member's badge too.
4013
+ await refreshMember(deps, key);
3759
4014
  // ⚠️ WHAT WAS BOOKED LEAVES THE TRIP; WHAT WAS NOT STAYS IN IT, add-ons
3760
4015
  // and all - to ask again, or to put another hotel in its place (robin,
3761
4016
  // 2026-10-14).
@@ -3962,6 +4217,16 @@ const bookTripLines = async (deps, t, ctx, key, store, args, nothingThere) => {
3962
4217
  const again = await currentTrip(deps, key, identity(deps, ctx, key), store);
3963
4218
  return { ...again, text: [words, again.text].join('\n\n') };
3964
4219
  }
4220
+ // The coupon asked for cannot be used: nothing was booked, and the trip
4221
+ // is shown again with it refused and why - the guest decides what next.
4222
+ if (err.code === 'coupon') {
4223
+ const again = await currentTrip(deps, key, identity(deps, ctx, key), store, couponOf(deps, args));
4224
+ return {
4225
+ ...again,
4226
+ structured: { ...again.structured, notBooked: 'coupon' },
4227
+ text: [words, again.text].join('\n\n'),
4228
+ };
4229
+ }
3965
4230
  if (err.code === 'mixed-supplier') {
3966
4231
  const again = await currentTrip(deps, key, identity(deps, ctx, key), store);
3967
4232
  return {
@@ -4657,6 +4922,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
4657
4922
  : {}),
4658
4923
  ...(r.freeCancellationUntil ? { freeCancellationLocal: freeUntilText(r) } : {}),
4659
4924
  }));
4925
+ const memberRates = memberRatesOf(enough, roomsWanted);
4660
4926
  return {
4661
4927
  structured: {
4662
4928
  view: 'hotel',
@@ -4669,9 +4935,11 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
4669
4935
  // Types with no room to book for these nights, said and not left out.
4670
4936
  ...(byTheHour || !soldOut.length ? {} : { soldOut }),
4671
4937
  ...(notSold ? { notSoldHere: notSold } : {}),
4938
+ ...(memberRates ? { memberRates } : {}),
4672
4939
  ...flex.structured,
4673
4940
  },
4674
4941
  text: [
4942
+ memberRates?.said ?? '',
4675
4943
  ...roomsText(h.name, byTheHour
4676
4944
  ? (rs) => `${rs.length} ${rs.length === 1 ? 'room' : 'rooms'} by the hour for today, ${stay.checkIn}, ${stay.guests} guests`
4677
4945
  : (rs) => `${offerCount(rs)} for ${stay.checkIn} to ${stay.checkOut}, ${stay.guests} guests${roomsWanted > 1 ? `, ${roomsWanted} rooms` : ''}`, byTheHour ? `today, ${stay.checkIn}` : `${stay.checkIn} to ${stay.checkOut}`, enough, roomsWanted, terms),
@@ -4955,7 +5223,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
4955
5223
  return direct(await putRoomLine(deps, t, ctx, key, slot, args, found.detail));
4956
5224
  }
4957
5225
  if (name === t.viewTrip)
4958
- return currentTrip(deps, key, identity(deps, ctx, key));
5226
+ return currentTrip(deps, key, identity(deps, ctx, key), key, couponOf(deps, args));
4959
5227
  if (name === t.clearTrip) {
4960
5228
  deps.trips.clear(key);
4961
5229
  return {
@@ -5367,10 +5635,15 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5367
5635
  }
5368
5636
  : {}),
5369
5637
  toolToConfirm: t.cancelBooking,
5638
+ // Whether cancelling hands the coupon back - the same sentence as the text.
5639
+ ...(preview.coupon
5640
+ ? { coupon: { ...preview.coupon, said: couponBackWords(preview.coupon) } }
5641
+ : {}),
5370
5642
  },
5371
5643
  text: [
5372
5644
  `Cancelling ${preview.confirmationCode} would cost ${formatMoney(preview.fee)}${preview.fee.minor === 0 ? ' - nothing' : ''}.`,
5373
5645
  preview.rule,
5646
+ preview.coupon ? couponBackWords(preview.coupon) : '',
5374
5647
  preview.cancellable
5375
5648
  ? `Say the word and it is cancelled (${t.cancelBooking} with \`confirmed\`).`
5376
5649
  : '',
@@ -5405,6 +5678,8 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5405
5678
  const cancelled = await deps.backend.cancelBooking(ref, who, str(args.reason) || undefined);
5406
5679
  if (!cancelled)
5407
5680
  return asPreview;
5681
+ // A coupon handed back is back on the member's badge.
5682
+ await refreshMember(deps, key);
5408
5683
  // ⚠️ WHAT THE HOTEL SAYS IT CHARGED, once it has said. A group's own system
5409
5684
  // answers a cancellation with its penalty; the preview was our reading of
5410
5685
  // the rate's terms, and where the two differ the hotel's is the one that
@@ -5435,6 +5710,11 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5435
5710
  ? // Nobody charges a demonstration anything (2026-10-05 audit, P2).
5436
5711
  `${cancelled.confirmationCode} is cancelled. ${charged.minor === 0 ? 'No fee.' : `A real booking on this rate would be charged ${formatMoney(charged)}; this is a demonstration, and nothing is charged.`}`
5437
5712
  : `${cancelled.confirmationCode} is cancelled. ${charged.minor === 0 ? 'No fee.' : `The hotel charges ${formatMoney(charged)}.`}`,
5713
+ cancelled.quote.coupon && cancelled.couponReturned !== undefined
5714
+ ? cancelled.couponReturned
5715
+ ? `The coupon ${couponName(cancelled.quote.coupon)} is back with the member, usable until ${cancelled.quote.coupon.validUntil}.`
5716
+ : `The coupon ${couponName(cancelled.quote.coupon)} ran out on ${cancelled.quote.coupon.validUntil}, so it is not given back.`
5717
+ : '',
5438
5718
  demoBookingNote(deps, cancelled.supplier),
5439
5719
  deps.vocab.demo ? noteFor(deps.vocab) : '',
5440
5720
  ]
@@ -5652,17 +5932,24 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5652
5932
  }
5653
5933
  deps.members.set(key, member);
5654
5934
  return {
5655
- structured: { view: 'member', ...common(deps, key), member },
5935
+ structured: {
5936
+ view: 'member',
5937
+ ...common(deps, key),
5938
+ member: memberForCard(member, deps.vocab.brand),
5939
+ },
5656
5940
  text: [
5657
- `Signed in as ${member.name} · ${member.programme} ${member.tier.name} · ${member.points.toLocaleString('en-GB')} points.`,
5941
+ `Signed in as ${member.name}${member.names?.zh ? ` (${member.names.zh})` : ''} · ${member.programme} ${member.tier.name} · ${member.points.toLocaleString('en-GB')} points.`,
5658
5942
  `Rates now show the ${member.tier.name} member rate, ${Math.round(member.tier.discount * 100)}% off the public one.`,
5943
+ ...((deps.backend.capabilities ?? FULL_CAPABILITIES).coupons
5944
+ ? memberSaid(member, deps.vocab.brand)
5945
+ : [POINTS_DO_NOT_PAY]),
5659
5946
  member.preferences?.length
5660
5947
  ? `Saved preferences on this membership: ${member.preferences.map((k) => REQUEST_WORDS[k]).join(', ')}. They are ticked on the booking form; mention them, and ask before booking with them.`
5661
5948
  : '',
5662
5949
  deps.vocab.demo ? noteFor(deps.vocab) : '',
5663
5950
  ]
5664
5951
  .filter(Boolean)
5665
- .join(' '),
5952
+ .join('\n'),
5666
5953
  };
5667
5954
  }
5668
5955
  if (name === t.signOut) {
@@ -5688,7 +5975,11 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5688
5975
  deps.members.set(key, member);
5689
5976
  deps.members.setPreferences(m.number, []);
5690
5977
  return {
5691
- structured: { view: 'member', ...common(deps, key), member },
5978
+ structured: {
5979
+ view: 'member',
5980
+ ...common(deps, key),
5981
+ member: memberForCard(member, deps.vocab.brand),
5982
+ },
5692
5983
  text: `Forgot the room preferences saved on ${m.programme} ${m.tier.name}. Bookings already made keep what they asked for.`,
5693
5984
  };
5694
5985
  }