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/dist/cli.js CHANGED
@@ -320,6 +320,7 @@ var LATE_MERGE_POLL_MS = 3e3;
320
320
  var LATE_MERGE_GRACE_MS = 9e4;
321
321
  var WAIT_FOR_SPLIT = (process.env.LETSFG_WAIT_FOR_SPLIT || "").trim() !== "0";
322
322
  var NON_TERMINAL = ["pending", "searching"];
323
+ var HOTEL_BOOKING_FINAL_STATUSES = ["succeeded", "failed", "attention"];
323
324
  var LetsFG = class {
324
325
  bearerToken;
325
326
  apiKey;
@@ -427,13 +428,23 @@ var LetsFG = class {
427
428
  return Array.isArray(data) ? data : data.locations || [];
428
429
  }
429
430
  /**
430
- * Unlock a flight offer — confirms live price, reveals direct airline booking URL.
431
- * Developer API only, legacy — there is no unlock endpoint on a PFS Bearer
432
- * token, so PFS callers use book() directly.
431
+ * RETIRED 2026-09-08. Throws instead of calling the server.
432
+ *
433
+ * There is no unlock step on any lane. Unlock existed to confirm a live price
434
+ * before charging; booking now HOLDS the fare on the connected payment method
435
+ * and captures only once a real airline PNR exists, so a fare that moved
436
+ * cannot become a charge for a ticket you did not get. If it moves at
437
+ * checkout you get a `price_change` question to accept or decline instead.
438
+ *
439
+ * Kept as a method, and throwing locally rather than making the request, so an
440
+ * older caller gets one clear sentence at the line that is actually wrong —
441
+ * not a 410 body to decode, and not a TypeError somewhere else.
433
442
  */
434
- async unlock(offerId) {
435
- this.requireApiKey();
436
- return this.post("/developers/api/v1/bookings/unlock", { offer_id: offerId });
443
+ async unlock(_offerId) {
444
+ throw new LetsFGError(
445
+ "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",
446
+ 410
447
+ );
437
448
  }
438
449
  /**
439
450
  * Book a flight.
@@ -444,9 +455,14 @@ var LetsFG = class {
444
455
  * complete, { ok, booked: false, booking_url } — hand the link to the user,
445
456
  * nothing was charged.
446
457
  *
447
- * Developer API (X-API-Key): charges ticket price + service fee via Stripe,
448
- * creates a real PNR. Requires unlock() first. Always provide
449
- * idempotencyKey to prevent double-bookings on retry.
458
+ * Developer API (X-API-Key): POST /flights/book. NO unlock step. searchId is
459
+ * REQUIRED — an offer is bookable only inside the search that produced it.
460
+ * The connected Revolut method is HELD, not charged; a LetsFG booking agent
461
+ * buys the ticket and the hold is captured only against a real airline PNR.
462
+ * Returns the 202 { ok, booking_id, state, held, charged: 0, poll_url } —
463
+ * poll getBooking(bookingId) until `terminal`, or use bookAndWait().
464
+ * Always provide idempotencyKey: a retry with the same key returns the
465
+ * existing booking instead of opening a second hold on the card.
450
466
  */
451
467
  async book(offerId, passengers, contactEmail, contactPhone = "", idempotencyKey = "", searchId = "") {
452
468
  this.requireAuth();
@@ -467,28 +483,107 @@ var LetsFG = class {
467
483
  });
468
484
  }
469
485
  this.requireApiKey();
486
+ if (!searchId) {
487
+ throw new LetsFGError(
488
+ "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.)",
489
+ 400
490
+ );
491
+ }
492
+ const pax = passengers.map((p) => ({ ...p }));
493
+ if (contactPhone && pax.length && !pax[0].phone_number) pax[0].phone_number = contactPhone;
470
494
  const body = {
495
+ search_id: searchId,
471
496
  offer_id: offerId,
472
- booking_type: "flight",
473
- passengers,
474
- contact_email: contactEmail,
475
- contact_phone: contactPhone
497
+ passengers: pax,
498
+ contact_email: contactEmail
476
499
  };
477
500
  if (idempotencyKey) body.idempotency_key = idempotencyKey;
478
- return this.post("/developers/api/v1/bookings/book", body);
501
+ return this.post("/developers/api/v1/flights/book", body);
502
+ }
503
+ /**
504
+ * Poll a Developer API flight booking.
505
+ *
506
+ * Poll every few seconds until `terminal` is true. The poll is ALSO how LetsFG
507
+ * knows you are still there, which is what keeps a booking paused on a
508
+ * question alive — so do not back off to minutes.
509
+ *
510
+ * States: authorised, card_issued, booking_in_progress, awaiting_settlement,
511
+ * then completed (with `pnr` and `charged_amount`), failed (hold released,
512
+ * nothing charged) or needs_attention (a human at LetsFG is on it — do not
513
+ * book again).
514
+ */
515
+ async getBooking(bookingId) {
516
+ this.requireApiKey();
517
+ return this.getWithAuth(
518
+ `/developers/api/v1/flights/bookings/${encodeURIComponent(bookingId)}`
519
+ );
520
+ }
521
+ /**
522
+ * Answer the open `question` on a booking.
523
+ *
524
+ * Echo the question's `round`. A stale round is refused with 409 rather than
525
+ * guessed at, so an answer to an old question can never be applied to a new
526
+ * one. Seat: { seats: [...] } or { skip: true }. Price change or paid extra:
527
+ * { confirm: true } or { skip: true } — declining an extra still completes
528
+ * the booking, without it.
529
+ */
530
+ async answerBooking(bookingId, round, answer = {}) {
531
+ this.requireApiKey();
532
+ return this.post(
533
+ `/developers/api/v1/flights/bookings/${encodeURIComponent(bookingId)}/answer`,
534
+ { round, ...answer }
535
+ );
536
+ }
537
+ /**
538
+ * Book and poll to a terminal state. Mirrors bookHotelAndWait().
539
+ *
540
+ * Blocks for as long as the booking takes (4–11 minutes typically), so use
541
+ * book() + getBooking() instead if your caller has a request timeout.
542
+ *
543
+ * `onQuestion` returns the answer for answerBooking(). Without it, a fare
544
+ * increase is ACCEPTED and a paid extra is DECLINED — the conservative
545
+ * reading of "the traveller asked for this flight".
546
+ */
547
+ async bookAndWait(offerId, passengers, contactEmail, searchId, opts = {}) {
548
+ const { contactPhone = "", idempotencyKey = "", pollMs = 5e3, timeoutMs = 9e5, onQuestion } = opts;
549
+ const started = await this.book(
550
+ offerId,
551
+ passengers,
552
+ contactEmail,
553
+ contactPhone,
554
+ idempotencyKey,
555
+ searchId
556
+ );
557
+ if (!started || started.ok !== true) return started;
558
+ const bookingId = String(started.booking_id);
559
+ const deadline = Date.now() + timeoutMs;
560
+ while (Date.now() < deadline) {
561
+ await new Promise((r) => setTimeout(r, pollMs));
562
+ const state = await this.getBooking(bookingId);
563
+ const question = state.question;
564
+ if (question) {
565
+ const answer = onQuestion ? onQuestion(question) : question.kind === "extra" ? { skip: true } : { confirm: true };
566
+ await this.answerBooking(bookingId, Number(question.round), answer);
567
+ continue;
568
+ }
569
+ if (state.terminal) return state;
570
+ }
571
+ return this.getBooking(bookingId);
479
572
  }
