@curless/hotel-storefront 0.52.1 → 0.54.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.
Files changed (81) hide show
  1. package/dist/about.d.ts +24 -0
  2. package/dist/about.d.ts.map +1 -0
  3. package/dist/about.js +28 -0
  4. package/dist/about.js.map +1 -0
  5. package/dist/aggregate/hotels.d.ts.map +1 -1
  6. package/dist/aggregate/hotels.js +156 -37
  7. package/dist/aggregate/hotels.js.map +1 -1
  8. package/dist/aggregate/make.js +2 -2
  9. package/dist/aggregate/make.js.map +1 -1
  10. package/dist/backend.d.ts +34 -2
  11. package/dist/backend.d.ts.map +1 -1
  12. package/dist/backend.js +21 -0
  13. package/dist/backend.js.map +1 -1
  14. package/dist/card.generated.d.ts +1 -1
  15. package/dist/card.generated.js +2 -2
  16. package/dist/card.generated.js.map +1 -1
  17. package/dist/coupon-words.d.ts +12 -0
  18. package/dist/coupon-words.d.ts.map +1 -0
  19. package/dist/coupon-words.js +19 -0
  20. package/dist/coupon-words.js.map +1 -0
  21. package/dist/dates.js +1 -1
  22. package/dist/dates.js.map +1 -1
  23. package/dist/demo-hotels.d.ts +6 -0
  24. package/dist/demo-hotels.d.ts.map +1 -1
  25. package/dist/demo-hotels.js +80 -27
  26. package/dist/demo-hotels.js.map +1 -1
  27. package/dist/huazhu/backend.d.ts +6 -0
  28. package/dist/huazhu/backend.d.ts.map +1 -1
  29. package/dist/huazhu/backend.js +38 -30
  30. package/dist/huazhu/backend.js.map +1 -1
  31. package/dist/huazhu/catalog.d.ts.map +1 -1
  32. package/dist/huazhu/catalog.js +43 -6
  33. package/dist/huazhu/catalog.js.map +1 -1
  34. package/dist/huazhu/client.js +1 -1
  35. package/dist/huazhu/client.js.map +1 -1
  36. package/dist/huazhu/drift.d.ts.map +1 -1
  37. package/dist/huazhu/drift.js +3 -0
  38. package/dist/huazhu/drift.js.map +1 -1
  39. package/dist/huazhu/make.d.ts.map +1 -1
  40. package/dist/huazhu/make.js +1 -0
  41. package/dist/huazhu/make.js.map +1 -1
  42. package/dist/huazhu/sandbox.d.ts.map +1 -1
  43. package/dist/huazhu/sandbox.js +8 -1
  44. package/dist/huazhu/sandbox.js.map +1 -1
  45. package/dist/huazhu/wire.d.ts +7 -0
  46. package/dist/huazhu/wire.d.ts.map +1 -1
  47. package/dist/huazhu/wire.js.map +1 -1
  48. package/dist/money.d.ts.map +1 -1
  49. package/dist/money.js +3 -0
  50. package/dist/money.js.map +1 -1
  51. package/dist/paid.d.ts.map +1 -1
  52. package/dist/paid.js +3 -1
  53. package/dist/paid.js.map +1 -1
  54. package/dist/present.d.ts +14 -2
  55. package/dist/present.d.ts.map +1 -1
  56. package/dist/present.js +98 -7
  57. package/dist/present.js.map +1 -1
  58. package/dist/reference.d.ts.map +1 -1
  59. package/dist/reference.js +13 -6
  60. package/dist/reference.js.map +1 -1
  61. package/dist/server.d.ts +2 -2
  62. package/dist/server.js +4 -4
  63. package/dist/server.js.map +1 -1
  64. package/dist/supplier.d.ts +6 -0
  65. package/dist/supplier.d.ts.map +1 -1
  66. package/dist/supplier.js +1 -0
  67. package/dist/supplier.js.map +1 -1
  68. package/dist/surface.d.ts.map +1 -1
  69. package/dist/surface.js +27 -18
  70. package/dist/surface.js.map +1 -1
  71. package/dist/tools.d.ts +11 -0
  72. package/dist/tools.d.ts.map +1 -1
  73. package/dist/tools.js +480 -110
  74. package/dist/tools.js.map +1 -1
  75. package/dist/types.d.ts +53 -6
  76. package/dist/types.d.ts.map +1 -1
  77. package/dist/vocab.d.ts +15 -1
  78. package/dist/vocab.d.ts.map +1 -1
  79. package/dist/vocab.js +26 -2
  80. package/dist/vocab.js.map +1 -1
  81. package/package.json +1 -1
package/dist/tools.js CHANGED
@@ -1,4 +1,6 @@
1
- import { DEFAULT_PAGE, FULL_CAPABILITIES, LASTING_REQUESTS, MAX_PAGE, REQUEST_KINDS, REQUEST_NOTE_MAX, guestTooLong, unitWords, } from './backend.js';
1
+ import { aboutLines } from './about.js';
2
+ import { DEFAULT_PAGE, FULL_CAPABILITIES, LASTING_REQUESTS, MAX_PAGE, REQUEST_KINDS, REQUEST_NOTE_MAX, guestTooLong, notPricedForNoAnswer, unansweredBy, unitWords, } from './backend.js';
3
+ import { usedOnWords } from './coupon-words.js';
2
4
  import { MAX_GUESTS, addDays, isDay, nights as nightsBetween } from './dates.js';
3
5
  import { FACILITIES } from './facilities.js';
4
6
  import { DESCRIPTION_MAX, POLICY_MAX, hotelName, hotelText } from './fence.js';
@@ -302,8 +304,8 @@ const forCapabilities = (tools, n, v, caps) => {
302
304
  ? 'A rate marked paid in advance is booked on its own, and not at once: the answer gives a card page in test mode and the time it is paid by, and the stay is booked once it is paid - no card is collected here. Every other rate is paid by nobody.'
303
305
  : 'No payment is taken and no card is collected.'} All of the trip is booked or none of it. Preferences - a bed type, a floor, a late arrival - ride along on the booking as requests.`
304
306
  : v.sandbox
305
- ? `Makes a booking in a simulated copy of ${v.brand}'s booking system, for testing: each stay gets its own confirmation code there, and none of it is a ${v.brand} reservation or holds a real room. ${caps.prepaid
306
- ? 'A rate marked paid in advance is booked on its own, and not at once: the answer gives a card page in test mode and the time it is paid by, and the stay is booked once it is paid - no card is collected here. Every other rate is paid by nobody.'
307
+ ? `Makes a simulated booking in a simulated copy of ${v.brand}'s booking system: each stay gets its own confirmation code there, and none of it is a ${v.brand} reservation or holds a real room. ${caps.prepaid
308
+ ? 'A rate marked paid in advance is booked on its own, and not at once: the answer gives a card page where no real money moves and the time it is paid by, and the stay is booked once it is paid - no card is collected here. Every other rate is paid by nobody.'
307
309
  : 'No payment is taken and no card is collected.'} All of the trip is booked or none of it. Preferences - a bed type, a floor, a late arrival - ride along on the booking as requests.`
308
310
  : `Books the trip's rooms with ${v.brand} for a named guest: each stay becomes the hotel's own reservation, with its own confirmation code, paid at the hotel. No payment is taken here and no card is collected. All of the trip is booked or none of it. Preferences - a bed type, a floor, a late arrival - ride along on the booking as requests the desk tries to meet.`,
309
311
  };
