@curless/hotel-storefront 0.52.1 → 0.53.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
@@ -1,4 +1,4 @@
1
- import { DEFAULT_PAGE, FULL_CAPABILITIES, LASTING_REQUESTS, MAX_PAGE, REQUEST_KINDS, REQUEST_NOTE_MAX, guestTooLong, unitWords, } from './backend.js';
1
+ import { DEFAULT_PAGE, FULL_CAPABILITIES, LASTING_REQUESTS, MAX_PAGE, REQUEST_KINDS, REQUEST_NOTE_MAX, guestTooLong, notPricedForNoAnswer, unansweredBy, unitWords, } from './backend.js';
2
2
  import { MAX_GUESTS, addDays, isDay, nights as nightsBetween } from './dates.js';
3
3
  import { FACILITIES } from './facilities.js';
4
4
  import { DESCRIPTION_MAX, POLICY_MAX, hotelName, hotelText } from './fence.js';
@@ -1288,6 +1288,8 @@ const BOOKING_SCHEMA = {
1288
1288
  again: OBJ,
1289
1289
  /** Of `bookings`, the ids asked for minutes before, when the others are new (`BookingResult.reused`). */
1290
1290
  repeated: STRS,
1291
+ /** The coupon asked for was not used: the booking returned was made before, without it (`couponNotUsedOf`). */
1292
+ couponNotUsed: OBJ,
1291
1293
  /** `trip`, drawn again: see `stampOf`. */
1292
1294
  stamp: STR,
1293
1295
  /** The trip changed after the card that sent the form was drawn, so nothing was booked (`stampOf`). */
@@ -1730,6 +1732,11 @@ const holderOf = (deps, supplier) => {
1730
1732
  */
1731
1733
  const bookingForCard = (b) => ({
1732
1734
  ...b,
1735
+ // A booking cancelled before the reason was kept: a coupon not returned could
1736
+ // then only have run out (C10) - said so, as the text says it.
1737
+ ...(b.couponReturned === false && !b.couponNotReturnedBecause
1738
+ ? { couponNotReturnedBecause: 'expired' }
1739
+ : {}),
1733
1740
  quote: quoteForCard(b.quote),
1734
1741
  // What a cancelled booking was charged to cancel, as text like every amount the
1735
1742
  // card is given: "Cancelled" in My bookings says it beside the struck-out total
@@ -1908,10 +1915,33 @@ const heldLine = (deps, b) => {
1908
1915
  : `${b.holdUntil?.slice(0, 16).replace('T', ' ')} UTC`;
1909
1916
  // ⚠️ NOT "PRESENTS A CARD AT CHECK-IN" (2026-10-05 audit, P2): a demo
1910
1917
  // booking has no check-in, and saying so sent a guest towards a real desk.
1918
+ //
1919
+ // ⚠️ AND NOT A CLOCK TIME (2026-11-03 review, T2). "lapses on its own at Tue 3
1920
+ // Nov, 11:39" was told as 「今天 11:41 自动失效」 - the booking void today, when
1921
+ // the stay is weeks away. A demonstration hold lapses by itself and means
1922
+ // nothing for a real stay; said as how long it lasts, and that it is only the
1923
+ // demonstration.
1911
1924
  if (h.demo)
1912
- return `${total} all in. **${demoBookingWords(h)}** Nothing has been charged and no card is needed. The demonstration hold lapses on its own at ${lapses}.`;
1925
+ return `${total} all in. **${demoBookingWords(h)}** Nothing has been charged and no card is needed. ${demoHoldWords(b, deps.trips.now())}`;
1913
1926
  return `${total} all in. **Nothing has been charged** - the rooms are held until ${lapses}, and the guest presents a card at check-in.`;
1914
1927
  };
1928
+ /**
1929
+ * How long a demonstration hold lasts, as "in N minutes" from now - the hold
1930
+ * lapses by itself, and says nothing about the stay (T2, `heldLine`).
1931
+ */
1932
+ const demoHoldWords = (b, now) => {
1933
+ const minutes = b.holdUntil ? Math.ceil((Date.parse(b.holdUntil) - now) / 60_000) : Number.NaN;
1934
+ if (Number.isNaN(minutes))
1935
+ return '';
1936
+ const lasting = minutes <= 1
1937
+ ? 'in a minute'
1938
+ : minutes < 90
1939
+ ? `in ${minutes} minutes`
1940
+ : minutes < 48 * 60
1941
+ ? `in about ${Math.round(minutes / 60)} hours`
1942
+ : `in about ${Math.round(minutes / (24 * 60))} days`;
1943
+ return `This is a demonstration: it lapses by itself ${lasting} (demo only - no real room is held; the stay's own dates are not affected).`;
1944
+ };
1915
1945
  /** What a demonstration booking is, in one sentence - see `demoBookingNote`. */
1916
1946
  const demoBookingWords = (h) => `This is a demonstration booking: ${h.name} has not received it and is not holding any room.`;
1917
1947
  /**
@@ -2133,10 +2163,29 @@ const couponTerms = (c, brand) => [
2133
2163
  ? `usable from ${c.validFrom} to ${c.validUntil}`
2134
2164
  : `until ${c.validUntil}`,
2135
2165
  ].join(' · ');
2136
- /** What cancelling does to the coupon a booking used. */
2137
- const couponBackWords = (c) => c.returned
2138
- ? `The coupon ${couponName(c)} it used comes back to the member on cancelling (usable until ${c.validUntil}).`
2139
- : `The coupon ${couponName(c)} it used ran out on ${c.validUntil}, so cancelling does not give it back.`;
2166
+ /**
2167
+ * What cancelling does to the coupon a booking used - and why.
2168
+ *
2169
+ * ⚠️ BACK ONLY FOR A FREE CANCELLATION IN THE COUPON'S DATES (robin,
2170
+ * 2026-11-03, C10). A cancellation that charges a fee counts the coupon as
2171
+ * used: it already took money off a stay that is being paid for. The reason is
2172
+ * said in the words (`fee` / `expired`) and as the same code in the structure
2173
+ * (`notReturnedBecause`), so a host that draws no card has it too. `fee` is the
2174
+ * charge as text, for the sentence.
2175
+ */
2176
+ const couponBackWords = (c, fee,
2177
+ /** Said after the cancellation, not before it. */
2178
+ done = false) => c.returned
2179
+ ? done
2180
+ ? `The coupon ${couponName(c)} is back with the member, usable until ${c.validUntil}: the cancellation was free.`
2181
+ : `The coupon ${couponName(c)} it used comes back to the member on cancelling, because the cancellation is free and the coupon is usable until ${c.validUntil}.`
2182
+ : c.notReturnedBecause === 'fee'
2183
+ ? done
2184
+ ? `The coupon ${couponName(c)} is NOT given back: this cancellation was charged a fee${fee ? ` (${fee})` : ''}, and a cancellation that is charged counts the coupon as used.`
2185
+ : `The coupon ${couponName(c)} it used does NOT come back: cancelling costs a fee${fee ? ` (${fee})` : ''}, and a cancellation that is charged counts the coupon as used.`
2186
+ : done
2187
+ ? `The coupon ${couponName(c)} ran out on ${c.validUntil}, so it is not given back.`
2188
+ : `The coupon ${couponName(c)} it used ran out on ${c.validUntil}, so cancelling does not give it back.`;
2140
2189
  /**
2141
2190
  * Said wherever points are: they are shown and earned, never spent here -
2142
2191
  * nobody has published what a point is worth (hotel-connectors.md §5).
@@ -2174,9 +2223,6 @@ const memberTripLines = (q) => {
2174
2223
  `${m.programme} ${m.tier} member rate: public rate ${formatMoney(m.publicTotal)} → member rate ${formatMoney(memberTotal)} (${formatMoney(m.saved)} less)${q.coupon
2175
2224
  ? ` → coupon ${couponName(q.coupon)} −${formatMoney(q.coupon.off)}${stays > 1 ? ` (on the stay at ${q.coupon.hotelName})` : ''}`
2176
2225
  : ''} → **${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' : ''}.`,
2177
- q.couponRefused
2178
- ? `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.`
2179
- : '',
2180
2226
  ...(q.coupons?.length
2181
2227
  ? [
2182
2228
  `The member's coupons for this trip - ${COUPON_RULE}`,
@@ -2189,7 +2235,26 @@ const memberTripLines = (q) => {
2189
2235
  : []),
2190
2236
  ].filter(Boolean);
2191
2237
  };
2238
+ /**
2239
+ * The coupon asked for that was not taken off, and why.
2240
+ *
2241
+ * ⚠️ SAID WHENEVER THERE IS ONE, SIGNED IN OR NOT (2026-11-03 review, C2). The
2242
+ * sentence lived in `memberTripLines`, which says nothing without a member - so
2243
+ * a coupon refused because nobody is signed in (`signed-out`) was in
2244
+ * `structuredContent.couponRefused` and not in the words, and a host that
2245
+ * hands its model the text read "Total ¥1,200" and could say it was the price
2246
+ * with the coupon.
2247
+ */
2248
+ const couponRefusedLine = (q) => q.couponRefused
2249
+ ? `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.`
2250
+ : '';
2192
2251
  const tripText = (quote, demo, noCards) => {
2252
+ // ⚠️ NOT "EMPTY" WHEN SOMETHING WAS LEFT OUT: a trip whose only group did not
2253
+ // answer is not an empty trip (2026-11-03 review, C5).
2254
+ if (quote.lines.length === 0 && quote.dropped?.length)
2255
+ return [...quote.dropped.map((d) => `Left out: ${d.title} — ${d.why}`), demo ? DEMO_NOTE : '']
2256
+ .filter(Boolean)
2257
+ .join('\n');
2193
2258
  if (quote.lines.length === 0)
2194
2259
  return 'The trip is empty.';
2195
2260
  const lines = quote.lines.map((l) => l.kind === 'room'
@@ -2198,6 +2263,7 @@ const tripText = (quote, demo, noCards) => {
2198
2263
  return [
2199
2264
  ...lines,
2200
2265
  `Taxes and fees ${quote.taxesIncluded ? 'included in the prices' : formatMoney(quote.taxesAndFees)}${quote.coupon ? ` · coupon −${formatMoney(quote.coupon.off)}` : ''} · **Total ${formatMoney(quote.total)}**`,
2266
+ couponRefusedLine(quote),
2201
2267
  ...memberTripLines(quote),
2202
2268
  ...(quote.dropped ?? []).map((d) => `Left out: ${d.title} — ${d.why}`),
2203
2269
  demo ? DEMO_NOTE : '',
@@ -2645,7 +2711,9 @@ const bookingLine = (b, noCards) => {
2645
2711
  ? ` · coupon ${couponName(b.quote.coupon)} −${formatMoney(b.quote.coupon.off)}${b.couponReturned === true
2646
2712
  ? ' (given back)'
2647
2713
  : b.couponReturned === false
2648
- ? ' (ran out - not given back)'
2714
+ ? b.couponNotReturnedBecause === 'fee'
2715
+ ? ' (not given back - the cancellation was charged a fee)'
2716
+ : ' (ran out - not given back)'
2649
2717
  : ''}`
2650
2718
  : '',
2651
2719
  b.payment
@@ -2851,12 +2919,14 @@ const listAnd = (xs) => xs.length > 1 ? `${xs.slice(0, -1).join(', ')} and ${xs.
2851
2919
  * hotel. The sentence says the first and denies the second, in the text and in
2852
2920
  * `structuredContent` alike - a host with no card has only the words.
2853
2921
  */
2854
- const degradedOf = (deps, answer) => {
2922
+ const degradedOf = (deps, answer,
2923
+ /** What is missing from the list: the hotels of a search, the bookings of "my bookings". */
2924
+ noun = deps.vocab.nounPlural) => {
2855
2925
  const down = answer.degraded ?? [];
2856
2926
  if (down.length === 0)
2857
2927
  return { structured: {}, said: '' };
2858
2928
  const one = down.length === 1;
2859
- const said = `${listAnd(down.map((d) => d.name))} did not answer in time, so none of ${one ? 'its' : 'their'} ${deps.vocab.nounPlural} is in this list - this does not mean ${one ? 'it has' : 'they have'} none here; asking again in a moment may bring them.`;
2929
+ const said = `${listAnd(down.map((d) => d.name))} did not answer in time, so none of ${one ? 'its' : 'their'} ${noun} is in this list - this does not mean ${one ? 'it has' : 'they have'} none here; asking again in a moment may bring them.`;
2860
2930
  return {
2861
2931
  structured: {
2862
2932
  degraded: down.map((d) => ({ supplier: d.supplier, name: d.name })),
@@ -2865,6 +2935,37 @@ const degradedOf = (deps, answer) => {
2865
2935
  said,
2866
2936
  };
2867
2937
  };
2938
+ /**
2939
+ * What is said when a read could not be had because hotel groups did not answer
2940
+ * in time - one group, or several, or every one.
2941
+ *
2942
+ * ⚠️ NEVER JUST THE FIRST GROUP, NEVER "NONE HERE" (2026-11-03 review, C5/C8).
2943
+ * Where every group of a market is silent it says so ("no group answered this
2944
+ * time"), not the name of whichever was asked first; and in every case it
2945
+ * denies the reading a guest would make of silence, that there is nothing.
2946
+ */
2947
+ const notAnswered = (deps, key, down) => {
2948
+ const groups = deps.backend.suppliers?.().length ?? 0;
2949
+ const names = listAnd(down.map((d) => d.name));
2950
+ const one = down.length === 1;
2951
+ const said = groups > 1 && down.length >= groups
2952
+ ? `No hotel group answered this time: ${names} did not answer in time - this does not mean there is nothing here; asking again in a moment may bring them.`
2953
+ : `${names} did not answer in time, so nothing from ${one ? 'it' : 'them'} could be read just now - this does not mean ${one ? 'it has' : 'they have'} none here; asking again in a moment may bring ${one ? 'it' : 'them'}.`;
2954
+ return {
2955
+ structured: {
2956
+ view: 'error',
2957
+ ...common(deps, key),
2958
+ error: 'supplier-timeout',
2959
+ detail: said,
2960
+ degraded: down.map((d) => ({ supplier: d.supplier, name: d.name })),
2961
+ degradedSaid: said,
2962
+ },
2963
+ text: said,
2964
+ // Like `bad-count`: the card still draws it, and a host that checks a
2965
+ // result against the tool's output schema does not hold an error to it.
2966
+ isError: true,
2967
+ };
2968
+ };
2868
2969
  /** The counts a call can carry, and what each may be. */
2869
2970
  const COUNTS = ['guests', 'rooms', 'quantity'];
2870
2971
  /** "2" is 2: a host that sends a count as a string has not asked for something else. */
@@ -2916,6 +3017,13 @@ const countProblem = (t, name, args) => {
2916
3017
  */
2917
3018
  const guestWords = (err, fault = 'That could not be done just now - nothing was changed. Try again in a moment.') => {
2918
3019
  const e = err;
3020
+ // ⚠️ A READ THAT TIMED OUT IS NOT A REFUSAL (2026-11-03 review, C5). After a
3021
+ // booking was made, "Atour did not answer within 8000 ms" would be read
3022
+ // back as the booking's own answer - and sound like it was not made.
3023
+ if (unansweredBy(err)) {
3024
+ console.error('[tools] a read timed out:', e.message);
3025
+ return fault;
3026
+ }
2919
3027
  if (e instanceof Error && (typeof e.code === 'string' || e.constructor === Error))
2920
3028
  return e.message;
2921
3029
  console.error('[tools] failed:', err);
@@ -2969,14 +3077,42 @@ const guarded = async (deps, ctx, who, code, run) => {
2969
3077
  */
2970
3078
  const quoteParts = async (deps, lines, who, coupon) => {
2971
3079
  const byCurrency = new Map();
3080
+ // ⚠️ A HOTEL WHOSE GROUP DID NOT ANSWER IS LEFT OUT OF THE PRICE, SAID (2026-11-03
3081
+ // review, C5): its currency cannot be read, so it cannot be priced with the
3082
+ // rest - and it is not an empty trip, nor a trip without it.
3083
+ const unread = new Map();
2972
3084
  for (const l of lines) {
2973
- const c = (await deps.backend.getHotel(l.hotelId))?.hotel.currency ?? '';
2974
- byCurrency.set(c, [...(byCurrency.get(c) ?? []), l]);
3085
+ try {
3086
+ const c = (await deps.backend.getHotel(l.hotelId))?.hotel.currency ?? '';
3087
+ byCurrency.set(c, [...(byCurrency.get(c) ?? []), l]);
3088
+ }
3089
+ catch (err) {
3090
+ const down = unansweredBy(err);
3091
+ if (!down)
3092
+ throw err;
3093
+ for (const g of down)
3094
+ unread.set(g.supplier, g);
3095
+ }
2975
3096
  }
2976
3097
  const asked = coupon ? { coupon } : {};
3098
+ if (byCurrency.size === 0 && unread.size) {
3099
+ throw Object.assign(new Error(`${listAnd([...unread.values()].map((g) => g.name))} did not answer`), { code: 'supplier-timeout', unanswered: [...unread.values()] });
3100
+ }
2977
3101
  if (byCurrency.size === 0)
2978
3102
  return [await deps.backend.quote({ lines: [], ...asked }, who)];
2979
- const parts = await Promise.all([...byCurrency.values()].map((g) => deps.backend.quote({ lines: g, ...asked }, who)));
3103
+ const priced = await Promise.all([...byCurrency.values()].map((g) => deps.backend.quote({ lines: g, ...asked }, who)));
3104
+ const parts = unread.size && priced[0]
3105
+ ? [
3106
+ {
3107
+ ...priced[0],
3108
+ dropped: [
3109
+ ...(priced[0].dropped ?? []),
3110
+ ...[...unread.values()].map(notPricedForNoAnswer),
3111
+ ],
3112
+ },
3113
+ ...priced.slice(1),
3114
+ ]
3115
+ : priced;
2980
3116
  // A coupon goes on one part of a trip in two currencies: the others need
2981
3117
  // not say it is in the wrong one.
2982
3118
  return parts.some((p) => p.coupon) ? parts.map(({ couponRefused: _no, ...p }) => p) : parts;
@@ -3434,7 +3570,17 @@ export const tripGone = (deps) => {
3434
3570
  };
3435
3571
  export const handleTool = async (deps, name, rawArgs, ctx) => {
3436
3572
  const { result, written } = await keepingAmounts(async () => {
3437
- const answer = await answerTool(deps, name, rawArgs, ctx);
3573
+ // ⚠️ A GROUP THAT DID NOT ANSWER A READ IS AN ANSWER, NOT A FAULT (2026-11-03
3574
+ // review, C5/C8): the hotel page, the add-ons, the dates, a booking looked
3575
+ // up. Thrown by the market past its time limit, it reached the host as "the
3576
+ // connector could not answer that" - or, for a search where every group was
3577
+ // silent, as the first group's name alone. Said here, naming every group.
3578
+ const answer = await answerTool(deps, name, rawArgs, ctx).catch((err) => {
3579
+ const down = unansweredBy(err);
3580
+ if (!down)
3581
+ throw err;
3582
+ return notAnswered(deps, ctx.tripKey ?? tripKey(ctx.session, deps.vocab.brand), down);
3583
+ });
3438
3584
  const suggestions = await suggestionsFor(deps, answer.structured).catch(() => []);
3439
3585
  return { answer, suggestions };
3440
3586
  });
@@ -3530,7 +3676,13 @@ const listedInWords = (h) => {
3530
3676
  const price = f
3531
3677
  ? `${formatMoney(f.total)} for the stay incl. taxes and fees, ${withZh(f.room, f.roomNames, true)}${f.rooms > 1 ? ` × ${f.rooms}` : ''}${terms.length ? ` [${terms.join(', ')}]` : ''}`
3532
3678
  : h.noRoomsForStay
3533
- ? 'no room for those nights'
3679
+ ? // ⚠️ NOT SOLD HERE IS NOT SOLD OUT (2026-11-03 review, U11): a hotel with no
3680
+ // rooms on sale through this connector was told as "no room for those
3681
+ // nights", and the model said "no vacancy tonight" - as if another night
3682
+ // would do. It says what the hotel's own page says.
3683
+ h.noRoomsForStayCode === 'none-on-sale'
3684
+ ? h.noRoomsForStay
3685
+ : 'no room for those nights'
3534
3686
  : h.fromNightly
3535
3687
  ? `from ${formatMoney(h.fromNightly)} ${nightWords(true)}`
3536
3688
  : '';
@@ -3763,6 +3915,57 @@ const LIVE_STATUSES = new Set([
3763
3915
  'unconfirmed',
3764
3916
  'awaiting-payment',
3765
3917
  ]);
3918
+ /**
3919
+ * A coupon asked for on a booking that was already made, without it - or
3920
+ * nothing.
3921
+ *
3922
+ * ⚠️ NEVER SILENT THAT THE COUPON WAS NOT USED (2026-11-03 review, C3). The
3923
+ * same stay asked for again within minutes is answered with the first
3924
+ * booking (`repeat`); a member who first booked without a coupon and then said
3925
+ * "use the 满500减50" was handed the first booking at its first price, with
3926
+ * nothing said of the coupon they had just agreed to. The coupon is NOT put in
3927
+ * the repeat key - that would book the stay twice - so the answer says it:
3928
+ * the booking stands as it was, the coupon was not spent, and what it takes to
3929
+ * use it is cancelling this one (free until when, or what it costs) and
3930
+ * booking again. In the structure as `couponNotUsed` and in the same words.
3931
+ */
3932
+ const couponNotUsedOf = (deps, key, asked,
3933
+ /** The bookings that were asked for before and returned as they were. */
3934
+ reusedBookings,
3935
+ /** Every booking in the answer: the coupon may have gone on one made now. */
3936
+ answered) => {
3937
+ const want = asked?.trim().toUpperCase();
3938
+ if (!want || reusedBookings.length === 0)
3939
+ return undefined;
3940
+ if (answered.some((b) => b.quote.coupon?.id.toUpperCase() === want))
3941
+ return undefined;
3942
+ const t = deps.tools;
3943
+ const held = deps.members.read(key)?.coupons?.find((c) => c.id.toUpperCase() === want);
3944
+ const label = held ? couponName(held) : (asked ?? '');
3945
+ const codes = reusedBookings.map((b) => b.confirmationCode).filter(Boolean);
3946
+ const names = codes.length ? codes.join(', ') : 'it';
3947
+ const now = deps.trips.now();
3948
+ // Free to cancel still: every room line has a window that has not closed.
3949
+ const rooms = reusedBookings.flatMap((b) => b.quote.lines.filter((l) => l.kind === 'room'));
3950
+ const open = rooms.filter((l) => l.freeCancellationUntil && Date.parse(l.freeCancellationUntil) > now);
3951
+ const first = open
3952
+ .slice()
3953
+ .sort((a, b) => (a.freeCancellationUntil ?? '').localeCompare(b.freeCancellationUntil ?? ''))[0];
3954
+ const freeUntil = rooms.length > 0 && open.length === rooms.length && first ? freeUntilText(first) : undefined;
3955
+ const total = reusedBookings.map((b) => formatMoney(b.quote.total)).join(' + ');
3956
+ const said = `THE COUPON WAS NOT USED: ${reusedBookings.length > 1 ? 'these bookings were' : 'this booking was'} made a few minutes ago without the coupon ${label}, and a booking already made is not changed to take one - ${reusedBookings.length > 1 ? 'they stand' : 'it stands'} at ${total} all in, and the coupon is still the member's. To use it, cancel ${names} (${freeUntil ? `free to cancel until ${freeUntil}` : `${t.previewCancellation} says what cancelling costs`}) and book again with the coupon - only if the guest wants that.`;
3957
+ return {
3958
+ structured: {
3959
+ id: asked,
3960
+ ...(held ? { name: held.name, ...(held.names ? { names: held.names } : {}) } : {}),
3961
+ bookings: codes,
3962
+ cancelFree: !!freeUntil,
3963
+ ...(freeUntil ? { freeUntil } : {}),
3964
+ said,
3965
+ },
3966
+ said,
3967
+ };
3968
+ };
3766
3969
  /**
3767
3970
  * The booking this very form made a few minutes ago, when the slot is empty
3768
3971
  * because of it - or undefined.
@@ -3803,6 +4006,7 @@ const bookedFromThisForm = async (deps, ctx, key, args) => {
3803
4006
  x.quote.lines.some((l) => l.kind === 'room' && l.roomRef === room && (l.quantity ?? 1) === rooms));
3804
4007
  if (!b)
3805
4008
  return undefined;
4009
+ const notUsed = couponNotUsedOf(deps, key, couponOf(deps, args), [b], [b]);
3806
4010
  return {
3807
4011
  structured: {
3808
4012
  view: 'booking',
@@ -3812,9 +4016,11 @@ const bookedFromThisForm = async (deps, ctx, key, args) => {
3812
4016
  holdUntil: b.holdUntil,
3813
4017
  total: formatMoney(b.quote.total),
3814
4018
  repeat: true,
4019
+ ...(notUsed ? { couponNotUsed: notUsed.structured } : {}),
3815
4020
  },
3816
4021
  text: [
3817
4022
  'NOTHING NEW WAS BOOKED: this booking form already booked this room a few minutes ago, and that answer did not reach the card - this is the booking made then.',
4023
+ notUsed?.said ?? '',
3818
4024
  `**${confirming(deps, b) ? 'Being confirmed' : b.confirmationCode}** · ${b.hotelName} · ${stayWords(b.stay, b.hourly)}`,
3819
4025
  bookedLine(deps, b) ?? '',
3820
4026
  deps.vocab.demo ? noteFor(deps.vocab) : '',
@@ -3869,6 +4075,47 @@ const putRoomLine = async (deps, t, ctx, key, store, args, detail) => {
3869
4075
  return currentTrip(deps, key, who, store, couponOf(deps, args), ctx);
3870
4076
  }
3871
4077
  }
4078
+ // ⚠️ LISTED, NOT BOOKED HERE (robin, 2026-10-30): a rate paid in advance
4079
+ // where this deployment takes no payment goes in no trip and no pick. The
4080
+ // press on its Reserve - or the assistant asking for it - is answered with
4081
+ // what is true and what is left: nothing booked, nothing charged; the rates
4082
+ // here paid at the hotel, a press each; other dates; or the group itself.
4083
+ // Never a review that fails at Book, after the guest typed their name.
4084
+ // ONE ANSWER FOR A ROOM BY THE NIGHT AND A ROOM BY THE HOUR (2026-11-03
4085
+ // review, C6): the hourly branch put such a room in the trip before this was
4086
+ // asked, and the guest met the refusal at "Book".
4087
+ const prepaidNotHere = (room, open, stay, when) => {
4088
+ const group = groupNameOf(deps, hotelId);
4089
+ const said = [
4090
+ `Nothing was booked or charged: ${room.name} is paid in advance, and a stay paid in advance cannot be booked here yet - this connector books rates paid at the hotel.`,
4091
+ open.length
4092
+ ? `Paid at the hotel at ${detail.hotel.name} ${when}: ${open.map((r) => `${r.name} (\`${r.id}\`)`).join(', ')}.`
4093
+ : `No rate at ${detail.hotel.name} ${when} is paid at the hotel.`,
4094
+ `Other dates, or booking this rate with ${group} directly, are the other ways on.`,
4095
+ ].join(' ');
4096
+ return {
4097
+ structured: {
4098
+ view: 'error',
4099
+ ...common(deps, key),
4100
+ error: 'prepaid-not-here',
4101
+ room: { id: room.id, name: room.name, ...(room.names ? { names: room.names } : {}) },
4102
+ rooms: open.map((r) => ({
4103
+ id: r.id,
4104
+ name: r.name,
4105
+ ...(r.names ? { names: r.names } : {}),
4106
+ })),
4107
+ hotel: {
4108
+ id: detail.hotel.id,
4109
+ name: detail.hotel.name,
4110
+ ...(detail.hotel.names ? { names: detail.hotel.names } : {}),
4111
+ },
4112
+ stay,
4113
+ group,
4114
+ said,
4115
+ },
4116
+ text: said,
4117
+ };
4118
+ };
3872
4119
  const today = (startTime || !asStay(args)
3873
4120
  ? deps.backend.hourlyRooms?.(hotelId, guestsOf(args), who)
3874
4121
  : undefined);
@@ -3897,6 +4144,15 @@ const putRoomLine = async (deps, t, ctx, key, store, args, detail) => {
3897
4144
  : ((await today)?.noRooms ?? `${detail.hotel.name} has no rooms by the hour today.`),
3898
4145
  };
3899
4146
  }
4147
+ if (hourlyRoom.notSoldHere) {
4148
+ const wantedHere = Math.max(1, clampCount(args.rooms ?? 1, MAX_ROOMS_PER_LINE));
4149
+ const open = ((await today)?.rooms ?? []).filter((r) => r.hourly && !r.notSoldHere && r.roomsLeft >= wantedHere);
4150
+ return prepaidNotHere(hourlyRoom, open, {
4151
+ checkIn: hourlyRoom.hourly.date,
4152
+ checkOut: hourlyRoom.hourly.date,
4153
+ guests: guestsOf(args),
4154
+ }, 'by the hour today');
4155
+ }
3900
4156
  const bad = startProblem(hourlyRoom.hourly, startTime);
3901
4157
  if (bad) {
3902
4158
  return {
@@ -4026,44 +4282,10 @@ const putRoomLine = async (deps, t, ctx, key, store, args, detail) => {
4026
4282
  text: `${type ? `${detail.hotel.name} has no ${type.name} that sleeps ${stay.guests} and has ${wanted === 1 ? 'a room' : `${wanted} rooms`} left for those nights.` : `${detail.hotel.name} has no room type by that name for those nights.`} It has: ${dated.rooms.map((r) => `${r.name} (${han && r.names?.zh ? `${r.names.zh}, ` : ''}\`${r.id}\`)`).join(', ')}. Call again with the id.`,
4027
4283
  };
4028
4284
  }
4029
- // ⚠️ LISTED, NOT BOOKED HERE (robin, 2026-10-30): a rate paid in advance
4030
- // where this deployment takes no payment goes in no trip and no pick. The
4031
- // press on its Reserve - or the assistant asking for it - is answered with
4032
- // what is true and what is left: nothing booked, nothing charged; the rates
4033
- // here paid at the hotel, a press each; other dates; or the group itself.
4034
- // Never a review that fails at Book, after the guest typed their name.
4285
+ // Listed, not booked here - see `prepaidNotHere`.
4035
4286
  if (room.notSoldHere) {
4036
4287
  const open = dated.rooms.filter((r) => !r.notSoldHere && r.sleeps >= stay.guests && r.roomsLeft >= wanted);
4037
- const group = groupNameOf(deps, hotelId);
4038
- const said = [
4039
- `Nothing was booked or charged: ${room.name} is paid in advance, and a stay paid in advance cannot be booked here yet - this connector books rates paid at the hotel.`,
4040
- open.length
4041
- ? `Paid at the hotel at ${detail.hotel.name} for those nights: ${open.map((r) => `${r.name} (\`${r.id}\`)`).join(', ')}.`
4042
- : `No rate at ${detail.hotel.name} for those nights is paid at the hotel.`,
4043
- `Other dates, or booking this rate with ${group} directly, are the other ways on.`,
4044
- ].join(' ');
4045
- return {
4046
- structured: {
4047
- view: 'error',
4048
- ...common(deps, key),
4049
- error: 'prepaid-not-here',
4050
- room: { id: room.id, name: room.name, ...(room.names ? { names: room.names } : {}) },
4051
- rooms: open.map((r) => ({
4052
- id: r.id,
4053
- name: r.name,
4054
- ...(r.names ? { names: r.names } : {}),
4055
- })),
4056
- hotel: {
4057
- id: detail.hotel.id,
4058
- name: detail.hotel.name,
4059
- ...(detail.hotel.names ? { names: detail.hotel.names } : {}),
4060
- },
4061
- stay,
4062
- group,
4063
- said,
4064
- },
4065
- text: said,
4066
- };
4288
+ return prepaidNotHere(room, open, stay, 'for those nights');
4067
4289
  }
4068
4290
  deps.trips.set(store, {
4069
4291
  kind: 'room',
@@ -4103,7 +4325,7 @@ const bookTripLines = async (deps, t, ctx, key, store, args, nothingThere) => {
4103
4325
  // the trip as it is now (no stamp); a card's form books the one it showed.
4104
4326
  const drawn = str(args.stamp);
4105
4327
  if (drawn && drawn !== stampOf(inTrip, deps.trips.shown(store))) {
4106
- const again = await currentTrip(deps, key, identity(deps, ctx, key), store);
4328
+ const again = await currentTrip(deps, key, identity(deps, ctx, key), store, undefined, ctx);
4107
4329
  return {
4108
4330
  ...again,
4109
4331
  structured: { ...again.structured, tripChanged: true },
@@ -4196,7 +4418,7 @@ const bookTripLines = async (deps, t, ctx, key, store, args, nothingThere) => {
4196
4418
  // booking is held to (ShownQuote).
4197
4419
  const rest = lines.length === inTrip.length && !failed.length
4198
4420
  ? undefined
4199
- : await currentTrip(deps, key, identity(deps, ctx, key), store);
4421
+ : await currentTrip(deps, key, identity(deps, ctx, key), store, undefined, ctx);
4200
4422
  const others = rest && lines.length !== inTrip.length;
4201
4423
  // What could be booked instead of each, found now (robin, 2026-10-14).
4202
4424
  const instead = await Promise.all(failed.map((f, i) => i < MAX_FAILED_WITH_ALTERNATIVES
@@ -4243,7 +4465,9 @@ const bookTripLines = async (deps, t, ctx, key, store, args, nothingThere) => {
4243
4465
  // at 15:00, and a generic "check-in is in the afternoon" is not an answer.
4244
4466
  // Per booking: each is its own hotel, with its own hour and its own desk.
4245
4467
  const booked = await Promise.all(made.map(async (booking, i) => {
4246
- const hotel = await deps.backend.getHotel(booking.hotelId);
4468
+ // ⚠️ THE BOOKING IS MADE: a read after it that fails (a group slow to
4469
+ // answer) loses the check-in hour, never the answer.
4470
+ const hotel = await deps.backend.getHotel(booking.hotelId).catch(() => null);
4247
4471
  return {
4248
4472
  booking,
4249
4473
  // The requests that could not both be asked for are said once.
@@ -4254,6 +4478,10 @@ const bookTripLines = async (deps, t, ctx, key, store, args, nothingThere) => {
4254
4478
  const several = booked.length > 1;
4255
4479
  // The stays asked for minutes before, beside new ones - see `reused`.
4256
4480
  const earlier = booked.filter(({ booking }) => reused?.includes(booking.id));
4481
+ // The coupon asked for, when the stays asked for before did not carry it
4482
+ // and nothing new did either - see `couponNotUsedOf`.
4483
+ const askedCoupon = couponOf(deps, args);
4484
+ const notUsed = couponNotUsedOf(deps, key, askedCoupon, repeat ? made : earlier.map(({ booking }) => booking), made);
4257
4485
  const waiting = booked.filter(({ booking }) => confirming(deps, booking)).length;
4258
4486
  const notBooked = failed.map((f, i) => ({
4259
4487
  hotelId: f.hotelId,
@@ -4348,6 +4576,7 @@ const bookTripLines = async (deps, t, ctx, key, store, args, nothingThere) => {
4348
4576
  ...(notBooked.length ? { failed: notBooked } : {}),
4349
4577
  ...(repeat ? { repeat: true } : {}),
4350
4578
  ...(earlier.length ? { repeated: earlier.map(({ booking }) => booking.id) } : {}),
4579
+ ...(notUsed ? { couponNotUsed: notUsed.structured } : {}),
4351
4580
  },
4352
4581
  text: [
4353
4582
  // ⚠️ THE SAME BOOKING ASKED FOR AGAIN IS SAID TO BE THAT, never a new
@@ -4357,6 +4586,7 @@ const bookTripLines = async (deps, t, ctx, key, store, args, nothingThere) => {
4357
4586
  : earlier.length
4358
4587
  ? `NOT ALL NEW: ${listAnd(earlier.map(({ booking }) => booking.confirmationCode || booking.hotelName))} ${earlier.length > 1 ? 'were' : 'was'} asked for a few minutes ago for the same guest and ${earlier.length > 1 ? 'are the bookings' : 'is the booking'} made then; the rest were booked now.`
4359
4588
  : '',
4589
+ notUsed?.said ?? '',
4360
4590
  // ⚠️ BOOKED IS CONFIRMED: a stay still being confirmed is said as
4361
4591
  // that, and not counted (总线程, 2026-10-14).
4362
4592
  notBooked.length || (booked.length > 1 && waiting)
@@ -4386,13 +4616,16 @@ const bookTripLines = async (deps, t, ctx, key, store, args, nothingThere) => {
4386
4616
  // and the trip is shown again at today's - which is then what the guest
4387
4617
  // has seen, and what a second ask books.
4388
4618
  if (err.code === 'price-changed') {
4389
- const again = await currentTrip(deps, key, identity(deps, ctx, key), store);
4619
+ const again = await currentTrip(deps, key, identity(deps, ctx, key), store, undefined, ctx);
4390
4620
  return { ...again, text: [words, again.text].join('\n\n') };
4391
4621
  }
4392
4622
  // The coupon asked for cannot be used: nothing was booked, and the trip
4393
4623
  // is shown again with it refused and why - the guest decides what next.
4394
4624
  if (err.code === 'coupon') {
4395
- const again = await currentTrip(deps, key, identity(deps, ctx, key), store, couponOf(deps, args));
4625
+ // ⚠️ WITH THE CONTEXT, like every other answer of the trip (2026-11-03
4626
+ // review, T1): without it the restated trip lost the Chinese room and
4627
+ // hotel names a host with no card is given beside the plain ones.
4628
+ const again = await currentTrip(deps, key, identity(deps, ctx, key), store, couponOf(deps, args), ctx);
4396
4629
  return {
4397
4630
  ...again,
4398
4631
  structured: { ...again.structured, notBooked: 'coupon' },
@@ -4400,7 +4633,7 @@ const bookTripLines = async (deps, t, ctx, key, store, args, nothingThere) => {
4400
4633
  };
4401
4634
  }
4402
4635
  if (err.code === 'mixed-supplier') {
4403
- const again = await currentTrip(deps, key, identity(deps, ctx, key), store);
4636
+ const again = await currentTrip(deps, key, identity(deps, ctx, key), store, undefined, ctx);
4404
4637
  return {
4405
4638
  ...again,
4406
4639
  text: [
@@ -4500,7 +4733,14 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
4500
4733
  // FAILED either, for the same reason as the ceiling above: the place
4501
4734
  // matched. What matched the place and the words, before either:
4502
4735
  const placedCount = (a) => a.beforeFilters ?? a.beforeCeiling ?? a.total;
4503
- if (placedCount(answer) === 0) {
4736
+ // ⚠️ AND NOT WHEN A GROUP DID NOT ANSWER (2026-11-03 review, C4). The place
4737
+ // "matched nothing" among the groups that answered; the one that did not
4738
+ // may have it. A wider search then found something else, took over as the
4739
+ // answer, and the first search's "X did not answer" went with it - "nothing
4740
+ // matched Hong Kong, so this is Shenzhen", with Hong Kong's group silent.
4741
+ // It also cost another time limit. The empty answer is given as it is, with
4742
+ // the sentence that the group did not answer (`degradedOf`).
4743
+ if (placedCount(answer) === 0 && !answer.degraded?.length) {
4504
4744
  const wider = widen(ask);
4505
4745
  if (wider) {
4506
4746
  const second = await deps.backend.searchHotels(wider.ask, who);
@@ -4666,8 +4906,11 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
4666
4906
  guests: guestsOf(args),
4667
4907
  };
4668
4908
  const priced = await answerTool(deps, t.listHotels, { ...args, ...tonight }, ctx);
4909
+ // ⚠️ THE ROOMS ASKED FOR, not "one room" (2026-11-03 review, C12): `rooms: 2`
4910
+ // rides along into the priced list, and every price in it is two rooms'.
4911
+ const roomCount = ask.rooms ?? 1;
4669
4912
  // In both, in the same words: the model reads the structure.
4670
- const stayDefault = `No dates were asked for, so this opens on tonight by default: ${tonight.checkIn} to ${tonight.checkOut} (one night, on the hotels' own date), ${tonight.guests} ${tonight.guests === 1 ? 'guest' : 'guests'}, one room - every price here is for that night. Say so: the prices shown are tonight's, and the guest can ${ctx.noCards ? 'name other dates' : 'change the dates on the card or name others'}. Do not ask for dates before showing them.`;
4913
+ const stayDefault = `No dates were asked for, so this opens on tonight by default: ${tonight.checkIn} to ${tonight.checkOut} (one night, on the hotels' own date), ${tonight.guests} ${tonight.guests === 1 ? 'guest' : 'guests'}, ${roomCount === 1 ? 'one room' : `${roomCount} rooms`} - every price here is for that night. Say so: the prices shown are tonight's, and the guest can ${ctx.noCards ? 'name other dates' : 'change the dates on the card or name others'}. Do not ask for dates before showing them.`;
4671
4914
  // Right after what is here - "every one, and no others" stays the first thing said.
4672
4915
  const inAll = String(priced.structured.inAll ?? '');
4673
4916
  return {
@@ -4910,8 +5153,12 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
4910
5153
  ask.filters?.hourly && hotels.some((h) => h.forStay?.hours)
4911
5154
  ? `By the hour, today: each price is that ${deps.vocab.noun}'s cheapest room by the hour, for its hours, taxes included.`
4912
5155
  : ask.stay
4913
- ? `Priced for ${ask.stay.checkIn} to ${ask.stay.checkOut}${ask.rooms && ask.rooms > 1 ? `, ${ask.rooms} rooms` : ''}: each price is that ${deps.vocab.noun}'s cheapest room for the whole stay, taxes included${hotels.some((h) => h.noRoomsForStay)
4914
- ? `; ${hotels.filter((h) => h.noRoomsForStay).length} in this answer have no room for those nights`
5156
+ ? `Priced for ${ask.stay.checkIn} to ${ask.stay.checkOut}${ask.rooms && ask.rooms > 1 ? `, ${ask.rooms} rooms` : ''}: each price is that ${deps.vocab.noun}'s cheapest room for the whole stay, taxes included${hotels.some((h) => h.noRoomsForStay && h.noRoomsForStayCode !== 'none-on-sale')
5157
+ ? `; ${hotels.filter((h) => h.noRoomsForStay && h.noRoomsForStayCode !== 'none-on-sale').length} in this answer have no room for those nights`
5158
+ : ''}${
5159
+ // The other kind, said as its own (U11): other nights will not help.
5160
+ hotels.some((h) => h.noRoomsForStayCode === 'none-on-sale')
5161
+ ? `; ${hotels.filter((h) => h.noRoomsForStayCode === 'none-on-sale').length} in this answer ${hotels.filter((h) => h.noRoomsForStayCode === 'none-on-sale').length === 1 ? 'has' : 'have'} no rooms on sale through this connector - not sold out, and other nights make no difference`
4915
5162
  : ''}.`
4916
5163
  : '',
4917
5164
  ].filter(Boolean);
@@ -5445,7 +5692,9 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5445
5692
  ...(isDay(str(args.day)) ? { day: str(args.day) } : {}),
5446
5693
  };
5447
5694
  deps.trips.set(key, line);
5448
- return currentTrip(deps, key, identity(deps, ctx, key), key, undefined, ctx);
5695
+ // ⚠️ THE COUPON STAYS ON, like on a room (2026-11-03 review, C11): its
5696
+ // schema says it keeps the new price, and `undefined` here dropped it.
5697
+ return currentTrip(deps, key, identity(deps, ctx, key), key, couponOf(deps, args), ctx);
5449
5698
  }
5450
5699
  return putRoomLine(deps, t, ctx, key, key, args, detail);
5451
5700
  }
@@ -5492,6 +5741,9 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5492
5741
  if (name === t.listBookings) {
5493
5742
  const who = identity(deps, ctx, key, args);
5494
5743
  const here = await deps.backend.listBookings(who, typeof args.limit === 'number' ? args.limit : undefined);
5744
+ // Read here, before a filter or a spread drops it: the groups whose
5745
+ // bookings are not in `here` (2026-11-03 review, C5).
5746
+ const missing = degradedOf(deps, { degraded: here.degraded }, 'bookings');
5495
5747
  // Elsewhere, only with the code AND the email - see `listBookings`.
5496
5748
  const code = str(args.booking);
5497
5749
  const { found: there, tooMany } = code && str(args.email)
@@ -5517,6 +5769,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5517
5769
  // than "nothing in this conversation".
5518
5770
  ...(missed ? { notFound: code, notFoundLine: missLine } : {}),
5519
5771
  ...(tooMany ? { tooMany: true } : {}),
5772
+ ...missing.structured,
5520
5773
  },
5521
5774
  text: [
5522
5775
  missed ? missLine : '',
@@ -5524,7 +5777,11 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5524
5777
  ? bookings.map((b) => bookingLine(b, ctx.noCards)).join('\n')
5525
5778
  : code
5526
5779
  ? ''
5527
- : 'No bookings in this conversation. A booking made elsewhere is found with its confirmation code and the email it was made with.',
5780
+ : // ⚠️ NOT "NO BOOKINGS" when a group did not answer: it may hold them.
5781
+ missing.said
5782
+ ? ''
5783
+ : 'No bookings in this conversation. A booking made elsewhere is found with its confirmation code and the email it was made with.',
5784
+ missing.said,
5528
5785
  ]
5529
5786
  .filter(Boolean)
5530
5787
  .join('\n'),
@@ -5881,13 +6138,18 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5881
6138
  toolToConfirm: t.cancelBooking,
5882
6139
  // Whether cancelling hands the coupon back - the same sentence as the text.
5883
6140
  ...(preview.coupon
5884
- ? { coupon: { ...preview.coupon, said: couponBackWords(preview.coupon) } }
6141
+ ? {
6142
+ coupon: {
6143
+ ...preview.coupon,
6144
+ said: couponBackWords(preview.coupon, formatMoney(preview.fee)),
6145
+ },
6146
+ }
5885
6147
  : {}),
5886
6148
  },
5887
6149
  text: [
5888
6150
  `Cancelling ${preview.confirmationCode} would cost ${formatMoney(preview.fee)}${preview.fee.minor === 0 ? ' - nothing' : ''}.`,
5889
6151
  preview.rule,
5890
- preview.coupon ? couponBackWords(preview.coupon) : '',
6152
+ preview.coupon ? couponBackWords(preview.coupon, formatMoney(preview.fee)) : '',
5891
6153
  preview.cancellable
5892
6154
  ? `Say the word and it is cancelled (${t.cancelBooking} with \`confirmed\`).`
5893
6155
  : '',
@@ -5930,6 +6192,28 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5930
6192
  // will be charged.
5931
6193
  const charged = cancelled.cancellationFee ?? preview.fee;
5932
6194
  const h = holderOf(deps, cancelled.supplier);
6195
+ // What became of the coupon the booking used, said once and handed over in
6196
+ // both: the structure's `coupon` and the text's sentence are this one string.
6197
+ const couponNow = cancelled.quote.coupon && cancelled.couponReturned !== undefined
6198
+ ? (() => {
6199
+ const returned = cancelled.couponReturned === true;
6200
+ const c = {
6201
+ id: cancelled.quote.coupon.id,
6202
+ name: cancelled.quote.coupon.name,
6203
+ ...(cancelled.quote.coupon.names ? { names: cancelled.quote.coupon.names } : {}),
6204
+ validUntil: cancelled.quote.coupon.validUntil,
6205
+ returned,
6206
+ // A booking cancelled before the reason was kept: a coupon not
6207
+ // returned could then only have run out.
6208
+ ...(returned
6209
+ ? {}
6210
+ : {
6211
+ notReturnedBecause: cancelled.couponNotReturnedBecause ?? 'expired',
6212
+ }),
6213
+ };
6214
+ return { ...c, said: couponBackWords(c, formatMoney(charged), true) };
6215
+ })()
6216
+ : undefined;
5933
6217
  return {
5934
6218
  structured: {
5935
6219
  view: 'cancelled',
@@ -5940,6 +6224,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5940
6224
  fee: formatMoney(charged),
5941
6225
  rule: preview.rule,
5942
6226
  cancellable: false,
6227
+ ...(couponNow ? { coupon: couponNow } : {}),
5943
6228
  },
5944
6229
  text: [
5945
6230
  cancelled.payment
@@ -5954,11 +6239,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5954
6239
  ? // Nobody charges a demonstration anything (2026-10-05 audit, P2).
5955
6240
  `${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.`}`
5956
6241
  : `${cancelled.confirmationCode} is cancelled. ${charged.minor === 0 ? 'No fee.' : `The hotel charges ${formatMoney(charged)}.`}`,
5957
- cancelled.quote.coupon && cancelled.couponReturned !== undefined
5958
- ? cancelled.couponReturned
5959
- ? `The coupon ${couponName(cancelled.quote.coupon)} is back with the member, usable until ${cancelled.quote.coupon.validUntil}.`
5960
- : `The coupon ${couponName(cancelled.quote.coupon)} ran out on ${cancelled.quote.coupon.validUntil}, so it is not given back.`
5961
- : '',
6242
+ couponNow?.said ?? '',
5962
6243
  demoBookingNote(deps, cancelled.supplier),
5963
6244
  deps.vocab.demo ? noteFor(deps.vocab) : '',
5964
6245
  ]
@@ -5982,7 +6263,10 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5982
6263
  who: identity(deps, ctx, key),
5983
6264
  });
5984
6265
  if (comparison.columns.length === 0) {
5985
- const unknown = comparison.dropped.filter((d) => !d.candidates?.length);
6266
+ // A group that did not answer is not "not ours" (2026-11-03 review, C5):
6267
+ // its hotels are said as left out for that.
6268
+ const silent = comparison.dropped.filter((d) => d.code === 'supplier-timeout');
6269
+ const unknown = comparison.dropped.filter((d) => !d.candidates?.length && d.code !== 'supplier-timeout');
5986
6270
  const fitsMany = fitsManyOf(deps, comparison.dropped);
5987
6271
  return {
5988
6272
  structured: {
@@ -5992,12 +6276,13 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5992
6276
  },
5993
6277
  // A name that fits several IS one of ours - only the rest are "none".
5994
6278
  text: [
5995
- ...(unknown.length || !fitsMany.length
6279
+ ...(unknown.length || (!fitsMany.length && !silent.length)
5996
6280
  ? [
5997
6281
  `None of those are ${catalogue(deps.vocab)}: ${unknown.map((d) => d.name).join(', ')}.`,
5998
6282
  ]
5999
6283
  : []),
6000
6284
  ...fitsMany,
6285
+ ...silent.map((d) => `Left out: ${d.name} — ${d.why}`),
6001
6286
  ].join('\n'),
6002
6287
  };
6003
6288
  }
@@ -6048,6 +6333,25 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
6048
6333
  }
6049
6334
  if (name === t.itinerary) {
6050
6335
  const days = Array.isArray(args.days) ? args.days : [];
6336
+ // ⚠️ WHICH STAYS ARE BOOKED ALREADY marks the plan; a group that did not
6337
+ // answer cannot be checked, and the plan says so rather than showing its
6338
+ // stays as not booked (2026-11-03 review, C5).
6339
+ let planBookings = [];
6340
+ let unchecked = [];
6341
+ try {
6342
+ const mine = await deps.backend.listBookings(identity(deps, ctx, key, args), 50);
6343
+ planBookings = mine;
6344
+ unchecked = mine.degraded ?? [];
6345
+ }
6346
+ catch (err) {
6347
+ const down = unansweredBy(err);
6348
+ if (!down)
6349
+ throw err;
6350
+ unchecked = down;
6351
+ }
6352
+ const uncheckedSaid = unchecked.length
6353
+ ? `${listAnd(unchecked.map((d) => d.name))} did not answer in time, so ${unchecked.length === 1 ? 'its' : 'their'} bookings were not checked against this plan - a stay there may already be booked without the plan saying so; asking again in a moment may check them.`
6354
+ : '';
6051
6355
  const plan = await buildItinerary(deps.backend, days.map((d) => ({
6052
6356
  label: str(d.label),
6053
6357
  note: typeof d.note === 'string' ? d.note : undefined,
@@ -6058,7 +6362,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
6058
6362
  title: str(args.title),
6059
6363
  stay: asStay(args),
6060
6364
  trip: deps.trips.read(key),
6061
- bookings: await deps.backend.listBookings(identity(deps, ctx, key, args), 50),
6365
+ bookings: planBookings,
6062
6366
  who: identity(deps, ctx, key),
6063
6367
  });
6064
6368
  if (plan.days.length === 0) {
@@ -6120,6 +6424,12 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
6120
6424
  gap,
6121
6425
  ...(nudges.length ? { nudges } : {}),
6122
6426
  ...(overlaps.length ? { overlaps } : {}),
6427
+ ...(uncheckedSaid
6428
+ ? {
6429
+ degraded: unchecked.map((d) => ({ supplier: d.supplier, name: d.name })),
6430
+ degradedSaid: uncheckedSaid,
6431
+ }
6432
+ : {}),
6123
6433
  // In both, like the nudges: a host with no card may hand its model
6124
6434
  // this rather than the text.
6125
6435
  ...(ctx.noCards ? { notDrawn: PLAN_NOT_DRAWN } : {}),
@@ -6139,6 +6449,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
6139
6449
  : []),
6140
6450
  ...plan.dropped.map((d) => `Left out: ${d.name} — ${d.why}`),
6141
6451
  ...fitsManyOf(deps, plan.dropped),
6452
+ uncheckedSaid,
6142
6453
  ctx.noCards ? PLAN_NOT_DRAWN : PLAN_DRAWN_ONCE,
6143
6454
  deps.vocab.demo ? noteFor(deps.vocab) : '',
6144
6455
  ]