fortrip 0.1.0 → 0.1.2

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/AGENT_GUIDE.md ADDED
@@ -0,0 +1,124 @@
1
+ # Fortrip hotels
2
+
3
+ Fortrip sells hotel rooms at supplier net prices. The
4
+ `fortrip` CLI (npm package `fortrip`) calls the Fortrip API as the
5
+ signed-in user and prints JSON.
6
+
7
+ ## When to use
8
+
9
+ - The user wants hotels in a city, near a point, or by name, for given dates.
10
+ - The user wants live rooms, meal plans, cancellation terms or prices for
11
+ specific hotels.
12
+ - The user has picked a rate and wants to book it (you return a payment link;
13
+ the user pays in their browser).
14
+
15
+ Not for flights, activities, or changing/cancelling an existing booking.
16
+
17
+ ## Setup
18
+
19
+ Run every command as `npx -y fortrip@latest <command>`, the same version that
20
+ printed this guide. The examples below shorten that to `fortrip`.
21
+
22
+ When the user first asks about hotels, run `fortrip whoami` before asking
23
+ follow-up questions, so a login can happen while they answer.
24
+
25
+ If a command exits with code **3** and stderr has `loginUrl`, show it to the
26
+ user as a link together with `userCode`, e.g. "Sign in to Fortrip: <loginUrl>
27
+ (code BCDF-GHJK) and tell me when you're done." When they confirm, rerun the
28
+ same command; it finishes the login itself. Don't run `fortrip login` — it
29
+ waits for up to 10 minutes. If the rerun gives a new `loginUrl`, the old one
30
+ expired or was denied; show the new one.
31
+
32
+ If exit 3 has no `loginUrl`, the saved token was revoked: run `fortrip logout`,
33
+ then rerun the command to get a link.
34
+
35
+ In CI, the user can set `FORTRIP_TOKEN` instead. Never ask the user to paste a
36
+ token into the chat.
37
+
38
+ ## Exit codes
39
+
40
+ | Code | Meaning | What to do |
41
+ |---|---|---|
42
+ | 0 | Success; JSON on stdout | Continue |
43
+ | 1 | Other error (supplier error, rate no longer bookable, network) | Read `error` on stderr; re-run `rates` if a rate went stale |
44
+ | 2 | Invalid arguments | Fix the flags; stderr `issues` lists the bad fields |
45
+ | 3 | Not logged in | Show `loginUrl` to the user, then rerun (above) |
46
+ | 4 | Rate limited | Wait `retryAfterSeconds`, then retry once |
47
+
48
+ ## Recommended order
49
+
50
+ 1. **Search** — find candidates and indicative prices:
51
+
52
+ ```bash
53
+ fortrip search --query "Bangkok" --check-in 2026-11-10 --check-out 2026-11-12 \
54
+ --star-min 4 --sort price-asc --page-size 10
55
+ ```
56
+
57
+ Use `--near lat,lng,radiusKm` for "near X", `--city-code` when known.
58
+ `--facility` names must match the catalogue exactly (e.g. `Swimming pool`,
59
+ `Parking`), otherwise nothing matches. Prices here (`price.nightlyFrom`,
60
+ `totalFrom`, CNY) are **indicative floors**, not quotes — say so.
61
+
62
+ 2. **Hotel detail** (optional) — description, policies, facilities, photos:
63
+
64
+ ```bash
65
+ fortrip hotel 266000 [--with-rooms]
66
+ ```
67
+
68
+ 3. **Rates** — live rooms and prices for up to 15 hotels:
69
+
70
+ ```bash
71
+ fortrip rates --hotel 266000 --hotel 266001 --check-in 2026-11-10 \
72
+ --check-out 2026-11-12 --rooms 1 --adults 2 [--child-age 6] \
73
+ [--nationality CN] [--board BB] [--free-cancellation]
74
+ ```
75
+
76
+ This call takes about 15 seconds. `totalPrice` is CNY at a fixed exchange
77
+ rate. Present each option with meal plan (`boardCode`: RO room only,
78
+ BB breakfast, HB half board, FB full board, AI all inclusive),
79
+ `freeCancelUntil` / `refundable`, `isInstantConfirm`, and any `fees`
80
+ (taxes paid at the hotel, in their own currency).
81
+
82
+ 4. **Book** — only after the user picks one rate and gives guest details:
83
+
84
+ ```bash
85
+ fortrip book --hotel 266000 --room-id 9191658 --rate-plan "<ratePlanCode>" \
86
+ --check-in 2026-11-10 --check-out 2026-11-12 --rooms 1 --adults 2 \
87
+ --guest "John Smith" --contact-name "John Smith" \
88
+ --contact-phone "+8613800000000" [--contact-email john@example.com]
89
+ ```
90
+
91
+ `--hotel`, `--room-id`, `--rate-plan`, dates, `--rooms`, `--adults` and
92
+ `--child-age` must be exactly the values used in `rates`. Guest names must
93
+ be in Latin letters as on the passport ("First Last"; last word is the last
94
+ name; `"First Last:2"` puts the guest in room 2). Each room needs at least
95
+ one guest. `--special-requests` must be in English and isn't guaranteed.
96
+
97
+ ## Before giving the payment link
98
+
99
+ `book` returns `paymentUrl`, `amount`, `currency`, `terms`, `statusUrl` and
100
+ `orderUrl`. Before you hand over `paymentUrl`:
101
+
102
+ 1. **Show the `terms`** and get the user's confirmation: meal plan
103
+ (`boardCode`, `boardCount`), cancellation (`refundable`, `freeCancelUntil`,
104
+ the `cancellation` ladder), confirmation (`isInstantConfirm`,
105
+ `confirmByMins`) and `payAtHotelFees`.
106
+ 2. **Explain the amount.** `rates` showed CNY, but the charge is `amount` in
107
+ `currency` (usually USD), and the Stripe page may display the user's local
108
+ currency at Stripe's rate. The amount on the payment page is what will be
109
+ charged; the CNY figure was a reference.
110
+ 3. The link is for **this user only**: whoever has it can pay. Don't post it
111
+ in shared channels, logs or tickets. It expires after about 3 hours 45
112
+ minutes; call `book` again with the same details for a fresh one.
113
+
114
+ After paying, the user lands on `statusUrl`, which shows confirmation progress
115
+ without signing in. `orderUrl` is the signed-in order page.
116
+
117
+ ## Errors from `book`
118
+
119
+ - `409` with `agentOrderId`: this exact stay is already paid or completed —
120
+ show `orderUrl`, don't book again.
121
+ - `409` with `changes`: price or terms changed — re-run `rates` and confirm
122
+ again with the user.
123
+ - `422`: the booking was refused (guest names, too many attempts, bookings
124
+ paused, or a preview deployment). Read `error`.
package/README.md CHANGED
@@ -41,6 +41,10 @@ Output is JSON on stdout. Errors are JSON on stderr, with fixed exit codes:
41
41
  - `login` saves the token to `~/.config/fortrip/credentials.json`
