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 +89 -49
- package/dist/{chunk-GO3FXQXC.mjs → chunk-GVKYPTY3.mjs} +214 -62
- package/dist/cli.js +237 -101
- package/dist/cli.mjs +25 -40
- package/dist/index.d.mts +172 -46
- package/dist/index.d.ts +172 -46
- package/dist/index.js +215 -62
- package/dist/index.mjs +3 -1
- package/package.json +56 -56
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
|
-
*
|
|
431
|
-
*
|
|
432
|
-
*
|
|
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(
|
|
435
|
-
|
|
436
|
-
|
|
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):
|
|
448
|
-
*
|
|
449
|
-
*
|
|
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
|
-
|
|
473
|
-
|
|
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/
|
|
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
|
|
483
|
-
// deliberate: a hotel search opens a real session at the
|
|
484
|
-
// blocks a real rate, so a caller is never allowed to
|
|
485
|
-
// commitment only to discover it cannot pay. The same
|
|
486
|
-
// flight booking authorises hotels
|
|
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
|
-
//
|
|
489
|
-
//
|
|
490
|
-
//
|
|
491
|
-
//
|
|
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
|
-
*
|
|
513
|
-
*
|
|
514
|
-
* `
|
|
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
|
-
*
|
|
517
|
-
*
|
|
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
|
-
*
|
|
539
|
-
*
|
|
540
|
-
*
|
|
541
|
-
*
|
|
542
|
-
*
|
|
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
|
-
*
|
|
545
|
-
*
|
|
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`
|
|
548
|
-
* them
|
|
549
|
-
*
|
|
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
|
|
552
|
-
* the
|
|
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
|
-
|
|
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'
|
|
581
|
-
*
|
|
582
|
-
*
|
|
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
|
-
*
|
|
595
|
-
*
|
|
596
|
-
*
|
|
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 ??
|
|
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
|
-
|
|
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
|
-
*
|
|
620
|
-
*
|
|
621
|
-
*
|
|
622
|
-
*
|
|
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
|
|
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
|
|
639
|
-
* search and booking
|
|
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
|
|
776
|
+
async connectPayment() {
|
|
643
777
|
this.requireApiKey();
|
|
644
|
-
return this.post("/developers/api/v1/agents/
|
|
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
|
|
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(
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
1354
|
+
const url = result.connect_url;
|
|
1355
|
+
if (url) {
|
|
1225
1356
|
console.log(`
|
|
1226
|
-
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
|
|
1277
|
-
|
|
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-
|
|
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
|
|
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(
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
481
|
+
const url = result.connect_url;
|
|
482
|
+
if (url) {
|
|
503
483
|
console.log(`
|
|
504
|
-
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
|
|
555
|
-
|
|
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;
|