480
573
  // ── Hotels ──────────────────────────────────────────────────────────
481
574
  //
482
- // A card on file is required for EVERY hotel call, search included. That is
483
- // deliberate: a hotel search opens a real session at the supplier and booking
484
- // blocks a real rate, so a caller is never allowed to reach the point of
485
- // commitment only to discover it cannot pay. The same card that authorises
486
- // flight booking authorises hotels — there is no separate hotel enrolment.
575
+ // A connected payment method is required for EVERY hotel call, search
576
+ // included. That is deliberate: a hotel search opens a real session at the
577
+ // supplier and booking blocks a real rate, so a caller is never allowed to
578
+ // reach the point of commitment only to discover it cannot pay. The same
579
+ // method that authorises flight booking authorises hotels.
487
580
  //
488
- // Only free-cancellation, pay-later rates are sold. Those are the rates where
489
- // the guest's balance can safely be settled with the supplier after booking,
490
- // which is what makes 5%-now/rest-later work. The result set is smaller than
491
- // a metasearch's, and every row in it can actually be booked.
581
+ // How a hotel is paid (since 2026-09-11): the full `price` is HELD on the
582
+ // connected Revolut method, LetsFG books and pays the supplier itself, and the
583
+ // hold is captured only once the supplier has confirmed. A booking that fails
584
+ // releases the hold. There is no reservation fee, no deposit and no pay link —
585
+ // those belonged to the process retired on 2026-09-11. Every rate type is
586
+ // sold, refundable and non-refundable.
492
587
  /**
493
588
  * Resolve a place name to the city id that searchHotels() needs.
494
589
  *
@@ -509,12 +604,17 @@ var LetsFG = class {
509
604
  * Slow by nature — the supplier streams a whole city and every rate is priced
510
605
  * — so this gets its own generous timeout rather than the client default.
511
606
  *
512
- * Each offer carries `price` (what the guest pays), `reservation_fee_now`
513
- * (the 5% taken at booking), `balance_to_supplier`, `balance_due_by` and
514
- * `free_cancellation_until`. There is no wholesale figure to quote by mistake.
607
+ * The response carries `session_id`, `currency`, `supplier_currency`,
608
+ * `markup_rate`, `fx_rate`, `fx_as_of`, `count`, `hotels`, `terms` and
609
+ * `caveats`. Each offer carries `price` (what the guest pays, in `currency`),
610
+ * `currency`, `fx_rate`, `expected_cost` (the supplier's cost, in PLN),
611
+ * `refundable`, `free_cancellation_until` (refundable rates only),
612
+ * `cancellation_policy` and its own `session_id`.
515
613
  *
516
- * Keep `session_id` and the chosen offer's `combination_id_v2`: together they
517
- * identify the exact rate, and booking needs both.
614
+ * `price` is the supplier's cost plus `markup_rate` (6.4% for Revolut Pay or an
615
+ * EEA-issued card, 8.3% for a card issued outside the EEA); nothing is added at
616
+ * booking. Keep the chosen offer whole: bookHotel() needs its `session_id`,
617
+ * `combination_id_v2`, `price`, `expected_cost`, `currency` and `fx_rate`.
518
618
  */
