@commercelayer/agent-cli 0.5.1 → 0.7.0
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 +51 -6
- package/dist/api/client.js +39 -1
- package/dist/api/stub/http.js +2 -2
- package/dist/api/stub/index.js +1 -1
- package/dist/api/stub/management.js +19 -2
- package/dist/api/stub/messages.js +126 -0
- package/dist/api/stub/orders.js +299 -33
- package/dist/api/stub/theme.js +16 -1
- package/dist/api/stub/ucp.js +2 -2
- package/dist/api/transport.js +24 -7
- package/dist/bin.js +28 -6
- package/dist/commands.js +490 -16
- package/dist/cursor.js +31 -0
- package/dist/files.js +5 -3
- package/dist/inbox/cursor.js +11 -0
- package/dist/inbox/fake.js +95 -0
- package/dist/inbox/imap.js +139 -0
- package/dist/inbox/mailbox.js +25 -0
- package/dist/inbox/message.js +88 -0
- package/dist/ops.js +190 -0
- package/dist/orderRequest.js +141 -0
- package/dist/output.js +7 -3
- package/dist/ucp/images.js +62 -0
- package/dist/ucp/shop.js +2 -0
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -29,13 +29,20 @@ npx -y @commercelayer/agent-cli@latest init --name "The Blue Brand" # runs c11
|
|
|
29
29
|
| `links create` | Create a shareable link to a microstore (sku, skus or category) or an order's checkout, from a spec in YAML or JSON on stdin or `-f <file>` (see [Links](#links)). Prints the link, `url` first. |
|
|
30
30
|
| `links enable <id>` · `disable <id>` · `delete <id>` | Turn a link on or off, or delete it (ask the human first). |
|
|
31
31
|
| `orders test [--sku <id>] [--quantity <n>] [--country <CC>] [--code <discount>] [--via-cart] [--decline]` | Buy from the store over UCP as a shopping agent would: discover, search, (cart,) checkout, address, pay with the sandbox handler, read the order back. Prints the order, the `discounts` applied (promotions and codes, in minor units like the totals) and every step. |
|
|
32
|
-
| `orders list [--status placed\|approved\|cancelled\|editing\|all] [--payment <status>] [--fulfillment <status>] [--archived [all]] [--limit <n>]` | The store's orders, live in Commerce Layer, newest first; by default placed and approved, not archived, 25 at most (see [Orders and fulfillment](#orders-and-fulfillment)). |
|
|
32
|
+
| `orders list [--status pending\|placed\|approved\|cancelled\|editing\|all] [--channel message\|link] [--payment <status>] [--fulfillment <status>] [--archived [all]] [--limit <n>]` | The store's orders, live in Commerce Layer, newest first; by default placed and approved, not archived, 25 at most (see [Orders and fulfillment](#orders-and-fulfillment)). Pending orders (from emails and messages) only with `--status pending`. |
|
|
33
|
+
| `orders create -f <file> \| <flags> --source email:<id> [--place\|--hold] [--force]` | An order from an email or a message: the agent writes the request (customer, address, lines by sku or by the words used); the API resolves the products at catalog prices and makes it pending, or answers `needs_review` with nothing created (see [Orders from emails and messages](#orders-from-emails-and-messages)). |
|
|
34
|
+
| `orders place <n>` | Place a pending order once the human said yes. |
|
|
33
35
|
| `orders show <number or id>` | One order as the merchant sees it: line items, totals, addresses, payment and transactions, shipments, and `next`, what it waits for. |
|
|
34
36
|
| `orders approve <n> [--capture]` · `capture` · `cancel` · `refund <n> [--amount <major>]` · `archive` · `unarchive` | Move an order; each prints the order after it. |
|
|
35
37
|
| `fulfillment list [--status <status>\|all] [--limit <n>]` · `show <shipment>` | The shipments, by default the ones not shipped yet. A shipment is its number (`1001/S/001`), its id, or its order's number. |
|
|
36
38
|
| `fulfillment hold` · `pick` · `pack` · `ready` · `ship <shipment> [--carrier] [--tracking] [--tracking-url]` · `deliver` | Move a shipment; each prints the shipment after it. |
|
|
39
|
+
| `ops check [--since 24h\|7d\|<ISO>]` | What is new since the last check and what the policy in `.commercelayer/ops.yml` would do, without changing anything: new orders, what it would approve and capture, what it holds for the human and why, orders with no payment yet, stuck shipments (see [Running the store on a schedule](#running-the-store-on-a-schedule)). |
|
|
40
|
+
| `ops run [--dry-run]` | Apply the policy: approve, and capture if it says so, every placed order it does not hold. Never ships, cancels or refunds. |
|
|
41
|
+
| `ops digest [--since 24h\|7d\|<ISO>]` | A summary for the human: orders, revenue per currency, what was approved, what is held or stuck, top products, as `summary` text. |
|
|
42
|
+
| `insights [--since 24h\|7d\|30d\|<ISO>] [--until <ISO>] [--by product\|day\|market\|country\|channel\|promotion]` | Sales over a span (default 30 days): orders, units, revenue and average order per currency, refunds and cancellations apart, top products, channels, and with `--by` one row per product, day, market, country, channel or promotion; a `summary` to read. |
|
|
43
|
+
| `inbox check` · `list [--unseen] [--since 24h] [--limit 20]` · `pull <id>\|--unseen` · `tag <id> [--label <name>]… [--unlabel <name>]… [--move <folder>] [--seen]` | The inbox orders arrive in, over IMAP: list it, save messages and their attachments under `.commercelayer/inbox/` for the agent to read, label or move them when done. No sending: replies stay with the human. |
|
|
37
44
|
| `ucp profile [--agent]` | The store's UCP business profile (`/.well-known/ucp`); with `--agent`, this CLI's own platform profile. |
|
|
38
|
-
| `ucp search [<words>] [--category <name>] [--min-price <n>] [--max-price <n>] [--country <CC>]` · `ucp product <id
|
|
45
|
+
| `ucp search [<words>] [--category <name>] [--min-price <n>] [--max-price <n>] [--country <CC>] [--images]` · `ucp product <id> [--images]` | Buy for the human, step by step (see [Buying for the human](#buying-for-the-human)): the store's products with their categories, prices in major units in `price.display`, sale prices, shipping and the market the prices are for. |
|
|
39
46
|
| `ucp checkout create --item <variant id>[:<qty>] … [--code <discount>] [--country <CC>]` | Open a checkout; `messages` say what is still missing (email, shipping address). |
|
|
40
47
|
| `ucp checkout update <id> [--email …] [--street …] [--item <variant id>:<qty>] [--code …] [--shipping <option>]` | Give the buyer's details (flags, or YAML or JSON on stdin or `-f`), change items or codes; `ready_for_complete` once nothing is missing, with shipping in the total. |
|
|
41
48
|
| `ucp checkout show <id>` · `cancel <id>` · `complete <id> [--decline]` | Read, cancel, or pay a checkout with the sandbox handler (after the human said yes): `order` has `label` and `permalink_url`. |
|
|
@@ -157,12 +164,48 @@ One rule for images: `img/<file>` anywhere in `store.yml` (product `image_url`,
|
|
|
157
164
|
|
|
158
165
|
A custom storefront replaces the theme: `storefront.path` in `store.yml` names its folder, `storefront init` writes a drop-in.js scaffold, `storefront publish` uploads it, `storefront check` checks it. Its pages link product images at `/img/`; its own files (fonts, icons, styles) go in `assets/`, not `img/`.
|
|
159
166
|
|
|
160
|
-
Error codes: `invalid_theme`, `invalid_storefront`, `invalid_promotion`, `promotion_exists`, `promotion_not_managed`, `flex_unavailable`, `promotions_unsupported`, `invalid_link`, `link_exists`, `link_not_managed` (reserved), `links_unsupported`, `missing_images`, `asset_too_large`, `no_store`, `invalid_yaml`, `invalid_store`, `invalid_market`, `invalid_product`, `missing_price`, `deletes_need_approval`, `apply_failed`, `apply_pending`, `apply_in_progress`, `storefront_exists`, `no_storefront`, `storefront_unreachable`, `not_applied`, `not_found`, `missing_argument`, `invalid_argument`, `unknown_command`, `unauthorized`, `expired`, `api_unreachable`, `api_error`, `no_product`, `checkout_not_ready`, `ucp_error`, `ucp_profile_unreachable`, `ucp_profile_malformed`, `ucp_version_unsupported`, `ucp_no_rest_service`, `internal`.
|
|
167
|
+
Error codes: `invalid_theme`, `invalid_storefront`, `invalid_promotion`, `promotion_exists`, `promotion_not_managed`, `flex_unavailable`, `promotions_unsupported`, `invalid_link`, `link_exists`, `link_not_managed` (reserved), `links_unsupported`, `missing_images`, `asset_too_large`, `no_store`, `invalid_yaml`, `invalid_store`, `invalid_market`, `invalid_product`, `missing_price`, `deletes_need_approval`, `apply_failed`, `apply_pending`, `apply_in_progress`, `storefront_exists`, `no_storefront`, `storefront_unreachable`, `not_applied`, `not_found`, `country_not_served`, `too_many_items`, `store_unavailable`, `orders_unsupported`, `missing_credentials`, `invalid_credentials`, `inbox_unreachable`, `labels_unsupported`, `invalid_policy`, `missing_argument`, `invalid_argument`, `unknown_command`, `unauthorized`, `expired`, `api_unreachable`, `api_error`, `no_product`, `checkout_not_ready`, `ucp_error`, `ucp_profile_unreachable`, `ucp_profile_malformed`, `ucp_version_unsupported`, `ucp_no_rest_service`, `internal`.
|
|
161
168
|
## Orders and fulfillment
|
|
162
169
|
|
|
163
|
-
Orders land **placed** with the payment **authorized**, not taken: the store no longer approves or captures them. The merchant, through the agent, accepts an order with `orders approve <n> --capture` (or approves now and runs `orders capture <n>` later), cancels one it doesn't want (`orders cancel`, which voids
|
|
170
|
+
Orders land **placed** with the payment **authorized**, not taken: the store no longer approves or captures them. The merchant, through the agent, accepts an order with `orders approve <n> --capture` (or approves now and runs `orders capture <n>` later; Commerce Layer also captures a placed order), cancels one it doesn't want before it ships (`orders cancel`, which voids an authorization or refunds a captured payment; a shipped order is refunded, not cancelled), and gives money back with `orders refund <n> [--amount <major units>]` (the last of it cancels the order). `orders archive` hides an order from the list, whatever its status; `orders list --archived` shows them. Every command prints the order after it and a `next` that says what it waits for; a move the order doesn't allow answers `invalid_transition`, with the commands it allows now in the hint.
|
|
164
171
|
|
|
165
|
-
Each order has one shipment, `upcoming` when placed. `fulfillment hold` works on a placed order; `pick`, `pack`, `ready` and `ship` need the order approved, and `on_hold`, `picking`, `packing` and `ready_to_ship` move freely between each other. `ship` takes the carrier's `--carrier`, `--tracking` and `--tracking-url` (https); `deliver` follows `ship`. An API older than these routes answers `orders_unsupported` or `fulfillment_unsupported`.
|
|
172
|
+
Each order has one shipment, `upcoming` when placed. `fulfillment hold` works on a placed order; `pick`, `pack`, `ready` and `ship` need the order approved, and `on_hold`, `picking`, `packing` and `ready_to_ship` move freely between each other and on to `shipped`. An `upcoming` shipment goes only to `hold` or `pick`, as Commerce Layer refuses to ship it straight away. `ship` takes the carrier's `--carrier`, `--tracking` and `--tracking-url` (https); `deliver` follows `ship`. An API older than these routes answers `orders_unsupported` or `fulfillment_unsupported`.
|
|
173
|
+
|
|
174
|
+
### Running the store on a schedule
|
|
175
|
+
|
|
176
|
+
An agent can run the store on a schedule (a cron job, a CI workflow, a loop) with `ops check` and `ops run`, under a policy the human agrees to. The policy is `.commercelayer/ops.yml`, committed with the store; the CLI reads it and never sends it to the API, and `plan` checks it:
|
|
177
|
+
|
|
178
|
+
```yaml
|
|
179
|
+
auto_approve: true # approve placed orders (default false: every order waits for the human)
|
|
180
|
+
auto_capture: true # take the payment when approving, and of orders the human approved (default false)
|
|
181
|
+
review_above: 500 # hold orders above this total, in major units of the order's currency
|
|
182
|
+
hold_countries: [US] # hold orders shipping to these countries
|
|
183
|
+
stuck_days: 2 # a shipment picking, packing or ready to ship longer is stuck (on hold never is)
|
|
184
|
+
transit_days: 7 # a shipment shipped longer and not delivered is stuck in transit
|
|
185
|
+
mail_orders: # orders made from emails and messages (orders create)
|
|
186
|
+
auto_place: false # place them at once (default false: pending until the human says yes)
|
|
187
|
+
labels: { pending: pending, ordered: ordered, declined: declined } # what the agent labels the emails (these by default)
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
`ops check` remembers what it reported in `.commercelayer/ops.json` (machine state, gitignored), so the next check does not repeat an order; `--since` looks back without moving it. `ops run` approves and captures what the policy allows, one order at a time, and reports what it did, what it held and why, and what is stuck; with `--dry-run` it only says what it would do. Shipping, cancelling and refunding stay with the human. `ops digest` and `insights` summarise for the human, in plain text they can read as is.
|
|
191
|
+
|
|
192
|
+
### Orders from emails and messages
|
|
193
|
+
|
|
194
|
+
Merchants get orders by email: a sentence in the body, a purchase order as PDF, a list in a sheet. The agent reads the message (the CLI saves attachments as they came; the agent opens PDFs and sheets itself) and writes the request:
|
|
195
|
+
|
|
196
|
+
```yaml
|
|
197
|
+
source: { kind: email, id: "<po-114@bar.example>" } # required: the same source never makes a second order
|
|
198
|
+
customer: { email: buyer@bar.example, company: Bar Centrale, phone: "+33 1 23 45 67 89" }
|
|
199
|
+
ship_to: { line_1: 3 rue de la Paix, city: Paris, postal_code: "75002", country: FR }
|
|
200
|
+
items:
|
|
201
|
+
- { sku: ETH, quantity: 6 }
|
|
202
|
+
- { query: white mug, quantity: 2 } # the words the customer used
|
|
203
|
+
reference: PO-2026-114 # their purchase order number
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
`orders create -f request.yml` sends it; the API finds each line in the catalog (a sku, or the store's search for the words), at the catalog's prices, never the email's. A line it is not sure of comes back in `resolution` with up to three candidates and status `needs_review`, and nothing is created: the agent asks the human which product and runs it again with `--item <sku>:<qty>`, or with `--force` to leave those lines out. Nothing is invented: a missing phone, postal code or country is `invalid_argument` with the `path`. The order is made **pending**, shown to the human, and placed on their yes with `orders place <n>`; `mail_orders.auto_place: true` places it at once, `--hold` keeps it pending anyway. `orders cancel` drops a pending order (it stays archived).
|
|
207
|
+
|
|
208
|
+
`inbox` reads the mailbox when the agent has no mail connector of its own. The connection goes in `.commercelayer/credentials` (gitignored), written by the human: `C11R_IMAP_HOST`, `C11R_IMAP_USER`, `C11R_IMAP_PASSWORD` (for Gmail an [app password](https://myaccount.google.com/apppasswords), never the account password), and optionally `C11R_IMAP_PORT` (993) and `C11R_IMAP_FOLDER` (INBOX); no output ever shows the password. `inbox pull` writes `.commercelayer/inbox/<id>/` (gitignored): `message.json`, `body.txt` and `attachments/`; `.commercelayer/inbox.json` remembers what was pulled. `inbox tag` labels the email (Gmail labels, else IMAP keywords, else `--move` to a folder) and `--seen` marks it read.
|
|
166
209
|
|
|
167
210
|
## The store speaks UCP
|
|
168
211
|
Each store is a UCP business at `https://<org>.sandbox.commercelayer.store` (live stores: `<slug>.commercelayer.store`), implementing release **2026-08-25** over REST:
|
|
@@ -182,9 +225,11 @@ The CLI is a UCP **platform**: its profile is served at `https://commercelayer.a
|
|
|
182
225
|
|
|
183
226
|
An agent can buy from any UCP store on behalf of its human, one step per command, talking to the human in between: `ucp search` to show the products (words in any order; `--category`, which includes the subcategories, and `--min-price`/`--max-price` in major units narrow it; the `messages` the store sends, such as its categories when nothing matched, are passed on), `ucp checkout create --item <variant id>:<qty>` for what they picked (the `variants[].id` search lists; the sku on Commerce Layer stores), `ucp checkout update <id>` with the email and shipping address they give (never invented), then, once they said yes to the total, `ucp checkout complete <id>`, and the order's `label` and `permalink_url` back to them. Money comes as `{ amount, currency, major, display }`: `amount` in minor units as UCP has it, `display` for the human. The store is `--store <hostname or origin>`, or this folder's store when there is one; checkout and order ids are remembered with their store (in `~/.local/state/c11r/ucp.json`), so later steps need no `--store`. Paying needs the store's sandbox handler (`ai.commercelayer.sandbox`, test payments only): any store without it (every store taking real payments) answers `payment_handler_unsupported`, and the human pays at the checkout's `continue_url`. Responses are signature-checked like `orders test`: `signature` is `verified`, `invalid`, `unknown_key` or `unsigned`.
|
|
184
227
|
|
|
228
|
+
`--images` is for agents that can read image files and want to look at or describe a product: it saves each product's `image_url` under `<tmpdir>/c11r/images/<store host>/` (the first 24, in parallel, 5 s each, a file already there reused) and adds `image_file`, the absolute path, or `image_error`. Showing a thumbnail in a chat needs only `image_url`, which checkout and order line items carry too.
|
|
229
|
+
|
|
185
230
|
## Requirements
|
|
186
231
|
|
|
187
|
-
Node 20 or later.
|
|
232
|
+
Node 20 or later. Runtime dependencies: `yaml`, and for `inbox` only `imapflow` and `postal-mime`, loaded when an inbox command runs (about 9 MB more on install).
|
|
188
233
|
|
|
189
234
|
## Links and license
|
|
190
235
|
|
package/dist/api/client.js
CHANGED
|
@@ -38,7 +38,7 @@ export function createApi(auth) {
|
|
|
38
38
|
return data;
|
|
39
39
|
const err = data?.error;
|
|
40
40
|
if (err)
|
|
41
|
-
fail(err.code, err.message, err.hint);
|
|
41
|
+
fail(err.code, err.message, err.hint, err.path);
|
|
42
42
|
fail('api_error', `The API answered ${res.status}.`, 'Try again. If it keeps happening, tell the human, with the output of `c11r status`.');
|
|
43
43
|
};
|
|
44
44
|
// Promotions and links: on an API older than them, the collection route is an unknown route (404 not_found).
|
|
@@ -120,8 +120,46 @@ export function createApi(auth) {
|
|
|
120
120
|
ordersUnsupported();
|
|
121
121
|
return o;
|
|
122
122
|
}),
|
|
123
|
+
createOrder: async (body) => {
|
|
124
|
+
try {
|
|
125
|
+
return (await request('POST', `${org()}/orders`, body));
|
|
126
|
+
}
|
|
127
|
+
catch (e) {
|
|
128
|
+
// An API before orders from messages has no POST /orders.
|
|
129
|
+
if (e instanceof CliError && e.code === 'not_found')
|
|
130
|
+
fail('orders_unsupported', 'This API cannot create orders from messages yet.', 'Try again later; tell the human if it persists.');
|
|
131
|
+
throw e;
|
|
132
|
+
}
|
|
133
|
+
},
|
|
123
134
|
orderAction: (id, action, body) => orderCall(() => request('POST', `${orderPath(id)}/${action}`, body ?? {})),
|
|
124
135
|
listShipments,
|
|
136
|
+
// An API before the snapshot answers not_found: ops_unsupported, and the CLI reads the lists instead.
|
|
137
|
+
getOps: async (query) => {
|
|
138
|
+
try {
|
|
139
|
+
const r = (await request('GET', `${org()}/ops${qs(query).replace(/^&/, '?')}`));
|
|
140
|
+
if (!r || typeof r !== 'object' || !r.orders || !r.shipments)
|
|
141
|
+
fail('ops_unsupported', 'This API has no operations snapshot yet.', 'The CLI reads the order and shipment lists instead.');
|
|
142
|
+
return r;
|
|
143
|
+
}
|
|
144
|
+
catch (e) {
|
|
145
|
+
if (e instanceof CliError && e.code === 'not_found')
|
|
146
|
+
fail('ops_unsupported', 'This API has no operations snapshot yet.', 'The CLI reads the order and shipment lists instead.');
|
|
147
|
+
throw e;
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
insights: async (query) => {
|
|
151
|
+
try {
|
|
152
|
+
const r = (await request('GET', `${org()}/insights${qs(query).replace(/^&/, '?')}`));
|
|
153
|
+
if (!r || typeof r !== 'object' || !('totals' in r))
|
|
154
|
+
fail('insights_unsupported', 'This API does not report insights yet.', 'Try again later; tell the human if it persists.');
|
|
155
|
+
return r;
|
|
156
|
+
}
|
|
157
|
+
catch (e) {
|
|
158
|
+
if (e instanceof CliError && e.code === 'not_found')
|
|
159
|
+
fail('insights_unsupported', 'This API does not report insights yet.', 'Try again later; tell the human if it persists.');
|
|
160
|
+
throw e;
|
|
161
|
+
}
|
|
162
|
+
},
|
|
125
163
|
getShipment: (id) => shipmentCall(() => request('GET', shipmentPath(id))),
|
|
126
164
|
shipmentAction: (id, action, body) => shipmentCall(() => request('POST', `${shipmentPath(id)}/${action}`, body ?? {})),
|
|
127
165
|
listPromotions: () => unsupported('promotions', request('GET', collection('promotions'))),
|
package/dist/api/stub/http.js
CHANGED
|
@@ -5,8 +5,8 @@ export function json(status, body, headers = {}) {
|
|
|
5
5
|
return { status, headers: { 'content-type': 'application/json', ...headers }, body: JSON.stringify(body) };
|
|
6
6
|
}
|
|
7
7
|
/** The management API's error shape, the same the CLI prints. */
|
|
8
|
-
export function error(status, code, message, hint) {
|
|
9
|
-
return json(status, { error: { code, message, hint } });
|
|
8
|
+
export function error(status, code, message, hint, path) {
|
|
9
|
+
return json(status, { error: { code, message, hint, ...(path ? { path } : {}) } });
|
|
10
10
|
}
|
|
11
11
|
export function readJson(req) {
|
|
12
12
|
if (!req.body)
|
package/dist/api/stub/index.js
CHANGED
|
@@ -44,7 +44,7 @@ function outdated(req, res) {
|
|
|
44
44
|
const current = req.headers['user-agent']?.match(/^c11r\/(\d+\.\d+\.\d+)/)?.[1];
|
|
45
45
|
if (!latest || !current || !older(current, latest) || !res.body)
|
|
46
46
|
return res;
|
|
47
|
-
const body = JSON.parse(res.body);
|
|
47
|
+
const body = JSON.parse(res.body.toString());
|
|
48
48
|
if (!body || typeof body !== 'object' || Array.isArray(body))
|
|
49
49
|
return res;
|
|
50
50
|
const warning = { code: 'cli_outdated', current, latest, hint: `Run the commands with npx -y @commercelayer/agent-cli@latest. If c11r is installed globally, tell the human it is on ${current} and ${latest} exists.` };
|
|
@@ -6,7 +6,8 @@ import { generateKey } from '../../ucp/signing.js';
|
|
|
6
6
|
import { localesOf } from '../../locales.js';
|
|
7
7
|
import { publicOrders, readDb, SANDBOX_DOMAIN, writeDb } from './db.js';
|
|
8
8
|
import { bearer, error, json, readJson } from './http.js';
|
|
9
|
-
import {
|
|
9
|
+
import { createOrder } from './messages.js';
|
|
10
|
+
import { insights, listOrders, opsSnapshot, listShipments, OrderError, orderAction, shipmentAction, showOrder, showShipment } from './orders.js';
|
|
10
11
|
function publicState(r) {
|
|
11
12
|
const { token: _token, ucp: _ucp, pending: _pending, asset_files: _files, ...state } = r;
|
|
12
13
|
return { ...state, name: r.name ?? null, policies: r.policies ?? null, theme: r.theme ?? null, storefront_mode: r.storefront_mode ?? 'hosted', locales: r.locales ?? [], assets: r.assets ?? {}, apply: r.apply ?? null, orders: publicOrders(r) };
|
|
@@ -363,11 +364,27 @@ export function handleManagement(req) {
|
|
|
363
364
|
const merchantView = q.get('view') === 'merchant';
|
|
364
365
|
if (req.method === 'GET' && rest === '/orders' && merchantView)
|
|
365
366
|
return merchant(() => listOrders(record, q));
|
|
366
|
-
|
|
367
|
+
if (req.method === 'POST' && rest === '/orders') {
|
|
368
|
+
try {
|
|
369
|
+
const out = createOrder(record, readJson(req));
|
|
370
|
+
writeDb(db);
|
|
371
|
+
return json(out.status, out.body);
|
|
372
|
+
}
|
|
373
|
+
catch (e) {
|
|
374
|
+
if (e instanceof OrderError)
|
|
375
|
+
return error(e.status, e.code, e.message, e.hint, e.path);
|
|
376
|
+
throw e;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
const act = rest.match(/^\/orders\/([^/]+)\/(place|approve|capture|cancel|refund|archive|unarchive)$/);
|
|
367
380
|
if (req.method === 'POST' && act)
|
|
368
381
|
return merchant(() => orderAction(record, decodeURIComponent(act[1]), act[2], readJson(req) ?? {}), true);
|
|
369
382
|
if (req.method === 'GET' && rest === '/shipments')
|
|
370
383
|
return merchant(() => listShipments(record, q));
|
|
384
|
+
if (req.method === 'GET' && rest === '/insights')
|
|
385
|
+
return merchant(() => insights(record, q));
|
|
386
|
+
if (req.method === 'GET' && rest === '/ops')
|
|
387
|
+
return merchant(() => opsSnapshot(record, q));
|
|
371
388
|
const ship = rest.match(/^\/shipments\/(.+?)(?:\/(hold|pick|pack|ready|ship|deliver))?$/);
|
|
372
389
|
if (ship && req.method === 'POST' && ship[2])
|
|
373
390
|
return merchant(() => shipmentAction(record, decodeURIComponent(ship[1]), ship[2], readJson(req) ?? {}), true);
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// POST /stores/:org/orders, as the API does it (its messages.ts): an order from an email or a
|
|
2
|
+
// message, made pending (or placed with place: true) from a checked request. Lines by sku are
|
|
3
|
+
// looked up; lines by words go through the store's search (stub/search.ts rank), with the API's
|
|
4
|
+
// confidence rule: every word 0.9, else 0.6 × the share of words (the stub has no embeddings for
|
|
5
|
+
// the 0.4 × cosine part), 0.2 less when the second candidate scores within 10%; 0.75 to match.
|
|
6
|
+
// A line under the bar and no force: needs_review, nothing created. Catalog prices only.
|
|
7
|
+
import { checkOrderRequest } from '../../orderRequest.js';
|
|
8
|
+
import { CliError } from '../../output.js';
|
|
9
|
+
import { money } from '../../ucp/shop.js';
|
|
10
|
+
import { toMinor } from '../../ucp/platform.js';
|
|
11
|
+
import { OrderError, showOrder } from './orders.js';
|
|
12
|
+
import { queryWords, rank } from './search.js';
|
|
13
|
+
const BAR = 0.75;
|
|
14
|
+
const round = (n) => Math.round(n * 100) / 100;
|
|
15
|
+
function resolveLine(products, market, line) {
|
|
16
|
+
const priced = (p) => typeof p.prices?.[market.currency] === 'number';
|
|
17
|
+
const card = (p) => ({ sku: p.sku, name: p.name, price: priced(p) ? money(toMinor(p.prices[market.currency], market.currency), market.currency) : null });
|
|
18
|
+
const requested = line.sku ?? line.query;
|
|
19
|
+
if (line.sku) {
|
|
20
|
+
const p = products.find((x) => x.sku === line.sku);
|
|
21
|
+
if (p)
|
|
22
|
+
return priced(p) ? { requested, quantity: line.quantity, sku: p.sku, name: p.name, confidence: 1, candidates: [card(p)], status: 'matched', product: p } : { requested, quantity: line.quantity, sku: p.sku, name: p.name, confidence: 1, candidates: [], status: 'unavailable' };
|
|
23
|
+
// An unknown sku is searched like words: "ETH-250" may still name a product.
|
|
24
|
+
}
|
|
25
|
+
const ranked = rank(products, requested);
|
|
26
|
+
const sellable = ranked.filter((x) => priced(x.product));
|
|
27
|
+
if (!ranked.length)
|
|
28
|
+
return { requested, quantity: line.quantity, sku: null, name: null, confidence: 0, candidates: [], status: 'not_found' };
|
|
29
|
+
if (!sellable.length)
|
|
30
|
+
return { requested, quantity: line.quantity, sku: ranked[0].product.sku, name: ranked[0].product.name, confidence: 0, candidates: [], status: 'unavailable' };
|
|
31
|
+
const [top, second] = sellable;
|
|
32
|
+
const asked = Math.max(1, queryWords(requested).length);
|
|
33
|
+
let confidence = top.match.all ? 0.9 : 0.6 * (top.match.words.length / asked);
|
|
34
|
+
if (second && second.match.score >= top.match.score * 0.9)
|
|
35
|
+
confidence -= 0.2;
|
|
36
|
+
confidence = round(Math.max(0, confidence));
|
|
37
|
+
const candidates = sellable.slice(0, 3).map((x) => card(x.product));
|
|
38
|
+
if (confidence >= BAR)
|
|
39
|
+
return { requested, quantity: line.quantity, sku: top.product.sku, name: top.product.name, confidence, candidates, status: 'matched', product: top.product };
|
|
40
|
+
return { requested, quantity: line.quantity, sku: null, name: null, confidence, candidates, status: 'ambiguous' };
|
|
41
|
+
}
|
|
42
|
+
export function createOrder(r, body) {
|
|
43
|
+
let req;
|
|
44
|
+
try {
|
|
45
|
+
req = checkOrderRequest(body);
|
|
46
|
+
}
|
|
47
|
+
catch (e) {
|
|
48
|
+
if (e instanceof CliError)
|
|
49
|
+
throw new OrderError(422, e.code, e.message, e.hint, e.path);
|
|
50
|
+
throw e;
|
|
51
|
+
}
|
|
52
|
+
const markets = Object.values(r.applied?.markets ?? {});
|
|
53
|
+
const market = markets.find((m) => m.countries.includes(req.ship_to.country));
|
|
54
|
+
if (!market) {
|
|
55
|
+
const served = [...new Set(markets.flatMap((m) => m.countries))].sort();
|
|
56
|
+
throw new OrderError(422, 'country_not_served', `The store does not ship to ${req.ship_to.country}.`, served.length ? `It ships to ${served.join(', ')}: ask the human what to do with this order.` : 'The store has no markets yet: run c11r apply.', 'ship_to.country');
|
|
57
|
+
}
|
|
58
|
+
// The same source again: the order it made, unless that one was cancelled.
|
|
59
|
+
if (req.source) {
|
|
60
|
+
const existing = Object.values(r.ucp.orders).reverse().find((o) => o._source?.kind === req.source.kind && o._source?.id === req.source.id && o._merchant?.status !== 'cancelled');
|
|
61
|
+
if (existing) {
|
|
62
|
+
const order = showOrder(r, existing.id);
|
|
63
|
+
return { status: 200, body: { status: order.status, order, resolution: [], warnings: [{ code: 'duplicate_source', message: `${req.source.kind} ${req.source.id} already made order ${order.number}.` }] } };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const products = Object.values(r.applied?.products ?? {});
|
|
67
|
+
const resolved = req.items.map((it) => resolveLine(products, market, it));
|
|
68
|
+
const resolution = resolved.map(({ product: _p, ...x }) => x);
|
|
69
|
+
const open = resolved.filter((x) => x.status !== 'matched');
|
|
70
|
+
// Two lines of the same sku become one.
|
|
71
|
+
const kept = [];
|
|
72
|
+
for (const x of resolved.filter((y) => y.status === 'matched')) {
|
|
73
|
+
const same = kept.find((k) => k.sku === x.sku);
|
|
74
|
+
if (same)
|
|
75
|
+
same.quantity = same.quantity + x.quantity;
|
|
76
|
+
else
|
|
77
|
+
kept.push({ ...x });
|
|
78
|
+
}
|
|
79
|
+
if ((open.length && !req.force) || !kept.length)
|
|
80
|
+
return { status: 200, body: { status: 'needs_review', order: null, resolution, warnings: [] } };
|
|
81
|
+
const warnings = open.map((x) => ({ code: 'line_dropped', message: `"${x.requested}" (${x.quantity}) was left out: ${x.status.replace('_', ' ')}.` }));
|
|
82
|
+
const orders = r.ucp.orders;
|
|
83
|
+
const id = `ord_${Math.random().toString(16).slice(2, 14)}`;
|
|
84
|
+
const at = new Date().toISOString();
|
|
85
|
+
const cur = market.currency;
|
|
86
|
+
const lines = kept.map((x, i) => {
|
|
87
|
+
const price = toMinor(x.product.prices[cur], cur);
|
|
88
|
+
return { id: `li_${i + 1}`, item: { id: x.product.sku, title: x.product.name, price, ...(x.product.image_url ? { image_url: x.product.image_url } : {}) }, quantity: { original: x.quantity, total: x.quantity, fulfilled: 0 }, totals: [{ type: 'total', amount: price * x.quantity }], status: 'processing' };
|
|
89
|
+
});
|
|
90
|
+
const subtotal = lines.reduce((s, l) => s + l.totals[0].amount, 0);
|
|
91
|
+
const ship = toMinor(market.shipping?.price ?? 0, cur);
|
|
92
|
+
const c = req.customer;
|
|
93
|
+
const to = req.ship_to;
|
|
94
|
+
const destination = { first_name: c.first_name, last_name: c.last_name, company: c.company, street_address: to.line_1, extended_address: to.line_2, address_locality: to.city, postal_code: to.postal_code, address_region: to.region, address_country: to.country, phone: to.phone ?? c.phone };
|
|
95
|
+
const number = `${1001 + Object.keys(orders).length}`;
|
|
96
|
+
const order = {
|
|
97
|
+
id,
|
|
98
|
+
label: `#${number}`,
|
|
99
|
+
permalink_url: `${r.preview_url}/orders/${id}`,
|
|
100
|
+
currency: cur,
|
|
101
|
+
line_items: lines,
|
|
102
|
+
fulfillment: { expectations: [{ id: 'exp_1', line_items: lines.map((l) => ({ id: l.id, quantity: l.quantity.total })), method_type: 'shipping', destination, description: market.shipping?.method ?? 'Standard', fulfillable_on: 'now' }], events: [] },
|
|
103
|
+
adjustments: [],
|
|
104
|
+
totals: [{ type: 'subtotal', display_text: 'Subtotal', amount: subtotal }, { type: 'fulfillment', display_text: 'Shipping', amount: ship }, { type: 'total', display_text: 'Total', amount: subtotal + ship }],
|
|
105
|
+
_platform: 'message',
|
|
106
|
+
_buyer: { email: c.email, first_name: c.first_name, last_name: c.last_name, company: c.company, phone_number: c.phone ?? to.phone },
|
|
107
|
+
_market: market.code,
|
|
108
|
+
_language: req.language,
|
|
109
|
+
_codes: [],
|
|
110
|
+
_source: req.source ?? null,
|
|
111
|
+
_notes: req.notes,
|
|
112
|
+
_reference: req.reference,
|
|
113
|
+
...(req.bill_to ? { _bill_to: { first_name: req.bill_to.first_name, last_name: req.bill_to.last_name, company: req.bill_to.company, street_address: req.bill_to.line_1, extended_address: req.bill_to.line_2, address_locality: req.bill_to.city, postal_code: req.bill_to.postal_code, address_region: req.bill_to.region, address_country: req.bill_to.country, phone: req.bill_to.phone } } : {}),
|
|
114
|
+
_merchant: {
|
|
115
|
+
status: req.place ? 'placed' : 'pending',
|
|
116
|
+
created_at: at,
|
|
117
|
+
placed_at: req.place ? at : null,
|
|
118
|
+
archived_at: null,
|
|
119
|
+
transactions: req.place ? [{ type: 'authorization', amount: subtotal + ship, succeeded: true, at }] : [],
|
|
120
|
+
shipment: { id: `shp_${id.slice(4)}`, number: `${number}/S/001`, status: 'upcoming', tracking: null, dates: { created_at: at, on_hold_at: null, picking_at: null, packing_at: null, ready_to_ship_at: null, shipped_at: null, delivered_at: null } },
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
orders[id] = order;
|
|
124
|
+
const shown = showOrder(r, id);
|
|
125
|
+
return { status: 201, body: { status: shown.status, order: shown, resolution, warnings } };
|
|
126
|
+
}
|