42
42
  (`$XDG_CONFIG_HOME` is honoured), mode `0600`. Tokens are stored per API URL,
43
43
  so a token is only ever sent to the server that issued it.
44
+ - Without a token, any other command starts a device login and exits 3 with
45
+ `loginUrl` on stderr instead of waiting. Once that link is approved in the
46
+ browser, rerunning the command saves the token and carries on. This is how
47
+ AI agents sign in, since they can't sit in `login`.
44
48
  - `FORTRIP_TOKEN` overrides the saved login (CI, remote sandboxes).
45
49
  - `FORTRIP_API_URL` overrides the base URL (default `https://fortrip.ai`).
46
50
  Plain `http` is only accepted for `localhost`.
@@ -49,9 +53,16 @@ Tokens don't expire; `logout` revokes them.
49
53
 
50
54
  ## Agent skill
51
55
 
52
- [`skills/fortrip-hotels/SKILL.md`](skills/fortrip-hotels/SKILL.md) tells an AI
53
- agent when to use the CLI, the call order, and what to show the user before
54
- handing over a payment link.
56
+ ```bash
57
+ npx skills add FortripEngineering/fortrip-cli
58
+ ```
59
+
60
+ [`skills/fortrip-hotels/SKILL.md`](skills/fortrip-hotels/SKILL.md) only tells
61
+ the agent to run `npx -y fortrip@latest skill`, which prints
62
+ [`AGENT_GUIDE.md`](AGENT_GUIDE.md): when to use the CLI, the call order, login,
63
+ and what to show the user before handing over a payment link. The guide ships
64
+ with the package, so a CLI release updates every installed skill. Edit
65
+ `AGENT_GUIDE.md`, not `SKILL.md`.
55
66
 
