@curless/hotel-storefront 0.54.0 → 0.55.1

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 (77) hide show
  1. package/dist/backend.d.ts +9 -0
  2. package/dist/backend.d.ts.map +1 -1
  3. package/dist/backend.js.map +1 -1
  4. package/dist/card.generated.d.ts +1 -1
  5. package/dist/card.generated.js +2 -2
  6. package/dist/card.generated.js.map +1 -1
  7. package/dist/config.d.ts +40 -2
  8. package/dist/config.d.ts.map +1 -1
  9. package/dist/config.js.map +1 -1
  10. package/dist/demo-hotels.d.ts.map +1 -1
  11. package/dist/demo-hotels.js +29 -13
  12. package/dist/demo-hotels.js.map +1 -1
  13. package/dist/fence.d.ts.map +1 -1
  14. package/dist/fence.js +4 -3
  15. package/dist/fence.js.map +1 -1
  16. package/dist/huazhu/backend.d.ts.map +1 -1
  17. package/dist/huazhu/backend.js +17 -22
  18. package/dist/huazhu/backend.js.map +1 -1
  19. package/dist/huazhu/catalog.d.ts +6 -6
  20. package/dist/huazhu/catalog.d.ts.map +1 -1
  21. package/dist/huazhu/catalog.js +6 -9
  22. package/dist/huazhu/catalog.js.map +1 -1
  23. package/dist/legal.d.ts.map +1 -1
  24. package/dist/legal.js +104 -80
  25. package/dist/legal.js.map +1 -1
  26. package/dist/lib.d.ts +1 -1
  27. package/dist/lib.d.ts.map +1 -1
  28. package/dist/lib.js +1 -1
  29. package/dist/lib.js.map +1 -1
  30. package/dist/otaless/backend.d.ts +31 -0
  31. package/dist/otaless/backend.d.ts.map +1 -0
  32. package/dist/otaless/backend.js +502 -0
  33. package/dist/otaless/backend.js.map +1 -0
  34. package/dist/otaless/client.d.ts +103 -0
  35. package/dist/otaless/client.d.ts.map +1 -0
  36. package/dist/otaless/client.js +110 -0
  37. package/dist/otaless/client.js.map +1 -0
  38. package/dist/otaless/fake.d.ts +27 -0
  39. package/dist/otaless/fake.d.ts.map +1 -0
  40. package/dist/otaless/fake.js +239 -0
  41. package/dist/otaless/fake.js.map +1 -0
  42. package/dist/otaless/make.d.ts +4 -0
  43. package/dist/otaless/make.d.ts.map +1 -0
  44. package/dist/otaless/make.js +71 -0
  45. package/dist/otaless/make.js.map +1 -0
  46. package/dist/paid.d.ts +4 -1
  47. package/dist/paid.d.ts.map +1 -1
  48. package/dist/paid.js +14 -14
  49. package/dist/paid.js.map +1 -1
  50. package/dist/present.d.ts +6 -1
  51. package/dist/present.d.ts.map +1 -1
  52. package/dist/present.js +20 -5
  53. package/dist/present.js.map +1 -1
  54. package/dist/reference.d.ts.map +1 -1
  55. package/dist/reference.js +10 -10
  56. package/dist/reference.js.map +1 -1
  57. package/dist/server.d.ts +8 -2
  58. package/dist/server.d.ts.map +1 -1
  59. package/dist/server.js +26 -4
  60. package/dist/server.js.map +1 -1
  61. package/dist/supplier.d.ts.map +1 -1
  62. package/dist/supplier.js +3 -0
  63. package/dist/supplier.js.map +1 -1
  64. package/dist/surface.d.ts.map +1 -1
  65. package/dist/surface.js +87 -67
  66. package/dist/surface.js.map +1 -1
  67. package/dist/tools.d.ts +12 -1
  68. package/dist/tools.d.ts.map +1 -1
  69. package/dist/tools.js +166 -111
  70. package/dist/tools.js.map +1 -1
  71. package/dist/types.d.ts +35 -0
  72. package/dist/types.d.ts.map +1 -1
  73. package/dist/vocab.d.ts +0 -28
  74. package/dist/vocab.d.ts.map +1 -1
  75. package/dist/vocab.js +19 -46
  76. package/dist/vocab.js.map +1 -1
  77. package/package.json +1 -1
package/dist/tools.js CHANGED
@@ -14,7 +14,7 @@ import { regionOf } from './regions.js';
14
14
  import { activeKeysOf, byRoomType, facetsOf, keyWords, matchesTerms, optionsOf, roomTermsOf, termWords, } from './room-filters.js';
15
15
  import { SAME_BOOKING_MS } from './same-booking.js';
16
16
  import { MAX_EXTRA_QUANTITY, MAX_ROOMS_PER_LINE, clampCount, pickedKey, shownOf, stampOf, tripKey, } from './trip.js';
17
- import { DEMO_NOTE, branded, catalogue, noteFor, upFirst } from './vocab.js';
17
+ import { branded, catalogue, upFirst } from './vocab.js';
18
18
  // The eleven things a guest can do: find hotels, look at one on dates, see what
19
19
  // else it offers, build a trip, ask for the rooms, and deal with a booking they
20
20
  // already have.
@@ -145,7 +145,29 @@ export const withheldTools = (n, caps) => new Set([
145
145
  ...(caps.changes || caps.extras ? [] : [n.previewChange, n.changeBooking]),
146
146
  ...(caps.policies === false ? [n.policies] : []),
147
147
  ...(caps.unionpay ? [] : [n.payWithUnionPay]),
148
+ // Nothing can be booked here yet (`readOnly`, Otaless 2026-11-03): no tool
149
+ // that books, finds, changes or cancels a booking - the card's own two
150
+ // for Reserve included, so no button that books is drawn.
151
+ ...(caps.readOnly
152
+ ? [
153
+ n.requestBooking,
154
+ n.pickRoom,
155
+ n.bookPicked,
156
+ n.listBookings,
157
+ n.previewCancellation,
158
+ n.cancelBooking,
159
+ n.previewChange,
160
+ n.changeBooking,
161
+ n.payWithUnionPay,
162
+ ]
163
+ : []),
148
164
  ]);
