letsfg 2026.5.72 → 2026.5.74

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -14,7 +14,7 @@
14
14
  | **Speed** | 8–10 s to first results; longer on a split | 2–5 s (discover) · 8–10 s to first results (full) |
15
15
  | **Setup** | `npm install letsfg`, then connect at [letsfg.co/developers/api/mcp](https://letsfg.co/developers/api/mcp) | [letsfg.co/developers](https://letsfg.co/developers) |
16
16
 
17
- > **Want direct airline URLs without any per-booking fee?** Use the [Developer API](https://letsfg.co/developers) — prepaid credits, results in seconds, no checkout step.
17
+ > **Building a product, or need hotels?** Use the [Developer API](https://letsfg.co/developers) — look-to-book search (200 free after every booking, then $0.01), booking through `POST /flights/book`, no booking fee and no transaction fee.
18
18
 
19
19
  ## Install
20
20
 
@@ -54,7 +54,7 @@ const flights = await bt.search('GDN', 'BER', '2026-03-03');
54
54
  const best = cheapestOffer(flights);
55
55
  console.log(offerSummary(best));
56
56
 
57
- // Book — ticket price only, no LetsFG fee, no unlock step. Starts the booking:
57
+ // Book — no booking fee, no transaction fee — our margin is already in the price you saw; no unlock step. Starts the booking:
58
58
  // the fare is HELD on your card and a LetsFG agent buys the ticket (4-11 min).
59
59
  const result = await bt.book(
60
60
  best.id,
@@ -111,8 +111,9 @@ address (passport optional). A missing detail returns `missing_details` with
111
111
  same trip while one is in progress — that would place a second hold.
112
112
 
113
113
  Prefer the paid Developer API instead? Pass `apiKey` instead of `bearerToken` —
114
- `search()`/`book()` dispatch automatically. That path requires `unlock()`
115
- (1% fee, min $3) before `book()`.
114
+ `search()`/`book()` dispatch automatically. That path needs a `searchId` (an offer
115
+ is bookable only inside the search that produced it) and has no unlock step:
116
+ `unlock()` was retired 2026-09-08, the route answers `410 Gone`, and there is no fee.
116
117
 
117
118
  ## Quick Start (CLI)
118
119
 
@@ -131,14 +132,15 @@ letsfg book off_xxx --search-id srch_xxx -p '{"given_name":"John","family_name":
131
132
 
132
133
  ### `bt.search(origin, destination, dateFrom, options?)`
133
134
  ### `bt.resolveLocation(query)`
134
- ### `bt.unlock(offerId)` — Developer API only
135
135
  ### `bt.book(offerId, passengers, contactEmail, contactPhone?, idempotencyKey?, searchId?)`
136
136
  Dispatches on which credential is set: `bearerToken` → PFS booking via
137
137
  `POST /api/agent-book` (pass `searchId`, one passenger with full details;
138
138
  returns `booking_ref` — poll `POST /api/agent-book/status`). `apiKey` → paid
139
- Developer API `book` (requires `unlock()` first, supports multiple passengers
140
- and `idempotencyKey`).
141
- ### `bt.setupPayment(token?)` — Developer API only
139
+ Developer API `book` (requires `searchId`, no unlock step, supports multiple
140
+ passengers and `idempotencyKey`; returns a `booking_id` to poll).
141
+ ### `bt.getBooking(bookingId)` / `bt.answerBooking(...)` / `bt.bookAndWait(...)` — Developer API only
142
+ ### `bt.connectPayment()` — Developer API only. Returns `connect_url`; nothing is charged
143
+ ### `bt.setupPayment()` / `bt.unlock()` — **retired 2026-09-08, both throw locally**
142
144
  ### `bt.me()`
143
145
  ### `LetsFG.register(agentName, email, baseUrl?, ownerName?, description?)` — Developer API only, most agents don't need this
144
146
 
@@ -178,7 +180,7 @@ MIT
178
180
 
179
181
  ## 🏨 Hotels — new, and live
180
182
 
181
- Your agent can book hotels as well as flights. Same card-backed token or API key, same card on file.
183
+ Your agent can book hotels as well as flights. Same card-backed token or API key, same connected payment method.
182
184
 
183
185
  ```python
184
186
  from letsfg import LetsFG
@@ -192,36 +194,52 @@ stays = lfg.search_hotels(
192
194
 
193
195
  hotel = stays["hotels"][0]
194
196
  offer = hotel["offers"][0]
195
- print(hotel["name"], offer["price"], stays["currency"])
196
- # Hotel Gromada Warszawa Centrum 669.86 PLN
197
+ print(hotel["name"], offer["price"], offer["currency"], offer["refundable"])
198
+ # prices are in stays["currency"]: USD unless you pass currency=
197
199
 
198
200
  booking = lfg.book_hotel_and_wait(
199
- session_id=stays["session_id"],
201
+ session_id=offer["session_id"],
200
202
  hotel_code=hotel["hotel_code"],
201
203
  combination_id_v2=offer["combination_id_v2"],
202
- expected_price=offer["price"],
203
- expected_balance=offer["balance_to_supplier"],
204
+ expected_price=offer["price"], # copy these four from the offer, verbatim
205
+ expected_cost=offer["expected_cost"],
206
+ currency=offer["currency"],
207
+ fx_rate=offer["fx_rate"],
204
208
  city_id=city["Id"], city_name=city["Name"],
205
209
  check_in="2026-11-10", check_out="2026-11-12",
206
- guests=[{"title": "Mr", "first_name": "Jan", "last_name": "Kowalski"}],
207
- email="guest@example.com", phone="512345678",
210
+ # ONE entry per guest in the room, children included: adults first, then children in child_ages order
211
+ guests=[{"title": "Mr", "first_name": "Jan", "last_name": "Kowalski"},
212
+ {"title": "Mrs", "first_name": "Anna", "last_name": "Kowalska"}],
213
+ email="GUEST_EMAIL", phone="512345678", # the guest's real e-mail and phone
208
214
  )
209
- print(booking["confirmation"], booking["pay_link"])
215
+ if booking["status"] == "succeeded":
216
+ print(booking["confirmation"], booking["total_price"], booking["currency"])
217
+ elif booking["status"] == "failed":
218
+ print("Not booked, nothing charged:", booking["error"])
219
+ else: # "attention": a person is checking it with the supplier, the hold is kept. Do not book again.
220
+ print(booking["status"], booking.get("error"))
210
221
  ```
211
222
 
212
223
  ### How you pay
213
224
 
214
- **5% now, the rest to the hotel later.** At booking we charge 5% of the price
215
- to your card as a reservation fee. The remaining balance is paid **directly to
216
- the supplier** through a `pay_link` we return — we never hold it.
225
+ **The full price is held, and taken only once the hotel confirms.** Booking holds the offer's
226
+ `price` on the Revolut payment method connected to your account — authorised, not charged.
227
+ LetsFG then books the room with the supplier and pays the supplier itself. The hold is captured
228
+ only after the supplier has confirmed the booking; if the booking fails for any reason (the rate
229
+ is gone, the price moved, the supplier declined, a guest detail was rejected), the hold is
230
+ released and nothing is charged.
217
231
 
218
- `balance_due_by` is the supplier's own auto-cancellation date, not a date we
219
- invent. Miss it and the room is released.
232
+ There is **no reservation fee, no deposit and no pay link**, and the guest owes the hotel nothing
233
+ further. (Those belonged to the process retired on 2026-09-11.)
220
234
 
221
- The 5% is **non-refundable**. Cancelling before `balance_due_by` costs nothing
222
- else; after it, the hotel's own cancellation ladder applies and can reach 100%.
223
- That ladder ships in the booking's `terms`, so you can always see the cost before
224
- you cancel.
235
+ `price` is the supplier's cost plus 6.4% (our margin and payment processing) for Revolut Pay or a
236
+ card issued in the EEA, or 8.3% for a card issued outside the EEA — the search response's
237
+ `markup_rate` says which. Nothing is added at booking. Prices are in the currency you search in:
238
+ USD unless you ask for another.
239
+
240
+ Cancelling a refundable rate before its `free_cancellation_until` costs nothing and refunds the
241
+ charge in full. A cancellation that would cost money is refused (409); the hotel's own ladder is in
242
+ the booking's `terms`.
225
243
 
226
244
  ### What search costs
227
245
 
@@ -236,27 +254,36 @@ is not metered, only the search call itself.
236
254
 
237
255
  ### Things worth knowing before you build
238
256
 
239
- - **A card on file is required for every hotel call, including search.** That is
240
- unusual and it is deliberate: a hotel search opens a real session at the
241
- supplier, and booking blocks a real rate. We would rather refuse up front than
242
- let you reach the point of commitment and discover you cannot pay. The same
243
- card that authorises flight booking authorises hotels — there is no separate
244
- hotel signup.
245
- - **Only free-cancellation, pay-later rates are sold.** Those are the rates where
246
- the balance can safely be settled with the supplier after booking, which is
247
- what makes 5%-now/rest-later work at all. You will see fewer results than a
248
- metasearch shows you. Every one of them can actually be booked.
249
- - **Booking is asynchronous.** `book_hotel` returns a `booking_job_id`, not a
250
- booking — the real thing takes minutes. Poll `hotel_booking(job_id)` until
251
- `status` is `succeeded` or `failed`, or call `book_hotel_and_wait` and let the
252
- SDK do it. This is not ceremony: it is what makes it impossible to charge a
253
- card and then lose the confirmation to a timeout.
254
- - **The fee is charged before the room is committed.** A declined card therefore
255
- costs nothing to unwind — no reservation exists and nothing is charged.
256
- - **Do not retry a booking blindly.** Calling `book_hotel` twice for the same
257
- rate books the room twice and charges two reservation fees.
258
- - `price` is what the guest pays. There is no wholesale figure in the response to
259
- quote by mistake.
257
+ - **A connected payment method is required for every hotel call, including search.** A hotel
258
+ search opens a real session at the supplier and booking blocks a real rate, so we refuse up
259
+ front rather than let you reach the point of commitment and discover you cannot pay. The same
260
+ method authorises flights and hotels — there is no separate hotel signup.
261
+ - **Every rate type is sold**, refundable and non-refundable. Each offer's `refundable` and
262
+ `free_cancellation_until` say which one you are buying.
263
+ - **Booking is asynchronous.** `book_hotel` returns a `booking_job_id`, not a booking — the real
264
+ thing takes minutes. Poll `hotel_booking(job_id)` every ~20 s until `status` is `succeeded`,
265
+ `failed` or `attention`, or call `book_hotel_and_wait` and let the SDK do it. All three are final:
266
+ - `succeeded` — `confirmation`, `total_price` + `currency` (what the guest is charged),
267
+ `supplier_paid` + `supplier_currency`, `refundable`, `free_cancellation_until`,
268
+ `cancellation_ladder` and `terms`.
269
+ - `failed` — `error`, written for the guest. The hold has been released; nothing was charged.
270
+ - `attention` — the outcome could not be settled automatically. The hold is kept (nothing is
271
+ charged) while a person checks with the supplier. Do not book again.
272
+ - **Copy the offer verbatim.** Send `expected_price` (the offer's `price`), `expected_cost`,
273
+ `currency` and `fx_rate` exactly as search returned them. A mis-copied price, or a USD offer sent
274
+ without its `currency`, is refused with `400 price_mismatch` before anything is held.
275
+ - **Guest details are checked before anything is held**: Latin-script names, a phone number valid
276
+ for its country code, and an e-mail. A problem returns `400 invalid_details` naming the fields.
277
+ - **One name per guest in the room, children included.** For a family, search with `children` and
278
+ `child_ages`; the party travels with the offer's session. `guests` then lists every guest — adults
279
+ first, then children in `child_ages` order. The hotel requires a name for every guest: fewer names
280
+ than guests is refused before anything is submitted, and the hold is released.
281
+ - **The guest is e-mailed however it ends**: a confirmation with the code and the cancellation
282
+ term, a note that it did not go through and nothing was charged, or a note that it is being
283
+ confirmed with the supplier.
284
+ - **A retry never books twice.** A second `book_hotel` for the same rate and guest returns the job
285
+ already under way (`duplicate: true`); pass `idempotency_key` to make that explicit. Still, never
286
+ re-post a booking whose job is running — poll it.
260
287
 
261
288
  ### JavaScript
262
289
 
@@ -270,8 +297,21 @@ const stays = await lfg.searchHotels({
270
297
  checkIn: '2026-11-10', checkOut: '2026-11-12', adults: 2,
271
298
  });
272
299
 
273
- const booking = await lfg.bookHotelAndWait({ /* ...offer + guest details... */ });
274
- console.log(booking.confirmation, booking.pay_link);
300
+ const hotel = stays.hotels[0];
301
+ const offer = hotel.offers[0];
302
+ const booking = await lfg.bookHotelAndWait({
303
+ sessionId: offer.session_id, hotelCode: hotel.hotel_code,
304
+ combinationIdV2: offer.combination_id_v2,
305
+ expectedPrice: offer.price, expectedCost: offer.expected_cost,
306
+ currency: offer.currency, fxRate: offer.fx_rate,
307
+ cityId: city.Id, cityName: city.Name,
308
+ checkIn: '2026-11-10', checkOut: '2026-11-12',
309
+ // ONE entry per guest in the room, children included: adults first, then children in childAges order
310
+ guests: [{ title: 'Mr', first_name: 'Jan', last_name: 'Kowalski' },
311
+ { title: 'Mrs', first_name: 'Anna', last_name: 'Kowalska' }],
312
+ email: 'GUEST_EMAIL', phone: '512345678',
313
+ });
314
+ console.log(booking.status, booking.confirmation, booking.total_price, booking.currency);
275
315
  ```
276
316
 
277
317
  ### MCP
@@ -1564,6 +1564,7 @@ var LATE_MERGE_POLL_MS = 3e3;
1564
1564
  var LATE_MERGE_GRACE_MS = 9e4;
1565
1565
  var WAIT_FOR_SPLIT = (process.env.LETSFG_WAIT_FOR_SPLIT || "").trim() !== "0";
1566
1566
  var NON_TERMINAL = ["pending", "searching"];
1567
+ var HOTEL_BOOKING_FINAL_STATUSES = ["succeeded", "failed", "attention"];
1567
1568
  var LetsFG = class {
1568
1569
  bearerToken;
1569
1570
  apiKey;
@@ -1671,13 +1672,23 @@ var LetsFG = class {
1671
1672
  return Array.isArray(data) ? data : data.locations || [];
1672
1673
  }
1673
1674
  /**
1674
- * Unlock a flight offer — confirms live price, reveals direct airline booking URL.
1675
- * Developer API only, legacy — there is no unlock endpoint on a PFS Bearer
1676
- * token, so PFS callers use book() directly.
1675
+ * RETIRED 2026-09-08. Throws instead of calling the server.
1676
+ *
1677
+ * There is no unlock step on any lane. Unlock existed to confirm a live price
1678
+ * before charging; booking now HOLDS the fare on the connected payment method
1679
+ * and captures only once a real airline PNR exists, so a fare that moved
1680
+ * cannot become a charge for a ticket you did not get. If it moves at
1681
+ * checkout you get a `price_change` question to accept or decline instead.
1682
+ *
1683
+ * Kept as a method, and throwing locally rather than making the request, so an
1684
+ * older caller gets one clear sentence at the line that is actually wrong —
1685
+ * not a 410 body to decode, and not a TypeError somewhere else.
1677
1686
  */
1678
- async unlock(offerId) {
1679
- this.requireApiKey();
1680
- return this.post("/developers/api/v1/bookings/unlock", { offer_id: offerId });
1687
+ async unlock(_offerId) {
1688
+ throw new LetsFGError(
1689
+ "unlock() was retired on 2026-09-08 and the endpoint answers 410 Gone. There is no unlock step: call book() directly. The fare is held on the connected payment method and captured only against a real airline PNR. See https://letsfg.co/developers/api/docs",
1690
+ 410
1691
+ );
1681
1692
  }
1682
1693
  /**
1683
1694
  * Book a flight.
@@ -1688,9 +1699,14 @@ var LetsFG = class {
1688
1699
  * complete, { ok, booked: false, booking_url } — hand the link to the user,
1689
1700
  * nothing was charged.
1690
1701
  *
1691
- * Developer API (X-API-Key): charges ticket price + service fee via Stripe,
1692
- * creates a real PNR. Requires unlock() first. Always provide
1693
- * idempotencyKey to prevent double-bookings on retry.
1702
+ * Developer API (X-API-Key): POST /flights/book. NO unlock step. searchId is
1703
+ * REQUIRED — an offer is bookable only inside the search that produced it.
1704
+ * The connected Revolut method is HELD, not charged; a LetsFG booking agent
1705
+ * buys the ticket and the hold is captured only against a real airline PNR.
1706
+ * Returns the 202 { ok, booking_id, state, held, charged: 0, poll_url } —
1707
+ * poll getBooking(bookingId) until `terminal`, or use bookAndWait().
1708
+ * Always provide idempotencyKey: a retry with the same key returns the
1709
+ * existing booking instead of opening a second hold on the card.
1694
1710
  */
1695
1711
  async book(offerId, passengers, contactEmail, contactPhone = "", idempotencyKey = "", searchId = "") {
1696
1712
  this.requireAuth();
@@ -1711,28 +1727,107 @@ var LetsFG = class {
1711
1727
  });
1712
1728
  }
1713
1729
  this.requireApiKey();
1730
+ if (!searchId) {
1731
+ throw new LetsFGError(
1732
+ "searchId is required to book on the Developer API \u2014 pass the search_id from search()'s result. An offer can only be booked inside the search that produced it. (Before 2026-09-08 this argument was ignored on this path.)",
1733
+ 400
1734
+ );
1735
+ }
1736
+ const pax = passengers.map((p) => ({ ...p }));
1737
+ if (contactPhone && pax.length && !pax[0].phone_number) pax[0].phone_number = contactPhone;
1714
1738
  const body = {
1739
+ search_id: searchId,
1715
1740
  offer_id: offerId,
1716
- booking_type: "flight",
1717
- passengers,
1718
- contact_email: contactEmail,
1719
- contact_phone: contactPhone
1741
+ passengers: pax,
1742
+ contact_email: contactEmail
1720
1743
  };
1721
1744
  if (idempotencyKey) body.idempotency_key = idempotencyKey;
1722
- return this.post("/developers/api/v1/bookings/book", body);
1745
+ return this.post("/developers/api/v1/flights/book", body);
1746
+ }
1747
+ /**
1748
+ * Poll a Developer API flight booking.
1749
+ *
1750
+ * Poll every few seconds until `terminal` is true. The poll is ALSO how LetsFG
1751
+ * knows you are still there, which is what keeps a booking paused on a
1752
+ * question alive — so do not back off to minutes.
1753
+ *
1754
+ * States: authorised, card_issued, booking_in_progress, awaiting_settlement,
1755
+ * then completed (with `pnr` and `charged_amount`), failed (hold released,
1756
+ * nothing charged) or needs_attention (a human at LetsFG is on it — do not
1757
+ * book again).
1758
+ */
1759
+ async getBooking(bookingId) {
1760
+ this.requireApiKey();
1761
+ return this.getWithAuth(
1762
+ `/developers/api/v1/flights/bookings/${encodeURIComponent(bookingId)}`
1763
+ );
1764
+ }
1765
+ /**
1766
+ * Answer the open `question` on a booking.
1767
+ *
1768
+ * Echo the question's `round`. A stale round is refused with 409 rather than
1769
+ * guessed at, so an answer to an old question can never be applied to a new
1770
+ * one. Seat: { seats: [...] } or { skip: true }. Price change or paid extra:
1771
+ * { confirm: true } or { skip: true } — declining an extra still completes
1772
+ * the booking, without it.
1773
+ */
1774
+ async answerBooking(bookingId, round, answer = {}) {
1775
+ this.requireApiKey();
1776
+ return this.post(
1777
+ `/developers/api/v1/flights/bookings/${encodeURIComponent(bookingId)}/answer`,
1778
+ { round, ...answer }
1779
+ );
1780
+ }
1781
+ /**
1782
+ * Book and poll to a terminal state. Mirrors bookHotelAndWait().
1783
+ *
1784
+ * Blocks for as long as the booking takes (4–11 minutes typically), so use
1785
+ * book() + getBooking() instead if your caller has a request timeout.
1786
+ *
1787
+ * `onQuestion` returns the answer for answerBooking(). Without it, a fare
1788
+ * increase is ACCEPTED and a paid extra is DECLINED — the conservative
1789
+ * reading of "the traveller asked for this flight".
1790
+ */
1791
+ async bookAndWait(offerId, passengers, contactEmail, searchId, opts = {}) {
1792
+ const { contactPhone = "", idempotencyKey = "", pollMs = 5e3, timeoutMs = 9e5, onQuestion } = opts;
1793
+ const started = await this.book(
1794
+ offerId,
1795
+ passengers,
1796
+ contactEmail,
1797
+ contactPhone,
1798
+ idempotencyKey,
1799
+ searchId
1800
+ );
1801
+ if (!started || started.ok !== true) return started;
1802
+ const bookingId = String(started.booking_id);
1803
+ const deadline = Date.now() + timeoutMs;
1804
+ while (Date.now() < deadline) {
1805
+ await new Promise((r) => setTimeout(r, pollMs));
1806
+ const state = await this.getBooking(bookingId);
1807
+ const question = state.question;
1808
+ if (question) {
1809
+ const answer = onQuestion ? onQuestion(question) : question.kind === "extra" ? { skip: true } : { confirm: true };
1810
+ await this.answerBooking(bookingId, Number(question.round), answer);
1811
+ continue;
1812
+ }
1813
+ if (state.terminal) return state;
1814
+ }
1815
+ return this.getBooking(bookingId);
1723
1816
  }
1724
1817
  // ── Hotels ──────────────────────────────────────────────────────────
1725
1818
  //
1726
- // A card on file is required for EVERY hotel call, search included. That is
1727
- // deliberate: a hotel search opens a real session at the supplier and booking
1728
- // blocks a real rate, so a caller is never allowed to reach the point of
1729
- // commitment only to discover it cannot pay. The same card that authorises
1730
- // flight booking authorises hotels — there is no separate hotel enrolment.
1819
+ // A connected payment method is required for EVERY hotel call, search
1820
+ // included. That is deliberate: a hotel search opens a real session at the
1821
+ // supplier and booking blocks a real rate, so a caller is never allowed to
1822
+ // reach the point of commitment only to discover it cannot pay. The same
1823
+ // method that authorises flight booking authorises hotels.
1731
1824
  //
1732
- // Only free-cancellation, pay-later rates are sold. Those are the rates where
1733
- // the guest's balance can safely be settled with the supplier after booking,
1734
- // which is what makes 5%-now/rest-later work. The result set is smaller than
1735
- // a metasearch's, and every row in it can actually be booked.
1825
+ // How a hotel is paid (since 2026-09-11): the full `price` is HELD on the
1826
+ // connected Revolut method, LetsFG books and pays the supplier itself, and the
1827
+ // hold is captured only once the supplier has confirmed. A booking that fails
1828
+ // releases the hold. There is no reservation fee, no deposit and no pay link —
1829
+ // those belonged to the process retired on 2026-09-11. Every rate type is
1830
+ // sold, refundable and non-refundable.
1736
1831
  /**
1737
1832
  * Resolve a place name to the city id that searchHotels() needs.
1738
1833
  *
@@ -1753,12 +1848,17 @@ var LetsFG = class {
1753
1848
  * Slow by nature — the supplier streams a whole city and every rate is priced
1754
1849
  * — so this gets its own generous timeout rather than the client default.
1755
1850
  *
1756
- * Each offer carries `price` (what the guest pays), `reservation_fee_now`
1757
- * (the 5% taken at booking), `balance_to_supplier`, `balance_due_by` and
1758
- * `free_cancellation_until`. There is no wholesale figure to quote by mistake.
1851
+ * The response carries `session_id`, `currency`, `supplier_currency`,
1852
+ * `markup_rate`, `fx_rate`, `fx_as_of`, `count`, `hotels`, `terms` and
1853
+ * `caveats`. Each offer carries `price` (what the guest pays, in `currency`),
1854
+ * `currency`, `fx_rate`, `expected_cost` (the supplier's cost, in PLN),
1855
+ * `refundable`, `free_cancellation_until` (refundable rates only),
1856
+ * `cancellation_policy` and its own `session_id`.
1759
1857
  *
1760
- * Keep `session_id` and the chosen offer's `combination_id_v2`: together they
1761
- * identify the exact rate, and booking needs both.
1858
+ * `price` is the supplier's cost plus `markup_rate` (6.4% for Revolut Pay or an
1859
+ * EEA-issued card, 8.3% for a card issued outside the EEA); nothing is added at
1860
+ * booking. Keep the chosen offer whole: bookHotel() needs its `session_id`,
1861
+ * `combination_id_v2`, `price`, `expected_cost`, `currency` and `fx_rate`.
1762
1862
  */
1763
1863
  async searchHotels(params) {
1764
1864
  this.requireApiKey();
@@ -1771,7 +1871,8 @@ var LetsFG = class {
1771
1871
  children: params.children ?? 0,
1772
1872
  nationality: params.nationality ?? "PL",
1773
1873
  limit: params.limit ?? 40,
1774
- with_images: params.withImages ?? true
1874
+ with_images: params.withImages ?? true,
1875
+ currency: params.currency ?? "USD"
1775
1876
  };
1776
1877
  if (params.childAges?.length) body.child_ages = params.childAges;
1777
1878
  return this.post("/developers/api/v1/hotels/search", body, 24e4);
@@ -1779,30 +1880,42 @@ var LetsFG = class {
1779
1880
  /**
1780
1881
  * Start a booking. Returns a job immediately — it does NOT book inline.
1781
1882
  *
1782
- * A booking takes minutes: the rate is re-blocked at the supplier, every
1783
- * price and date rail is checked, the 5% reservation fee is charged to your
1784
- * card, and only then is the room committed. No proxy holds a connection that
1785
- * long, so this returns at once and you poll hotelBooking() for the outcome.
1786
- * Use bookHotelAndWait() if you would rather block.
1883
+ * What happens, in order: the offer's full `price` is HELD on the Revolut
1884
+ * payment method connected to this account (authorised, not taken); LetsFG
1885
+ * books the room with the supplier and pays the supplier itself; the hold is
1886
+ * captured only once the supplier has confirmed. If the booking fails for any
1887
+ * reason, the hold is released and nothing is charged. There is no
1888
+ * reservation fee, no deposit and no pay link.
1787
1889
  *
1788
- * Because the fee is taken BEFORE the commit, a declined card costs nothing
1789
- * to unwind: no reservation exists and nothing is charged.
1890
+ * A booking takes minutes and no proxy holds a connection that long, so this
1891
+ * returns at once and you poll hotelBooking() for the outcome. Use
1892
+ * bookHotelAndWait() if you would rather block.
1790
1893
  *
1791
- * Send `expectedPrice` and `expectedBalance` back exactly as search returned
1792
- * them — the booking is refused if the supplier has moved beyond tolerance,
1793
- * so a guest is never charged a price they did not agree to.
1894
+ * Send `expectedPrice` (the offer's `price`), `expectedCost`, `currency` and
1895
+ * `fxRate` exactly as search returned them. The booking is refused if the
1896
+ * supplier's live cost is above `expectedCost`, and a USD offer sent without
1897
+ * its `currency` is refused with 400 price_mismatch (the API assumes PLN).
1898
+ * Guest names, phone and e-mail are checked before anything is held; a problem
1899
+ * returns 400 invalid_details naming the fields.
1794
1900
  *
1795
- * Do NOT call this again for the same rate while a job is running: that books
1796
- * the room twice and charges two reservation fees.
1901
+ * Do NOT call this again for a booking whose job is still running: poll it.
1902
+ * A retry of the same booking returns the job already under way
1903
+ * (`duplicate: true`) rather than holding the money twice.
1797
1904
  */
1798
1905
  async bookHotel(params) {
1799
1906
  this.requireApiKey();
1907
+ if (typeof params.expectedCost !== "number") {
1908
+ throw new LetsFGError(
1909
+ "bookHotel() needs expectedCost: copy the offer's expected_cost, currency and fx_rate. " + ("expectedBalance" in params ? "expectedBalance belonged to the reservation-fee process retired on 2026-09-11 and is not sent. " : "") + "See https://letsfg.co/developers/api/docs",
1910
+ 400
1911
+ );
1912
+ }
1800
1913
  const body = {
1801
1914
  session_id: params.sessionId,
1802
1915
  hotel_code: params.hotelCode,
1803
1916
  combination_id_v2: params.combinationIdV2,
1804
1917
  expected_price: params.expectedPrice,
1805
- expected_balance: params.expectedBalance,
1918
+ expected_cost: params.expectedCost,
1806
1919
  city_id: params.cityId,
1807
1920
  city_name: params.cityName,
1808
1921
  check_in: params.checkIn,
@@ -1814,16 +1927,30 @@ var LetsFG = class {
1814
1927
  phone_country_code: params.phoneCountryCode ?? "48",
1815
1928
  special_requests: params.specialRequests ?? []
1816
1929
  };
1930
+ if (params.currency) body.currency = params.currency;
1931
+ if (params.fxRate != null) body.fx_rate = params.fxRate;
1817
1932
  if (params.combinationId != null) body.combination_id = params.combinationId;
1818
1933
  if (params.hotelName) body.hotel_name = params.hotelName;
1934
+ if (params.idempotencyKey) body.idempotency_key = params.idempotencyKey;
1819
1935
  return this.post("/developers/api/v1/hotels/book", body, 9e4);
1820
1936
  }
1821
1937
  /**
1822
1938
  * Collect the result of a booking started with bookHotel().
1823
1939
  *
1824
- * `status` is 'in_progress', 'succeeded' or 'failed'. On success you get
1825
- * `confirmation`, `reservation_fee_charged`, `pay_link`, `balance_due`,
1826
- * `balance_due_by` and `terms` (including the full cancellation ladder).
1940
+ * `status` is 'in_progress', 'succeeded', 'failed' or 'attention'; the last
1941
+ * three are final (HOTEL_BOOKING_FINAL_STATUSES).
1942
+ *
1943
+ * - succeeded: `confirmation`, `booking_id`, `hotel`, `room`, `total_price` +
1944
+ * `currency` (what the guest is charged), `supplier_paid` +
1945
+ * `supplier_currency` (what the supplier was paid), `payment_status`,
1946
+ * `refundable`, `free_cancellation_until`, `cancellation_ladder`, `terms`.
1947
+ * - failed: `error`, written for the guest. The hold has been released and
1948
+ * nothing was charged.
1949
+ * - attention: `error` (and `confirmation` when known). The outcome could not
1950
+ * be settled automatically; the hold is kept — nothing is charged — while a
1951
+ * person checks with the supplier. Do not book again.
1952
+ *
1953
+ * The guest is e-mailed in every case.
1827
1954
  */
1828
1955
  async hotelBooking(bookingJobId) {
1829
1956
  this.requireApiKey();
@@ -1835,13 +1962,15 @@ var LetsFG = class {
1835
1962
  /**
1836
1963
  * bookHotel(), then poll until the booking settles. Convenience only.
1837
1964
  *
1838
- * Giving up after `maxWaitMs` does NOT cancel anything — the booking may
1839
- * still complete. The returned object carries `booking_job_id` so you can
1840
- * keep polling, and the confirmation is emailed to the guest regardless.
1965
+ * Stops at 'succeeded', 'failed' or 'attention' and never re-books. Giving up
1966
+ * after `maxWaitMs` (default 30 minutes: a booking usually takes 5-10, and
1967
+ * hotel bookings run one at a time) does NOT cancel anything — the booking may
1968
+ * still complete. The returned object carries `booking_job_id` so you can keep
1969
+ * polling, and the guest is e-mailed the outcome regardless.
1841
1970
  */
1842
1971
  async bookHotelAndWait(params) {
1843
1972
  const pollIntervalMs = params.pollIntervalMs ?? 2e4;
1844
- const maxWaitMs = params.maxWaitMs ?? 6e5;
1973
+ const maxWaitMs = params.maxWaitMs ?? 18e5;
1845
1974
  const job = await this.bookHotel(params);
1846
1975
  const jobId = job.booking_job_id;
1847
1976
  if (!jobId) return job;
@@ -1851,19 +1980,19 @@ var LetsFG = class {
1851
1980
  await new Promise((r) => setTimeout(r, pollIntervalMs));
1852
1981
  waited += pollIntervalMs;
1853
1982
  result = await this.hotelBooking(jobId);
1854
- const st = result.status;
1855
- if (st === "succeeded" || st === "failed") return result;
1983
+ if (HOTEL_BOOKING_FINAL_STATUSES.includes(result.status)) return result;
1856
1984
  }
1857
1985
  if (result.booking_job_id == null) result.booking_job_id = jobId;
1858
1986
  return result;
1859
1987
  }
1860
1988
  /**
1861
- * Release a reservation at the supplier.
1989
+ * Release a reservation at the supplier and refund the guest.
1862
1990
  *
1863
- * Free until `balance_due_by`; after that the hotel's own cancellation ladder
1864
- * applies and can reach 100%. The ladder ships in the booking's `terms`, so
1865
- * you can see the cost before calling this. The 5% reservation fee is NOT
1866
- * refunded.
1991
+ * Only this account's own bookings can be cancelled (anything else is 404). A
1992
+ * zero-charge cancellation — a refundable rate before its
1993
+ * `free_cancellation_until` — refunds the charge in full, or releases a hold
1994
+ * not yet captured. A cancellation that would cost money is refused with 409;
1995
+ * the hotel's own ladder is in the booking's `terms`.
1867
1996
  *
1868
1997
  * This drives a browser at the supplier and takes over a minute. If it times
1869
1998
  * out, do not assume it failed — re-check before retrying.
@@ -1877,15 +2006,37 @@ var LetsFG = class {
1877
2006
  );
1878
2007
  }
1879
2008
  /**
1880
- * [Developer API only] Attach a card to a PAID prepaid Developer API account.
2009
+ * [Developer API] Mint a one-time link for connecting a Revolut payment method.
2010
+ *
2011
+ * This replaced setupPayment() on 2026-09-08. Nothing is charged to connect, and
2012
+ * card details never touch LetsFG: the returned `connect_url` opens a hosted page
2013
+ * where the developer saves a card, Revolut Pay or Google Pay. A PERSON must open
2014
+ * it in a browser — there is no endpoint that takes card details, so do not ask a
2015
+ * user for a card number and do not try to automate this step.
1881
2016
  *
1882
- * Most agents should NOT call this. It is unrelated to authenticating for
1883
- * search and booking — for that, run `letsfg auth`, which puts a card on file
1884
- * through a zero-amount setup and creates no billing account.
2017
+ * Most agents should NOT need a Developer API account at all. To authenticate for
2018
+ * search and booking, run `letsfg auth`, which creates no billing account.
1885
2019
  */
1886
- async setupPayment(token = "tok_visa") {
2020
+ async connectPayment() {
1887
2021
  this.requireApiKey();
1888
- return this.post("/developers/api/v1/agents/setup-payment", { token });
2022
+ return this.post("/developers/api/v1/agents/connect-payment", {});
2023
+ }
2024
+ /**
2025
+ * RETIRED 2026-09-08 with Stripe. Throws instead of calling the server.
2026
+ *
2027
+ * `/agents/setup-payment` answers 410 Gone. Payment enrolment moved onto the same
2028
+ * Revolut rail as the rest of the product: call connectPayment() and open the
2029
+ * `connect_url` it returns.
2030
+ *
2031
+ * Kept as a method, and throwing locally rather than making the request, for the
2032
+ * same reason as unlock() — an older caller gets one clear sentence at the line that
2033
+ * is actually wrong, not a 410 body to decode and not a TypeError somewhere else.
2034
+ */
2035
+ async setupPayment(_token) {
2036
+ throw new LetsFGError(
2037
+ "setupPayment() was retired on 2026-09-08 with Stripe and the endpoint answers 410 Gone. Call connectPayment() instead and open the connect_url it returns; nothing is charged to connect. See https://letsfg.co/developers/api/docs",
2038
+ 410
2039
+ );
1889
2040
  }
1890
2041
  /**
1891
2042
  * Get current agent profile and usage stats.
@@ -1994,6 +2145,7 @@ export {
1994
2145
  ValidationError,
1995
2146
  offerSummary,
1996
2147
  cheapestOffer,
2148
+ HOTEL_BOOKING_FINAL_STATUSES,
1997
2149
  LetsFG,
1998
2150
  index_default,
1999
2151
  BoostedTravel,