56
67
  ## Development
57
68
 
package/dist/cli.js CHANGED
@@ -22,7 +22,10 @@ function usageError(message) {
22
22
  }
23
23
 
24
24
  // src/version.ts
25
- var VERSION = "0.1.0";
25
+ import { readFileSync } from "fs";
26
+ var VERSION = JSON.parse(
27
+ readFileSync(new URL("../package.json", import.meta.url), "utf8")
28
+ ).version;
26
29
 
27
30
  // src/api.ts
28
31
  var LOGIN_HINT = "Run `npx fortrip login` first.";
@@ -96,7 +99,7 @@ function unwrap(response) {
96
99
  }
97
100
 
98
101
  // src/args.ts
99
- import { readFileSync } from "fs";
102
+ import { readFileSync as readFileSync2 } from "fs";
100
103
  import { parseArgs } from "util";
101
104
  var BODY_OPTION = { body: { type: "string" } };
102
105
  function parse(argv, options) {
@@ -112,7 +115,7 @@ function baseBody(values) {
112
115
  let text = raw;
113
116
  if (raw.startsWith("@")) {
114
117
  try {
115
- text = readFileSync(raw.slice(1), "utf8");
118
+ text = readFileSync2(raw.slice(1), "utf8");
116
119
  } catch (error) {
117
120
  throw usageError(`Cannot read --body file: ${error.message}`);
118
121
  }
@@ -331,11 +334,21 @@ function resolveApiUrl(env = process.env) {
331
334
  async function readCredentials(path) {
332
335
  try {
333
336
  const parsed = JSON.parse(await readFile(path, "utf8"));
334
- return { tokens: parsed?.tokens ?? {} };
337
+ return { tokens: parsed?.tokens ?? {}, pending: parsed?.pending ?? {} };
335
338
  } catch {
336
- return { tokens: {} };
339
+ return { tokens: {}, pending: {} };
337
340
  }
338
341
  }
342
+ async function writeCredentials(path, file) {
343
+ if (Object.keys(file.tokens).length === 0 && Object.keys(file.pending).length === 0) {
344
+ await rm(path, { force: true });
345
+ return;
346
+ }
347
+ await mkdir(dirname(path), { recursive: true, mode: 448 });
348
+ await writeFile(path, `${JSON.stringify(file, null, 2)}
349
+ `, { mode: 384 });
350
+ await chmod(path, 384);
351
+ }
339
352
  async function loadStoredToken(apiUrl, env = process.env) {
340
353
  const file = await readCredentials(credentialsPath(env));
341
354
  return file.tokens[apiUrl]?.accessToken ?? null;
@@ -350,31 +363,51 @@ async function saveToken(apiUrl, accessToken, env = process.env) {
350
363
  const path = credentialsPath(env);
351
364
  const file = await readCredentials(path);
352
365
  file.tokens[apiUrl] = { accessToken, createdAt: (/* @__PURE__ */ new Date()).toISOString() };
353
- await mkdir(dirname(path), { recursive: true, mode: 448 });
354
- await writeFile(path, `${JSON.stringify(file, null, 2)}
355
- `, { mode: 384 });
356
- await chmod(path, 384);
366
+ delete file.pending[apiUrl];
367
+ await writeCredentials(path, file);
357
368
  }
358
369
  async function deleteToken(apiUrl, env = process.env) {
359
370
  const path = credentialsPath(env);
360
371
  const file = await readCredentials(path);
361
372
  delete file.tokens[apiUrl];
362
- if (Object.keys(file.tokens).length === 0) {
363
- await rm(path, { force: true });
364
- return;
365
- }
366
- await writeFile(path, `${JSON.stringify(file, null, 2)}
367
- `, { mode: 384 });
368
- await chmod(path, 384);
373
+ await writeCredentials(path, file);
374
+ }
375
+ async function loadPendingLogin(apiUrl, env = process.env) {
376
+ const pending = (await readCredentials(credentialsPath(env))).pending[apiUrl];
377
+ return pending && Date.parse(pending.expiresAt) > Date.now() ? pending : null;
378
+ }
379
+ async function savePendingLogin(apiUrl, pending, env = process.env) {
380
+ const path = credentialsPath(env);
381
+ const file = await readCredentials(path);
382
+ if (pending) file.pending[apiUrl] = pending;
383
+ else delete file.pending[apiUrl];
384
+ await writeCredentials(path, file);
369
385
  }
370
386
 
371
387
  // src/auth.ts
372
388
  async function requireToken(apiUrl) {
373
389
  const resolved = await resolveToken(apiUrl);
374
- if (!resolved) {
375
- throw new CliError(`Not logged in to ${apiUrl}. ${LOGIN_HINT}`, EXIT.unauthenticated);
390
+ if (resolved) return resolved.token;
391
+ const pending = await loadPendingLogin(apiUrl);
392
+ if (pending) {
393
+ const result = await exchange(apiUrl, pending.deviceCode);
394
+ if (result.status === "ok") {
395
+ await saveToken(apiUrl, result.accessToken);
396
+ return result.accessToken;
397
+ }
398
+ if (result.status === "authorization_pending" || result.status === "slow_down") {
399
+ throw loginRequired(apiUrl, pending);
400
+ }
376
401
  }
377
- return resolved.token;
402
+ const start = await startDeviceLogin(apiUrl);
403
+ const fresh = {
404
+ deviceCode: start.deviceCode,
405
+ userCode: start.userCode,
406
+ loginUrl: start.verificationUriComplete,
407
+ expiresAt: new Date(Date.now() + start.expiresIn * 1e3).toISOString()
408
+ };
409
+ await savePendingLogin(apiUrl, fresh);
410
+ throw loginRequired(apiUrl, fresh);
378
411
  }
379
412
  async function login({
380
413
  apiUrl,
@@ -382,12 +415,7 @@ async function login({
382
415
  log,
383
416
  wait = sleep
384
417
  }) {
385
- const start = unwrap(
386
- await request(apiUrl, "/api/cli/auth/device", {
387
- method: "POST",
388
- body: { label: deviceLabel() }
389
- })
390
- );
418
+ const start = await startDeviceLogin(apiUrl);
391
419
  log(`Confirm this code in your browser: ${start.userCode}`);
392
420
  log(`If the browser doesn't open, visit: ${start.verificationUriComplete}`);
393
421
  if (shouldOpen) openBrowser(start.verificationUriComplete);
@@ -396,21 +424,16 @@ async function login({
396
424
  const deadline = Date.now() + start.expiresIn * 1e3;
397
425
  while (Date.now() < deadline) {
398
426
  await wait(interval * 1e3);
399
- const poll = await request(apiUrl, "/api/cli/auth/token", {
400
- method: "POST",
401
- body: { deviceCode: start.deviceCode }
402
- });
403
- if (poll.status === 200 && poll.body.success === true) {
404
- const { accessToken } = poll.body.data;
405
- await saveToken(apiUrl, accessToken);
406
- log("Logged in.");
407
- return whoami(apiUrl, accessToken);
408
- }
409
- switch (poll.body.error) {
427
+ const result = await exchange(apiUrl, start.deviceCode);
428
+ switch (result.status) {
429
+ case "ok":
430
+ await saveToken(apiUrl, result.accessToken);
431
+ log("Logged in.");
432
+ return whoami(apiUrl, result.accessToken);
410
433
  case "authorization_pending":
411
434
  continue;
412
435
  case "slow_down":
413
- interval = typeof poll.body.interval === "number" ? poll.body.interval : interval + 5;
436
+ interval = result.interval ?? interval + 5;
414
437
  continue;
415
438
  case "access_denied":
416
439
  throw new CliError("Login was denied in the browser.", EXIT.error);
@@ -419,12 +442,56 @@ async function login({
419
442
  "The login code expired. Run `login` again.",
420
443
  EXIT.error
421
444
  );
422
- default:
423
- unwrap(poll);
424
445
  }
425
446
  }
426
447
  throw new CliError("The login code expired. Run `login` again.", EXIT.error);
427
448
  }
449
+ async function startDeviceLogin(apiUrl) {
450
+ return unwrap(
451
+ await request(apiUrl, "/api/cli/auth/device", {
452
+ method: "POST",
453
+ body: { label: deviceLabel(), platform: process.platform }
454
+ })
455
+ );
456
+ }
457
+ async function exchange(apiUrl, deviceCode) {
458
+ const poll = await request(apiUrl, "/api/cli/auth/token", {
459
+ method: "POST",
460
+ body: { deviceCode }
461
+ });
462
+ if (poll.status === 200 && poll.body.success === true) {
463
+ const { accessToken } = poll.body.data;
464
+ return { status: "ok", accessToken };
465
+ }
466
+ switch (poll.body.error) {
467
+ case "authorization_pending":
468
+ case "access_denied":
469
+ case "expired_token":
470
+ return { status: poll.body.error };
471
+ case "slow_down":
472
+ return {
473
+ status: "slow_down",
474
+ interval: typeof poll.body.interval === "number" ? poll.body.interval : void 0
475
+ };
476
+ default:
477
+ unwrap(poll);
478
+ throw new CliError(`Unexpected login response ${poll.status}`, EXIT.error);
479
+ }
480
+ }
481
+ function loginRequired(apiUrl, pending) {
482
+ return new CliError(
483
+ `Not logged in to ${apiUrl}. Ask the user to open loginUrl and approve, then rerun this command (or run \`npx fortrip login\` in a terminal).`,
484
+ EXIT.unauthenticated,
485
+ {
486
+ loginUrl: pending.loginUrl,
487
+ userCode: pending.userCode,
488
+ expiresInSeconds: Math.max(
489
+ 0,
490
+ Math.round((Date.parse(pending.expiresAt) - Date.now()) / 1e3)
491
+ )
492
+ }
493
+ );
494
+ }
428
495
  async function logout(apiUrl) {
429
496
  const token = await loadStoredToken(apiUrl);
430
497
  if (!token) {
@@ -449,6 +516,12 @@ function deviceLabel() {
449
516
  return hostname().replace(/[^\w .@-]/g, "").slice(0, 64);
450
517
  }
451
518
 
519
+ // src/guide.ts
520
+ import { readFile as readFile2 } from "fs/promises";
521
+ function readGuide() {
522
+ return readFile2(new URL("../AGENT_GUIDE.md", import.meta.url), "utf8");
523
+ }
524
+
452
525
  // src/cli.ts
453
526
  var HELP = `fortrip ${VERSION} \u2014 search, price and book Fortrip hotels
454
527
 
@@ -462,6 +535,7 @@ Commands:
462
535
  hotel <hotelId> Static hotel detail [--with-rooms]
463
536
  rates Live rooms and rates (CNY) for up to 15 hotels
464
537
  book Create an order and get a payment link
538
+ skill Print the guide for AI agents (Markdown)
465
539
 
466
540
  search: --query <text> | --city-code <n> | --near <lat,lng,km>
467
541
  --check-in YYYY-MM-DD --check-out YYYY-MM-DD
@@ -498,6 +572,11 @@ async function run(argv) {
498
572
  `);
499
573
  return void 0;
500
574
  }
575
+ if (command === "skill") {
576
+ parse(rest, {});
577
+ process.stdout.write(await readGuide());
578
+ return void 0;
579
+ }
501
580
  const apiUrl = resolveApiUrl();
502
581
  const log = (line) => process.stderr.write(`${line}
503
582
  `);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fortrip",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Search, price and book Fortrip hotels from the terminal or an AI agent.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,6 +9,7 @@
9
9
  "files": [
10
10
  "dist",
11
11
  "skills",
12
+ "AGENT_GUIDE.md",
12
13
  "README.md"
13
14
  ],
14
15
  "engines": {
@@ -5,120 +5,16 @@ description: Search hotels, compare live room rates and create a hotel booking w
5
5
 
6
6
  # Fortrip hotels
7
7
 
8
- Fortrip sells hotel rooms at supplier net prices. The
9
- `fortrip` CLI (npm package `fortrip`) calls the Fortrip API as the
10
- signed-in user and prints JSON.
11
-
12
- ## When to use
13
-
14
- - The user wants hotels in a city, near a point, or by name, for given dates.
15
- - The user wants live rooms, meal plans, cancellation terms or prices for
16
- specific hotels.
17
- - The user has picked a rate and wants to book it (you return a payment link;
18
- the user pays in their browser).
19
-
20
- Not for flights, activities, or changing/cancelling an existing booking.
21
-
22
- ## Setup
23
-
24
- Run commands with `npx -y fortrip <command>` (or `fortrip <command>`
25
- if installed globally).
26
-
27
- If any command exits with code **3** (not logged in, or the token was revoked),
28
- ask the user to run this in their own terminal, then retry:
8
+ Before anything else, run:
29
9
 
30
10
  ```bash
31
- npx fortrip login
11
+ npx -y fortrip@latest skill
32
12
  ```
33
13
 
34
- It opens a browser page where they sign in to Fortrip and confirm a code that
35
- matches the one in the terminal. In CI or a remote sandbox, the user can set
36
- `FORTRIP_TOKEN` instead. Never ask the user to paste a token into the chat.
37
-
38
- ## Exit codes
39
-
40
- | Code | Meaning | What to do |
41
- |---|---|---|
42
- | 0 | Success; JSON on stdout | Continue |
43
- | 1 | Other error (supplier error, rate no longer bookable, network) | Read `error` on stderr; re-run `rates` if a rate went stale |
44
- | 2 | Invalid arguments | Fix the flags; stderr `issues` lists the bad fields |
45
- | 3 | Not logged in | Ask the user to run `login` (above) |
46
- | 4 | Rate limited | Wait `retryAfterSeconds`, then retry once |
47
-
48
- ## Recommended order
49
-
50
- 1. **Search** — find candidates and indicative prices:
51
-
52
- ```bash
53
- fortrip search --query "Bangkok" --check-in 2026-11-10 --check-out 2026-11-12 \
54
- --star-min 4 --sort price-asc --page-size 10
55
- ```
56
-
57
- Use `--near lat,lng,radiusKm` for "near X", `--city-code` when known.
58
- `--facility` names must match the catalogue exactly (e.g. `Swimming pool`,
59
- `Parking`), otherwise nothing matches. Prices here (`price.nightlyFrom`,
60
- `totalFrom`, CNY) are **indicative floors**, not quotes — say so.
61
-
62
- 2. **Hotel detail** (optional) — description, policies, facilities, photos:
63
-
64
- ```bash
65
- fortrip hotel 266000 [--with-rooms]
66
- ```
67
-
68
- 3. **Rates** — live rooms and prices for up to 15 hotels:
69
-
70
- ```bash
71
- fortrip rates --hotel 266000 --hotel 266001 --check-in 2026-11-10 \
72
- --check-out 2026-11-12 --rooms 1 --adults 2 [--child-age 6] \
73
- [--nationality CN] [--board BB] [--free-cancellation]
74
- ```
75
-
76
- This call takes about 15 seconds. `totalPrice` is CNY at a fixed exchange
77
- rate. Present each option with meal plan (`boardCode`: RO room only,
78
- BB breakfast, HB half board, FB full board, AI all inclusive),
79
- `freeCancelUntil` / `refundable`, `isInstantConfirm`, and any `fees`
80
- (taxes paid at the hotel, in their own currency).
81
-
82
- 4. **Book** — only after the user picks one rate and gives guest details:
83
-
84
- ```bash
85
- fortrip book --hotel 266000 --room-id 9191658 --rate-plan "<ratePlanCode>" \
86
- --check-in 2026-11-10 --check-out 2026-11-12 --rooms 1 --adults 2 \
87
- --guest "John Smith" --contact-name "John Smith" \
88
- --contact-phone "+8613800000000" [--contact-email john@example.com]
89
- ```
90
-
91
- `--hotel`, `--room-id`, `--rate-plan`, dates, `--rooms`, `--adults` and
92
- `--child-age` must be exactly the values used in `rates`. Guest names must
93
- be in Latin letters as on the passport ("First Last"; last word is the last
94
- name; `"First Last:2"` puts the guest in room 2). Each room needs at least
95
- one guest. `--special-requests` must be in English and isn't guaranteed.
96
-
97
- ## Before giving the payment link
98
-
99
- `book` returns `paymentUrl`, `amount`, `currency`, `terms`, `statusUrl` and
100
- `orderUrl`. Before you hand over `paymentUrl`:
101
-
102
- 1. **Show the `terms`** and get the user's confirmation: meal plan
103
- (`boardCode`, `boardCount`), cancellation (`refundable`, `freeCancelUntil`,
104
- the `cancellation` ladder), confirmation (`isInstantConfirm`,
105
- `confirmByMins`) and `payAtHotelFees`.
106
- 2. **Explain the amount.** `rates` showed CNY, but the charge is `amount` in
107
- `currency` (usually USD), and the Stripe page may display the user's local
108
- currency at Stripe's rate. The amount on the payment page is what will be
109
- charged; the CNY figure was a reference.
110
- 3. The link is for **this user only**: whoever has it can pay. Don't post it
111
- in shared channels, logs or tickets. It expires after about 3 hours 45
112
- minutes; call `book` again with the same details for a fresh one.
113
-
114
- After paying, the user lands on `statusUrl`, which shows confirmation progress
115
- without signing in. `orderUrl` is the signed-in order page.
116
-
117
- ## Errors from `book`
14
+ It prints the current instructions for the Fortrip CLI: commands, call order,
15
+ login, and what to show the user before giving a payment link. Follow them.
16
+ They ship with each CLI release, so this file stays short and doesn't go out
17
+ of date.
118
18
 
119
- - `409` with `agentOrderId`: this exact stay is already paid or completed —
120
- show `orderUrl`, don't book again.
121
- - `409` with `changes`: price or terms changed — re-run `rates` and confirm
122
- again with the user.
123
- - `422`: the booking was refused (guest names, too many attempts, bookings
124
- paused, or a preview deployment). Read `error`.
19
+ Needs Node.js 22 or newer. If the command fails, tell the user why instead of
20
+ guessing the CLI's flags.