165
+ /**
166
+ * What to tell a guest who wants to book where nothing can be booked yet
167
+ * (`capabilities.readOnly`) - the same words in the instructions and in each
168
+ * answer that would otherwise say how to book.
169
+ */
170
+ export const readOnlyWords = (v) => `Nothing can be booked through ${v.brand} yet: it shows ${v.noun}s, rooms and prices only. When the guest wants to book, say so plainly - nothing is booked or held - and suggest booking with the ${v.noun}'s own group directly, on its website or app, with the ${v.noun}, room and dates from here.`;
149
171
  const forCapabilities = (tools, n, v, caps) => {
150
172
  const withheld = withheldTools(n, caps);
151
173
  const without = (schema, ...keys) => {
@@ -296,18 +318,16 @@ const forCapabilities = (tools, n, v, caps) => {
296
318
  if (t.name === n.requestBooking && caps.booking === 'pay-at-hotel' && !v.market) {
297
319
  out = {
298
320
  ...out,
299
- // ⚠️ A SANDBOX BOOKS NOTHING REAL, and its description says so
300
- // itself: the instructions carried "test environment" while this
301
- // line still promised "the hotel's own reservation" (2026-09-30).
302
- description: v.test
303
- ? `Makes a test booking in ${v.brand}'s own test system, for testing: each stay gets its own confirmation code there, and none of it holds a room for anyone or is a stay anyone goes to. ${caps.prepaid
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.'
321
+ // ⚠️ A BOOKING THAT IS NOT A RESERVATION SAYS SO ITSELF (2026-09-30: the
322
+ // description promised "the hotel's own reservation" on the sandbox) -
323
+ // as the fact, never "test" or "simulated" (robin, 2026-11-03).
324
+ description: v.test || v.demo
325
+ ? `Books the trip's rooms for a named guest: each stay gets its own confirmation code, and ${v.test
326
+ ? 'none of it holds a room for anyone or is a stay anyone goes to'
327
+ : `none of it is sent to ${v.brand} or holds a real room`}. ${caps.prepaid
328
+ ? '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.'
305
329
  : '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.`
306
- : v.sandbox
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.'
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.`
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.`,
330
+ : `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.`,
311
331
  };
312
332
  }
313
333
  }
@@ -596,10 +616,10 @@ const allTools = (n, v) => {
596
616
  ? // ⚠️ THE GROUP HOLDS THE BOOKING, NOT THE MARKET - and what a booking
597
617
  // makes is the group's: a hold, a booking that holds no room, or a
598
618
  // reservation paid at the hotel. The answer says which, stay by stay.
599
- // ⚠️ NO "DEMONSTRATION" / "TEST" / "SIMULATED" IN A MARKET (robin, 2026-11-03).
619
+ // ⚠️ NO "DEMONSTRATION" / "TEST" / "SIMULATED" (robin, 2026-11-03).
600
620
  `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.`
601
621
  : v.demo
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.`
622
+ ? `Makes a booking request for the trip's rooms, for a named guest: it returns a confirmation code and the moment the 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.`
603
623
  : `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.`,
604
624
  annotations: books(v.market ? `Request a booking on ${v.brand}` : `Request a ${v.brand} booking`, open),
605
625
  inputSchema: {
@@ -1159,7 +1179,8 @@ export const outputSchemasFor = (schemas, caps, lang, also = {}) => {
1159
1179
  !also.accent &&
1160
1180
  !also.primary &&
1161
1181
  !also.fonts &&
1162
- !also.homeImage)
1182
+ !also.homeImage &&
1183
+ !also.brandNames)
1163
1184
  return schemas;
1164
1185
  return Object.fromEntries(Object.entries(schemas).map(([name, schema]) => {
1165
1186
  const s = schema;
@@ -1177,6 +1198,9 @@ export const outputSchemasFor = (schemas, caps, lang, also = {}) => {
1177
1198
  // And the dark page's logo, where the group gave one.
1178
1199
  if (also.logoDark)
1179
1200
  properties.logoDarkUrl = STR;
1201
+ // And the group's own name in other languages, where its deployment gives them.
1202
+ if (also.brandNames)
1203
+ properties.brandNames = OBJ;
1180
1204
  // And the group's own colour, where its deployment gives one.
1181
1205
  if (also.accent)
1182
1206
  properties.accent = { type: 'string', pattern: '^#[0-9a-fA-F]{6}$' };
@@ -1709,7 +1733,7 @@ const holderOf = (deps, supplier) => {
1709
1733
  demo: s.mode !== 'live',
1710
1734
  sandbox: s.mode === 'sandbox',
1711
1735
  test: s.mode === 'test',
1712
- notReal: !!deps.vocab.market && s.mode !== 'live',
1736
+ notReal: s.mode !== 'live' && s.mode !== 'test',
1713
1737
  };
1714
1738
  return {
1715
1739
  name: deps.vocab.brand,
@@ -1717,7 +1741,7 @@ const holderOf = (deps, supplier) => {
1717
1741
  demo: deps.vocab.demo,
1718
1742
  sandbox: !!deps.vocab.sandbox,
1719
1743
  test: !!deps.vocab.test,
1720
- notReal: false,
1744
+ notReal: deps.vocab.demo && !deps.vocab.test,
1721
1745
  };
1722
1746
  };
1723
1747
  /**
@@ -1796,17 +1820,14 @@ const paidLine = (deps, b, fee) => {
1796
1820
  const back = formatMoney(p.refundAmount ?? p.amount);
1797
1821
  const whole = !p.refundAmount || p.refundAmount.minor === p.amount.minor;
1798
1822
  const unionpay = p.method === 'unionpay';
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.
1823
+ // ⚠️ THE FACT, NO ENVIRONMENT'S NAME (robin, 2026-11-03): no real money moves, and
1824
+ // the booking was not sent to the group - or, in its own test system, holds no
1825
+ // room for anyone.
1803
1826
  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
- : '';
1827
+ ? ` No real money moves, and this booking was not sent to ${h.name}.`
1828
+ : h.test
1829
+ ? ' No real money moves, and this booking holds no room for anyone.'
1830
+ : '';
1810
1831
  const ended = b.status === 'cancelled' || b.status === 'expired' || b.status === 'failed';
1811
1832
  const said = (() => {
1812
1833
  // Its money as it stands once the stay has ended - never "if it does not take it".
@@ -1913,13 +1934,11 @@ const heldLine = (deps, b) => {
1913
1934
  const total = formatMoney(b.quote.total);
1914
1935
  const h = holderOf(deps, b.supplier);
1915
1936
  if (h.caps.booking === 'pay-at-hotel') {
1916
- // ⚠️ A MARKET'S GROUP: the fact, in no environment's word (robin, 2026-11-03).
1937
+ // ⚠️ THE FACT, IN NO ENVIRONMENT'S WORD (robin, 2026-11-03).
1917
1938
  if (h.notReal)
1918
1939
  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.`;
1919
- if (h.sandbox)
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.`;
1921
1940
  if (h.test)
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.`;
1941
+ return `${total} all in. **This booking holds no room for anyone** - nothing is paid, now or at any hotel, and nobody needs to go there.`;
1923
1942
  return b.status === 'requested'
1924
1943
  ? `${total} all in, to be paid at the hotel. ${h.name} has not confirmed this booking yet - that takes up to eight minutes, and ${deps.tools.listBookings} shows when it has. Nothing has been charged.`
1925
1944
  : `${total} all in, paid at the hotel. **Nothing has been charged here** - this is ${h.name}'s own reservation, kept until it is cancelled, and the guest pays at check-in.`;
@@ -1936,14 +1955,14 @@ const heldLine = (deps, b) => {
1936
1955
  // nothing for a real stay; said as how long it lasts, and that it is only the
1937
1956
  // demonstration.
1938
1957
  if (h.demo)
1939
- return `${total} all in. **${demoBookingWords(h)}** Nothing has been charged and no card is needed. ${demoHoldWords(b, deps.trips.now(), h)}`;
1958
+ return `${total} all in. **${demoBookingWords(h)}** Nothing has been charged and no card is needed. ${demoHoldWords(b, deps.trips.now())}`;
1940
1959
  return `${total} all in. **Nothing has been charged** - the rooms are held until ${lapses}, and the guest presents a card at check-in.`;
1941
1960
  };
1942
1961
  /**
1943
1962
  * How long a demonstration hold lasts, as "in N minutes" from now - the hold
1944
1963
  * lapses by itself, and says nothing about the stay (T2, `heldLine`).
1945
1964
  */
1946
- const demoHoldWords = (b, now, h) => {
1965
+ const demoHoldWords = (b, now) => {
1947
1966
  const minutes = b.holdUntil ? Math.ceil((Date.parse(b.holdUntil) - now) / 60_000) : Number.NaN;
1948
1967
  if (Number.isNaN(minutes))
1949
1968
  return '';
@@ -1954,15 +1973,11 @@ const demoHoldWords = (b, now, h) => {
1954
1973
  : minutes < 48 * 60
1955
1974
  ? `in about ${Math.round(minutes / 60)} hours`
1956
1975
  : `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).`;
1976
+ // No "demonstration" (robin, 2026-11-03): only that it lapses and holds no room.
1977
+ return `It lapses by itself ${lasting} (no real room is held; the stay's own dates are not affected).`;
1961
1978
  };
1962
- /** What a demonstration booking is, in one sentence - see `demoBookingNote`. */
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.`;
1979
+ /** What a booking that is not sent to the group is, in one sentence - see `demoBookingNote`. */
1980
+ const demoBookingWords = (h) => `This booking was not sent to ${h.name}, and ${h.name} is not holding any room.`;
1966
1981
  /**
1967
1982
  * Said on every answer about a demonstration booking - made, changed or
1968
1983
  * cancelled - and on nothing else: a hold of the demo's that a guest took for
@@ -1976,6 +1991,8 @@ const common = (deps, key) => {
1976
1991
  const caps = deps.backend.capabilities ?? FULL_CAPABILITIES;
1977
1992
  return {
1978
1993
  brand: deps.vocab.brand,
1994
+ // The group in the card's language (「没有发到亚朵」) - see `HotelConfig.names`.
1995
+ ...(deps.brandNames ? { brandNames: deps.brandNames } : {}),
1979
1996
  ...(deps.logoUrl ? { logoUrl: deps.logoUrl } : {}),
1980
1997
  ...(deps.logoDarkUrl ? { logoDarkUrl: deps.logoDarkUrl } : {}),
1981
1998
  ...(deps.accent ? { accent: deps.accent } : {}),
@@ -2011,10 +2028,10 @@ const common = (deps, key) => {
2011
2028
  ? { market: { name: deps.vocab.brand, suppliers: deps.backend.suppliers() } }
2012
2029
  : {}),
2013
2030
  vocab: { noun: deps.vocab.noun, nounPlural: deps.vocab.nounPlural },
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 }),
2031
+ // ⚠️ NO NOTE ABOUT THE DATA (robin, 2026-11-03: 「不用特意写不是真的」): `demo`
2032
+ // still tells the card a booking here is not a real one, which it says as a
2033
+ // booking's facts; nothing labels the environment.
2034
+ demo: deps.vocab.demo,
2018
2035
  ...(key ? memberBadge(deps, key) : {}),
2019
2036
  ...(langOf(deps, key) ? { lang: langOf(deps, key) } : {}),
2020
2037
  };
@@ -2034,6 +2051,8 @@ const langOf = (deps, _key) => deps.lang;
2034
2051
  * hotel's own clock when the backend knows it, else the instant in UTC - never
2035
2052
  * the date alone (see `atHotelTime`).
2036
2053
  */
2054
+ /** A rate whose own name already says it cannot be refunded - see T10 where a booking is said. */
2055
+ const SAYS_NON_REFUNDABLE = /non-?refundable|不可取消|不可退|不能取消/i;
2037
2056
  const freeUntilText = (r) => r.freeCancellationLocal ??
2038
2057
  `${(r.freeCancellationUntil ?? '').slice(0, 16).replace('T', ' ')} UTC`;
2039
2058
  /** Any Han character: a name already in Chinese needs none beside it. */
@@ -2111,11 +2130,15 @@ const quoteForCard = (quote, groupOf) => ({
2111
2130
  // stepper read 3 and sent 3 ± 1 as the new count (2026-09-28 audit).
2112
2131
  ...(l.ordered !== undefined ? { ordered: l.ordered } : {}),
2113
2132
  unit: formatMoney(l.unit),
2133
+ ...(l.publicUnit ? { publicUnit: formatMoney(l.publicUnit) } : {}),
2114
2134
  total: formatMoney(l.total),
2115
2135
  // The line with its taxes in, where the backend knows them per line: what the
2116
2136
  // review and the trip show, as the hotel's page does (r10 #23).
2117
2137
  ...(l.totalWithTaxes ? { totalWithTaxes: formatMoney(l.totalWithTaxes) } : {}),
2118
2138
  refundable: l.refundable,
2139
+ // The supplier's own sentence about cancelling, where it gives only that
2140
+ // (`QuoteLine.terms`, Otaless): the card says it as it came.
2141
+ ...(l.terms !== undefined ? { terms: l.terms } : {}),
2119
2142
  freeCancellationUntil: l.freeCancellationUntil,
2120
2143
  // The deadline on the hotel's clock, where the backend knows it - the
2121
2144
  // review restates the terms beside the total (round-2 jury).
@@ -2144,7 +2167,20 @@ const quoteForCard = (quote, groupOf) => ({
2144
2167
  // the receipt every guest gets: room, taxes, less the tier, less the
2145
2168
  // coupon, is the total (0.54.0, M1: a member's started from the
2146
2169
  // 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)),
2170
+ // ⚠️ THE PUBLIC RATE'S OWN ROOM CHARGE (0.55.0, review r3 N7) where the
2171
+ // backend gives it: the public total less the taxes CHARGED held the public
2172
+ // rate's larger tax as well, and the line's 「公开价每晚 ¥478」 for 2 nights and
2173
+ // 2 rooms (¥1,912) did not meet 「房费(公开价)¥1,922」. The tier's row is then
2174
+ // what it took off the rooms (`roomSaved`), and room, taxes, less the tier,
2175
+ // less the coupon is still the total.
2176
+ publicSubtotal: formatMoney(quote.member.publicSubtotal ??
2177
+ money(quote.member.publicTotal.minor -
2178
+ (quote.taxesIncluded ? 0 : quote.taxesAndFees.minor), quote.total.currency)),
2179
+ ...(quote.member.publicSubtotal
2180
+ ? {
2181
+ roomSaved: formatMoney(money(quote.member.publicSubtotal.minor - quote.subtotal.minor, quote.total.currency)),
2182
+ }
2183
+ : {}),
2148
2184
  memberTotal: formatMoney(money(quote.member.publicTotal.minor - quote.member.saved.minor, quote.total.currency)),
2149
2185
  },
2150
2186
  }
@@ -2275,13 +2311,11 @@ const memberTripLines = (q) => {
2275
2311
  const couponRefusedLine = (q) => q.couponRefused
2276
2312
  ? `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
2313
  : '';
2278
- const tripText = (quote, demo, noCards) => {
2314
+ const tripText = (quote, noCards) => {
2279
2315
  // ⚠️ NOT "EMPTY" WHEN SOMETHING WAS LEFT OUT: a trip whose only group did not
2280
2316
  // answer is not an empty trip (2026-11-03 review, C5).
2281
2317
  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');
2318
+ return quote.dropped.map((d) => `Left out: ${d.title} — ${d.why}`).join('\n');
2285
2319
  if (quote.lines.length === 0)
2286
2320
  return 'The trip is empty.';
2287
2321
  const lines = quote.lines.map((l) => l.kind === 'room'
@@ -2293,7 +2327,6 @@ const tripText = (quote, demo, noCards) => {
2293
2327
  couponRefusedLine(quote),
2294
2328
  ...memberTripLines(quote),
2295
2329
  ...(quote.dropped ?? []).map((d) => `Left out: ${d.title} — ${d.why}`),
2296
- demo ? DEMO_NOTE : '',
2297
2330
  ]
2298
2331
  .filter(Boolean)
2299
2332
  .join('\n');
@@ -2565,12 +2598,20 @@ export const notSoldHereWords = (group) => `Rates marked paid in advance are lis
2565
2598
  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.`;
2566
2599
  /** The group a hotel is with, by name - the deployment's own brand outside a market. */
2567
2600
  const groupNameOf = (deps, hotelId) => holderOf(deps, deps.backend.supplierOf?.(hotelId)).name;
2601
+ /** A rate's cancellation terms in the supplier's own words (`Room.terms`), quoted - or that none were given. */
2602
+ export const termsWords = (terms) => terms
2603
+ ? `cancellation terms, in the hotel group's own words: "${terms}"`
2604
+ : 'no cancellation terms given';
2568
2605
  /** What a rate says about cancelling, as the text puts it. */
2569
- const cancelWords = (r) => r.freeCancellationUntil
2570
- ? `free to cancel until ${freeUntilText(r)}`
2571
- : r.refundable
2572
- ? 'the free cancellation window has closed - cancelling now has a fee'
2573
- : 'non-refundable';
2606
+ const cancelWords = (r) =>
2607
+ // The supplier's own sentence, as it came - see `Room.terms`.
2608
+ r.terms !== undefined
2609
+ ? termsWords(r.terms)
2610
+ : r.freeCancellationUntil
2611
+ ? `free to cancel until ${freeUntilText(r)}`
2612
+ : r.refundable
2613
+ ? 'the free cancellation window has closed - cancelling now has a fee'
2614
+ : 'non-refundable';
2574
2615
  /**
2575
2616
  * What a room is, in a parenthesis: its beds, how many it sleeps and - for one
2576
2617
  * rate, not a type whose rates differ - breakfast, unless the rate's labels
@@ -2731,7 +2772,11 @@ const heldRoomsWords = (b) => b.quote.lines
2731
2772
  : l.freeCancellationUntil
2732
2773
  ? `, free to cancel until ${freeUntilText(l)}`
2733
2774
  : l.refundable === false
2734
- ? ', non-refundable'
2775
+ ? // ⚠️ NOT TWICE (0.55.0, review r3 T10): 「… · Non-refundable(… · 不可取消),
2776
+ // non-refundable」 - a rate whose name says it is not told it again.
2777
+ SAYS_NON_REFUNDABLE.test(`${l.title} ${Object.values(l.titleNames ?? {}).join(' ')}`)
2778
+ ? ''
2779
+ : ', non-refundable'
2735
2780
  : l.refundable
2736
2781
  ? ', past its free cancellation - cancelling has a fee'
2737
2782
  : ''}`)
@@ -2961,12 +3006,19 @@ const listAnd = (xs) => xs.length > 1 ? `${xs.slice(0, -1).join(', ')} and ${xs.
2961
3006
  */
2962
3007
  const degradedOf = (deps, answer,
2963
3008
  /** What is missing from the list: the hotels of a search, the bookings of "my bookings". */
2964
- noun = deps.vocab.nounPlural) => {
3009
+ noun = deps.vocab.nounPlural,
3010
+ /**
3011
+ * The client draws no card: each group's own Chinese name goes beside its
3012
+ * name, as a hotel's does (`withZh`) - told only "JI Hotel", a model with no
3013
+ * card called it 锦江之星, another group (11-03, text-only eval). A card host
3014
+ * is told the English name alone; the card says the group in its own words.
3015
+ */
3016
+ noCards = false) => {
2965
3017
  const down = answer.degraded ?? [];
2966
3018
  if (down.length === 0)
2967
3019
  return { structured: {}, said: '' };
2968
3020
  const one = down.length === 1;
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.`;
3021
+ const said = `${listAnd(down.map((d) => withZh(d.name, d.names, noCards)))} 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.`;
2970
3022
  return {
2971
3023
  structured: {
2972
3024
  degraded: down.map((d) => ({ supplier: d.supplier, name: d.name })),
@@ -3274,7 +3326,9 @@ const currentTrip = async (deps, key, who, store = key, coupon, ctx) => {
3274
3326
  // came back "needs a phone number". Whose need it is is the trip's groups',
3275
3327
  // as `requestBooking` judges it.
3276
3328
  const nextStep = ctx?.noCards && parts.some((p) => p.lines.some((l) => l.kind === 'room'))
3277
- ? `Nothing in the trip is booked yet. To book it, ${deps.tools.requestBooking} takes the guest's own ${guestDetailsFor(deps, lines.filter((l) => l.kind === 'room'))} - ask for them if the guest has not typed them in this conversation.`
3329
+ ? (deps.backend.capabilities ?? FULL_CAPABILITIES).readOnly
3330
+ ? readOnlyWords(deps.vocab)
3331
+ : `Nothing in the trip is booked yet. To book it, ${deps.tools.requestBooking} takes the guest's own ${guestDetailsFor(deps, lines.filter((l) => l.kind === 'room'))} - ask for them if the guest has not typed them in this conversation.`
3278
3332
  : '';
3279
3333
  if (parts.length === 1 && only) {
3280
3334
  return {
@@ -3288,13 +3342,7 @@ const currentTrip = async (deps, key, who, store = key, coupon, ctx) => {
3288
3342
  count: lines.length,
3289
3343
  ...(nextStep ? { nextStep } : {}),
3290
3344
  },
3291
- text: [
3292
- tripText(only, false, ctx?.noCards),
3293
- ...overlaps.map((o) => o.said),
3294
- orphans.line,
3295
- nextStep,
3296
- deps.vocab.demo && lines.length ? noteFor(deps.vocab) : '',
3297
- ]
3345
+ text: [tripText(only, ctx?.noCards), ...overlaps.map((o) => o.said), orphans.line, nextStep]
3298
3346
  .filter(Boolean)
3299
3347
  .join('\n'),
3300
3348
  };
@@ -3336,11 +3384,10 @@ const currentTrip = async (deps, key, who, store = key, coupon, ctx) => {
3336
3384
  },
3337
3385
  text: [
3338
3386
  `This trip is in ${parts.length} currencies, priced separately and not converted - each hotel is booked and paid in its own.`,
3339
- ...parts.map((q) => tripText(q, false, ctx?.noCards)),
3387
+ ...parts.map((q) => tripText(q, ctx?.noCards)),
3340
3388
  ...overlaps.map((o) => o.said),
3341
3389
  orphans.line,
3342
3390
  nextStep,
3343
- deps.vocab.demo ? noteFor(deps.vocab) : '',
3344
3391
  ]
3345
3392
  .filter(Boolean)
3346
3393
  .join('\n\n'),
@@ -3749,11 +3796,13 @@ const comparedInWords = (c) => {
3749
3796
  const priced = p
3750
3797
  ? [
3751
3798
  `${withZh(p.room, p.roomNames, true)} at ${p.nightly} ${nightWords(true)}, ${p.stayTotal} for the stay incl. taxes and fees`,
3752
- p.freeCancellationUntil
3753
- ? `free to cancel until ${freeUntilText(p)}`
3754
- : p.refundable
3755
- ? 'no free cancellation'
3756
- : 'non-refundable',
3799
+ p.terms !== undefined
3800
+ ? termsWords(p.terms)
3801
+ : p.freeCancellationUntil
3802
+ ? `free to cancel until ${freeUntilText(p)}`
3803
+ : p.refundable
3804
+ ? 'no free cancellation'
3805
+ : 'non-refundable',
3757
3806
  p.payment === 'prepaid'
3758
3807
  ? p.notSoldHere
3759
3808
  ? 'paid in advance - not booked here'
@@ -4063,7 +4112,6 @@ const bookedFromThisForm = async (deps, ctx, key, args) => {
4063
4112
  notUsed?.said ?? '',
4064
4113
  `**${confirming(deps, b) ? 'Being confirmed' : b.confirmationCode}** · ${b.hotelName} · ${stayWords(b.stay, b.hourly)}`,
4065
4114
  bookedLine(deps, b) ?? '',
4066
- deps.vocab.demo ? noteFor(deps.vocab) : '',
4067
4115
  ]
4068
4116
  .filter(Boolean)
4069
4117
  .join('\n'),
@@ -4644,7 +4692,6 @@ const bookTripLines = async (deps, t, ctx, key, store, args, nothingThere) => {
4644
4692
  ...failedText,
4645
4693
  remembered?.line ?? '',
4646
4694
  others ? `The trip's other stays are still in it, not booked:\n${rest?.text ?? ''}` : '',
4647
- deps.vocab.demo ? noteFor(deps.vocab) : '',
4648
4695
  ]
4649
4696
  .filter(Boolean)
4650
4697
  .join('\n'),
@@ -5028,7 +5075,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5028
5075
  : `${c.name} (${c.count})`)
5029
5076
  .join(', ');
5030
5077
  const n = deps.vocab.nounPlural;
5031
- const degraded = degradedOf(deps, answer);
5078
+ const degraded = degradedOf(deps, answer, undefined, ctx?.noCards);
5032
5079
  if (namedCountry) {
5033
5080
  const [only] = countries;
5034
5081
  return {
@@ -5065,13 +5112,25 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5065
5112
  ask: { ...stayArgs },
5066
5113
  ...(ask.stay ? { stay: ask.stay } : {}),
5067
5114
  ...(today ? { today } : {}),
5115
+ // ⚠️ IN A MARKET, HOW MANY HOTELS EACH GROUP HAS HERE (11-03): the card
5116
+ // says "from N groups" of the groups that have one - not of every group
5117
+ // on the market's list, six of which may have none. Only the counts.
5118
+ ...(deps.backend.suppliers && answer.refine?.suppliers
5119
+ ? { refine: { suppliers: answer.refine.suppliers } }
5120
+ : {}),
5068
5121
  ...degraded.structured,
5069
5122
  },
5070
- text: `${total} ${branded(deps.vocab, n)} in ${facets.cities.length} cities across ${countries.length} ${countries.length === 1 ? 'country' : 'countries'}, to choose from by region, country and city - ${regions.map((r) => `${r.name}: ${ctx.noCards ? everyWithCities(r.countries) : every(r.countries)}`).join('; ')}. A city or a country lists its ${n}. ${degraded.said ? `${degraded.said} ` : ''}${forPlans}`,
5123
+ // ⚠️ NOTHING TO SHOW IS SAID AS THAT (0.55.1), as the card says it: 「0 hotels in 0
5124
+ // cities across 0 countries, to choose from by region, country and city - .」 was a
5125
+ // home of places over none - Otaless on a backend with no group answering. Then the
5126
+ // groups that did not answer, by name; no places, no plans.
5127
+ text: total === 0
5128
+ ? `There are no ${n} to show right now.${degraded.said ? ` ${degraded.said}` : ''}`
5129
+ : `${total} ${branded(deps.vocab, n)} in ${facets.cities.length} cities across ${countries.length} ${countries.length === 1 ? 'country' : 'countries'}, to choose from by region, country and city - ${regions.map((r) => `${r.name}: ${ctx.noCards ? everyWithCities(r.countries) : every(r.countries)}`).join('; ')}. A city or a country lists its ${n}. ${degraded.said ? `${degraded.said} ` : ''}${forPlans}`,
5071
5130
  };
5072
5131
  }
5073
5132
  if (hotels.length === 0) {
5074
- const degraded = degradedOf(deps, answer);
5133
+ const degraded = degradedOf(deps, answer, undefined, ctx?.noCards);
5075
5134
  const nearest = (answer.nearest ?? []).map((h) => ({
5076
5135
  ...h,
5077
5136
  summary: hotelText('description', h.name, h.summary, DESCRIPTION_MAX).text,
@@ -5218,7 +5277,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5218
5277
  priceNotes.push(notSold);
5219
5278
  // Every one by name and city, and that there are no others - or, told
5220
5279
  // only "2 hotels", the model asked for the list again to learn which.
5221
- const degraded = degradedOf(deps, answer);
5280
+ const degraded = degradedOf(deps, answer, undefined, ctx?.noCards);
5222
5281
  const inAll = homeList
5223
5282
  ? `There are ${total} ${branded(deps.vocab, deps.vocab.nounPlural)} here in all, and this answer lists every one: ${hotels
5224
5283
  .map((h) => `${hotelName(h.name)}${h.city ? ` in ${h.city}` : ''}`)
@@ -5424,12 +5483,21 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5424
5483
  const notSold = enough.some((r) => r.notSoldHere)
5425
5484
  ? notSoldHereWords(groupNameOf(deps, h.id))
5426
5485
  : '';
5427
- const rooms = enough.map((r) => ({
5486
+ const rooms = enough.map(({ roomsLeft, refundable, ...r }) => ({
5428
5487
  ...r,
5429
- facets: facetsOf(r),
5488
+ // ⚠️ NO COUNT WHERE THE SUPPLIER GIVES NONE, and no yes / no on
5489
+ // cancelling beside its own sentence (`Room.uncounted`, `Room.terms`).
5490
+ ...(r.uncounted ? {} : { roomsLeft }),
5491
+ ...(r.terms !== undefined ? {} : { refundable }),
5492
+ facets: facetsOf({ ...r, roomsLeft, refundable }),
5430
5493
  nightly: formatMoney(r.nightly),
5431
5494
  stayTotal: formatMoney(r.stayTotal),
5432
- taxesAndFees: formatMoney(r.taxesAndFees),
5495
+ // ⚠️ "INCLUDED", NOT "¥0", where the supplier's price has its taxes in and
5496
+ // gives no split (`Room.terms`, Otaless): told "taxesAndFees ¥0", the
5497
+ // model said "税费 ¥0" - no tax - of a price the group says includes it.
5498
+ taxesAndFees: r.terms !== undefined && r.taxesAndFees.minor === 0
5499
+ ? 'Included'
5500
+ : formatMoney(r.taxesAndFees),
5433
5501
  // ⚠️ THE NUMBER THE GUEST IS LOOKING AT HAS TO BE THE ONE THEY ASKED FOR.
5434
5502
  // With two rooms picked, the card was still showing one room's total and
5435
5503
  // nothing said so - robin changed the count and watched the price not
@@ -5478,7 +5546,6 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5478
5546
  ? `${flex.text} (That is the cheapest room of any kind, not only one with ${termWords(terms)}.)`
5479
5547
  : flex.text,
5480
5548
  notSold,
5481
- deps.vocab.demo ? noteFor(deps.vocab) : '',
5482
5549
  ]
5483
5550
  .filter(Boolean)
5484
5551
  .join('\n'),
@@ -5521,7 +5588,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5521
5588
  total = found.total;
5522
5589
  degradedFound = found;
5523
5590
  }
5524
- const degraded = degradedOf(deps, degradedFound);
5591
+ const degraded = degradedOf(deps, degradedFound, undefined, ctx?.noCards);
5525
5592
  const unique = [...new Set(ids)];
5526
5593
  // A name that fits more than one is said with the ones it fits; the rest are not found.
5527
5594
  const notFound = dropped.filter((d) => !candidates.some((c) => c.for === d));
@@ -5586,7 +5653,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5586
5653
  // answer says them.
5587
5654
  ...(ctx.noCards
5588
5655
  ? roomTypeLines(rooms, roomsWanted, undefined, true)
5589
- : rooms.map((r) => `- ${r.name} · sleeps ${r.sleeps}${r.breakfast ? ' · breakfast' : ''} · ${formatMoney(r.stayTotal)} for the stay incl. taxes and fees${roomsWanted > 1 ? ` (${formatMoney(multiplyMoney(r.stayTotal, roomsWanted))} for ${roomsWanted} rooms)` : ''}${r.freeCancellationUntil ? `, free to cancel until ${freeUntilText(r)}` : r.refundable ? '' : ', non-refundable'}`)),
5656
+ : rooms.map((r) => `- ${r.name} · sleeps ${r.sleeps}${r.breakfast ? ' · breakfast' : ''} · ${formatMoney(r.stayTotal)} for the stay incl. taxes and fees${roomsWanted > 1 ? ` (${formatMoney(multiplyMoney(r.stayTotal, roomsWanted))} for ${roomsWanted} rooms)` : ''}${r.terms !== undefined ? `, ${termsWords(r.terms)}` : r.freeCancellationUntil ? `, free to cancel until ${freeUntilText(r)}` : r.refundable ? '' : ', non-refundable'}`)),
5590
5657
  ].join('\n')
5591
5658
  : (detail.noRooms ?? `No room type here has ${roomsWanted} left for those nights.`),
5592
5659
  extras.length
@@ -5628,7 +5695,6 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5628
5695
  ctx.noCards
5629
5696
  ? 'answer with what the guest is told, for a guest who asked to see a list, a hotel or its add-ons.'
5630
5697
  : 'would each draw a card of their own.'}`,
5631
- deps.vocab.demo ? noteFor(deps.vocab) : '',
5632
5698
  ]
5633
5699
  .filter(Boolean)
5634
5700
  .join('\n\n'),
@@ -5712,7 +5778,6 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5712
5778
  text: extras.length
5713
5779
  ? extras
5714
5780
  .map((e) => `- ${e.name}${ctx.noCards ? ` (\`${e.id}\`)` : ''} — ${formatMoney(e.price)} ${unitWords(e.per)}${held[e.id] ? ` (${booked?.confirmationCode} holds ${held[e.id]})` : ''}`)
5715
- .concat(deps.vocab.demo && noteFor(deps.vocab) ? [noteFor(deps.vocab)] : [])
5716
5781
  .join('\n')
5717
5782
  : `${detail.hotel.name} has no add-ons listed here.`,
5718
5783
  };
@@ -5799,7 +5864,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5799
5864
  const here = await deps.backend.listBookings(who, typeof args.limit === 'number' ? args.limit : undefined);
5800
5865
  // Read here, before a filter or a spread drops it: the groups whose
5801
5866
  // bookings are not in `here` (2026-11-03 review, C5).
5802
- const missing = degradedOf(deps, { degraded: here.degraded }, 'bookings');
5867
+ const missing = degradedOf(deps, { degraded: here.degraded }, 'bookings', ctx?.noCards);
5803
5868
  // Elsewhere, only with the code AND the email - see `listBookings`.
5804
5869
  const code = str(args.booking);
5805
5870
  const { found: there, tooMany } = code && str(args.email)
@@ -6015,7 +6080,6 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
6015
6080
  preview.rule,
6016
6081
  `Say the word and it moves (${t.changeBooking} with \`confirmed\`).`,
6017
6082
  demoBookingNote(deps, preview.supplier),
6018
- deps.vocab.demo ? noteFor(deps.vocab) : '',
6019
6083
  ]
6020
6084
  .filter(Boolean)
6021
6085
  .join(' ')
@@ -6078,7 +6142,6 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
6078
6142
  `${changed.confirmationCode} now holds ${changed.stay.checkIn} to ${changed.stay.checkOut}, ${formatMoney(changed.quote.total)} all in.`,
6079
6143
  'The confirmation code has not changed. **Nothing has been charged.**',
6080
6144
  demoBookingNote(deps, changed.supplier),
6081
- deps.vocab.demo ? noteFor(deps.vocab) : '',
6082
6145
  ]
6083
6146
  .filter(Boolean)
6084
6147
  .join(' '),
@@ -6210,7 +6273,6 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
6210
6273
  ? `Say the word and it is cancelled (${t.cancelBooking} with \`confirmed\`).`
6211
6274
  : '',
6212
6275
  demoBookingNote(deps, preview.supplier),
6213
- deps.vocab.demo ? noteFor(deps.vocab) : '',
6214
6276
  ]
6215
6277
  .filter(Boolean)
6216
6278
  .join(' '),
@@ -6287,20 +6349,14 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
6287
6349
  ? // Paid here in advance: what becomes of the money is the answer.
6288
6350
  `${cancelled.confirmationCode} is ${cancelled.status === 'expired' ? 'called off before it was paid' : 'cancelled'}. ${paidLine(deps, cancelled, charged)}`
6289
6351
  : h.notReal
6290
- ? // A market's group: the fact, in no environment's word (robin, 2026-11-03).
6352
+ ? // The fact, in no environment's word (robin, 2026-11-03); nobody charges
6353
+ // a booking that was never sent anything (2026-10-05 audit, P2).
6291
6354
  `${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)}.`}`,
6355
+ : h.test
6356
+ ? `${cancelled.confirmationCode} is cancelled. ${charged.minor === 0 ? 'No fee.' : `A real booking on this rate would be charged ${formatMoney(charged)}; this booking held no room for anyone, and nothing is charged.`}`
6357
+ : `${cancelled.confirmationCode} is cancelled. ${charged.minor === 0 ? 'No fee.' : `The hotel charges ${formatMoney(charged)}.`}`,
6301
6358
  couponNow?.said ?? '',
6302
6359
  demoBookingNote(deps, cancelled.supplier),
6303
- deps.vocab.demo ? noteFor(deps.vocab) : '',
6304
6360
  ]
6305
6361
  .filter(Boolean)
6306
6362
  .join(' '),
@@ -6384,7 +6440,6 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
6384
6440
  : '',
6385
6441
  ...comparison.dropped.map((d) => `Left out: ${d.name} — ${d.why}`),
6386
6442
  ...fitsManyOf(deps, comparison.dropped),
6387
- deps.vocab.demo ? noteFor(deps.vocab) : '',
6388
6443
  ]
6389
6444
  .filter(Boolean)
6390
6445
  .join('\n'),
@@ -6450,11 +6505,13 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
6450
6505
  // With no card, until when it is free to cancel too: the reply is told to
6451
6506
  // give each stay's terms (2026-11-02 audit, P2-3).
6452
6507
  const termsOf = (x) => [
6453
- x.refundable === false
6454
- ? 'non-refundable'
6455
- : ctx.noCards && x.freeCancellationUntil
6456
- ? `free to cancel until ${freeUntilText(x)}`
6457
- : '',
6508
+ x.terms
6509
+ ? `cancellation: "${x.terms}"`
6510
+ : x.refundable === false
6511
+ ? 'non-refundable'
6512
+ : ctx.noCards && x.freeCancellationUntil
6513
+ ? `free to cancel until ${freeUntilText(x)}`
6514
+ : '',
6458
6515
  x.payment === 'prepaid'
6459
6516
  ? x.notSoldHere
6460
6517
  ? 'paid in advance - not booked here'
@@ -6510,7 +6567,6 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
6510
6567
  ...fitsManyOf(deps, plan.dropped),
6511
6568
  uncheckedSaid,
6512
6569
  ctx.noCards ? PLAN_NOT_DRAWN : PLAN_DRAWN_ONCE,
6513
- deps.vocab.demo ? noteFor(deps.vocab) : '',
6514
6570
  ]
6515
6571
  .filter(Boolean)
6516
6572
  .join('\n'),
@@ -6566,7 +6622,6 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
6566
6622
  member.preferences?.length
6567
6623
  ? `Saved preferences on this membership: ${member.preferences.map((k) => REQUEST_WORDS[k]).join(', ')}. They are ticked on the booking form; mention them, and ask before booking with them.`
6568
6624
  : '',
6569
- deps.vocab.demo ? noteFor(deps.vocab) : '',
6570
6625
  ]
6571
6626
  .filter(Boolean)
6572
6627
  .join('\n'),