519
619
  async searchHotels(params) {
520
620
  this.requireApiKey();
@@ -527,7 +627,8 @@ var LetsFG = class {
527
627
  children: params.children ?? 0,
528
628
  nationality: params.nationality ?? "PL",
529
629
  limit: params.limit ?? 40,
530
- with_images: params.withImages ?? true
630
+ with_images: params.withImages ?? true,
631
+ currency: params.currency ?? "USD"
531
632
  };
532
633
  if (params.childAges?.length) body.child_ages = params.childAges;
533
634
  return this.post("/developers/api/v1/hotels/search", body, 24e4);
@@ -535,30 +636,42 @@ var LetsFG = class {
535
636
  /**
536
637
  * Start a booking. Returns a job immediately — it does NOT book inline.
537
638
  *
538
- * A booking takes minutes: the rate is re-blocked at the supplier, every
539
- * price and date rail is checked, the 5% reservation fee is charged to your
540
- * card, and only then is the room committed. No proxy holds a connection that
541
- * long, so this returns at once and you poll hotelBooking() for the outcome.
542
- * Use bookHotelAndWait() if you would rather block.
639
+ * What happens, in order: the offer's full `price` is HELD on the Revolut
640
+ * payment method connected to this account (authorised, not taken); LetsFG
641
+ * books the room with the supplier and pays the supplier itself; the hold is
642
+ * captured only once the supplier has confirmed. If the booking fails for any
643
+ * reason, the hold is released and nothing is charged. There is no
644
+ * reservation fee, no deposit and no pay link.
543
645
  *
544
- * Because the fee is taken BEFORE the commit, a declined card costs nothing
545
- * to unwind: no reservation exists and nothing is charged.
646
+ * A booking takes minutes and no proxy holds a connection that long, so this
647
+ * returns at once and you poll hotelBooking() for the outcome. Use
648
+ * bookHotelAndWait() if you would rather block.
546
649
  *
547
- * Send `expectedPrice` and `expectedBalance` back exactly as search returned
548
- * them — the booking is refused if the supplier has moved beyond tolerance,
549
- * so a guest is never charged a price they did not agree to.
650
+ * Send `expectedPrice` (the offer's `price`), `expectedCost`, `currency` and
651
+ * `fxRate` exactly as search returned them. The booking is refused if the
652
+ * supplier's live cost is above `expectedCost`, and a USD offer sent without
653
+ * its `currency` is refused with 400 price_mismatch (the API assumes PLN).
654
+ * Guest names, phone and e-mail are checked before anything is held; a problem
655
+ * returns 400 invalid_details naming the fields.
550
656
  *
551
- * Do NOT call this again for the same rate while a job is running: that books
552
- * the room twice and charges two reservation fees.
657
+ * Do NOT call this again for a booking whose job is still running: poll it.
658
+ * A retry of the same booking returns the job already under way
659
+ * (`duplicate: true`) rather than holding the money twice.
553
660
  */
554
661
  async bookHotel(params) {
555
662
  this.requireApiKey();
663
+ if (typeof params.expectedCost !== "number") {
664
+ throw new LetsFGError(
665
+ "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",
666
+ 400
667
+ );
668
+ }
556
669
  const body = {
557
670
  session_id: params.sessionId,
558
671
  hotel_code: params.hotelCode,
559
672
  combination_id_v2: params.combinationIdV2,
560
673
  expected_price: params.expectedPrice,
561
- expected_balance: params.expectedBalance,
674
+ expected_cost: params.expectedCost,
562
675
  city_id: params.cityId,
563
676
  city_name: params.cityName,
564
677
  check_in: params.checkIn,
@@ -570,16 +683,30 @@ var LetsFG = class {
570
683
  phone_country_code: params.phoneCountryCode ?? "48",
571
684
  special_requests: params.specialRequests ?? []
572
685
  };
686
+ if (params.currency) body.currency = params.currency;
687
+ if (params.fxRate != null) body.fx_rate = params.fxRate;
573
688
  if (params.combinationId != null) body.combination_id = params.combinationId;
574
689
  if (params.hotelName) body.hotel_name = params.hotelName;
690
+ if (params.idempotencyKey) body.idempotency_key = params.idempotencyKey;
575
691
  return this.post("/developers/api/v1/hotels/book", body, 9e4);
576
692
  }
577
693
  /**
578
694
  * Collect the result of a booking started with bookHotel().
579
695
  *
580
- * `status` is 'in_progress', 'succeeded' or 'failed'. On success you get
581
- * `confirmation`, `reservation_fee_charged`, `pay_link`, `balance_due`,
582
- * `balance_due_by` and `terms` (including the full cancellation ladder).
696
+ * `status` is 'in_progress', 'succeeded', 'failed' or 'attention'; the last
697
+ * three are final (HOTEL_BOOKING_FINAL_STATUSES).
698
+ *
699
+ * - succeeded: `confirmation`, `booking_id`, `hotel`, `room`, `total_price` +
700
+ * `currency` (what the guest is charged), `supplier_paid` +
701
+ * `supplier_currency` (what the supplier was paid), `payment_status`,
702
+ * `refundable`, `free_cancellation_until`, `cancellation_ladder`, `terms`.
703
+ * - failed: `error`, written for the guest. The hold has been released and
704
+ * nothing was charged.
705
+ * - attention: `error` (and `confirmation` when known). The outcome could not
706
+ * be settled automatically; the hold is kept — nothing is charged — while a
707
+ * person checks with the supplier. Do not book again.
708
+ *
709
+ * The guest is e-mailed in every case.
583
710
  */
584
711
  async hotelBooking(bookingJobId) {
585
712
  this.requireApiKey();
@@ -591,13 +718,15 @@ var LetsFG = class {
591
718
  /**
592
719
  * bookHotel(), then poll until the booking settles. Convenience only.
593
720
  *
594
- * Giving up after `maxWaitMs` does NOT cancel anything — the booking may
595
- * still complete. The returned object carries `booking_job_id` so you can
596
- * keep polling, and the confirmation is emailed to the guest regardless.
721
+ * Stops at 'succeeded', 'failed' or 'attention' and never re-books. Giving up
722
+ * after `maxWaitMs` (default 30 minutes: a booking usually takes 5-10, and
723
+ * hotel bookings run one at a time) does NOT cancel anything — the booking may
724
+ * still complete. The returned object carries `booking_job_id` so you can keep
725
+ * polling, and the guest is e-mailed the outcome regardless.
597
726
  */
598
727
  async bookHotelAndWait(params) {
599
728
  const pollIntervalMs = params.pollIntervalMs ?? 2e4;
600
- const maxWaitMs = params.maxWaitMs ?? 6e5;
729
+ const maxWaitMs = params.maxWaitMs ?? 18e5;
601
730
  const job = await this.bookHotel(params);
602
731
  const jobId = job.booking_job_id;
603
732
  if (!jobId) return job;
@@ -607,19 +736,19 @@ var LetsFG = class {
607
736
  await new Promise((r) => setTimeout(r, pollIntervalMs));
608
737
  waited += pollIntervalMs;
609
738
  result = await this.hotelBooking(jobId);
610
- const st = result.status;
611
- if (st === "succeeded" || st === "failed") return result;
739
+ if (HOTEL_BOOKING_FINAL_STATUSES.includes(result.status)) return result;
612
740
  }
613
741
  if (result.booking_job_id == null) result.booking_job_id = jobId;
614
742
  return result;
615
743
  }
616
744
  /**
617
- * Release a reservation at the supplier.
745
+ * Release a reservation at the supplier and refund the guest.
618
746
  *
619
- * Free until `balance_due_by`; after that the hotel's own cancellation ladder
620
- * applies and can reach 100%. The ladder ships in the booking's `terms`, so
621
- * you can see the cost before calling this. The 5% reservation fee is NOT
622
- * refunded.
747
+ * Only this account's own bookings can be cancelled (anything else is 404). A
748
+ * zero-charge cancellation — a refundable rate before its
749
+ * `free_cancellation_until` — refunds the charge in full, or releases a hold
750
+ * not yet captured. A cancellation that would cost money is refused with 409;
751
+ * the hotel's own ladder is in the booking's `terms`.
623
752
  *
624
753
  * This drives a browser at the supplier and takes over a minute. If it times
625
754
  * out, do not assume it failed — re-check before retrying.
@@ -633,15 +762,37 @@ var LetsFG = class {
633
762
  );
634
763
  }
635
764
  /**
636
- * [Developer API only] Attach a card to a PAID prepaid Developer API account.
765
+ * [Developer API] Mint a one-time link for connecting a Revolut payment method.
766
+ *
767
+ * This replaced setupPayment() on 2026-09-08. Nothing is charged to connect, and
768
+ * card details never touch LetsFG: the returned `connect_url` opens a hosted page
769
+ * where the developer saves a card, Revolut Pay or Google Pay. A PERSON must open
770
+ * it in a browser — there is no endpoint that takes card details, so do not ask a
771
+ * user for a card number and do not try to automate this step.
637
772
  *
638
- * Most agents should NOT call this. It is unrelated to authenticating for
639
- * search and booking — for that, run `letsfg auth`, which puts a card on file
640
- * through a zero-amount setup and creates no billing account.
773
+ * Most agents should NOT need a Developer API account at all. To authenticate for
774
+ * search and booking, run `letsfg auth`, which creates no billing account.
641
775
  */
642
- async setupPayment(token = "tok_visa") {
776
+ async connectPayment() {
643
777
  this.requireApiKey();
644
- return this.post("/developers/api/v1/agents/setup-payment", { token });
778
+ return this.post("/developers/api/v1/agents/connect-payment", {});
779
+ }
780
+ /**
781
+ * RETIRED 2026-09-08 with Stripe. Throws instead of calling the server.
782
+ *
783
+ * `/agents/setup-payment` answers 410 Gone. Payment enrolment moved onto the same
784
+ * Revolut rail as the rest of the product: call connectPayment() and open the
785
+ * `connect_url` it returns.
786
+ *
787
+ * Kept as a method, and throwing locally rather than making the request, for the
788
+ * same reason as unlock() — an older caller gets one clear sentence at the line that
789
+ * is actually wrong, not a 410 body to decode and not a TypeError somewhere else.
790
+ */
791
+ async setupPayment(_token) {
792
+ throw new LetsFGError(
793
+ "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",
794
+ 410
795
+ );
645
796
  }
646
797
  /**
647
798
  * Get current agent profile and usage stats.
@@ -1051,38 +1202,15 @@ async function cmdSearch(args) {
1051
1202
  `);
1052
1203
  } else {
1053
1204
  console.log(`
1054
- To unlock: letsfg unlock <offer_id>`);
1055
- console.log(` Passenger IDs needed for booking: ${JSON.stringify(result.passenger_ids)}
1205
+ To book: letsfg book <offer_id> --search-id ${result.search_id} --passenger '{...}' --email you@example.com
1056
1206
  `);
1057
1207
  }
1058
1208
  }
1059
- async function cmdUnlock(args) {
1060
- const jsonOut = hasFlag(args, "--json") || hasFlag(args, "-j");
1061
- const apiKey = getFlag(args, "--api-key", "-k");
1062
- const baseUrl = getFlag(args, "--base-url");
1063
- const offerId = args[0];
1064
- if (!offerId) {
1065
- console.error("Usage: letsfg unlock <offer_id>");
1066
- process.exit(1);
1067
- }
1068
- const bt = new LetsFG({ apiKey, baseUrl });
1069
- const result = await bt.unlock(offerId);
1070
- if (jsonOut) {
1071
- console.log(JSON.stringify(result, null, 2));
1072
- return;
1073
- }
1074
- if (result.unlock_status === "unlocked") {
1075
- console.log(`
1076
- \u2713 Offer unlocked!`);
1077
- console.log(` Confirmed price: ${result.confirmed_currency} ${result.confirmed_price?.toFixed(2)}`);
1078
- console.log(` Expires at: ${result.offer_expires_at}`);
1079
- console.log(`
1080
- Next: letsfg book ${offerId} --passenger '{...}' --email you@example.com
1081
- `);
1082
- } else {
1083
- console.error(` \u2717 Unlock failed: ${result.message}`);
1084
- process.exit(1);
1085
- }
1209
+ async function cmdUnlock(_args) {
1210
+ console.error(
1211
+ "\n letsfg unlock was retired on 2026-09-08 and the endpoint answers 410 Gone.\n\n There is no unlock step any more. Book directly:\n letsfg book <offer_id> --search-id <search_id> --passenger '{...}' --email you@example.com\n\n The fare is HELD on your connected payment method and captured only once a real\n airline PNR exists, which is what unlock existed to protect against. If the fare moves\n at checkout you are asked to accept or decline it.\n"
1212
+ );
1213
+ process.exit(1);
1086
1214
  }
1087
1215
  async function cmdBook(args) {
1088
1216
  const jsonOut = hasFlag(args, "--json") || hasFlag(args, "-j");
@@ -1214,19 +1342,26 @@ async function cmdSetupPayment(args) {
1214
1342
  const jsonOut = hasFlag(args, "--json") || hasFlag(args, "-j");
1215
1343
  const apiKey = getFlag(args, "--api-key", "-k");
1216
1344
  const baseUrl = getFlag(args, "--base-url");
1217
- const token = getFlag(args, "--token", "-t") || "tok_visa";
1345
+ if (getFlag(args, "--token", "-t")) {
1346
+ console.log("\n Note: --token was part of the Stripe enrolment, retired 2026-09-08. Ignoring it.");
1347
+ }
1218
1348
  const bt = new LetsFG({ apiKey, baseUrl });
1219
- const result = await bt.setupPayment(token);
1349
+ const result = await bt.connectPayment();
1220
1350
  if (jsonOut) {
1221
1351
  console.log(JSON.stringify(result, null, 2));
1222
1352
  return;
1223
1353
  }
1224
- if (result.status === "ready") {
1354
+ const url = result.connect_url;
1355
+ if (url) {
1225
1356
  console.log(`
1226
- \u2713 Payment ready! You can now unlock offers and book flights.
1357
+ Open this in a browser to connect a payment method:
1358
+ `);
1359
+ console.log(` ${url}
1360
+ `);
1361
+ console.log(` Nothing is charged. Run \`letsfg me\` afterwards to confirm it landed.
1227
1362
  `);
1228
1363
  } else {
1229
- console.error(` \u2717 Payment setup failed: ${result.message || result.status}`);
1364
+ console.error(` \u2717 Could not mint a connect link: ${result.message || result.status}`);
1230
1365
  process.exit(1);
1231
1366
  }
1232
1367
  }
@@ -1247,10 +1382,9 @@ async function cmdMe(args) {
1247
1382
  console.log(` Email: ${p.email}`);
1248
1383
  console.log(` Tier: ${p.tier}`);
1249
1384
  const access = p.access_granted || false;
1250
- console.log(` Access: ${access ? "\u2713 Granted (search, unlock, book)" : "\u2717 Not granted"}`);
1385
+ console.log(` Access: ${access ? "\u2713 Granted (search, book)" : "\u2717 Not granted"}`);
1251
1386
  console.log(` Payment: ${p.payment_ready ? "\u2713 Ready" : "\u2014"}`);
1252
1387
  console.log(` Searches: ${u.total_searches || 0}`);
1253
- console.log(` Unlocks: ${u.total_unlocks || 0}`);
1254
1388
  console.log(` Bookings: ${u.total_bookings || 0}`);
1255
1389
  console.log(` Total spent: $${((u.total_spent_cents || 0) / 100).toFixed(2)}
1256
1390
  `);
@@ -1267,14 +1401,15 @@ Commands:
1267
1401
  auth Connect a card at letsfg.co/connect. Nothing charged
1268
1402
  search <origin> <dest> <date> Search for flights (free), prints search_id
1269
1403
  locations <query> Resolve city name to IATA codes
1270
- book <offer_id> --search-id ... Book a flight. No LetsFG fee, no unlock step
1404
+ book <offer_id> --search-id ... Book a flight. No booking or transaction fee, no unlock step
1271
1405
  me Show agent profile
1272
1406
 
1273
1407
  Developer API only (a SEPARATE paid product \u2014 most agents should not use these;
1274
1408
  they create a billing account. Use auth above instead):
1275
1409
  register --name ... --email ... Create a paid Developer API account
1276
- setup-payment Attach a card to that paid account
1277
- unlock <offer_id> [Developer API only] Unlock offer (legacy)
1410
+ connect-payment Print a link to connect a card to that paid account
1411
+ setup-payment Alias of connect-payment (the Stripe lane retired 2026-09-08)
1412
+ unlock <offer_id> RETIRED 2026-09-08 \u2014 no unlock step, book directly
1278
1413
 
1279
1414
  Options:
1280
1415
  --json, -j Output raw JSON
@@ -1310,6 +1445,7 @@ async function main() {
1310
1445
  case "register":
1311
1446
  await cmdRegister(args);
1312
1447
  break;
1448
+ case "connect-payment":
1313
1449
  case "setup-payment":
1314
1450
  await cmdSetupPayment(args);
1315
1451
  break;
package/dist/cli.mjs CHANGED
@@ -3,7 +3,7 @@ import {
3
3
  LetsFG,
4
4
  LetsFGError,
5
5
  offerSummary
6
- } from "./chunk-GO3FXQXC.mjs";
6
+ } from "./chunk-GVKYPTY3.mjs";
7
7
 
8
8
  // src/auth.ts
9
9
  import { readFileSync, writeFileSync, mkdirSync, chmodSync, existsSync } from "fs";
@@ -329,38 +329,15 @@ async function cmdSearch(args) {
329
329
  `);
330
330
  } else {
331
331
  console.log(`
332
- To unlock: letsfg unlock <offer_id>`);
333
- console.log(` Passenger IDs needed for booking: ${JSON.stringify(result.passenger_ids)}
332
+ To book: letsfg book <offer_id> --search-id ${result.search_id} --passenger '{...}' --email you@example.com
334
333
  `);
335
334
  }
336
335
  }
337
- async function cmdUnlock(args) {
338
- const jsonOut = hasFlag(args, "--json") || hasFlag(args, "-j");
339
- const apiKey = getFlag(args, "--api-key", "-k");
340
- const baseUrl = getFlag(args, "--base-url");
341
- const offerId = args[0];
342
- if (!offerId) {
343
- console.error("Usage: letsfg unlock <offer_id>");
344
- process.exit(1);
345
- }
346
- const bt = new LetsFG({ apiKey, baseUrl });
347
- const result = await bt.unlock(offerId);
348
- if (jsonOut) {
349
- console.log(JSON.stringify(result, null, 2));
350
- return;
351
- }
352
- if (result.unlock_status === "unlocked") {
353
- console.log(`
354
- \u2713 Offer unlocked!`);
355
- console.log(` Confirmed price: ${result.confirmed_currency} ${result.confirmed_price?.toFixed(2)}`);
356
- console.log(` Expires at: ${result.offer_expires_at}`);
357
- console.log(`
358
- Next: letsfg book ${offerId} --passenger '{...}' --email you@example.com
359
- `);
360
- } else {
361
- console.error(` \u2717 Unlock failed: ${result.message}`);
362
- process.exit(1);
363
- }
336
+ async function cmdUnlock(_args) {
337
+ console.error(
338
+ "\n letsfg unlock was retired on 2026-09-08 and the endpoint answers 410 Gone.\n\n There is no unlock step any more. Book directly:\n letsfg book <offer_id> --search-id <search_id> --passenger '{...}' --email you@example.com\n\n The fare is HELD on your connected payment method and captured only once a real\n airline PNR exists, which is what unlock existed to protect against. If the fare moves\n at checkout you are asked to accept or decline it.\n"
339
+ );
340
+ process.exit(1);
364
341
  }
365
342
  async function cmdBook(args) {
366
343
  const jsonOut = hasFlag(args, "--json") || hasFlag(args, "-j");
@@ -492,19 +469,26 @@ async function cmdSetupPayment(args) {
492
469
  const jsonOut = hasFlag(args, "--json") || hasFlag(args, "-j");
493
470
  const apiKey = getFlag(args, "--api-key", "-k");
494
471
  const baseUrl = getFlag(args, "--base-url");
495
- const token = getFlag(args, "--token", "-t") || "tok_visa";
472
+ if (getFlag(args, "--token", "-t")) {
473
+ console.log("\n Note: --token was part of the Stripe enrolment, retired 2026-09-08. Ignoring it.");
474
+ }
496
475
  const bt = new LetsFG({ apiKey, baseUrl });
497
- const result = await bt.setupPayment(token);
476
+ const result = await bt.connectPayment();
498
477
  if (jsonOut) {
499
478
  console.log(JSON.stringify(result, null, 2));
500
479
  return;
501
480
  }
502
- if (result.status === "ready") {
481
+ const url = result.connect_url;
482
+ if (url) {
503
483
  console.log(`
504
- \u2713 Payment ready! You can now unlock offers and book flights.
484
+ Open this in a browser to connect a payment method:
485
+ `);
486
+ console.log(` ${url}
487
+ `);
488
+ console.log(` Nothing is charged. Run \`letsfg me\` afterwards to confirm it landed.
505
489
  `);
506
490
  } else {
507
- console.error(` \u2717 Payment setup failed: ${result.message || result.status}`);
491
+ console.error(` \u2717 Could not mint a connect link: ${result.message || result.status}`);
508
492
  process.exit(1);
509
493
  }
510
494
  }
@@ -525,10 +509,9 @@ async function cmdMe(args) {
525
509
  console.log(` Email: ${p.email}`);
526
510
  console.log(` Tier: ${p.tier}`);
527
511
  const access = p.access_granted || false;
528
- console.log(` Access: ${access ? "\u2713 Granted (search, unlock, book)" : "\u2717 Not granted"}`);
512
+ console.log(` Access: ${access ? "\u2713 Granted (search, book)" : "\u2717 Not granted"}`);
529
513
  console.log(` Payment: ${p.payment_ready ? "\u2713 Ready" : "\u2014"}`);
530
514
  console.log(` Searches: ${u.total_searches || 0}`);
531
- console.log(` Unlocks: ${u.total_unlocks || 0}`);
532
515
  console.log(` Bookings: ${u.total_bookings || 0}`);
533
516
  console.log(` Total spent: $${((u.total_spent_cents || 0) / 100).toFixed(2)}
534
517
  `);
@@ -545,14 +528,15 @@ Commands:
545
528
  auth Connect a card at letsfg.co/connect. Nothing charged
546
529
  search <origin> <dest> <date> Search for flights (free), prints search_id
547
530
  locations <query> Resolve city name to IATA codes
548
- book <offer_id> --search-id ... Book a flight. No LetsFG fee, no unlock step
531
+ book <offer_id> --search-id ... Book a flight. No booking or transaction fee, no unlock step
549
532
  me Show agent profile
550
533
 
551
534
  Developer API only (a SEPARATE paid product \u2014 most agents should not use these;
552
535
  they create a billing account. Use auth above instead):
553
536
  register --name ... --email ... Create a paid Developer API account
554
- setup-payment Attach a card to that paid account
555
- unlock <offer_id> [Developer API only] Unlock offer (legacy)
537
+ connect-payment Print a link to connect a card to that paid account
538
+ setup-payment Alias of connect-payment (the Stripe lane retired 2026-09-08)
539
+ unlock <offer_id> RETIRED 2026-09-08 \u2014 no unlock step, book directly
556
540
 
557
541
  Options:
558
542
  --json, -j Output raw JSON
@@ -588,6 +572,7 @@ async function main() {
588
572
  case "register":
589
573
  await cmdRegister(args);
590
574
  break;
575
+ case "connect-payment":
591
576
  case "setup-payment":
592
577
  await cmdSetupPayment(args);
593
578
  break;