@@ -592,9 +594,10 @@ const allTools = (n, v) => {
592
594
  // could take the code to the real hotel.
593
595
  description: v.market
594
596
  ? // ⚠️ THE GROUP HOLDS THE BOOKING, NOT THE MARKET - and what a booking
595
- // makes is the group's: a hold, a test booking, or a reservation paid
596
- // at the hotel. The answer says which, stay by stay.
597
- `Books the trip's rooms for a named guest with the hotel group each stay belongs to, one group at a time: a trip holding two groups' hotels is booked group by group. What a booking makes is that group's - a demonstration hold that lapses on its own, a test booking in a simulated system, or the hotel's own reservation paid at the hotel - and the answer says which for every stay. No payment is taken here and no card is collected. Preferences - a bed type, a floor, a late arrival - ride along on the booking as requests.`
597
+ // makes is the group's: a hold, a booking that holds no room, or a
598
+ // reservation paid at the hotel. The answer says which, stay by stay.
599
+ // ⚠️ NO "DEMONSTRATION" / "TEST" / "SIMULATED" IN A MARKET (robin, 2026-11-03).
600
+ `Books the trip's rooms for a named guest with the hotel group each stay belongs to, one group at a time: a trip holding two groups' hotels is booked group by group. What a booking makes is that group's - a hold that lapses on its own and is not sent to the group, a booking that holds no room for anyone, or the hotel's own reservation paid at the hotel - and the answer says which for every stay. No payment is taken here and no card is collected. Preferences - a bed type, a floor, a late arrival - ride along on the booking as requests.`
598
601
  : v.demo
599
602
  ? `Makes a demonstration booking request for the trip's rooms, for a named guest: it returns a confirmation code and the moment the demonstration hold lapses on its own. ${v.brand} does not receive it and no real room is held. No payment is taken and no card is collected. Preferences - a bed type, a floor, a late arrival - ride along on the request.`
600
603
  : `Asks ${v.brand} to hold the trip's rooms for a named guest and returns a confirmation code and the moment the hold lapses. No payment is taken and no card is collected; the rooms are held, not paid for. Preferences - a bed type, a floor, a late arrival - ride along on the booking as requests the desk tries to meet.`,
@@ -1288,6 +1291,8 @@ const BOOKING_SCHEMA = {
1288
1291
  again: OBJ,
1289
1292
  /** Of `bookings`, the ids asked for minutes before, when the others are new (`BookingResult.reused`). */
1290
1293
  repeated: STRS,
1294
+ /** The coupon asked for was not used: the booking returned was made before, without it (`couponNotUsedOf`). */
1295
+ couponNotUsed: OBJ,
1291
1296
  /** `trip`, drawn again: see `stampOf`. */
1292
1297
  stamp: STR,
1293
1298
  /** The trip changed after the card that sent the form was drawn, so nothing was booked (`stampOf`). */
@@ -1704,6 +1709,7 @@ const holderOf = (deps, supplier) => {
1704
1709
  demo: s.mode !== 'live',
1705
1710
  sandbox: s.mode === 'sandbox',
1706
1711
  test: s.mode === 'test',
1712
+ notReal: !!deps.vocab.market && s.mode !== 'live',
1707
1713
  };
1708
1714
  return {
1709
1715
  name: deps.vocab.brand,
@@ -1711,6 +1717,7 @@ const holderOf = (deps, supplier) => {
1711
1717
  demo: deps.vocab.demo,
1712
1718
  sandbox: !!deps.vocab.sandbox,
1713
1719
  test: !!deps.vocab.test,
1720
+ notReal: false,
1714
1721
  };
1715
1722
  };
1716
1723
  /**
@@ -1730,6 +1737,11 @@ const holderOf = (deps, supplier) => {
1730
1737
  */
1731
1738
  const bookingForCard = (b) => ({
1732
1739
  ...b,
1740
+ // A booking cancelled before the reason was kept: a coupon not returned could
1741
+ // then only have run out (C10) - said so, as the text says it.
1742
+ ...(b.couponReturned === false && !b.couponNotReturnedBecause
1743
+ ? { couponNotReturnedBecause: 'expired' }
1744
+ : {}),
1733
1745
  quote: quoteForCard(b.quote),
1734
1746
  // What a cancelled booking was charged to cancel, as text like every amount the
1735
1747
  // card is given: "Cancelled" in My bookings says it beside the struck-out total
@@ -1784,11 +1796,17 @@ const paidLine = (deps, b, fee) => {
1784
1796
  const back = formatMoney(p.refundAmount ?? p.amount);
1785
1797
  const whole = !p.refundAmount || p.refundAmount.minor === p.amount.minor;
1786
1798
  const unionpay = p.method === 'unionpay';
1787
- const test = h.sandbox
1788
- ? ` This is a test: ${unionpay ? "UnionPay's test system" : 'the card page in test mode'}, no real money moves, and the booking is in the simulated system.`
1789
- : h.test
1790
- ? ` This is a test: ${unionpay ? "UnionPay's test system" : 'the card page in test mode'}, no real money moves, and the booking is a test booking in ${h.name}'s test system.`
1791
- : '';
1799
+ // ⚠️ ONE WORD PER ENVIRONMENT (0.54.0, M9): simulated on the simulated copy, test on a
1800
+ // group's test system - and in a market neither, only the fact (`notReal`). A
1801
+ // simulation names no "test card" or "UnionPay's test system": that is the payment
1802
+ // channel's own environment, not the simulation's word.
1803
+ const test = h.notReal
1804
+ ? ` No real money moves, and this is not a real booking with ${h.name}.`
1805
+ : h.sandbox
1806
+ ? ' This is simulated: no real money moves, and the booking is a simulated booking.'
1807
+ : h.test
1808
+ ? ` This is a test: ${unionpay ? "UnionPay's test system" : 'the card page in test mode'}, no real money moves, and the booking is a test booking in ${h.name}'s test system.`
1809
+ : '';
1792
1810
  const ended = b.status === 'cancelled' || b.status === 'expired' || b.status === 'failed';
1793
1811
  const said = (() => {
1794
1812
  // Its money as it stands once the stay has ended - never "if it does not take it".
@@ -1819,7 +1837,7 @@ const paidLine = (deps, b, fee) => {
1819
1837
  case 'refunded':
1820
1838
  return `${whole ? `All ${back}` : `${back} of the ${amount} paid`} has gone back to the card it was paid with${p.refundReason ? ` - ${p.refundReason}` : ''}.`;
1821
1839
  case 'hotel-refunds':
1822
- return `The money is with ${h.name}, who charged the one-time card UnionPay issued: what its terms give back${fee && fee.minor > 0 ? ` after its fee of ${formatMoney(fee)}` : ''}, it returns to that card - not this connector${p.refundReason ? ` (${p.refundReason})` : ''}.`;
1840
+ return `The money is with ${h.name}, who charged the one-time card UnionPay issued: what its terms give back${fee && fee.minor > 0 ? ` after its fee of ${formatMoney(fee)}` : ''}, it returns to that card - nothing is refunded from here${p.refundReason ? ` (${p.refundReason})` : ''}.`;
1823
1841
  }
1824
1842
  }
1825
1843
  switch (p.status) {
@@ -1853,7 +1871,7 @@ const paidLine = (deps, b, fee) => {
1853
1871
  case 'refunded':
1854
1872
  return `${whole ? `All ${back}` : `${back} of the ${amount} paid`} has gone back to the card it was paid with${p.refundReason ? ` - ${p.refundReason}` : ''}.`;
1855
1873
  case 'hotel-refunds':
1856
- return `The money is with ${h.name}, who returns what its terms give back to the card it charged - not this connector.`;
1874
+ return `The money is with ${h.name}, who returns what its terms give back to the card it charged - nothing is refunded from here.`;
1857
1875
  }
1858
1876
  })();
1859
1877
  return `${said}${test}`;
@@ -1895,8 +1913,11 @@ const heldLine = (deps, b) => {
1895
1913
  const total = formatMoney(b.quote.total);
1896
1914
  const h = holderOf(deps, b.supplier);
1897
1915
  if (h.caps.booking === 'pay-at-hotel') {
1916
+ // ⚠️ A MARKET'S GROUP: the fact, in no environment's word (robin, 2026-11-03).
1917
+ if (h.notReal)
1918
+ return `${total} all in. **This booking was not sent to ${h.name}** - no room is held for anyone and nothing is paid, now or at any hotel.`;
1898
1919
  if (h.sandbox)
1899
- return `${total} in the simulated system. **This is a test booking** - not a ${h.name} reservation: no room is held and nothing is paid, now or at any hotel.`;
1920
+ return `${total} in the simulated system. **This is a simulated booking** - not a ${h.name} reservation: no room is held and nothing is paid, now or at any hotel.`;
1900
1921
  if (h.test)
1901
1922
  return `${total} in ${h.name}'s test system. **This is a test booking** - it holds no room for anyone, nothing is paid, now or at any hotel, and nobody needs to go there.`;
1902
1923
  return b.status === 'requested'
@@ -1908,12 +1929,40 @@ const heldLine = (deps, b) => {
1908
1929
  : `${b.holdUntil?.slice(0, 16).replace('T', ' ')} UTC`;
1909
1930
  // ⚠️ NOT "PRESENTS A CARD AT CHECK-IN" (2026-10-05 audit, P2): a demo
1910
1931
  // booking has no check-in, and saying so sent a guest towards a real desk.
1932
+ //
1933
+ // ⚠️ AND NOT A CLOCK TIME (2026-11-03 review, T2). "lapses on its own at Tue 3
1934
+ // Nov, 11:39" was told as 「今天 11:41 自动失效」 - the booking void today, when
1935
+ // the stay is weeks away. A demonstration hold lapses by itself and means
1936
+ // nothing for a real stay; said as how long it lasts, and that it is only the
1937
+ // demonstration.
1911
1938
  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}.`;
1939
+ return `${total} all in. **${demoBookingWords(h)}** Nothing has been charged and no card is needed. ${demoHoldWords(b, deps.trips.now(), h)}`;
1913
1940
  return `${total} all in. **Nothing has been charged** - the rooms are held until ${lapses}, and the guest presents a card at check-in.`;
1914
1941
  };
1942
+ /**
1943
+ * How long a demonstration hold lasts, as "in N minutes" from now - the hold
1944
+ * lapses by itself, and says nothing about the stay (T2, `heldLine`).
1945
+ */
1946
+ const demoHoldWords = (b, now, h) => {
1947
+ const minutes = b.holdUntil ? Math.ceil((Date.parse(b.holdUntil) - now) / 60_000) : Number.NaN;
1948
+ if (Number.isNaN(minutes))
1949
+ return '';
1950
+ const lasting = minutes <= 1
1951
+ ? 'in a minute'
1952
+ : minutes < 90
1953
+ ? `in ${minutes} minutes`
1954
+ : minutes < 48 * 60
1955
+ ? `in about ${Math.round(minutes / 60)} hours`
1956
+ : `in about ${Math.round(minutes / (24 * 60))} days`;
1957
+ // In a market, no "demonstration" (robin, 2026-11-03): only that it lapses and holds no room.
1958
+ if (h.notReal)
1959
+ return `It lapses by itself ${lasting} (no real room is held; the stay's own dates are not affected).`;
1960
+ 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).`;
1961
+ };
1915
1962
  /** What a demonstration booking is, in one sentence - see `demoBookingNote`. */
1916
- const demoBookingWords = (h) => `This is a demonstration booking: ${h.name} has not received it and is not holding any room.`;
1963
+ const demoBookingWords = (h) => h.notReal
1964
+ ? `This booking was not sent to ${h.name}, and ${h.name} is not holding any room.`
1965
+ : `This is a demonstration booking: ${h.name} has not received it and is not holding any room.`;
1917
1966
  /**
1918
1967
  * Said on every answer about a demonstration booking - made, changed or
1919
1968
  * cancelled - and on nothing else: a hold of the demo's that a guest took for
@@ -1962,7 +2011,10 @@ const common = (deps, key) => {
1962
2011
  ? { market: { name: deps.vocab.brand, suppliers: deps.backend.suppliers() } }
1963
2012
  : {}),
1964
2013
  vocab: { noun: deps.vocab.noun, nounPlural: deps.vocab.nounPlural },
1965
- ...(deps.vocab.demo ? { demo: true, demoNote: noteFor(deps.vocab) } : { demo: false }),
2014
+ // A market's note is empty (`noteFor`): it labels no environment (robin, 2026-11-03).
2015
+ ...(deps.vocab.demo
2016
+ ? { demo: true, ...(noteFor(deps.vocab) ? { demoNote: noteFor(deps.vocab) } : {}) }
2017
+ : { demo: false }),
1966
2018
  ...(key ? memberBadge(deps, key) : {}),
1967
2019
  ...(langOf(deps, key) ? { lang: langOf(deps, key) } : {}),
1968
2020
  };
@@ -2088,6 +2140,11 @@ const quoteForCard = (quote, groupOf) => ({
2088
2140
  // The review's own order - public, member, coupon, total - each
2089
2141
  // figure the server's, so the card adds nothing up (10-30, ①).
2090
2142
  publicTotal: formatMoney(quote.member.publicTotal),
2143
+ // The room charges at the public rate, the taxes a row of their own -
2144
+ // the receipt every guest gets: room, taxes, less the tier, less the
2145
+ // coupon, is the total (0.54.0, M1: a member's started from the
2146
+ // public rate taxes in, a guest's from the room before them).
2147
+ publicSubtotal: formatMoney(money(quote.member.publicTotal.minor - (quote.taxesIncluded ? 0 : quote.taxesAndFees.minor), quote.total.currency)),
2091
2148
  memberTotal: formatMoney(money(quote.member.publicTotal.minor - quote.member.saved.minor, quote.total.currency)),
2092
2149
  },
2093
2150
  }
@@ -2128,15 +2185,34 @@ const couponTerms = (c, brand) => [
2128
2185
  c.state === 'expired'
2129
2186
  ? `ran out on ${c.validUntil} - cannot be used`
2130
2187
  : c.state === 'used'
2131
- ? 'already used on a booking'
2188
+ ? `already used ${usedOnWords(c.usedOn)}`
2132
2189
  : c.state === 'not-yet'
2133
2190
  ? `usable from ${c.validFrom} to ${c.validUntil}`
2134
2191
  : `until ${c.validUntil}`,
2135
2192
  ].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.`;
2193
+ /**
2194
+ * What cancelling does to the coupon a booking used - and why.
2195
+ *
2196
+ * ⚠️ BACK ONLY FOR A FREE CANCELLATION IN THE COUPON'S DATES (robin,
2197
+ * 2026-11-03, C10). A cancellation that charges a fee counts the coupon as
2198
+ * used: it already took money off a stay that is being paid for. The reason is
2199
+ * said in the words (`fee` / `expired`) and as the same code in the structure
2200
+ * (`notReturnedBecause`), so a host that draws no card has it too. `fee` is the
2201
+ * charge as text, for the sentence.
2202
+ */
2203
+ const couponBackWords = (c, fee,
2204
+ /** Said after the cancellation, not before it. */
2205
+ done = false) => c.returned
2206
+ ? done
2207
+ ? `The coupon ${couponName(c)} is back with the member, usable until ${c.validUntil}: the cancellation was free.`
2208
+ : `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}.`
2209
+ : c.notReturnedBecause === 'fee'
2210
+ ? done
2211
+ ? `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.`
2212
+ : `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.`
2213
+ : done
2214
+ ? `The coupon ${couponName(c)} ran out on ${c.validUntil}, so it is not given back.`
2215
+ : `The coupon ${couponName(c)} it used ran out on ${c.validUntil}, so cancelling does not give it back.`;
2140
2216
  /**
2141
2217
  * Said wherever points are: they are shown and earned, never spent here -
2142
2218
  * nobody has published what a point is worth (hotel-connectors.md §5).
@@ -2174,9 +2250,6 @@ const memberTripLines = (q) => {
2174
2250
  `${m.programme} ${m.tier} member rate: public rate ${formatMoney(m.publicTotal)} → member rate ${formatMoney(memberTotal)} (${formatMoney(m.saved)} less)${q.coupon
2175
2251
  ? ` → coupon ${couponName(q.coupon)} −${formatMoney(q.coupon.off)}${stays > 1 ? ` (on the stay at ${q.coupon.hotelName})` : ''}`
2176
2252
  : ''} → **${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
2253
  ...(q.coupons?.length
2181
2254
  ? [
2182
2255
  `The member's coupons for this trip - ${COUPON_RULE}`,
@@ -2189,7 +2262,26 @@ const memberTripLines = (q) => {
2189
2262
  : []),
2190
2263
  ].filter(Boolean);
2191
2264
  };
2265
+ /**
2266
+ * The coupon asked for that was not taken off, and why.
2267
+ *
2268
+ * ⚠️ SAID WHENEVER THERE IS ONE, SIGNED IN OR NOT (2026-11-03 review, C2). The
2269
+ * sentence lived in `memberTripLines`, which says nothing without a member - so
2270
+ * a coupon refused because nobody is signed in (`signed-out`) was in
2271
+ * `structuredContent.couponRefused` and not in the words, and a host that
2272
+ * hands its model the text read "Total ¥1,200" and could say it was the price
2273
+ * with the coupon.
2274
+ */
2275
+ const couponRefusedLine = (q) => q.couponRefused
2276
+ ? `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.`
2277
+ : '';
2192
2278
  const tripText = (quote, demo, noCards) => {
2279
+ // ⚠️ NOT "EMPTY" WHEN SOMETHING WAS LEFT OUT: a trip whose only group did not
2280
+ // answer is not an empty trip (2026-11-03 review, C5).
2281
+ if (quote.lines.length === 0 && quote.dropped?.length)
2282
+ return [...quote.dropped.map((d) => `Left out: ${d.title} — ${d.why}`), demo ? DEMO_NOTE : '']
2283
+ .filter(Boolean)
2284
+ .join('\n');
2193
2285
  if (quote.lines.length === 0)
2194
2286
  return 'The trip is empty.';
2195
2287
  const lines = quote.lines.map((l) => l.kind === 'room'
@@ -2198,6 +2290,7 @@ const tripText = (quote, demo, noCards) => {
2198
2290
  return [
2199
2291
  ...lines,
2200
2292
  `Taxes and fees ${quote.taxesIncluded ? 'included in the prices' : formatMoney(quote.taxesAndFees)}${quote.coupon ? ` · coupon −${formatMoney(quote.coupon.off)}` : ''} · **Total ${formatMoney(quote.total)}**`,
2293
+ couponRefusedLine(quote),
2201
2294
  ...memberTripLines(quote),
2202
2295
  ...(quote.dropped ?? []).map((d) => `Left out: ${d.title} — ${d.why}`),
2203
2296
  demo ? DEMO_NOTE : '',
@@ -2449,6 +2542,8 @@ const labelWords = (r) => {
2449
2542
  ];
2450
2543
  return words.length ? ` [${words.join(', ')}]` : '';
2451
2544
  };
2545
+ /** What a list and a page both say of a hotel with nothing on sale here: other nights would not help. */
2546
+ const NOT_SOLD_OUT = 'not sold out, and other nights make no difference';
2452
2547
  /**
2453
2548
  * What is said of rates listed but not booked here (`Room.notSoldHere`) - in
2454
2549
  * the text and, word for word, in the structure (`notSoldHere`): the model reads
@@ -2456,7 +2551,18 @@ const labelWords = (r) => {
2456
2551
  * card says no more than 预付 on them (robin, 2026-10-30), so the assistant is
2457
2552
  * the one who knows - and says it when the guest wants one.
2458
2553
  */
2459
- export const notSoldHereWords = (group) => `Rates marked paid in advance are listed as ${group} sells them, but a stay paid in advance cannot be booked here yet: this connector books rates paid at the hotel. If the guest wants one, say so plainly - nothing is booked or charged - and offer a rate paid at the hotel, other dates, or booking it with ${group} directly.`;
2554
+ export const notSoldHereWords = (group) => `Rates marked paid in advance are listed as ${group} sells them, but a stay paid in advance cannot be booked here yet; only rates paid at the hotel can be. If the guest wants one, say so plainly - nothing is booked or charged - and offer a rate paid at the hotel, other dates, or booking it with ${group} directly.`;
2555
+ /**
2556
+ * A hotel none of whose rooms are on sale here (`none-on-sale`), said on its page
2557
+ * with the way out (2026-11-03 review, T5). The page's text had one sentence -
2558
+ * "This hotel has no rooms on sale here." - and the card said where to book it
2559
+ * instead, so a client that draws no card left the guest with a dead end. The
2560
+ * clause is the list's (see `NOT_SOLD_OUT`), so the list and the page say one
2561
+ * thing; the group is the deployment's own, or the hotel's in a market, never a
2562
+ * name written into the sentence. In the text and, word for word, in the
2563
+ * structure (`noRooms`): the model reads the structure.
2564
+ */
2565
+ export const notOnSaleWords = (group, why) => `${why.replace(/[.\s]+$/, '')} - ${NOT_SOLD_OUT}. To book it, the guest can use ${group}'s own channels: its app, its website or by phone.`;
2460
2566
  /** The group a hotel is with, by name - the deployment's own brand outside a market. */
2461
2567
  const groupNameOf = (deps, hotelId) => holderOf(deps, deps.backend.supplierOf?.(hotelId)).name;
2462
2568
  /** What a rate says about cancelling, as the text puts it. */
@@ -2645,7 +2751,9 @@ const bookingLine = (b, noCards) => {
2645
2751
  ? ` · coupon ${couponName(b.quote.coupon)} −${formatMoney(b.quote.coupon.off)}${b.couponReturned === true
2646
2752
  ? ' (given back)'
2647
2753
  : b.couponReturned === false
2648
- ? ' (ran out - not given back)'
2754
+ ? b.couponNotReturnedBecause === 'fee'
2755
+ ? ' (not given back - the cancellation was charged a fee)'
2756
+ : ' (ran out - not given back)'
2649
2757
  : ''}`
2650
2758
  : '',
2651
2759
  b.payment
@@ -2851,12 +2959,14 @@ const listAnd = (xs) => xs.length > 1 ? `${xs.slice(0, -1).join(', ')} and ${xs.
2851
2959
  * hotel. The sentence says the first and denies the second, in the text and in
2852
2960
  * `structuredContent` alike - a host with no card has only the words.
2853
2961
  */
2854
- const degradedOf = (deps, answer) => {
2962
+ const degradedOf = (deps, answer,
2963
+ /** What is missing from the list: the hotels of a search, the bookings of "my bookings". */
2964
+ noun = deps.vocab.nounPlural) => {
2855
2965
  const down = answer.degraded ?? [];
2856
2966
  if (down.length === 0)
2857
2967
  return { structured: {}, said: '' };
2858
2968
  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.`;
2969
+ 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
2970
  return {
2861
2971
  structured: {
2862
2972
  degraded: down.map((d) => ({ supplier: d.supplier, name: d.name })),
@@ -2865,6 +2975,37 @@ const degradedOf = (deps, answer) => {
2865
2975
  said,
2866
2976
  };
2867
2977
  };
2978
+ /**
2979
+ * What is said when a read could not be had because hotel groups did not answer
2980
+ * in time - one group, or several, or every one.
2981
+ *
2982
+ * ⚠️ NEVER JUST THE FIRST GROUP, NEVER "NONE HERE" (2026-11-03 review, C5/C8).
2983
+ * Where every group of a market is silent it says so ("no group answered this
2984
+ * time"), not the name of whichever was asked first; and in every case it
2985
+ * denies the reading a guest would make of silence, that there is nothing.
2986
+ */
2987
+ const notAnswered = (deps, key, down) => {
2988
+ const groups = deps.backend.suppliers?.().length ?? 0;
2989
+ const names = listAnd(down.map((d) => d.name));
2990
+ const one = down.length === 1;
2991
+ const said = groups > 1 && down.length >= groups
2992
+ ? `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.`
2993
+ : `${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'}.`;
2994
+ return {
2995
+ structured: {
2996
+ view: 'error',
2997
+ ...common(deps, key),
2998
+ error: 'supplier-timeout',
2999
+ detail: said,
3000
+ degraded: down.map((d) => ({ supplier: d.supplier, name: d.name })),
3001
+ degradedSaid: said,
3002
+ },
3003
+ text: said,
3004
+ // Like `bad-count`: the card still draws it, and a host that checks a
3005
+ // result against the tool's output schema does not hold an error to it.
3006
+ isError: true,
3007
+ };
3008
+ };
2868
3009
  /** The counts a call can carry, and what each may be. */
2869
3010
  const COUNTS = ['guests', 'rooms', 'quantity'];
2870
3011
  /** "2" is 2: a host that sends a count as a string has not asked for something else. */
@@ -2916,6 +3057,13 @@ const countProblem = (t, name, args) => {
2916
3057
  */
2917
3058
  const guestWords = (err, fault = 'That could not be done just now - nothing was changed. Try again in a moment.') => {
2918
3059
  const e = err;
3060
+ // ⚠️ A READ THAT TIMED OUT IS NOT A REFUSAL (2026-11-03 review, C5). After a
3061
+ // booking was made, "Atour did not answer within 8000 ms" would be read
3062
+ // back as the booking's own answer - and sound like it was not made.
3063
+ if (unansweredBy(err)) {
3064
+ console.error('[tools] a read timed out:', e.message);
3065
+ return fault;
3066
+ }
2919
3067
  if (e instanceof Error && (typeof e.code === 'string' || e.constructor === Error))
2920
3068
  return e.message;
2921
3069
  console.error('[tools] failed:', err);
@@ -2969,14 +3117,42 @@ const guarded = async (deps, ctx, who, code, run) => {
2969
3117
  */
2970
3118
  const quoteParts = async (deps, lines, who, coupon) => {
2971
3119
  const byCurrency = new Map();
3120
+ // ⚠️ A HOTEL WHOSE GROUP DID NOT ANSWER IS LEFT OUT OF THE PRICE, SAID (2026-11-03
3121
+ // review, C5): its currency cannot be read, so it cannot be priced with the
3122
+ // rest - and it is not an empty trip, nor a trip without it.
3123
+ const unread = new Map();
2972
3124
  for (const l of lines) {
2973
- const c = (await deps.backend.getHotel(l.hotelId))?.hotel.currency ?? '';
2974
- byCurrency.set(c, [...(byCurrency.get(c) ?? []), l]);
3125
+ try {
3126
+ const c = (await deps.backend.getHotel(l.hotelId))?.hotel.currency ?? '';
3127
+ byCurrency.set(c, [...(byCurrency.get(c) ?? []), l]);
3128
+ }
3129
+ catch (err) {
3130
+ const down = unansweredBy(err);
3131
+ if (!down)
3132
+ throw err;
3133
+ for (const g of down)
3134
+ unread.set(g.supplier, g);
3135
+ }
2975
3136
  }
2976
3137
  const asked = coupon ? { coupon } : {};
3138
+ if (byCurrency.size === 0 && unread.size) {
3139
+ throw Object.assign(new Error(`${listAnd([...unread.values()].map((g) => g.name))} did not answer`), { code: 'supplier-timeout', unanswered: [...unread.values()] });
3140
+ }
2977
3141
  if (byCurrency.size === 0)
2978
3142
  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)));
3143
+ const priced = await Promise.all([...byCurrency.values()].map((g) => deps.backend.quote({ lines: g, ...asked }, who)));
3144
+ const parts = unread.size && priced[0]
3145
+ ? [
3146
+ {
3147
+ ...priced[0],
3148
+ dropped: [
3149
+ ...(priced[0].dropped ?? []),
3150
+ ...[...unread.values()].map(notPricedForNoAnswer),
3151
+ ],
3152
+ },
3153
+ ...priced.slice(1),
3154
+ ]
3155
+ : priced;
2980
3156
  // A coupon goes on one part of a trip in two currencies: the others need
2981
3157
  // not say it is in the wrong one.
2982
3158
  return parts.some((p) => p.coupon) ? parts.map(({ couponRefused: _no, ...p }) => p) : parts;
@@ -3434,7 +3610,17 @@ export const tripGone = (deps) => {
3434
3610
  };
3435
3611
  export const handleTool = async (deps, name, rawArgs, ctx) => {
3436
3612
  const { result, written } = await keepingAmounts(async () => {
3437
- const answer = await answerTool(deps, name, rawArgs, ctx);
3613
+ // ⚠️ A GROUP THAT DID NOT ANSWER A READ IS AN ANSWER, NOT A FAULT (2026-11-03
3614
+ // review, C5/C8): the hotel page, the add-ons, the dates, a booking looked
3615
+ // up. Thrown by the market past its time limit, it reached the host as "the
3616
+ // connector could not answer that" - or, for a search where every group was
3617
+ // silent, as the first group's name alone. Said here, naming every group.
3618
+ const answer = await answerTool(deps, name, rawArgs, ctx).catch((err) => {
3619
+ const down = unansweredBy(err);
3620
+ if (!down)
3621
+ throw err;
3622
+ return notAnswered(deps, ctx.tripKey ?? tripKey(ctx.session, deps.vocab.brand), down);
3623
+ });
3438
3624
  const suggestions = await suggestionsFor(deps, answer.structured).catch(() => []);
3439
3625
  return { answer, suggestions };
3440
3626
  });
@@ -3530,7 +3716,13 @@ const listedInWords = (h) => {
3530
3716
  const price = f
3531
3717
  ? `${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
3718
  : h.noRoomsForStay
3533
- ? 'no room for those nights'
3719
+ ? // ⚠️ NOT SOLD HERE IS NOT SOLD OUT (2026-11-03 review, U11): a hotel with no
3720
+ // rooms on sale through this connector was told as "no room for those
3721
+ // nights", and the model said "no vacancy tonight" - as if another night
3722
+ // would do. It says what the hotel's own page says.
3723
+ h.noRoomsForStayCode === 'none-on-sale'
3724
+ ? h.noRoomsForStay
3725
+ : 'no room for those nights'
3534
3726
  : h.fromNightly
3535
3727
  ? `from ${formatMoney(h.fromNightly)} ${nightWords(true)}`
3536
3728
  : '';
@@ -3763,6 +3955,57 @@ const LIVE_STATUSES = new Set([
3763
3955
  'unconfirmed',
3764
3956
  'awaiting-payment',
3765
3957
  ]);
3958
+ /**
3959
+ * A coupon asked for on a booking that was already made, without it - or
3960
+ * nothing.
3961
+ *
3962
+ * ⚠️ NEVER SILENT THAT THE COUPON WAS NOT USED (2026-11-03 review, C3). The
3963
+ * same stay asked for again within minutes is answered with the first
3964
+ * booking (`repeat`); a member who first booked without a coupon and then said
3965
+ * "use the 满500减50" was handed the first booking at its first price, with
3966
+ * nothing said of the coupon they had just agreed to. The coupon is NOT put in
3967
+ * the repeat key - that would book the stay twice - so the answer says it:
3968
+ * the booking stands as it was, the coupon was not spent, and what it takes to
3969
+ * use it is cancelling this one (free until when, or what it costs) and
3970
+ * booking again. In the structure as `couponNotUsed` and in the same words.
3971
+ */
3972
+ const couponNotUsedOf = (deps, key, asked,
3973
+ /** The bookings that were asked for before and returned as they were. */
3974
+ reusedBookings,
3975
+ /** Every booking in the answer: the coupon may have gone on one made now. */
3976
+ answered) => {
3977
+ const want = asked?.trim().toUpperCase();
3978
+ if (!want || reusedBookings.length === 0)
3979
+ return undefined;
3980
+ if (answered.some((b) => b.quote.coupon?.id.toUpperCase() === want))
3981
+ return undefined;
3982
+ const t = deps.tools;
3983
+ const held = deps.members.read(key)?.coupons?.find((c) => c.id.toUpperCase() === want);
3984
+ const label = held ? couponName(held) : (asked ?? '');
3985
+ const codes = reusedBookings.map((b) => b.confirmationCode).filter(Boolean);
3986
+ const names = codes.length ? codes.join(', ') : 'it';
3987
+ const now = deps.trips.now();
3988
+ // Free to cancel still: every room line has a window that has not closed.
3989
+ const rooms = reusedBookings.flatMap((b) => b.quote.lines.filter((l) => l.kind === 'room'));
3990
+ const open = rooms.filter((l) => l.freeCancellationUntil && Date.parse(l.freeCancellationUntil) > now);
3991
+ const first = open
3992
+ .slice()
3993
+ .sort((a, b) => (a.freeCancellationUntil ?? '').localeCompare(b.freeCancellationUntil ?? ''))[0];
3994
+ const freeUntil = rooms.length > 0 && open.length === rooms.length && first ? freeUntilText(first) : undefined;
3995
+ const total = reusedBookings.map((b) => formatMoney(b.quote.total)).join(' + ');
3996
+ 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.`;
3997
+ return {
3998
+ structured: {
3999
+ id: asked,
4000
+ ...(held ? { name: held.name, ...(held.names ? { names: held.names } : {}) } : {}),
4001
+ bookings: codes,
4002
+ cancelFree: !!freeUntil,
4003
+ ...(freeUntil ? { freeUntil } : {}),
4004
+ said,
4005
+ },
4006
+ said,
4007
+ };
4008
+ };
3766
4009
  /**
3767
4010
  * The booking this very form made a few minutes ago, when the slot is empty
3768
4011
  * because of it - or undefined.
@@ -3803,6 +4046,7 @@ const bookedFromThisForm = async (deps, ctx, key, args) => {
3803
4046
  x.quote.lines.some((l) => l.kind === 'room' && l.roomRef === room && (l.quantity ?? 1) === rooms));
3804
4047
  if (!b)
3805
4048
  return undefined;
4049
+ const notUsed = couponNotUsedOf(deps, key, couponOf(deps, args), [b], [b]);
3806
4050
  return {
3807
4051
  structured: {
3808
4052
  view: 'booking',
@@ -3812,9 +4056,11 @@ const bookedFromThisForm = async (deps, ctx, key, args) => {
3812
4056
  holdUntil: b.holdUntil,
3813
4057
  total: formatMoney(b.quote.total),
3814
4058
  repeat: true,
4059
+ ...(notUsed ? { couponNotUsed: notUsed.structured } : {}),
3815
4060
  },
3816
4061
  text: [
3817
4062
  '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.',
4063
+ notUsed?.said ?? '',
3818
4064
  `**${confirming(deps, b) ? 'Being confirmed' : b.confirmationCode}** · ${b.hotelName} · ${stayWords(b.stay, b.hourly)}`,
3819
4065
  bookedLine(deps, b) ?? '',
3820
4066
  deps.vocab.demo ? noteFor(deps.vocab) : '',
@@ -3869,6 +4115,47 @@ const putRoomLine = async (deps, t, ctx, key, store, args, detail) => {
3869
4115
  return currentTrip(deps, key, who, store, couponOf(deps, args), ctx);
3870
4116
  }
3871
4117
  }
4118
+ // ⚠️ LISTED, NOT BOOKED HERE (robin, 2026-10-30): a rate paid in advance
4119
+ // where this deployment takes no payment goes in no trip and no pick. The
4120
+ // press on its Reserve - or the assistant asking for it - is answered with
4121
+ // what is true and what is left: nothing booked, nothing charged; the rates
4122
+ // here paid at the hotel, a press each; other dates; or the group itself.
4123
+ // Never a review that fails at Book, after the guest typed their name.
4124
+ // ONE ANSWER FOR A ROOM BY THE NIGHT AND A ROOM BY THE HOUR (2026-11-03
4125
+ // review, C6): the hourly branch put such a room in the trip before this was
4126
+ // asked, and the guest met the refusal at "Book".
4127
+ const prepaidNotHere = (room, open, stay, when) => {
4128
+ const group = groupNameOf(deps, hotelId);
4129
+ const said = [
4130
+ `Nothing was booked or charged: ${room.name} is paid in advance, and a stay paid in advance cannot be booked here yet - only rates paid at the hotel can be.`,
4131
+ open.length
4132
+ ? `Paid at the hotel at ${detail.hotel.name} ${when}: ${open.map((r) => `${r.name} (\`${r.id}\`)`).join(', ')}.`
4133
+ : `No rate at ${detail.hotel.name} ${when} is paid at the hotel.`,
4134
+ `Other dates, or booking this rate with ${group} directly, are the other ways on.`,
4135
+ ].join(' ');
4136
+ return {
4137
+ structured: {
4138
+ view: 'error',
4139
+ ...common(deps, key),
4140
+ error: 'prepaid-not-here',
4141
+ room: { id: room.id, name: room.name, ...(room.names ? { names: room.names } : {}) },
4142
+ rooms: open.map((r) => ({
4143
+ id: r.id,
4144
+ name: r.name,
4145
+ ...(r.names ? { names: r.names } : {}),
4146
+ })),
4147
+ hotel: {
4148
+ id: detail.hotel.id,
4149
+ name: detail.hotel.name,
4150
+ ...(detail.hotel.names ? { names: detail.hotel.names } : {}),
4151
+ },
4152
+ stay,
4153
+ group,
4154
+ said,
4155
+ },
4156
+ text: said,
4157
+ };
4158
+ };
3872
4159
  const today = (startTime || !asStay(args)
3873
4160
  ? deps.backend.hourlyRooms?.(hotelId, guestsOf(args), who)
3874
4161
  : undefined);
@@ -3897,6 +4184,15 @@ const putRoomLine = async (deps, t, ctx, key, store, args, detail) => {
3897
4184
  : ((await today)?.noRooms ?? `${detail.hotel.name} has no rooms by the hour today.`),
3898
4185
  };
3899
4186
  }
4187
+ if (hourlyRoom.notSoldHere) {
4188
+ const wantedHere = Math.max(1, clampCount(args.rooms ?? 1, MAX_ROOMS_PER_LINE));
4189
+ const open = ((await today)?.rooms ?? []).filter((r) => r.hourly && !r.notSoldHere && r.roomsLeft >= wantedHere);
4190
+ return prepaidNotHere(hourlyRoom, open, {
4191
+ checkIn: hourlyRoom.hourly.date,
4192
+ checkOut: hourlyRoom.hourly.date,
4193
+ guests: guestsOf(args),
4194
+ }, 'by the hour today');
4195
+ }
3900
4196
  const bad = startProblem(hourlyRoom.hourly, startTime);
3901
4197
  if (bad) {
3902
4198
  return {
@@ -4026,44 +4322,10 @@ const putRoomLine = async (deps, t, ctx, key, store, args, detail) => {
4026
4322
  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
4323
  };
4028
4324
  }
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.
4325
+ // Listed, not booked here - see `prepaidNotHere`.
4035
4326
  if (room.notSoldHere) {
4036
4327
  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
- };
4328
+ return prepaidNotHere(room, open, stay, 'for those nights');
4067
4329
  }
4068
4330
  deps.trips.set(store, {
4069
4331
  kind: 'room',
@@ -4103,7 +4365,7 @@ const bookTripLines = async (deps, t, ctx, key, store, args, nothingThere) => {
4103
4365
  // the trip as it is now (no stamp); a card's form books the one it showed.
4104
4366
  const drawn = str(args.stamp);
4105
4367
  if (drawn && drawn !== stampOf(inTrip, deps.trips.shown(store))) {
4106
- const again = await currentTrip(deps, key, identity(deps, ctx, key), store);
4368
+ const again = await currentTrip(deps, key, identity(deps, ctx, key), store, undefined, ctx);
4107
4369
  return {
4108
4370
  ...again,
4109
4371
  structured: { ...again.structured, tripChanged: true },
@@ -4196,7 +4458,7 @@ const bookTripLines = async (deps, t, ctx, key, store, args, nothingThere) => {
4196
4458
  // booking is held to (ShownQuote).
4197
4459
  const rest = lines.length === inTrip.length && !failed.length
4198
4460
  ? undefined
4199
- : await currentTrip(deps, key, identity(deps, ctx, key), store);
4461
+ : await currentTrip(deps, key, identity(deps, ctx, key), store, undefined, ctx);
4200
4462
  const others = rest && lines.length !== inTrip.length;
4201
4463
  // What could be booked instead of each, found now (robin, 2026-10-14).
4202
4464
  const instead = await Promise.all(failed.map((f, i) => i < MAX_FAILED_WITH_ALTERNATIVES
@@ -4243,7 +4505,9 @@ const bookTripLines = async (deps, t, ctx, key, store, args, nothingThere) => {
4243
4505
  // at 15:00, and a generic "check-in is in the afternoon" is not an answer.
4244
4506
  // Per booking: each is its own hotel, with its own hour and its own desk.
4245
4507
  const booked = await Promise.all(made.map(async (booking, i) => {
4246
- const hotel = await deps.backend.getHotel(booking.hotelId);
4508
+ // ⚠️ THE BOOKING IS MADE: a read after it that fails (a group slow to
4509
+ // answer) loses the check-in hour, never the answer.
4510
+ const hotel = await deps.backend.getHotel(booking.hotelId).catch(() => null);
4247
4511
  return {
4248
4512
  booking,
4249
4513
  // The requests that could not both be asked for are said once.
@@ -4254,6 +4518,10 @@ const bookTripLines = async (deps, t, ctx, key, store, args, nothingThere) => {
4254
4518
  const several = booked.length > 1;
4255
4519
  // The stays asked for minutes before, beside new ones - see `reused`.
4256
4520
  const earlier = booked.filter(({ booking }) => reused?.includes(booking.id));
4521
+ // The coupon asked for, when the stays asked for before did not carry it
4522
+ // and nothing new did either - see `couponNotUsedOf`.
4523
+ const askedCoupon = couponOf(deps, args);
4524
+ const notUsed = couponNotUsedOf(deps, key, askedCoupon, repeat ? made : earlier.map(({ booking }) => booking), made);
4257
4525
  const waiting = booked.filter(({ booking }) => confirming(deps, booking)).length;
4258
4526
  const notBooked = failed.map((f, i) => ({
4259
4527
  hotelId: f.hotelId,
@@ -4348,6 +4616,7 @@ const bookTripLines = async (deps, t, ctx, key, store, args, nothingThere) => {
4348
4616
  ...(notBooked.length ? { failed: notBooked } : {}),
4349
4617
  ...(repeat ? { repeat: true } : {}),
4350
4618
  ...(earlier.length ? { repeated: earlier.map(({ booking }) => booking.id) } : {}),
4619
+ ...(notUsed ? { couponNotUsed: notUsed.structured } : {}),
4351
4620
  },
4352
4621
  text: [
4353
4622
  // ⚠️ THE SAME BOOKING ASKED FOR AGAIN IS SAID TO BE THAT, never a new
@@ -4357,6 +4626,7 @@ const bookTripLines = async (deps, t, ctx, key, store, args, nothingThere) => {
4357
4626
  : earlier.length
4358
4627
  ? `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
4628
  : '',
4629
+ notUsed?.said ?? '',
4360
4630
  // ⚠️ BOOKED IS CONFIRMED: a stay still being confirmed is said as
4361
4631
  // that, and not counted (总线程, 2026-10-14).
4362
4632
  notBooked.length || (booked.length > 1 && waiting)
@@ -4386,13 +4656,16 @@ const bookTripLines = async (deps, t, ctx, key, store, args, nothingThere) => {
4386
4656
  // and the trip is shown again at today's - which is then what the guest
4387
4657
  // has seen, and what a second ask books.
4388
4658
  if (err.code === 'price-changed') {
4389
- const again = await currentTrip(deps, key, identity(deps, ctx, key), store);
4659
+ const again = await currentTrip(deps, key, identity(deps, ctx, key), store, undefined, ctx);
4390
4660
  return { ...again, text: [words, again.text].join('\n\n') };
4391
4661
  }
4392
4662
  // The coupon asked for cannot be used: nothing was booked, and the trip
4393
4663
  // is shown again with it refused and why - the guest decides what next.
4394
4664
  if (err.code === 'coupon') {
4395
- const again = await currentTrip(deps, key, identity(deps, ctx, key), store, couponOf(deps, args));
4665
+ // ⚠️ WITH THE CONTEXT, like every other answer of the trip (2026-11-03
4666
+ // review, T1): without it the restated trip lost the Chinese room and
4667
+ // hotel names a host with no card is given beside the plain ones.
4668
+ const again = await currentTrip(deps, key, identity(deps, ctx, key), store, couponOf(deps, args), ctx);
4396
4669
  return {
4397
4670
  ...again,
4398
4671
  structured: { ...again.structured, notBooked: 'coupon' },
@@ -4400,7 +4673,7 @@ const bookTripLines = async (deps, t, ctx, key, store, args, nothingThere) => {
4400
4673
  };
4401
4674
  }
4402
4675
  if (err.code === 'mixed-supplier') {
4403
- const again = await currentTrip(deps, key, identity(deps, ctx, key), store);
4676
+ const again = await currentTrip(deps, key, identity(deps, ctx, key), store, undefined, ctx);
4404
4677
  return {
4405
4678
  ...again,
4406
4679
  text: [
@@ -4500,7 +4773,14 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
4500
4773
  // FAILED either, for the same reason as the ceiling above: the place
4501
4774
  // matched. What matched the place and the words, before either:
4502
4775
  const placedCount = (a) => a.beforeFilters ?? a.beforeCeiling ?? a.total;
4503
- if (placedCount(answer) === 0) {
4776
+ // ⚠️ AND NOT WHEN A GROUP DID NOT ANSWER (2026-11-03 review, C4). The place
4777
+ // "matched nothing" among the groups that answered; the one that did not
4778
+ // may have it. A wider search then found something else, took over as the
4779
+ // answer, and the first search's "X did not answer" went with it - "nothing
4780
+ // matched Hong Kong, so this is Shenzhen", with Hong Kong's group silent.
4781
+ // It also cost another time limit. The empty answer is given as it is, with
4782
+ // the sentence that the group did not answer (`degradedOf`).
4783
+ if (placedCount(answer) === 0 && !answer.degraded?.length) {
4504
4784
  const wider = widen(ask);
4505
4785
  if (wider) {
4506
4786
  const second = await deps.backend.searchHotels(wider.ask, who);
@@ -4524,7 +4804,15 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
4524
4804
  // backend contract, but "the next backend honours it" is not something the
4525
4805
  // card can be built on - and the cost of it being wrong is the whole group
4526
4806
  // in the model's context.
4527
- const hotels = answer.hotels.slice(0, MAX_PAGE);
4807
+ // ⚠️ A HOTEL NOT ON SALE HERE SAYS WHERE IT IS (review r2, T5): its page said
4808
+ // the group's own channels, its tile on a list - the answer a model often
4809
+ // stops at - only that nothing was on sale. In the text and the structure.
4810
+ const hotels = answer.hotels.slice(0, MAX_PAGE).map((h) => h.noRoomsForStayCode === 'none-on-sale' && h.noRoomsForStay
4811
+ ? {
4812
+ ...h,
4813
+ noRoomsForStay: `${h.noRoomsForStay.replace(/[.\s]+$/, '')} - it can be booked through ${groupNameOf(deps, h.id)}'s own channels: its app, its website or by phone.`,
4814
+ }
4815
+ : h);
4528
4816
  const offset = asked.offset ?? 0;
4529
4817
  const total = Math.max(answer.total, offset + hotels.length);
4530
4818
  const cities = [...new Set(hotels.map((h) => h.city))];
@@ -4666,8 +4954,11 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
4666
4954
  guests: guestsOf(args),
4667
4955
  };
4668
4956
  const priced = await answerTool(deps, t.listHotels, { ...args, ...tonight }, ctx);
4957
+ // ⚠️ THE ROOMS ASKED FOR, not "one room" (2026-11-03 review, C12): `rooms: 2`
4958
+ // rides along into the priced list, and every price in it is two rooms'.
4959
+ const roomCount = ask.rooms ?? 1;
4669
4960
  // 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.`;
4961
+ 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
4962
  // Right after what is here - "every one, and no others" stays the first thing said.
4672
4963
  const inAll = String(priced.structured.inAll ?? '');
4673
4964
  return {
@@ -4693,7 +4984,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
4693
4984
  // assistant ask which city to list the rest in (2026-10-15 smoke run).
4694
4985
  // And not "tell me another city": the second run said there was one
4695
4986
  // hotel, then offered to look in another city (2026-10-15).
4696
- const inAll = `This connector has one ${branded(deps.vocab, deps.vocab.noun)} in all, ${sole.name}${sole.city ? ` in ${sole.city}` : ''}. There is no other hotel and no other city here - nothing else to search for or to offer.`;
4987
+ const inAll = `There is one ${branded(deps.vocab, deps.vocab.noun)} here in all, ${sole.name}${sole.city ? ` in ${sole.city}` : ''}. There is no other hotel and no other city here - nothing else to search for or to offer.`;
4697
4988
  return {
4698
4989
  ...page,
4699
4990
  // In both, in the same words: a host may hand its model this rather
@@ -4910,8 +5201,12 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
4910
5201
  ask.filters?.hourly && hotels.some((h) => h.forStay?.hours)
4911
5202
  ? `By the hour, today: each price is that ${deps.vocab.noun}'s cheapest room by the hour, for its hours, taxes included.`
4912
5203
  : 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`
5204
+ ? `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')
5205
+ ? `; ${hotels.filter((h) => h.noRoomsForStay && h.noRoomsForStayCode !== 'none-on-sale').length} in this answer have no room for those nights`
5206
+ : ''}${
5207
+ // The other kind, said as its own (U11): other nights will not help.
5208
+ hotels.some((h) => h.noRoomsForStayCode === 'none-on-sale')
5209
+ ? `; ${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 here - ${NOT_SOLD_OUT}`
4915
5210
  : ''}.`
4916
5211
  : '',
4917
5212
  ].filter(Boolean);
@@ -4925,7 +5220,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
4925
5220
  // only "2 hotels", the model asked for the list again to learn which.
4926
5221
  const degraded = degradedOf(deps, answer);
4927
5222
  const inAll = homeList
4928
- ? `This connector has ${total} ${branded(deps.vocab, deps.vocab.nounPlural)} in all, and this answer lists every one: ${hotels
5223
+ ? `There are ${total} ${branded(deps.vocab, deps.vocab.nounPlural)} here in all, and this answer lists every one: ${hotels
4929
5224
  .map((h) => `${hotelName(h.name)}${h.city ? ` in ${h.city}` : ''}`)
4930
5225
  .join('; ')}. There is no other ${deps.vocab.noun} and no other city here - nothing else to list, search for or offer.`
4931
5226
  : '';
@@ -5029,7 +5324,9 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5029
5324
  };
5030
5325
  }
5031
5326
  const h = detail.hotel;
5032
- const description = hotelText('description', h.name, h.summary, DESCRIPTION_MAX, deps.vocab.demo);
5327
+ const description = hotelText('description', h.name,
5328
+ // Its 【…】 parts a line each, the words as they came (`aboutLines`).
5329
+ aboutLines(h.summary), DESCRIPTION_MAX, deps.vocab.demo);
5033
5330
  const policies = await deps.backend.policies(h.id);
5034
5331
  const extras = await deps.backend.extrasFor(h.id, stay);
5035
5332
  const base = {
@@ -5076,17 +5373,23 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5076
5373
  : [];
5077
5374
  const flex = dateFlex(dates, stay);
5078
5375
  if (detail.noRooms || !detail.rooms) {
5376
+ // ⚠️ NOTHING ON SALE HERE SAYS WHERE TO BOOK IT (T5): the same sentence
5377
+ // in the text and in `noRooms`, which the card does not draw for this code
5378
+ // (it says it in the guest's language from `noRoomsCode`).
5379
+ const said = detail.noRooms && detail.noRoomsCode === 'none-on-sale'
5380
+ ? notOnSaleWords(groupNameOf(deps, h.id), detail.noRooms)
5381
+ : detail.noRooms;
5079
5382
  return {
5080
5383
  structured: {
5081
5384
  view: 'no-rooms',
5082
5385
  ...base,
5083
5386
  stay,
5084
- noRooms: detail.noRooms,
5387
+ noRooms: said,
5085
5388
  ...(detail.noRoomsCode ? { noRoomsCode: detail.noRoomsCode } : {}),
5086
5389
  ...(detail.noRoomsArgs ? { noRoomsArgs: detail.noRoomsArgs } : {}),
5087
5390
  ...flex.structured,
5088
5391
  },
5089
- text: [detail.noRooms ?? 'Nothing is available for those nights.', flex.text]
5392
+ text: [said ?? 'Nothing is available for those nights.', flex.text]
5090
5393
  .filter(Boolean)
5091
5394
  .join(' '),
5092
5395
  };
@@ -5254,7 +5557,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5254
5557
  continue;
5255
5558
  const h = detail.hotel;
5256
5559
  read.push({ id: h.id, name: h.name });
5257
- const about = hotelText('description', h.name, [h.summary, h.amenities.length ? `Has: ${h.amenities.join(', ')}.` : '']
5560
+ const about = hotelText('description', h.name, [aboutLines(h.summary), h.amenities.length ? `Has: ${h.amenities.join(', ')}.` : '']
5258
5561
  .filter(Boolean)
5259
5562
  .join('\n'), DESCRIPTION_MAX, deps.vocab.demo);
5260
5563
  const extras = await deps.backend.extrasFor(h.id, stay);
@@ -5409,7 +5712,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5409
5712
  text: extras.length
5410
5713
  ? extras
5411
5714
  .map((e) => `- ${e.name}${ctx.noCards ? ` (\`${e.id}\`)` : ''} — ${formatMoney(e.price)} ${unitWords(e.per)}${held[e.id] ? ` (${booked?.confirmationCode} holds ${held[e.id]})` : ''}`)
5412
- .concat(deps.vocab.demo ? [noteFor(deps.vocab)] : [])
5715
+ .concat(deps.vocab.demo && noteFor(deps.vocab) ? [noteFor(deps.vocab)] : [])
5413
5716
  .join('\n')
5414
5717
  : `${detail.hotel.name} has no add-ons listed here.`,
5415
5718
  };
@@ -5445,7 +5748,9 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5445
5748
  ...(isDay(str(args.day)) ? { day: str(args.day) } : {}),
5446
5749
  };
5447
5750
  deps.trips.set(key, line);
5448
- return currentTrip(deps, key, identity(deps, ctx, key), key, undefined, ctx);
5751
+ // ⚠️ THE COUPON STAYS ON, like on a room (2026-11-03 review, C11): its
5752
+ // schema says it keeps the new price, and `undefined` here dropped it.
5753
+ return currentTrip(deps, key, identity(deps, ctx, key), key, couponOf(deps, args), ctx);
5449
5754
  }
5450
5755
  return putRoomLine(deps, t, ctx, key, key, args, detail);
5451
5756
  }
@@ -5492,6 +5797,9 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5492
5797
  if (name === t.listBookings) {
5493
5798
  const who = identity(deps, ctx, key, args);
5494
5799
  const here = await deps.backend.listBookings(who, typeof args.limit === 'number' ? args.limit : undefined);
5800
+ // Read here, before a filter or a spread drops it: the groups whose
5801
+ // bookings are not in `here` (2026-11-03 review, C5).
5802
+ const missing = degradedOf(deps, { degraded: here.degraded }, 'bookings');
5495
5803
  // Elsewhere, only with the code AND the email - see `listBookings`.
5496
5804
  const code = str(args.booking);
5497
5805
  const { found: there, tooMany } = code && str(args.email)
@@ -5517,6 +5825,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5517
5825
  // than "nothing in this conversation".
5518
5826
  ...(missed ? { notFound: code, notFoundLine: missLine } : {}),
5519
5827
  ...(tooMany ? { tooMany: true } : {}),
5828
+ ...missing.structured,
5520
5829
  },
5521
5830
  text: [
5522
5831
  missed ? missLine : '',
@@ -5524,7 +5833,11 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5524
5833
  ? bookings.map((b) => bookingLine(b, ctx.noCards)).join('\n')
5525
5834
  : code
5526
5835
  ? ''
5527
- : 'No bookings in this conversation. A booking made elsewhere is found with its confirmation code and the email it was made with.',
5836
+ : // ⚠️ NOT "NO BOOKINGS" when a group did not answer: it may hold them.
5837
+ missing.said
5838
+ ? ''
5839
+ : 'No bookings in this conversation. A booking made elsewhere is found with its confirmation code and the email it was made with.',
5840
+ missing.said,
5528
5841
  ]
5529
5842
  .filter(Boolean)
5530
5843
  .join('\n'),
@@ -5881,13 +6194,18 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5881
6194
  toolToConfirm: t.cancelBooking,
5882
6195
  // Whether cancelling hands the coupon back - the same sentence as the text.
5883
6196
  ...(preview.coupon
5884
- ? { coupon: { ...preview.coupon, said: couponBackWords(preview.coupon) } }
6197
+ ? {
6198
+ coupon: {
6199
+ ...preview.coupon,
6200
+ said: couponBackWords(preview.coupon, formatMoney(preview.fee)),
6201
+ },
6202
+ }
5885
6203
  : {}),
5886
6204
  },
5887
6205
  text: [
5888
6206
  `Cancelling ${preview.confirmationCode} would cost ${formatMoney(preview.fee)}${preview.fee.minor === 0 ? ' - nothing' : ''}.`,
5889
6207
  preview.rule,
5890
- preview.coupon ? couponBackWords(preview.coupon) : '',
6208
+ preview.coupon ? couponBackWords(preview.coupon, formatMoney(preview.fee)) : '',
5891
6209
  preview.cancellable
5892
6210
  ? `Say the word and it is cancelled (${t.cancelBooking} with \`confirmed\`).`
5893
6211
  : '',
@@ -5930,6 +6248,28 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5930
6248
  // will be charged.
5931
6249
  const charged = cancelled.cancellationFee ?? preview.fee;
5932
6250
  const h = holderOf(deps, cancelled.supplier);
6251
+ // What became of the coupon the booking used, said once and handed over in
6252
+ // both: the structure's `coupon` and the text's sentence are this one string.
6253
+ const couponNow = cancelled.quote.coupon && cancelled.couponReturned !== undefined
6254
+ ? (() => {
6255
+ const returned = cancelled.couponReturned === true;
6256
+ const c = {
6257
+ id: cancelled.quote.coupon.id,
6258
+ name: cancelled.quote.coupon.name,
6259
+ ...(cancelled.quote.coupon.names ? { names: cancelled.quote.coupon.names } : {}),
6260
+ validUntil: cancelled.quote.coupon.validUntil,
6261
+ returned,
6262
+ // A booking cancelled before the reason was kept: a coupon not
6263
+ // returned could then only have run out.
6264
+ ...(returned
6265
+ ? {}
6266
+ : {
6267
+ notReturnedBecause: cancelled.couponNotReturnedBecause ?? 'expired',
6268
+ }),
6269
+ };
6270
+ return { ...c, said: couponBackWords(c, formatMoney(charged), true) };
6271
+ })()
6272
+ : undefined;
5933
6273
  return {
5934
6274
  structured: {
5935
6275
  view: 'cancelled',
@@ -5940,25 +6280,25 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5940
6280
  fee: formatMoney(charged),
5941
6281
  rule: preview.rule,
5942
6282
  cancellable: false,
6283
+ ...(couponNow ? { coupon: couponNow } : {}),
5943
6284
  },
5944
6285
  text: [
5945
6286
  cancelled.payment
5946
6287
  ? // Paid here in advance: what becomes of the money is the answer.
5947
6288
  `${cancelled.confirmationCode} is ${cancelled.status === 'expired' ? 'called off before it was paid' : 'cancelled'}. ${paidLine(deps, cancelled, charged)}`
5948
- : h.sandbox
5949
- ? // A test booking cancelled in the simulation: no hotel, no charge (2026-10-05 audit, P2).
5950
- `${cancelled.confirmationCode} is cancelled in the simulated system. ${charged.minor === 0 ? 'No fee.' : `A real booking on this rate would be charged ${formatMoney(charged)}; this one is a test, and nothing is charged.`}`
5951
- : h.test
5952
- ? `${cancelled.confirmationCode} is cancelled in ${h.name}'s test system. ${charged.minor === 0 ? 'No fee.' : `A live booking on this rate would be charged ${formatMoney(charged)}; this one is a test, and nothing is charged.`}`
5953
- : h.demo
5954
- ? // Nobody charges a demonstration anything (2026-10-05 audit, P2).
5955
- `${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
- : `${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
- : '',
6289
+ : h.notReal
6290
+ ? // A market's group: the fact, in no environment's word (robin, 2026-11-03).
6291
+ `${cancelled.confirmationCode} is cancelled. ${charged.minor === 0 ? 'No fee.' : `A real booking on this rate would be charged ${formatMoney(charged)}; this booking was never sent to ${h.name}, and nothing is charged.`}`
6292
+ : h.sandbox
6293
+ ? // A simulated booking cancelled in the simulation: no hotel, no charge (2026-10-05 audit, P2).
6294
+ `${cancelled.confirmationCode} is cancelled in the simulated system. ${charged.minor === 0 ? 'No fee.' : `A real booking on this rate would be charged ${formatMoney(charged)}; this one is simulated, and nothing is charged.`}`
6295
+ : h.test
6296
+ ? `${cancelled.confirmationCode} is cancelled in ${h.name}'s test system. ${charged.minor === 0 ? 'No fee.' : `A live booking on this rate would be charged ${formatMoney(charged)}; this one is a test, and nothing is charged.`}`
6297
+ : h.demo
6298
+ ? // Nobody charges a demonstration anything (2026-10-05 audit, P2).
6299
+ `${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.`}`
6300
+ : `${cancelled.confirmationCode} is cancelled. ${charged.minor === 0 ? 'No fee.' : `The hotel charges ${formatMoney(charged)}.`}`,
6301
+ couponNow?.said ?? '',
5962
6302
  demoBookingNote(deps, cancelled.supplier),
5963
6303
  deps.vocab.demo ? noteFor(deps.vocab) : '',
5964
6304
  ]
@@ -5982,7 +6322,10 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5982
6322
  who: identity(deps, ctx, key),
5983
6323
  });
5984
6324
  if (comparison.columns.length === 0) {
5985
- const unknown = comparison.dropped.filter((d) => !d.candidates?.length);
6325
+ // A group that did not answer is not "not ours" (2026-11-03 review, C5):
6326
+ // its hotels are said as left out for that.
6327
+ const silent = comparison.dropped.filter((d) => d.code === 'supplier-timeout');
6328
+ const unknown = comparison.dropped.filter((d) => !d.candidates?.length && d.code !== 'supplier-timeout');
5986
6329
  const fitsMany = fitsManyOf(deps, comparison.dropped);
5987
6330
  return {
5988
6331
  structured: {
@@ -5992,12 +6335,13 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5992
6335
  },
5993
6336
  // A name that fits several IS one of ours - only the rest are "none".
5994
6337
  text: [
5995
- ...(unknown.length || !fitsMany.length
6338
+ ...(unknown.length || (!fitsMany.length && !silent.length)
5996
6339
  ? [
5997
6340
  `None of those are ${catalogue(deps.vocab)}: ${unknown.map((d) => d.name).join(', ')}.`,
5998
6341
  ]
5999
6342
  : []),
6000
6343
  ...fitsMany,
6344
+ ...silent.map((d) => `Left out: ${d.name} — ${d.why}`),
6001
6345
  ].join('\n'),
6002
6346
  };
6003
6347
  }
@@ -6048,6 +6392,25 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
6048
6392
  }
6049
6393
  if (name === t.itinerary) {
6050
6394
  const days = Array.isArray(args.days) ? args.days : [];
6395
+ // ⚠️ WHICH STAYS ARE BOOKED ALREADY marks the plan; a group that did not
6396
+ // answer cannot be checked, and the plan says so rather than showing its
6397
+ // stays as not booked (2026-11-03 review, C5).
6398
+ let planBookings = [];
6399
+ let unchecked = [];
6400
+ try {
6401
+ const mine = await deps.backend.listBookings(identity(deps, ctx, key, args), 50);
6402
+ planBookings = mine;
6403
+ unchecked = mine.degraded ?? [];
6404
+ }
6405
+ catch (err) {
6406
+ const down = unansweredBy(err);
6407
+ if (!down)
6408
+ throw err;
6409
+ unchecked = down;
6410
+ }
6411
+ const uncheckedSaid = unchecked.length
6412
+ ? `${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.`
6413
+ : '';
6051
6414
  const plan = await buildItinerary(deps.backend, days.map((d) => ({
6052
6415
  label: str(d.label),
6053
6416
  note: typeof d.note === 'string' ? d.note : undefined,
@@ -6058,7 +6421,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
6058
6421
  title: str(args.title),
6059
6422
  stay: asStay(args),
6060
6423
  trip: deps.trips.read(key),
6061
- bookings: await deps.backend.listBookings(identity(deps, ctx, key, args), 50),
6424
+ bookings: planBookings,
6062
6425
  who: identity(deps, ctx, key),
6063
6426
  });
6064
6427
  if (plan.days.length === 0) {
@@ -6120,6 +6483,12 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
6120
6483
  gap,
6121
6484
  ...(nudges.length ? { nudges } : {}),
6122
6485
  ...(overlaps.length ? { overlaps } : {}),
6486
+ ...(uncheckedSaid
6487
+ ? {
6488
+ degraded: unchecked.map((d) => ({ supplier: d.supplier, name: d.name })),
6489
+ degradedSaid: uncheckedSaid,
6490
+ }
6491
+ : {}),
6123
6492
  // In both, like the nudges: a host with no card may hand its model
6124
6493
  // this rather than the text.
6125
6494
  ...(ctx.noCards ? { notDrawn: PLAN_NOT_DRAWN } : {}),
@@ -6139,6 +6508,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
6139
6508
  : []),
6140
6509
  ...plan.dropped.map((d) => `Left out: ${d.name} — ${d.why}`),
6141
6510
  ...fitsManyOf(deps, plan.dropped),
6511
+ uncheckedSaid,
6142
6512
  ctx.noCards ? PLAN_NOT_DRAWN : PLAN_DRAWN_ONCE,
6143
6513
  deps.vocab.demo ? noteFor(deps.vocab) : '',
6144
6514
  ]
@@ -6177,7 +6547,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
6177
6547
  // would find out at the desk.
6178
6548
  return {
6179
6549
  structured: { view: 'member-not-found', ...common(deps, key), query: number },
6180
- text: `No ${deps.vocab.brand} membership matches that number and surname. Check both as they are printed; nothing else is needed - this connector never asks for a password.`,
6550
+ text: `No ${deps.vocab.brand} membership matches that number and surname. Check both as they are printed; nothing else is needed - a password is never asked for here.`,
6181
6551
  };
6182
6552
  }
6183
6553
  deps.members.set(key, member);