@commercelayer/agent-cli 1.0.0-dev.2 → 1.0.0-dev.21
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 +83 -11
- package/dist/argv.js +23 -5
- package/dist/builtins/auth.js +21 -3
- package/dist/builtins/extensions.js +329 -5
- package/dist/builtins/index.js +4 -2
- package/dist/builtins/loops.js +68 -31
- package/dist/builtins/sandbox.js +45 -15
- package/dist/builtins/shop.js +504 -0
- package/dist/builtins/skills.js +355 -0
- package/dist/config.js +25 -8
- package/dist/confirm.js +17 -6
- package/dist/engine.js +192 -28
- package/dist/errors.js +6 -0
- package/dist/help.js +36 -6
- package/dist/http.js +10 -0
- package/dist/inputs.js +21 -1
- package/dist/project.js +185 -0
- package/dist/schema.js +15 -4
- package/dist/schemas.js +82 -8
- package/dist/secrets.js +25 -7
- package/dist/storefront.js +280 -0
- package/dist/ucp.js +207 -0
- package/package.json +1 -1
package/dist/builtins/sandbox.js
CHANGED
|
@@ -1,17 +1,30 @@
|
|
|
1
|
-
// c11r sandbox create [--name] [--seed], get, reset, claim, destroy (contract
|
|
1
|
+
// c11r sandbox create [--name] [--seed [type]] [--catalogue file], get, reset, claim, destroy (contract sections 4 and 48), and
|
|
2
2
|
// c11r status (section 5). They are built in for what happens on this machine: create stores the
|
|
3
3
|
// sandbox token (mode 600, ~/.config/c11r/credentials.json) and links the folder (.c11r.json);
|
|
4
4
|
// reset moves both to the new organization; destroy forgets them. The token is printed never,
|
|
5
5
|
// not even by create: only its hint.
|
|
6
6
|
import crypto from 'node:crypto';
|
|
7
|
-
import { credentialsFile, defaultOrg,
|
|
7
|
+
import { credentialsFile, defaultOrg, forgetOutWritten, storedLogin, forgetOrg, LINK_FILE, readLink, removeLink, renameOrg, storedOrgs, storeOrg, writeLink, hintOf } from '../config.js';
|
|
8
8
|
import { CliError, fail } from '../errors.js';
|
|
9
9
|
import { apiUrl, envelope, request } from '../http.js';
|
|
10
10
|
import { parseSchema } from '../schema.js';
|
|
11
11
|
import { OUT_NOT_TRACKED, removeOrgSecrets } from '../secrets.js';
|
|
12
12
|
import { VERSION } from '../version.js';
|
|
13
|
+
import { readCatalogue, SEED_TYPES, withShop } from '../storefront.js';
|
|
13
14
|
/** The longest sandbox name the API takes (contract: 422 invalid_argument past it). */
|
|
14
15
|
const NAME_MAX = 25;
|
|
16
|
+
const SEED = {
|
|
17
|
+
name: 'seed',
|
|
18
|
+
type: 'enum',
|
|
19
|
+
values: [...SEED_TYPES],
|
|
20
|
+
bare: true,
|
|
21
|
+
description: 'Loads a demo shop of this type: coffee, tshirts or games, six products each, or custom, the person\'s own from --catalogue. Each has two markets, shipping, a manual payment setting (Wire transfer) and a storefront on, enough to place a test order at once. --seed alone is coffee.',
|
|
22
|
+
};
|
|
23
|
+
const CATALOGUE = {
|
|
24
|
+
name: 'catalogue',
|
|
25
|
+
type: 'string',
|
|
26
|
+
description: 'With --seed custom: a YAML file with the shop\'s name, its products (name, description, prices in EUR and USD, code) and optionally its look. Checked here, then sent in the same call.',
|
|
27
|
+
};
|
|
15
28
|
export const sandbox = {
|
|
16
29
|
schema: parseSchema({
|
|
17
30
|
noun: 'sandbox',
|
|
@@ -28,9 +41,10 @@ export const sandbox = {
|
|
|
28
41
|
live: false,
|
|
29
42
|
body: [
|
|
30
43
|
{ name: 'name', type: 'string', description: 'The organization name; without it, an invented one. A name starting zztest marks a test organization.' },
|
|
31
|
-
|
|
44
|
+
SEED,
|
|
45
|
+
CATALOGUE,
|
|
32
46
|
],
|
|
33
|
-
effect: 'Creates a sandbox, stores its token on this machine (never printed) and links this folder to it with .c11r.json. With --seed it answers once the seed
|
|
47
|
+
effect: 'Creates a sandbox, stores its token on this machine (never printed) and links this folder to it with .c11r.json. With --seed it answers once the seed and its storefront are in place, in about 20 seconds, with the shop link.',
|
|
34
48
|
},
|
|
35
49
|
{ verb: 'get', method: 'GET', path: '/v1/sandbox', live: false, effect: 'Returns the sandbox: organization, expiry, whether it is seeded, the token hint and the claim status.' },
|
|
36
50
|
{
|
|
@@ -39,8 +53,8 @@ export const sandbox = {
|
|
|
39
53
|
path: '/v1/sandbox/reset',
|
|
40
54
|
live: false,
|
|
41
55
|
destructive: true,
|
|
42
|
-
body: [{
|
|
43
|
-
effect: 'Replaces the sandbox with a fresh organization behind the same token, seeded as before, and retires the old one. The slug changes; this machine and .c11r.json follow it.',
|
|
56
|
+
body: [{ ...SEED, description: 'Seeds the fresh organization with this type, or not (--seed false); without it, as before. custom needs --catalogue again.' }, CATALOGUE],
|
|
57
|
+
effect: 'Replaces the sandbox with a fresh organization behind the same token, seeded as before (its storefront on again), and retires the old one. The slug changes; this machine and .c11r.json follow it.',
|
|
44
58
|
},
|
|
45
59
|
{ verb: 'claim', method: 'POST', path: '/v1/sandbox/claim', live: false, effect: 'Returns a one-time claim URL, valid 30 minutes, for the human to open: they sign in with Commerce Layer and pick a plan. The agent never opens it for them.' },
|
|
46
60
|
{
|
|
@@ -76,7 +90,7 @@ async function create(run, values) {
|
|
|
76
90
|
if (name !== undefined && (name.trim() === '' || name.length > NAME_MAX)) {
|
|
77
91
|
fail('invalid_value', `--name ${JSON.stringify(name)} is ${name.trim() === '' ? 'empty' : `${name.length} characters`}: a sandbox name has 1 to ${NAME_MAX}.`, { path: 'name', next_step: { action: 'fix_request', command: null } });
|
|
78
92
|
}
|
|
79
|
-
const seed =
|
|
93
|
+
const { seed, catalogue } = seedBody(values);
|
|
80
94
|
const test = process.env.C11R_TEST === '1' || Boolean(name?.startsWith('zztest'));
|
|
81
95
|
// The token goes only when this folder, --org or C11R_ORG names a sandbox: the API then
|
|
82
96
|
// finishes a failed seed there, or answers 409 conflict naming it, rather than make a second
|
|
@@ -85,7 +99,7 @@ async function create(run, values) {
|
|
|
85
99
|
// A fresh key per create: after a 5xx or a lost connection the same create is repeated with it,
|
|
86
100
|
// and the API answers the sandbox (and token) it made, rather than leave one with a lost token.
|
|
87
101
|
const idempotencyKey = `c11r-${crypto.randomBytes(16).toString('base64url')}`;
|
|
88
|
-
const r = await request({ method: 'POST', path: '/sandboxes', body: { ...(name ? { name } : {}), seed, test }, dryRun: run.globals.dryRun, timeoutMs: seed ?
|
|
102
|
+
const r = await request({ method: 'POST', path: '/sandboxes', body: { ...(name ? { name } : {}), seed, ...(catalogue ? { catalogue } : {}), test }, dryRun: run.globals.dryRun, timeoutMs: seed ? 180_000 : 60_000, idempotencyKey }, { live: false, token: named ? run.org.token : null });
|
|
89
103
|
if (run.globals.dryRun)
|
|
90
104
|
return r;
|
|
91
105
|
const { token, ...data } = r.data ?? {};
|
|
@@ -98,10 +112,28 @@ async function create(run, values) {
|
|
|
98
112
|
const warnings = r.warnings.filter((w) => w.code !== 'token_shown_once');
|
|
99
113
|
if (link.previous)
|
|
100
114
|
warnings.push({ code: 'relinked', message: `${link.file} named ${link.previous}; it now names ${data.org}. The token of ${link.previous} stays stored.` });
|
|
101
|
-
return { ...r, data: { ...data, token_hint: hint, credentials: credentialsFile(), linked: link.file }, warnings };
|
|
115
|
+
return withShop({ ...r, data: { ...data, token_hint: hint, credentials: credentialsFile(), linked: link.file }, warnings }, data.store);
|
|
116
|
+
}
|
|
117
|
+
/** --seed and --catalogue as the API takes them: custom only with a catalogue, a catalogue only with custom. */
|
|
118
|
+
function seedBody(values) {
|
|
119
|
+
const seed = values.seed ?? false;
|
|
120
|
+
const file = values.catalogue;
|
|
121
|
+
if (seed === 'custom' && file === undefined) {
|
|
122
|
+
fail('missing_value', '--seed custom needs --catalogue: a YAML file with the shop\'s name and its products. Write it from what the human described, show it to them, then run the command with --catalogue <file>.', { path: 'catalogue', next_step: { action: 'fix_request', command: null } });
|
|
123
|
+
}
|
|
124
|
+
if (file !== undefined && seed !== 'custom')
|
|
125
|
+
fail('invalid_value', '--catalogue goes only with --seed custom: the other types bring their own products.', { path: 'catalogue', next_step: { action: 'fix_request', command: null } });
|
|
126
|
+
return { seed, catalogue: file === undefined ? undefined : readCatalogue(file) };
|
|
102
127
|
}
|
|
103
128
|
async function reset(run, values) {
|
|
104
|
-
const
|
|
129
|
+
const body = {};
|
|
130
|
+
if (values.seed !== undefined || values.catalogue !== undefined) {
|
|
131
|
+
const { seed, catalogue } = seedBody(values);
|
|
132
|
+
body.seed = seed;
|
|
133
|
+
if (catalogue)
|
|
134
|
+
body.catalogue = catalogue;
|
|
135
|
+
}
|
|
136
|
+
const r = await run.send({ method: 'POST', path: '/sandbox/reset', body });
|
|
105
137
|
if (run.globals.dryRun)
|
|
106
138
|
return r;
|
|
107
139
|
const { org, previous_org: previous } = r.data ?? {};
|
|
@@ -112,7 +144,7 @@ async function reset(run, values) {
|
|
|
112
144
|
if (link?.org === before)
|
|
113
145
|
writeLink(org);
|
|
114
146
|
}
|
|
115
|
-
return r;
|
|
147
|
+
return withShop(r, r.data?.store);
|
|
116
148
|
}
|
|
117
149
|
async function destroy(run) {
|
|
118
150
|
let r;
|
|
@@ -124,8 +156,7 @@ async function destroy(run) {
|
|
|
124
156
|
// here. The sandbox is gone either way, so this machine forgets it too.
|
|
125
157
|
if (!(e instanceof CliError && e.code === 'unauthorized') || run.globals.dryRun || !run.org.org)
|
|
126
158
|
throw e;
|
|
127
|
-
const out =
|
|
128
|
-
markOutWritten(run.org.org, false);
|
|
159
|
+
const out = forgetOutWritten(run.org.org);
|
|
129
160
|
forgetOrg(run.org.org);
|
|
130
161
|
const link = removeLink(run.org.org);
|
|
131
162
|
const secrets = removeOrgSecrets(run.org.org);
|
|
@@ -136,8 +167,7 @@ async function destroy(run) {
|
|
|
136
167
|
const org = r.data?.org ?? run.org.org;
|
|
137
168
|
if (!org)
|
|
138
169
|
return r;
|
|
139
|
-
const out =
|
|
140
|
-
markOutWritten(org, false);
|
|
170
|
+
const out = forgetOutWritten(org);
|
|
141
171
|
forgetOrg(org);
|
|
142
172
|
const link = removeLink(org);
|
|
143
173
|
const secrets = removeOrgSecrets(org);
|
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
// c11r shop (shop-agent-stores): the agent buys for its human from any store that speaks UCP.
|
|
2
|
+
// Built in: the CLI talks to the store directly, needs no organization (state none), and holds no
|
|
3
|
+
// keys (see ucp.ts). Each step is one command, so the agent talks to the human in between:
|
|
4
|
+
//
|
|
5
|
+
// profile, search, get, ask read the store; nothing of the human's is sent.
|
|
6
|
+
// checkout sends the buyer's details, the human's own words, after their yes
|
|
7
|
+
// on exactly what goes to which host. No money moves.
|
|
8
|
+
// order pays with the sandbox handler, after their yes on the total. On a
|
|
9
|
+
// checkout already paid, it answers the order and pays nothing.
|
|
10
|
+
//
|
|
11
|
+
// Only the sandbox handler: a store without it is refused before anything of the human's is sent.
|
|
12
|
+
import { confirm } from '../confirm.js';
|
|
13
|
+
import { fail } from '../errors.js';
|
|
14
|
+
import { envelope } from '../http.js';
|
|
15
|
+
import { parseSchema } from '../schema.js';
|
|
16
|
+
import { call, discover, handlersOf, minorOf, money, mustPay, originOf, sandboxPayment } from '../ucp.js';
|
|
17
|
+
const ADDRESS = [
|
|
18
|
+
{ name: 'street', type: 'string', description: 'Street and number: Via Roma 1.' },
|
|
19
|
+
{ name: 'line_2', type: 'string', description: 'Apartment, floor, care of.' },
|
|
20
|
+
{ name: 'locality', type: 'string', description: 'The city or town.' },
|
|
21
|
+
{ name: 'postal_code', type: 'string', description: 'The postal code.' },
|
|
22
|
+
{ name: 'region', type: 'string', description: 'The state or province, where the country has them: CA, TO.' },
|
|
23
|
+
{ name: 'country', type: 'string', description: 'The country, as a two-letter code: IT.' },
|
|
24
|
+
];
|
|
25
|
+
const BUYER = [
|
|
26
|
+
{ name: 'email', type: 'string', description: 'The buyer\'s email, as the human gave it.' },
|
|
27
|
+
{ name: 'first_name', type: 'string', description: 'The buyer\'s first name, as the human gave it.' },
|
|
28
|
+
{ name: 'last_name', type: 'string', description: 'The buyer\'s last name, as the human gave it.' },
|
|
29
|
+
{ name: 'phone', type: 'string', description: 'The buyer\'s phone number, as the human gave it.' },
|
|
30
|
+
{ name: 'address', type: 'object', properties: ADDRESS, description: 'Where to ship, as the human gave it: --address.street, --address.locality, --address.postal-code, --address.region, --address.country.' },
|
|
31
|
+
];
|
|
32
|
+
const STORE_ID = { accepts: ['host', 'url'], example: 'shop.example.com' };
|
|
33
|
+
export const shop = {
|
|
34
|
+
schema: parseSchema({
|
|
35
|
+
noun: 'shop',
|
|
36
|
+
group: 'shop',
|
|
37
|
+
skill: 'shop-agent-stores',
|
|
38
|
+
kind: 'task',
|
|
39
|
+
description: 'Buying for the human from any store that speaks UCP, as a shopping agent: the CLI talks to the store directly, with no organization. It pays only with the sandbox test handler, where no money moves. The buyer\'s details are the human\'s, never invented, and nothing of theirs is sent or paid without their yes.',
|
|
40
|
+
id: STORE_ID,
|
|
41
|
+
verbs: [
|
|
42
|
+
{ verb: 'profile', method: 'GET', path: '/shop/{id}/profile', state: 'none', live: false, effect: 'Reads the store\'s UCP profile: what it offers this CLI, its payment handlers, and can_pay, whether this CLI can pay there.' },
|
|
43
|
+
{
|
|
44
|
+
verb: 'search',
|
|
45
|
+
method: 'GET',
|
|
46
|
+
path: '/shop/{id}/search',
|
|
47
|
+
state: 'none',
|
|
48
|
+
live: false,
|
|
49
|
+
query: [
|
|
50
|
+
{ name: 'query', type: 'string', description: 'What the human looks for, in their words.' },
|
|
51
|
+
{ name: 'country', type: 'string', description: 'The country to ship to, as a two-letter code: prices and shipping for it.' },
|
|
52
|
+
{ name: 'category', type: 'array', items: 'string', description: 'A category or a parent of one (Coffee for Coffee > Single origin), repeated: any of them matches.' },
|
|
53
|
+
{ name: 'min_price', type: 'number', description: 'The lowest price, in major units of the store\'s currency.' },
|
|
54
|
+
{ name: 'max_price', type: 'number', description: 'The highest price, in major units of the store\'s currency.' },
|
|
55
|
+
{ name: 'in_stock', type: 'boolean', description: 'Only what is in stock.' },
|
|
56
|
+
{ name: 'sort', type: 'enum', values: ['relevance', 'price_asc', 'price_desc'], description: 'The order of the results.' },
|
|
57
|
+
{ name: 'limit', type: 'integer', description: 'How many products, 50 at most. Default 20.' },
|
|
58
|
+
],
|
|
59
|
+
effect: 'Searches the store\'s catalog: products with their variants, prices, availability and images. A variant id is what checkout --item takes.',
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
verb: 'get',
|
|
63
|
+
method: 'GET',
|
|
64
|
+
path: '/shop/{id}/get',
|
|
65
|
+
state: 'none',
|
|
66
|
+
live: false,
|
|
67
|
+
query: [
|
|
68
|
+
{ name: 'variant', type: 'array', items: 'string', required: true, description: 'A variant id from search, repeated for several. The first bare value after the store is one.' },
|
|
69
|
+
{ name: 'country', type: 'string', description: 'The country to ship to, as a two-letter code.' },
|
|
70
|
+
],
|
|
71
|
+
effect: 'Looks variants up by id: the full product, its price, availability and how it ships.',
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
verb: 'ask',
|
|
75
|
+
method: 'GET',
|
|
76
|
+
path: '/shop/{id}/ask',
|
|
77
|
+
state: 'none',
|
|
78
|
+
live: false,
|
|
79
|
+
query: [{ name: 'question', type: 'string', required: true, description: 'The human\'s question, in their words. The first bare value after the store.' }],
|
|
80
|
+
effect: 'Asks the store a question in plain words, when it answers them over UCP (dev.ucp.common.ask).',
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
verb: 'checkout',
|
|
84
|
+
method: 'POST',
|
|
85
|
+
path: '/shop/{id}/checkout',
|
|
86
|
+
state: 'none',
|
|
87
|
+
live: false,
|
|
88
|
+
body: [
|
|
89
|
+
{ name: 'checkout', type: 'string', description: 'A checkout to change or read again, by the id checkout answered. The first bare value after the store. Without it, a new checkout.' },
|
|
90
|
+
{ name: 'item', type: 'array', items: 'string', description: 'A variant id and its quantity, ID=QTY (ID alone is 1), repeated. On a checkout given, QTY 0 takes it out.' },
|
|
91
|
+
...BUYER,
|
|
92
|
+
{ name: 'shipping', type: 'string', description: 'A shipping option id from shipping_options. Without it, the store\'s first.' },
|
|
93
|
+
],
|
|
94
|
+
effect: 'Opens a checkout, or changes one: items, the buyer and where to ship. No money moves. The buyer\'s details go to the store only after the human\'s yes on exactly what is shared and with which host; ask the human for each, never invent one. Answers the totals with shipping, and what the store still needs.',
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
verb: 'order',
|
|
98
|
+
method: 'POST',
|
|
99
|
+
path: '/shop/{id}/order',
|
|
100
|
+
state: 'none',
|
|
101
|
+
live: false,
|
|
102
|
+
body: [{ name: 'checkout', type: 'string', required: true, description: 'The checkout to pay, by the id checkout answered. The first bare value after the store.' }],
|
|
103
|
+
effect: 'Pays a checkout that is ready with the sandbox test handler (no money moves), after the human\'s yes on the total, and answers the order. On a checkout already paid, answers its order and pays nothing.',
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
}),
|
|
107
|
+
positional: { get: 'variant', ask: 'question', checkout: 'checkout', order: 'checkout' },
|
|
108
|
+
async run(run) {
|
|
109
|
+
const origin = originOf(run.parsed.id);
|
|
110
|
+
const store = await discover(origin);
|
|
111
|
+
switch (run.verb.name) {
|
|
112
|
+
case 'profile':
|
|
113
|
+
return profile(store);
|
|
114
|
+
case 'search':
|
|
115
|
+
return search(run, store);
|
|
116
|
+
case 'get':
|
|
117
|
+
return get(run, store);
|
|
118
|
+
case 'ask':
|
|
119
|
+
return ask(run, store);
|
|
120
|
+
case 'checkout':
|
|
121
|
+
return checkout(run, store);
|
|
122
|
+
case 'order':
|
|
123
|
+
return order(run, store);
|
|
124
|
+
}
|
|
125
|
+
return undefined;
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
const cmd = (store, ...rest) => ['c11r shop', ...rest.slice(0, 1), store.host, ...rest.slice(1)].join(' ');
|
|
129
|
+
const plain = (d) => (typeof d === 'string' ? d : (d?.plain ?? d?.markdown ?? d?.html));
|
|
130
|
+
const drop = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined && v !== null && v !== '' && !(Array.isArray(v) && !v.length)));
|
|
131
|
+
const upper = (v) => (typeof v === 'string' && v ? v.toUpperCase() : undefined);
|
|
132
|
+
/** An answer, with signed: false and a warning when the store did not sign it. */
|
|
133
|
+
function answer(data, c, extra = {}) {
|
|
134
|
+
const unsigned = c?.signature === 'unsigned';
|
|
135
|
+
return envelope(data, {
|
|
136
|
+
...extra,
|
|
137
|
+
warnings: [...(extra.warnings ?? []), ...(unsigned ? [{ code: 'answer_unsigned', message: 'The store did not sign this answer, so it cannot be checked as the store\'s own.' }] : [])],
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
// ---------- profile
|
|
141
|
+
function profile(store) {
|
|
142
|
+
const { handlers, can_pay } = handlersOf(store);
|
|
143
|
+
return envelope({
|
|
144
|
+
store: store.host,
|
|
145
|
+
name: store.profile.name ?? undefined,
|
|
146
|
+
ucp_version: store.profile.ucp?.version,
|
|
147
|
+
endpoint: store.endpoint,
|
|
148
|
+
capabilities: [...store.capabilities].sort(),
|
|
149
|
+
payment_handlers: handlers,
|
|
150
|
+
can_pay,
|
|
151
|
+
signed: store.keys.length > 0,
|
|
152
|
+
}, {
|
|
153
|
+
next: [{ command: cmd(store, 'search', '--query WORDS'), why: 'what the store sells' }],
|
|
154
|
+
warnings: can_pay ? [] : [{ code: 'cannot_pay_here', message: `This CLI pays only with the sandbox test handler; ${store.host} ${handlers.length ? `takes ${handlers.join(', ')}` : 'takes no payment over UCP'}. The human buys there in person.` }],
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
// ---------- reading
|
|
158
|
+
function variantView(v) {
|
|
159
|
+
const m = v.fulfillment?.methods?.[0];
|
|
160
|
+
return drop({
|
|
161
|
+
id: v.id,
|
|
162
|
+
sku: v.sku,
|
|
163
|
+
title: v.title,
|
|
164
|
+
price: v.price ? money(v.price.amount, v.price.currency) : undefined,
|
|
165
|
+
list_price: v.list_price && v.list_price.amount !== v.price?.amount ? money(v.list_price.amount, v.list_price.currency) : undefined,
|
|
166
|
+
available: v.availability?.available,
|
|
167
|
+
availability: v.availability?.status,
|
|
168
|
+
image_url: v.media?.find((x) => x.type === 'image')?.url,
|
|
169
|
+
ships_to: m?.ships_to,
|
|
170
|
+
ships_to_country: m?.destination ? m.destination.ships !== false : undefined,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
function productView(p, full) {
|
|
174
|
+
const description = plain(p.description);
|
|
175
|
+
return drop({
|
|
176
|
+
id: p.id,
|
|
177
|
+
title: p.title,
|
|
178
|
+
description: full || !description || description.length <= 200 ? description : `${description.slice(0, 197)}...`,
|
|
179
|
+
url: p.url,
|
|
180
|
+
image_url: p.media?.find((x) => x.type === 'image')?.url,
|
|
181
|
+
categories: Array.isArray(p.categories) ? p.categories.map((c) => c.value ?? c) : undefined,
|
|
182
|
+
variants: (p.variants ?? []).map(variantView),
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
const context = (country) => (upper(country) ? { context: { address_country: upper(country) } } : {});
|
|
186
|
+
async function search(run, store) {
|
|
187
|
+
if (!store.capabilities.has('dev.ucp.shopping.catalog.search'))
|
|
188
|
+
fail('not_supported', `${store.host} offers no catalog search over UCP.`, { next_step: { action: 'human_step', url: store.origin } });
|
|
189
|
+
const v = run.parsed.values;
|
|
190
|
+
const limit = Math.min(Math.max(Number(v.limit ?? 20), 1), 50);
|
|
191
|
+
const body = { pagination: { limit }, ...context(v.country) };
|
|
192
|
+
if (typeof v.query === 'string' && v.query)
|
|
193
|
+
body.query = v.query;
|
|
194
|
+
const filters = {};
|
|
195
|
+
if (Array.isArray(v.category) && v.category.length)
|
|
196
|
+
filters.categories = v.category;
|
|
197
|
+
if (v.in_stock)
|
|
198
|
+
filters.availability = 'in_stock';
|
|
199
|
+
const sort = v.sort && v.sort !== 'relevance' ? v.sort : undefined;
|
|
200
|
+
if (sort)
|
|
201
|
+
body.sort = sort;
|
|
202
|
+
if (v.min_price !== undefined || v.max_price !== undefined) {
|
|
203
|
+
// filters.price is in minor units of the currency the store prices in here: ask it which.
|
|
204
|
+
const probe = await call(store, 'POST', `${store.endpoint}/catalog/search`, { ...body, ...(Object.keys(filters).length ? { filters } : {}), pagination: { limit: 1 } }, 'a catalog search');
|
|
205
|
+
const currency = probe.body?.products?.[0]?.variants?.[0]?.price?.currency;
|
|
206
|
+
if (currency) {
|
|
207
|
+
body.context = { ...(body.context ?? {}), currency };
|
|
208
|
+
filters.price = drop({ min: v.min_price === undefined ? undefined : minorOf(Number(v.min_price), currency), max: v.max_price === undefined ? undefined : minorOf(Number(v.max_price), currency) });
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (Object.keys(filters).length)
|
|
212
|
+
body.filters = filters;
|
|
213
|
+
const c = await call(store, 'POST', `${store.endpoint}/catalog/search`, body, 'a catalog search');
|
|
214
|
+
const products = c.body?.products ?? [];
|
|
215
|
+
const first = products[0]?.variants?.[0]?.id;
|
|
216
|
+
return answer(drop({
|
|
217
|
+
store: store.host,
|
|
218
|
+
query: body.query,
|
|
219
|
+
count: products.length,
|
|
220
|
+
total_count: c.body?.pagination?.total_count,
|
|
221
|
+
has_more: c.body?.pagination?.has_next_page || undefined,
|
|
222
|
+
products: products.map((p) => productView(p, false)),
|
|
223
|
+
messages: c.body?.messages,
|
|
224
|
+
}), c, { next: first ? [{ command: cmd(store, 'get', first), why: 'the whole product, and how it ships' }] : [] });
|
|
225
|
+
}
|
|
226
|
+
async function get(run, store) {
|
|
227
|
+
if (!store.capabilities.has('dev.ucp.shopping.catalog.lookup'))
|
|
228
|
+
fail('not_supported', `${store.host} offers no catalog lookup over UCP: search finds products there.`, { next_step: { action: 'fix_request', command: cmd(store, 'search', '--query WORDS') } });
|
|
229
|
+
const ids = run.parsed.values.variant;
|
|
230
|
+
const c = await call(store, 'POST', `${store.endpoint}/catalog/lookup`, { ids, ...context(run.parsed.values.country) }, ids.length > 1 ? `variants ${ids.join(', ')}` : `variant ${ids[0]}`);
|
|
231
|
+
const products = c.body?.products ?? [];
|
|
232
|
+
const found = new Set(products.flatMap((p) => (p.variants ?? []).map((x) => x.id)));
|
|
233
|
+
const missing = ids.filter((id) => !found.has(id));
|
|
234
|
+
if (!products.length)
|
|
235
|
+
fail('not_found', `${store.host} sells no variant ${ids.join(', ')}.`, { path: 'variant', next_step: { action: 'fix_request', command: cmd(store, 'search', '--query WORDS') } });
|
|
236
|
+
return answer(drop({ store: store.host, products: products.map((p) => productView(p, true)), missing }), c, {
|
|
237
|
+
next: [{ command: cmd(store, 'checkout', `--item ${ids[0]}=QTY ${HUMAN_FLAGS}`), why: 'buy it: every detail from the human, never invented' }],
|
|
238
|
+
warnings: missing.length ? [{ code: 'not_found', message: `${store.host} did not answer ${missing.join(', ')}.`, path: 'variant' }] : [],
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
async function ask(run, store) {
|
|
242
|
+
if (!store.capabilities.has('dev.ucp.common.ask'))
|
|
243
|
+
fail('not_supported', `${store.host} does not answer questions over UCP: its catalog search may.`, { next_step: { action: 'fix_request', command: cmd(store, 'search', '--query WORDS') } });
|
|
244
|
+
const question = run.parsed.values.question;
|
|
245
|
+
const c = await call(store, 'POST', `${store.commonEndpoint ?? store.endpoint}/ask`, { query: question }, 'a question');
|
|
246
|
+
return answer(drop({ store: store.host, question, answer: plain(c.body?.answer), links: c.body?.links, messages: c.body?.messages }), c);
|
|
247
|
+
}
|
|
248
|
+
function items(raw) {
|
|
249
|
+
return (raw ?? []).map((s) => {
|
|
250
|
+
const m = s.match(/^(.+?)(?:=(\d+))?$/);
|
|
251
|
+
if (!m || !m[1].trim())
|
|
252
|
+
fail('invalid_value', `--item ${s} is not ID or ID=QTY: a variant id from search, as in ETH-250=2.`, { path: 'item' });
|
|
253
|
+
return { id: m[1].trim(), quantity: m[2] === undefined ? 1 : Number(m[2]) };
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
/** The buyer and the address as the flags give them, in UCP's names. Only what was given. */
|
|
257
|
+
function given(v) {
|
|
258
|
+
const a = (v.address ?? {});
|
|
259
|
+
const buyer = drop({ email: v.email, first_name: v.first_name, last_name: v.last_name, phone_number: v.phone });
|
|
260
|
+
const address = drop({ street_address: a.street, extended_address: a.line_2, address_locality: a.locality, postal_code: a.postal_code, address_region: upper(a.region), address_country: upper(a.country) });
|
|
261
|
+
return { buyer, address };
|
|
262
|
+
}
|
|
263
|
+
const NEEDED = [
|
|
264
|
+
['--email', (b) => b.email],
|
|
265
|
+
['--first-name', (b) => b.first_name],
|
|
266
|
+
['--last-name', (b) => b.last_name],
|
|
267
|
+
['--phone', (b) => b.phone_number],
|
|
268
|
+
['--address.street', (_, a) => a.street_address],
|
|
269
|
+
['--address.locality', (_, a) => a.address_locality],
|
|
270
|
+
['--address.postal-code', (_, a) => a.postal_code],
|
|
271
|
+
['--address.country', (_, a) => a.address_country],
|
|
272
|
+
];
|
|
273
|
+
/** Every detail a new checkout needs, each a CAPS word the human's value replaces. */
|
|
274
|
+
const HUMAN_FLAGS = '--email EMAIL --first-name FIRST_NAME --last-name LAST_NAME --phone PHONE --address.street STREET --address.locality CITY --address.postal-code POSTAL_CODE --address.country COUNTRY';
|
|
275
|
+
/** The flags that give what a store's messages say is missing, by the UCP field they name. */
|
|
276
|
+
const FIELD_FLAGS = [
|
|
277
|
+
[/\bemail\b/, '--email EMAIL'],
|
|
278
|
+
[/\bfirst_name\b/, '--first-name FIRST_NAME'],
|
|
279
|
+
[/\blast_name\b/, '--last-name LAST_NAME'],
|
|
280
|
+
[/\bphone(_number)?\b/, '--phone PHONE'],
|
|
281
|
+
[/\bstreet_address\b/, '--address.street STREET'],
|
|
282
|
+
[/\bextended_address\b/, '--address.line-2 LINE_2'],
|
|
283
|
+
[/\baddress_locality\b/, '--address.locality CITY'],
|
|
284
|
+
[/\bpostal_code\b/, '--address.postal-code POSTAL_CODE'],
|
|
285
|
+
[/\baddress_region\b/, '--address.region REGION'],
|
|
286
|
+
[/\baddress_country\b/, '--address.country COUNTRY'],
|
|
287
|
+
[/\bselected_option_id\b/, '--shipping OPTION_ID'],
|
|
288
|
+
[/\bline_items\b/, '--item VARIANT=QTY'],
|
|
289
|
+
];
|
|
290
|
+
/** A runnable command for what the store still needs, or null when its messages name no field. */
|
|
291
|
+
function stillNeeds(ck, store) {
|
|
292
|
+
const said = (ck.messages ?? []).map((m) => [m.path, m.code].filter(Boolean).join(' ')).join(' ');
|
|
293
|
+
const flags = FIELD_FLAGS.filter(([re]) => re.test(said)).map(([, f]) => f);
|
|
294
|
+
return flags.length ? cmd(store, 'checkout', ck.id, ...flags) : null;
|
|
295
|
+
}
|
|
296
|
+
/** What the human sees before their details go: every value, in one line. */
|
|
297
|
+
function sharing(buyer, address) {
|
|
298
|
+
const name = [buyer.first_name, buyer.last_name].filter(Boolean).join(' ');
|
|
299
|
+
const place = [[address.postal_code, address.address_locality].filter(Boolean).join(' '), address.address_region, address.address_country].filter(Boolean).join(' ');
|
|
300
|
+
return [name, buyer.email, buyer.phone_number, address.street_address, address.extended_address, place].filter(Boolean).join(', ');
|
|
301
|
+
}
|
|
302
|
+
/** The update payload for a checkout: everything the platform may write, sent whole again. */
|
|
303
|
+
function replay(ck) {
|
|
304
|
+
const out = { line_items: (ck.line_items ?? []).map((l) => ({ id: l.id, item: { id: l.item?.id }, quantity: l.quantity })) };
|
|
305
|
+
for (const k of ['buyer', 'context'])
|
|
306
|
+
if (ck[k] !== undefined)
|
|
307
|
+
out[k] = ck[k];
|
|
308
|
+
if (ck.discounts?.codes?.length)
|
|
309
|
+
out.discounts = { codes: ck.discounts.codes };
|
|
310
|
+
if (ck.fulfillment?.methods) {
|
|
311
|
+
out.fulfillment = {
|
|
312
|
+
methods: ck.fulfillment.methods.map((m) => ({
|
|
313
|
+
id: m.id,
|
|
314
|
+
type: m.type,
|
|
315
|
+
line_item_ids: m.line_item_ids,
|
|
316
|
+
destinations: m.destinations,
|
|
317
|
+
selected_destination_id: m.selected_destination_id ?? null,
|
|
318
|
+
groups: (m.groups ?? []).map((g) => ({ id: g.id, selected_option_id: g.selected_option_id ?? null })),
|
|
319
|
+
})),
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
return out;
|
|
323
|
+
}
|
|
324
|
+
const destinationOf = (ck) => {
|
|
325
|
+
const m = ck.fulfillment?.methods?.[0];
|
|
326
|
+
return m?.destinations?.find((d) => d.id === m.selected_destination_id);
|
|
327
|
+
};
|
|
328
|
+
function checkoutView(ck, store) {
|
|
329
|
+
const cur = ck.currency;
|
|
330
|
+
const group = ck.fulfillment?.methods?.[0]?.groups?.[0];
|
|
331
|
+
const options = group?.options ?? [];
|
|
332
|
+
const option = options.find((o) => o.id === group?.selected_option_id);
|
|
333
|
+
const totalOf = (t) => t?.find((x) => x.type === 'total')?.amount;
|
|
334
|
+
const optionView = (o) => drop({ id: o.id, title: o.title, description: plain(o.description), price: totalOf(o.totals) === undefined ? undefined : money(totalOf(o.totals), cur) });
|
|
335
|
+
const { id: _id, type: _type, ...ship_to } = destinationOf(ck) ?? {};
|
|
336
|
+
const total = totalOf(ck.totals);
|
|
337
|
+
return drop({
|
|
338
|
+
id: ck.id,
|
|
339
|
+
status: ck.status,
|
|
340
|
+
store: store.host,
|
|
341
|
+
currency: cur,
|
|
342
|
+
line_items: (ck.line_items ?? []).map((l) => drop({ item_id: l.item?.id, title: l.item?.title, quantity: l.quantity, unit_price: l.item?.price === undefined ? undefined : money(l.item.price, cur), total: totalOf(l.totals) === undefined ? undefined : money(totalOf(l.totals), cur) })),
|
|
343
|
+
buyer: ck.buyer ? drop(Object.fromEntries(Object.entries(ck.buyer).filter(([k]) => k !== 'consent'))) : undefined,
|
|
344
|
+
ship_to: Object.keys(ship_to).length ? ship_to : undefined,
|
|
345
|
+
shipping: option ? optionView(option) : undefined,
|
|
346
|
+
shipping_options: options.length > 1 ? options.map(optionView) : undefined,
|
|
347
|
+
totals: (ck.totals ?? []).map((t) => drop({ type: t.type, label: t.display_text, ...money(t.amount, cur) })),
|
|
348
|
+
total: total === undefined ? undefined : money(total, cur),
|
|
349
|
+
messages: ck.messages,
|
|
350
|
+
order: ck.order,
|
|
351
|
+
continue_url: ck.continue_url,
|
|
352
|
+
expires_at: ck.expires_at,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
function nextOf(ck, store) {
|
|
356
|
+
if (ck.status === 'ready_for_complete')
|
|
357
|
+
return [{ command: cmd(store, 'order', ck.id), why: `pay ${ck.totals ? money(ck.totals.find((t) => t.type === 'total')?.amount ?? 0, ck.currency).display : 'the total'} with the sandbox test handler, after the human's yes on it` }];
|
|
358
|
+
if (ck.status === 'completed' && ck.order?.id)
|
|
359
|
+
return [{ command: cmd(store, 'order', ck.id), why: 'the order' }];
|
|
360
|
+
const needs = stillNeeds(ck, store);
|
|
361
|
+
return needs ? [{ command: needs, why: 'what the store says in messages is missing, from the human' }] : [];
|
|
362
|
+
}
|
|
363
|
+
async function checkout(run, store) {
|
|
364
|
+
const v = run.parsed.values;
|
|
365
|
+
const id = v.checkout;
|
|
366
|
+
// Nothing of the human's goes to a store this CLI cannot pay at.
|
|
367
|
+
mustPay(store);
|
|
368
|
+
const lines = items(v.item);
|
|
369
|
+
const { buyer, address } = given(v);
|
|
370
|
+
const current = id ? (await call(store, 'GET', `${store.endpoint}/checkout-sessions/${encodeURIComponent(id)}`, undefined, `checkout ${id}`)).body : null;
|
|
371
|
+
if (current && (current.status === 'completed' || current.status === 'canceled')) {
|
|
372
|
+
if (lines.length || Object.keys(buyer).length || Object.keys(address).length || v.shipping) {
|
|
373
|
+
fail('invalid_transition', `Checkout ${id} is ${current.status}: it takes no changes.`, { details: { status: current.status }, next_step: current.status === 'completed' ? { action: 'fix_request', command: cmd(store, 'order', id) } : { action: 'fix_request', command: cmd(store, 'checkout', `--item VARIANT=QTY ${HUMAN_FLAGS}`) } }, 'rejected');
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
if (!current) {
|
|
377
|
+
if (!lines.length)
|
|
378
|
+
fail('missing_value', 'A new checkout needs at least one --item: a variant id from search, as in --item ETH-250=1.', { path: 'item', next_step: { action: 'fix_request', command: cmd(store, 'search', '--query WORDS') } });
|
|
379
|
+
const missing = NEEDED.filter(([, pick]) => !pick(buyer, address)).map(([f]) => f);
|
|
380
|
+
if (missing.length) {
|
|
381
|
+
fail('missing_value', `A checkout needs the buyer's ${missing.join(', ')}. Ask the human for each: they are the human's, never invented.`, { path: missing[0].replace(/^--/, '').replace(/-/g, '_'), details: { missing }, next_step: { action: 'human_step' } });
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
// A read: a checkout given with nothing to change.
|
|
385
|
+
if (current && !lines.length && !Object.keys(buyer).length && !Object.keys(address).length && !v.shipping) {
|
|
386
|
+
return envelope(checkoutView(current, store), { next: nextOf(current, store) });
|
|
387
|
+
}
|
|
388
|
+
// What goes to the store of the human's: every value, shown back to them first.
|
|
389
|
+
const shared = Object.keys(buyer).length || Object.keys(address).length ? sharing(buyer, address) : null;
|
|
390
|
+
if (shared && !run.globals.dryRun) {
|
|
391
|
+
await confirm(`Share the buyer's details with ${store.host}`, run.line, { yes: run.globals.yes, prompter: run.prompter, sentence: `Sharing with ${store.host}: ${shared}`, details: { store: store.host, buyer, ship_to: address } });
|
|
392
|
+
}
|
|
393
|
+
const body = current ? replay(current) : { line_items: lines.map((l) => ({ item: { id: l.id }, quantity: l.quantity })), ...context(address.address_country) };
|
|
394
|
+
if (current) {
|
|
395
|
+
for (const l of lines) {
|
|
396
|
+
const i = body.line_items.findIndex((x) => x.item.id === l.id);
|
|
397
|
+
if (l.quantity === 0) {
|
|
398
|
+
if (i >= 0)
|
|
399
|
+
body.line_items.splice(i, 1);
|
|
400
|
+
}
|
|
401
|
+
else if (i >= 0)
|
|
402
|
+
body.line_items[i].quantity = l.quantity;
|
|
403
|
+
else
|
|
404
|
+
body.line_items.push({ item: { id: l.id }, quantity: l.quantity });
|
|
405
|
+
}
|
|
406
|
+
if (!body.line_items.length)
|
|
407
|
+
fail('invalid_value', `That leaves checkout ${id} with no items: keep one, or let it expire.`, { path: 'item' });
|
|
408
|
+
}
|
|
409
|
+
if (Object.keys(buyer).length)
|
|
410
|
+
body.buyer = drop({ ...(body.buyer ?? {}), ...buyer });
|
|
411
|
+
if (Object.keys(address).length || v.shipping) {
|
|
412
|
+
const method = body.fulfillment?.methods?.[0] ?? { type: 'shipping' };
|
|
413
|
+
if (Object.keys(address).length) {
|
|
414
|
+
const prev = (current && destinationOf(current)) ?? {};
|
|
415
|
+
const b = (body.buyer ?? {});
|
|
416
|
+
const dest = drop({ ...prev, first_name: b.first_name, last_name: b.last_name, phone_number: b.phone_number, ...address, id: prev.id ?? 'dest_1' });
|
|
417
|
+
delete dest.type;
|
|
418
|
+
method.destinations = [dest];
|
|
419
|
+
method.selected_destination_id = dest.id;
|
|
420
|
+
if (address.address_country)
|
|
421
|
+
body.context = { ...(body.context ?? {}), address_country: address.address_country };
|
|
422
|
+
}
|
|
423
|
+
if (v.shipping)
|
|
424
|
+
method.groups = (method.groups?.length ? method.groups : [{ id: 'grp_1' }]).map((g) => ({ ...g, selected_option_id: v.shipping }));
|
|
425
|
+
body.fulfillment = { methods: [method, ...(body.fulfillment?.methods?.slice(1) ?? [])] };
|
|
426
|
+
}
|
|
427
|
+
if (run.globals.dryRun)
|
|
428
|
+
return envelope({ dry_run: true, store: store.host, checkout: id ?? null, would_send: body, sharing: shared });
|
|
429
|
+
let c;
|
|
430
|
+
if (current)
|
|
431
|
+
c = await call(store, 'PUT', `${store.endpoint}/checkout-sessions/${encodeURIComponent(id)}`, body, `checkout ${id}`);
|
|
432
|
+
else {
|
|
433
|
+
// A new checkout: the items first, then the buyer and the address on it, as the store takes them.
|
|
434
|
+
const { buyer: b, fulfillment: f, ...open } = body;
|
|
435
|
+
c = await call(store, 'POST', `${store.endpoint}/checkout-sessions`, open, 'a new checkout');
|
|
436
|
+
const again = replay(c.body);
|
|
437
|
+
if (b)
|
|
438
|
+
again.buyer = b;
|
|
439
|
+
if (f) {
|
|
440
|
+
const m = f.methods[0];
|
|
441
|
+
again.fulfillment = { methods: [{ ...m, line_item_ids: (c.body.line_items ?? []).map((l) => l.id) }] };
|
|
442
|
+
}
|
|
443
|
+
c = await call(store, 'PUT', `${store.endpoint}/checkout-sessions/${encodeURIComponent(c.body.id)}`, again, `checkout ${c.body.id}`);
|
|
444
|
+
}
|
|
445
|
+
// A store that offers shipping options without picking one: its first, said in the answer.
|
|
446
|
+
const group = c.body.fulfillment?.methods?.[0]?.groups?.[0];
|
|
447
|
+
if (group && !group.selected_option_id && group.options?.length) {
|
|
448
|
+
const again = replay(c.body);
|
|
449
|
+
again.fulfillment.methods[0].groups[0].selected_option_id = group.options[0].id;
|
|
450
|
+
c = await call(store, 'PUT', `${store.endpoint}/checkout-sessions/${encodeURIComponent(c.body.id)}`, again, `checkout ${c.body.id}`);
|
|
451
|
+
}
|
|
452
|
+
const ck = c.body;
|
|
453
|
+
return answer(checkoutView(ck, store), c, { next: nextOf(ck, store) });
|
|
454
|
+
}
|
|
455
|
+
// ---------- order
|
|
456
|
+
function orderView(o, store, checkoutId) {
|
|
457
|
+
const cur = o.currency;
|
|
458
|
+
const totalOf = (t) => t?.find((x) => x.type === 'total')?.amount;
|
|
459
|
+
return drop({
|
|
460
|
+
id: o.id,
|
|
461
|
+
label: o.label,
|
|
462
|
+
permalink_url: o.permalink_url,
|
|
463
|
+
store: store.host,
|
|
464
|
+
checkout: checkoutId,
|
|
465
|
+
currency: cur,
|
|
466
|
+
line_items: (o.line_items ?? []).map((l) => drop({ item_id: l.item?.id, title: l.item?.title, quantity: l.quantity?.total ?? l.quantity, status: l.status, total: totalOf(l.totals) === undefined || !cur ? undefined : money(totalOf(l.totals), cur) })),
|
|
467
|
+
fulfillment: (o.fulfillment?.expectations ?? []).map((e) => drop({ method: e.method_type, destination: e.destination, description: plain(e.description) })),
|
|
468
|
+
totals: cur ? (o.totals ?? []).map((t) => drop({ type: t.type, label: t.display_text, ...money(t.amount, cur) })) : undefined,
|
|
469
|
+
total: cur && totalOf(o.totals) !== undefined ? money(totalOf(o.totals), cur) : undefined,
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
async function placed(store, ck, paid) {
|
|
473
|
+
const ref = ck.order;
|
|
474
|
+
const c = await call(store, 'GET', `${store.endpoint}/orders/${encodeURIComponent(ref.id)}`, undefined, `order ${ref.id}`);
|
|
475
|
+
return answer({ ...orderView({ label: ref.label, permalink_url: ref.permalink_url, ...c.body }, store, ck.id), paid_now: paid }, c);
|
|
476
|
+
}
|
|
477
|
+
async function order(run, store) {
|
|
478
|
+
const id = run.parsed.values.checkout;
|
|
479
|
+
const ck = (await call(store, 'GET', `${store.endpoint}/checkout-sessions/${encodeURIComponent(id)}`, undefined, `checkout ${id}`)).body;
|
|
480
|
+
// Paid already: the order, and nothing paid again.
|
|
481
|
+
if (ck.status === 'completed' && ck.order?.id)
|
|
482
|
+
return placed(store, ck, false);
|
|
483
|
+
if (ck.status === 'canceled')
|
|
484
|
+
fail('invalid_transition', `Checkout ${id} was canceled: it cannot be paid.`, { details: { status: ck.status }, next_step: { action: 'fix_request', command: cmd(store, 'checkout', `--item VARIANT=QTY ${HUMAN_FLAGS}`) } }, 'rejected');
|
|
485
|
+
mustPay(store, { continue_url: ck.continue_url ?? null });
|
|
486
|
+
if (ck.status !== 'ready_for_complete') {
|
|
487
|
+
const said = (ck.messages ?? []).map((m) => m.content).filter(Boolean).join(' ');
|
|
488
|
+
fail('invalid_transition', `Checkout ${id} is ${ck.status}, not ready to pay${said ? `: ${said}` : '.'}`, { details: { status: ck.status, messages: ck.messages ?? [] }, next_step: stillNeeds(ck, store) ? { action: 'fix_request', command: stillNeeds(ck, store) } : { action: 'human_step', url: ck.continue_url ?? null } }, 'rejected');
|
|
489
|
+
}
|
|
490
|
+
const totalAmount = (ck.totals ?? []).find((t) => t.type === 'total')?.amount;
|
|
491
|
+
const total = totalAmount === undefined ? null : money(totalAmount, ck.currency);
|
|
492
|
+
const sentence = `Paying ${total?.display ?? 'the total'} to ${store.host} for checkout ${id}, with the sandbox test handler (no money moves)`;
|
|
493
|
+
if (run.globals.dryRun)
|
|
494
|
+
return envelope({ dry_run: true, store: store.host, checkout: id, total, would_pay: true });
|
|
495
|
+
await confirm(`Pay checkout ${id}`, run.line, { yes: run.globals.yes, prompter: run.prompter, sentence, details: { store: store.host, checkout: id, total } });
|
|
496
|
+
const c = await call(store, 'POST', `${store.endpoint}/checkout-sessions/${encodeURIComponent(id)}/complete`, sandboxBody(), `checkout ${id}`);
|
|
497
|
+
const done = c.body;
|
|
498
|
+
if (done.status !== 'completed' || !done.order?.id) {
|
|
499
|
+
const said = (done.messages ?? []).map((m) => m.content).filter(Boolean).join(' ');
|
|
500
|
+
fail('store_refused', `${store.host} did not take the payment for checkout ${id}${said ? `: ${said}` : '.'}`, { details: { status: done.status, messages: done.messages ?? [] }, next_step: { action: 'human_step', url: done.continue_url ?? null } }, 'rejected');
|
|
501
|
+
}
|
|
502
|
+
return placed(store, done, true);
|
|
503
|
+
}
|
|
504
|
+
const sandboxBody = () => ({ ...sandboxPayment(), signals: { 'dev.ucp.user_agent': 'c11r' } });
|