@commercelayer/agent-cli 0.3.1 → 0.5.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 +28 -7
- package/dist/api/client.js +59 -0
- package/dist/api/stub/locale.js +52 -0
- package/dist/api/stub/management.js +38 -2
- package/dist/api/stub/orders.js +359 -0
- package/dist/api/stub/search.js +78 -0
- package/dist/api/stub/shopping.js +84 -18
- package/dist/api/stub/theme.js +119 -30
- package/dist/api/stub/ucp.js +10 -6
- package/dist/bin.js +15 -5
- package/dist/catalog.js +40 -4
- package/dist/commands.js +149 -20
- package/dist/languages.js +49 -0
- package/dist/locales.js +43 -0
- package/dist/products.js +15 -5
- package/dist/store.js +95 -9
- package/dist/ucp/shop.js +53 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# @commercelayer/agent-cli
|
|
2
|
-
`c11r`, the Commerce Layer CLI for coding agents. An agent reads [commercelayer.ai/start.md](https://commercelayer.ai/start.md), runs this CLI, and the human ends up with a working store in a sandbox: products, prices, a market, a storefront at `<org>.sandbox.commercelayer.store`, and a
|
|
2
|
+
`c11r`, the Commerce Layer CLI for coding agents. An agent reads [commercelayer.ai/start.md](https://commercelayer.ai/start.md), runs this CLI, and the human ends up with a working store in a sandbox: products, prices, a market, a storefront at `<org>.sandbox.commercelayer.store`, and a test order to approve, capture and ship. No account needed until they decide to keep it.
|
|
3
3
|
Every store speaks the [Universal Commerce Protocol](https://ucp.dev) on its own hostname, so shopping agents can discover it, browse it and buy from it. The CLI itself buys through UCP (`orders test`) and checks the store against the spec (`ucp check`), so what it verifies is what any agent would get.
|
|
4
4
|
It is a separate tool from [`@commercelayer/cli`](https://www.npmjs.com/package/@commercelayer/cli), the CLI for humans. The package is `@commercelayer/agent-cli`; the command is `c11r` (c, eleven letters, r), short and unmistakable in an agent's transcript.
|
|
5
5
|
```
|
|
@@ -28,10 +28,13 @@ npx -y @commercelayer/agent-cli@latest init --name "The Blue Brand" # runs c11
|
|
|
28
28
|
| `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. |
|
|
29
29
|
| `links enable <id>` · `disable <id>` · `delete <id>` | Turn a link on or off, or delete it (ask the human first). |
|
|
30
30
|
| `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. |
|
|
31
|
-
| `orders list` | The orders
|
|
32
|
-
| `orders show <
|
|
31
|
+
| `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 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. |
|
|
33
|
+
| `orders approve <n> [--capture]` · `capture` · `cancel` · `refund <n> [--amount <major>]` · `archive` · `unarchive` | Move an order; each prints the order after it. |
|
|
34
|
+
| `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. |
|
|
35
|
+
| `fulfillment hold` · `pick` · `pack` · `ready` · `ship <shipment> [--carrier] [--tracking] [--tracking-url]` · `deliver` | Move a shipment; each prints the shipment after it. |
|
|
33
36
|
| `ucp profile [--agent]` | The store's UCP business profile (`/.well-known/ucp`); with `--agent`, this CLI's own platform profile. |
|
|
34
|
-
| `ucp search [<
|
|
37
|
+
| `ucp search [<words>] [--category <name>] [--min-price <n>] [--max-price <n>] [--country <CC>]` · `ucp product <id>` | 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. |
|
|
35
38
|
| `ucp checkout create --item <variant id>[:<qty>] … [--code <discount>] [--country <CC>]` | Open a checkout; `messages` say what is still missing (email, shipping address). |
|
|
36
39
|
| `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. |
|
|
37
40
|
| `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`. |
|
|
@@ -119,6 +122,18 @@ COFFEE-COL-250,"Colombia Huila, 250 g",,Coffee > Single origin,12.00,13.50
|
|
|
119
122
|
```
|
|
120
123
|
Errors name the file (and line, for CSV) and the sku, so one product file can be fixed without reading the rest. Moving a product between `store.yml` and a file is not a change to the store: `plan` keys everything by sku. The catalog has no stock: inventory is tracked live in Commerce Layer, and the sandbox treats every product as in stock. Prices in these files are what the store charges; a published storefront reads prices and availability live from Commerce Layer.
|
|
121
124
|
Rules `plan` and `apply` enforce: a name, at least one market and one product; every market has `code`, `name`, `countries` and an ISO 4217 `currency`; codes and skus are unique across all files; a product has only `sku`, `name`, `description`, `image_url`, `category` and `prices`; a `category` is a path of names from the top level, separated by ` > `; an `image_url` is an `https://` URL or a path in the storefront folder such as `img/eth.jpg` (served from the store host, given to shopping agents as a full URL); every product has a price in the currency of every market; `shipping`, if present, has `method` and a non-negative `price`; policy entries are URLs. Currency, languages and tax follow from the countries.
|
|
125
|
+
### Languages
|
|
126
|
+
`language:` at the top of `store.yml` is the language the products are written in (ISO 639-1, default `en`). A product's `name`, `description` and `category` can also be a map with the base language and others, and a CSV takes `name_<ll>`, `description_<ll>` and `category_<ll>` columns next to the plain ones:
|
|
127
|
+
```yaml
|
|
128
|
+
language: en
|
|
129
|
+
products:
|
|
130
|
+
- sku: ETH
|
|
131
|
+
name: { en: Ethiopia, it: Etiopia }
|
|
132
|
+
category: { en: Coffee > Single origin, it: Caffè > Monorigine } # the same levels, each translated
|
|
133
|
+
prices: { EUR: 14.50 }
|
|
134
|
+
```
|
|
135
|
+
A store has at most 6 languages, the base one included. Every country gets its own pages, `/<cc>-<ll>/` (`/it-it/`), in that country's language: the product texts in it when the product has them, the base language otherwise; `/` lets the buyer choose the country when there are several. Shopping agents get the same texts over UCP, in the language they ask for. `plan` warns about a language no country of the store speaks. A store without translations plans and applies as before.
|
|
136
|
+
|
|
122
137
|
### A catalog the human edits
|
|
123
138
|
When the products live in a tool the human edits too, `store.yml` names it and `c11r catalog pull` copies it into `.commercelayer/products/catalog.csv`, sorted by sku: same source, same file. Pull applies the product rules above before it writes, with errors naming the row in the source, and reports the skus added, changed and removed. Removed products leave the store only through `apply --allow-delete`. Every source has the same columns: `sku`, `name`, `description`, `image_url`, `category`, and one price column per market currency, named by its ISO 4217 code in lowercase (`eur`, `usd`).
|
|
124
139
|
```yaml
|
|
@@ -127,9 +142,9 @@ catalog:
|
|
|
127
142
|
url: https://docs.google.com/spreadsheets/d/e/2PACX-.../pub?output=csv
|
|
128
143
|
# table: products # supabase only
|
|
129
144
|
```
|
|
130
|
-
- `sheets`: a Google Sheet, by its CSV link (File > Share > Publish to web > Comma-separated values) or by its own link when anyone with the link can view it. No key.
|
|
145
|
+
- `sheets`: a Google Sheet, by its CSV link (File > Share > Publish to web > Comma-separated values) or by its own link when anyone with the link can view it. No key. Translations are `name_<ll>`, `description_<ll>` and `category_<ll>` columns, in sheets and Supabase tables alike.
|
|
131
146
|
- `supabase`: a table (default `products`), read over the REST API with the project's anon key: `C11R_SUPABASE_URL=https://<project>.supabase.co` and `C11R_SUPABASE_KEY=<anon key>` in `.commercelayer/credentials`, or the environment. The key needs a row level security policy that allows select. Never use the `service_role` key. Columns `id`, `created_at` and `updated_at` are skipped.
|
|
132
|
-
- `merchant`: a Google Merchant Center product feed URL, RSS 2.0 (XML, `g:` namespace) or TSV. `id` is the sku, `title` the name, `description`, `image_link` the image, `price` (`14.50 EUR`) the price in its currency, `product_type` the category. An id listed once per currency is one product with several prices. Other attributes, `sale_price` included, are ignored.
|
|
147
|
+
- `merchant`: a Google Merchant Center product feed URL, RSS 2.0 (XML, `g:` namespace) or TSV. `id` is the sku, `title` the name, `description`, `image_link` the image, `price` (`14.50 EUR`) the price in its currency, `product_type` the category. An id listed once per currency is one product with several prices. Other attributes, `sale_price` included, are ignored. Other languages: `catalog.translations: { it: <the Italian feed URL> }`, one feed per language, merged by `id` (`title`, `description`, `product_type`).
|
|
133
148
|
Error codes: `no_catalog_source`, `invalid_catalog`, `missing_credentials`, `catalog_unreachable`, `catalog_unauthorized`, `catalog_not_found`, `catalog_not_csv`, `catalog_error`, `empty_catalog`, plus `invalid_product` and `missing_price`.
|
|
134
149
|
|
|
135
150
|
### The website
|
|
@@ -142,6 +157,12 @@ One rule for images: `img/<file>` anywhere in `store.yml` (product `image_url`,
|
|
|
142
157
|
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/`.
|
|
143
158
|
|
|
144
159
|
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`.
|
|
160
|
+
## Orders and fulfillment
|
|
161
|
+
|
|
162
|
+
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 the authorization; a shipped order can't be cancelled, a captured one is refunded instead), 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.
|
|
163
|
+
|
|
164
|
+
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`.
|
|
165
|
+
|
|
145
166
|
## The store speaks UCP
|
|
146
167
|
Each store is a UCP business at `https://<org>.sandbox.commercelayer.store` (live stores: `<slug>.commercelayer.store`), implementing release **2026-08-25** over REST:
|
|
147
168
|
| What | Where |
|
|
@@ -158,7 +179,7 @@ Transport rules the store enforces: `UCP-Agent` (an HTTPS platform profile URL)
|
|
|
158
179
|
The CLI is a UCP **platform**: its profile is served at `https://commercelayer.ai/ucp/agent-cli/profile.json` (`ucp profile --agent` prints it) and goes in `UCP-Agent` on every call. The store negotiates the capability intersection with it. `orders test` is the buyer; `ucp check` runs 30-odd checks across profile, transport rules, catalog, cart, discount, checkout, fulfillment, consent, payment, signatures, idempotency, order, identity linking, ask and permalink.
|
|
159
180
|
### Buying for the human
|
|
160
181
|
|
|
161
|
-
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, `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`.
|
|
182
|
+
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`.
|
|
162
183
|
|
|
163
184
|
## Requirements
|
|
164
185
|
|
package/dist/api/client.js
CHANGED
|
@@ -50,7 +50,66 @@ export function createApi(auth) {
|
|
|
50
50
|
throw e;
|
|
51
51
|
}
|
|
52
52
|
};
|
|
53
|
+
// Orders and shipments for the merchant: an API older than them answers the UCP array on GET
|
|
54
|
+
// /orders?view=merchant and not_found on the routes it lacks. A not_found on one order or
|
|
55
|
+
// shipment is checked against the list, so a missing order is still not_found.
|
|
56
|
+
const qs = (query) => {
|
|
57
|
+
const p = new URLSearchParams(Object.entries(query).filter(([, v]) => v !== undefined && v !== ''));
|
|
58
|
+
return p.toString() ? `&${p}` : '';
|
|
59
|
+
};
|
|
60
|
+
const ordersUnsupported = () => fail('orders_unsupported', 'This API does not manage orders for the merchant yet.', 'Try again later; tell the human if it persists. c11r orders test still places test orders.');
|
|
61
|
+
const shipmentsUnsupported = () => fail('fulfillment_unsupported', 'This API does not manage shipments yet.', 'Try again later; tell the human if it persists.');
|
|
62
|
+
const listMerchantOrders = async (query) => {
|
|
63
|
+
const r = (await request('GET', `${org()}/orders?view=merchant${qs(query)}`));
|
|
64
|
+
if (Array.isArray(r) || !Array.isArray(r.orders))
|
|
65
|
+
ordersUnsupported();
|
|
66
|
+
return r;
|
|
67
|
+
};
|
|
68
|
+
const listShipments = async (query) => {
|
|
69
|
+
try {
|
|
70
|
+
const p = new URLSearchParams(Object.entries(query).filter(([, v]) => v !== undefined && v !== ''));
|
|
71
|
+
return (await request('GET', `${org()}/shipments${p.toString() ? `?${p}` : ''}`));
|
|
72
|
+
}
|
|
73
|
+
catch (e) {
|
|
74
|
+
if (e instanceof CliError && e.code === 'not_found')
|
|
75
|
+
shipmentsUnsupported();
|
|
76
|
+
throw e;
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
const orderCall = async (call) => {
|
|
80
|
+
try {
|
|
81
|
+
return await call();
|
|
82
|
+
}
|
|
83
|
+
catch (e) {
|
|
84
|
+
if (e instanceof CliError && e.code === 'not_found')
|
|
85
|
+
await listMerchantOrders({ limit: '1' });
|
|
86
|
+
throw e;
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
const shipmentCall = async (call) => {
|
|
90
|
+
try {
|
|
91
|
+
return await call();
|
|
92
|
+
}
|
|
93
|
+
catch (e) {
|
|
94
|
+
if (e instanceof CliError && e.code === 'not_found')
|
|
95
|
+
await listShipments({ limit: '1' });
|
|
96
|
+
throw e;
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
const orderPath = (id) => `${org()}/orders/${encodeURIComponent(id)}`;
|
|
100
|
+
const shipmentPath = (id) => `${org()}/shipments/${encodeURIComponent(id)}`;
|
|
53
101
|
return {
|
|
102
|
+
listMerchantOrders,
|
|
103
|
+
getMerchantOrder: (id) => orderCall(async () => {
|
|
104
|
+
const o = (await request('GET', `${orderPath(id)}?view=merchant`));
|
|
105
|
+
if (o && o.payment_status === undefined)
|
|
106
|
+
ordersUnsupported();
|
|
107
|
+
return o;
|
|
108
|
+
}),
|
|
109
|
+
orderAction: (id, action, body) => orderCall(() => request('POST', `${orderPath(id)}/${action}`, body ?? {})),
|
|
110
|
+
listShipments,
|
|
111
|
+
getShipment: (id) => shipmentCall(() => request('GET', shipmentPath(id))),
|
|
112
|
+
shipmentAction: (id, action, body) => shipmentCall(() => request('POST', `${shipmentPath(id)}/${action}`, body ?? {})),
|
|
54
113
|
listPromotions: () => unsupported('promotions', request('GET', collection('promotions'))),
|
|
55
114
|
createPromotion: (spec) => unsupported('promotions', request('POST', collection('promotions'), spec)),
|
|
56
115
|
getPromotion: (id) => found('promotions', id, request('GET', one('promotions', id))),
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Locales on the store host, as the store Worker does them: one per country, from the
|
|
2
|
+
// `locales` the API derives on apply (the stub keeps them on the record). The texts come
|
|
3
|
+
// from applied.products[sku].translations[ll], falling back to the base language.
|
|
4
|
+
export const COOKIE = 'c11r_locale';
|
|
5
|
+
export const baseLanguage = (r) => r.applied?.language ?? 'en';
|
|
6
|
+
/** The languages the store has: the base one, then every language a product is translated into. */
|
|
7
|
+
export function languagesOf(r) {
|
|
8
|
+
const out = new Set([baseLanguage(r)]);
|
|
9
|
+
for (const p of Object.values(r.applied?.products ?? {}))
|
|
10
|
+
for (const ll of Object.keys(p.translations ?? {}))
|
|
11
|
+
out.add(ll);
|
|
12
|
+
return out;
|
|
13
|
+
}
|
|
14
|
+
/** A product's name, description and category in a language, falling back to the base one. */
|
|
15
|
+
export function texts(p, ll) {
|
|
16
|
+
const t = p.translations?.[ll] ?? {};
|
|
17
|
+
return { name: t.name ?? p.name, description: t.description ?? p.description, category: t.category ?? p.category };
|
|
18
|
+
}
|
|
19
|
+
/** The store's locales; none before an apply that derived them (the site stays as it was). */
|
|
20
|
+
export const localesOf = (r) => r.locales ?? [];
|
|
21
|
+
/** The locale at /<path>/, by its country: a known country with the wrong language still matches. */
|
|
22
|
+
export const localeOfCountry = (r, country) => localesOf(r).find((l) => l.country === country.toUpperCase());
|
|
23
|
+
/** The locale a cookie names, if it is one of the store's. */
|
|
24
|
+
export function cookieLocale(r, cookie) {
|
|
25
|
+
const m = cookie?.match(new RegExp(`(?:^|;\\s*)${COOKIE}=([a-z]{2}-[a-z]{2})`));
|
|
26
|
+
return m ? localesOf(r).find((l) => l.path === m[1]) : undefined;
|
|
27
|
+
}
|
|
28
|
+
/** The locale of a product URL for a market: the buyer's country when it is in the market, else the market's first. */
|
|
29
|
+
export function marketLocale(r, market, country) {
|
|
30
|
+
const ls = localesOf(r).filter((l) => l.market === market);
|
|
31
|
+
return ls.find((l) => country && l.country === country.toUpperCase()) ?? ls[0];
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The language of a UCP catalog answer: context.language (primary subtag), else the first
|
|
35
|
+
* Accept-Language the store has, else the language of the buyer's country's locale, else the base.
|
|
36
|
+
*/
|
|
37
|
+
export function ucpLanguage(r, context, acceptLanguage) {
|
|
38
|
+
const has = languagesOf(r);
|
|
39
|
+
const primary = (tag) => tag.trim().split(/[-_;]/)[0].toLowerCase();
|
|
40
|
+
const asked = typeof context?.language === 'string' ? primary(context.language) : '';
|
|
41
|
+
if (has.has(asked))
|
|
42
|
+
return asked;
|
|
43
|
+
for (const part of (acceptLanguage ?? '').split(',')) {
|
|
44
|
+
const ll = primary(part);
|
|
45
|
+
if (ll && has.has(ll))
|
|
46
|
+
return ll;
|
|
47
|
+
}
|
|
48
|
+
const country = typeof context?.address_country === 'string' ? localeOfCountry(r, context.address_country) : undefined;
|
|
49
|
+
if (country && has.has(country.language))
|
|
50
|
+
return country.language;
|
|
51
|
+
return baseLanguage(r);
|
|
52
|
+
}
|
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
import crypto from 'node:crypto';
|
|
4
4
|
import { canonical, IMAGE_PATH } from '../../store.js';
|
|
5
5
|
import { generateKey } from '../../ucp/signing.js';
|
|
6
|
+
import { localesOf } from '../../locales.js';
|
|
6
7
|
import { publicOrders, readDb, SANDBOX_DOMAIN, writeDb } from './db.js';
|
|
7
8
|
import { bearer, error, json, readJson } from './http.js';
|
|
9
|
+
import { listOrders, listShipments, OrderError, orderAction, shipmentAction, showOrder, showShipment } from './orders.js';
|
|
8
10
|
function publicState(r) {
|
|
9
11
|
const { token: _token, ucp: _ucp, pending: _pending, asset_files: _files, ...state } = r;
|
|
10
|
-
return { ...state, name: r.name ?? null, policies: r.policies ?? null, theme: r.theme ?? null, storefront_mode: r.storefront_mode ?? 'hosted', assets: r.assets ?? {}, apply: r.apply ?? null, orders: publicOrders(r) };
|
|
12
|
+
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) };
|
|
11
13
|
}
|
|
12
14
|
/** Like Commerce Layer refusing a sku in an import. Only the stub does this, so tests can exercise apply_failed. */
|
|
13
15
|
const REFUSED = /^REFUSED-/;
|
|
@@ -175,6 +177,8 @@ export function handleManagement(req) {
|
|
|
175
177
|
return error(409, 'deletes_need_approval', `Apply would delete product ${removed.join(', ')}.`, 'Ask the human whether to delete these. If they agree, run `c11r apply --allow-delete`.');
|
|
176
178
|
// The name, policies and markets apply in the request; products go through an
|
|
177
179
|
// import job (SKUs, prices, then cleanups for removals) when any changed.
|
|
180
|
+
// Locales come from the markets, which apply in the request, as the API derives them.
|
|
181
|
+
record.locales = localesOf(applied);
|
|
178
182
|
record.name = name;
|
|
179
183
|
record.policies = policies ?? {};
|
|
180
184
|
if (theme !== undefined)
|
|
@@ -190,7 +194,9 @@ export function handleManagement(req) {
|
|
|
190
194
|
}
|
|
191
195
|
}
|
|
192
196
|
}
|
|
193
|
-
|
|
197
|
+
// Translations live in applied only: changing them makes no Commerce Layer call, like the API.
|
|
198
|
+
const commerce = (p) => canonical(p && { ...p, translations: undefined });
|
|
199
|
+
const changed = Object.keys(applied.products).filter((sku) => full || !(sku in before) || commerce(before[sku]) !== commerce(applied.products[sku]));
|
|
194
200
|
if (changed.length === 0 && removed.length === 0) {
|
|
195
201
|
record.applied = applied;
|
|
196
202
|
record.applied_hash = hash;
|
|
@@ -339,9 +345,39 @@ export function handleManagement(req) {
|
|
|
339
345
|
writeDb(db);
|
|
340
346
|
return json(200, record.storefront);
|
|
341
347
|
}
|
|
348
|
+
// The merchant's orders and shipments (view=merchant); a bare GET /orders stays the UCP list older CLIs read.
|
|
349
|
+
const merchant = (run, save = false) => {
|
|
350
|
+
try {
|
|
351
|
+
const out = run();
|
|
352
|
+
if (save)
|
|
353
|
+
writeDb(db);
|
|
354
|
+
return json(200, out);
|
|
355
|
+
}
|
|
356
|
+
catch (e) {
|
|
357
|
+
if (e instanceof OrderError)
|
|
358
|
+
return error(e.status, e.code, e.message, e.hint);
|
|
359
|
+
throw e;
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
const q = req.url.searchParams;
|
|
363
|
+
const merchantView = q.get('view') === 'merchant';
|
|
364
|
+
if (req.method === 'GET' && rest === '/orders' && merchantView)
|
|
365
|
+
return merchant(() => listOrders(record, q));
|
|
366
|
+
const act = rest.match(/^\/orders\/([^/]+)\/(approve|capture|cancel|refund|archive|unarchive)$/);
|
|
367
|
+
if (req.method === 'POST' && act)
|
|
368
|
+
return merchant(() => orderAction(record, decodeURIComponent(act[1]), act[2], readJson(req) ?? {}), true);
|
|
369
|
+
if (req.method === 'GET' && rest === '/shipments')
|
|
370
|
+
return merchant(() => listShipments(record, q));
|
|
371
|
+
const ship = rest.match(/^\/shipments\/(.+?)(?:\/(hold|pick|pack|ready|ship|deliver))?$/);
|
|
372
|
+
if (ship && req.method === 'POST' && ship[2])
|
|
373
|
+
return merchant(() => shipmentAction(record, decodeURIComponent(ship[1]), ship[2], readJson(req) ?? {}), true);
|
|
374
|
+
if (ship && req.method === 'GET' && !ship[2])
|
|
375
|
+
return merchant(() => showShipment(record, decodeURIComponent(ship[1])));
|
|
342
376
|
if (req.method === 'GET' && rest === '/orders')
|
|
343
377
|
return json(200, publicOrders(record));
|
|
344
378
|
const one = rest.match(/^\/orders\/([^/]+)$/);
|
|
379
|
+
if (req.method === 'GET' && one && merchantView)
|
|
380
|
+
return merchant(() => showOrder(record, decodeURIComponent(one[1])));
|
|
345
381
|
if (req.method === 'GET' && one) {
|
|
346
382
|
const wanted = decodeURIComponent(one[1]);
|
|
347
383
|
const found = publicOrders(record).find((o) => o.id === wanted || o.label === wanted || o.label === `#${wanted}`);
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
// Orders and shipments as the merchant manages them, the API's view=merchant and
|
|
2
|
+
// /shipments routes: the stub keeps the Commerce Layer state machine on each UCP
|
|
3
|
+
// order, in _merchant, so the tests cover the same transitions as the platform.
|
|
4
|
+
//
|
|
5
|
+
// An order lands placed with its payment authorized. approve (optionally with
|
|
6
|
+
// capture), capture, cancel, refund, archive and unarchive move it; its one shipment
|
|
7
|
+
// goes upcoming → on_hold | picking | packing | ready_to_ship → shipped → delivered.
|
|
8
|
+
import { money } from '../../ucp/shop.js';
|
|
9
|
+
import { minorUnits } from '../../ucp/platform.js';
|
|
10
|
+
import { baseLanguage } from './locale.js';
|
|
11
|
+
export const ORDER_STATUSES = ['placed', 'approved', 'cancelled', 'editing'];
|
|
12
|
+
export const PAYMENT_STATUSES = ['unpaid', 'authorized', 'partially_authorized', 'paid', 'partially_paid', 'voided', 'partially_voided', 'refunded', 'partially_refunded', 'free'];
|
|
13
|
+
export const FULFILLMENT_STATUSES = ['unfulfilled', 'in_progress', 'fulfilled', 'not_required'];
|
|
14
|
+
export const SHIPMENT_STATUSES = ['upcoming', 'on_hold', 'picking', 'packing', 'ready_to_ship', 'shipped', 'delivered', 'cancelled'];
|
|
15
|
+
const OPEN_SHIPMENTS = ['upcoming', 'on_hold', 'picking', 'packing', 'ready_to_ship'];
|
|
16
|
+
const WORKING = ['on_hold', 'picking', 'packing', 'ready_to_ship'];
|
|
17
|
+
export class OrderError extends Error {
|
|
18
|
+
status;
|
|
19
|
+
code;
|
|
20
|
+
hint;
|
|
21
|
+
constructor(status, code, message, hint) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.status = status;
|
|
24
|
+
this.code = code;
|
|
25
|
+
this.hint = hint;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const now = () => new Date().toISOString();
|
|
29
|
+
const number = (o) => o.label.replace(/^#/, '');
|
|
30
|
+
const total = (o) => o.totals.find((t) => t.type === 'total')?.amount ?? 0;
|
|
31
|
+
const sum = (o, type) => merchant(o).transactions.filter((t) => t.type === type && t.succeeded).reduce((s, t) => s + t.amount, 0);
|
|
32
|
+
/** The merchant state of an order, created on first use as Commerce Layer has it after placement. */
|
|
33
|
+
function merchant(o) {
|
|
34
|
+
if (!o._merchant) {
|
|
35
|
+
const at = o._placed_at ?? now();
|
|
36
|
+
o._merchant = {
|
|
37
|
+
status: 'placed',
|
|
38
|
+
placed_at: at,
|
|
39
|
+
archived_at: null,
|
|
40
|
+
transactions: [{ type: 'authorization', amount: total(o), succeeded: true, at }],
|
|
41
|
+
shipment: { id: `shp_${o.id.replace(/^ord_/, '')}`, number: `${number(o)}/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 } },
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
return o._merchant;
|
|
45
|
+
}
|
|
46
|
+
function paymentStatus(o) {
|
|
47
|
+
if (total(o) === 0)
|
|
48
|
+
return 'free';
|
|
49
|
+
const captured = sum(o, 'capture');
|
|
50
|
+
const refunded = sum(o, 'refund');
|
|
51
|
+
if (sum(o, 'void') > 0)
|
|
52
|
+
return 'voided';
|
|
53
|
+
if (refunded > 0)
|
|
54
|
+
return refunded >= captured ? 'refunded' : 'partially_refunded';
|
|
55
|
+
if (captured > 0)
|
|
56
|
+
return captured >= total(o) ? 'paid' : 'partially_paid';
|
|
57
|
+
return 'authorized';
|
|
58
|
+
}
|
|
59
|
+
function fulfillmentStatus(o) {
|
|
60
|
+
const s = merchant(o).shipment.status;
|
|
61
|
+
if (s === 'shipped' || s === 'delivered')
|
|
62
|
+
return 'fulfilled';
|
|
63
|
+
if (WORKING.includes(s))
|
|
64
|
+
return 'in_progress';
|
|
65
|
+
return 'unfulfilled';
|
|
66
|
+
}
|
|
67
|
+
const destination = (o) => o.fulfillment?.expectations?.[0]?.destination ?? {};
|
|
68
|
+
const units = (o) => o.line_items.reduce((s, l) => s + (l.quantity?.total ?? l.quantity ?? 0), 0);
|
|
69
|
+
function shipmentRef(o) {
|
|
70
|
+
const s = merchant(o).shipment;
|
|
71
|
+
return { id: s.id, number: s.number, status: s.status, tracking: s.tracking };
|
|
72
|
+
}
|
|
73
|
+
function row(o) {
|
|
74
|
+
const m = merchant(o);
|
|
75
|
+
return {
|
|
76
|
+
id: o.id,
|
|
77
|
+
number: number(o),
|
|
78
|
+
status: m.status,
|
|
79
|
+
payment_status: paymentStatus(o),
|
|
80
|
+
fulfillment_status: fulfillmentStatus(o),
|
|
81
|
+
placed_at: m.placed_at,
|
|
82
|
+
market: o._market ?? null,
|
|
83
|
+
country: destination(o).address_country ?? null,
|
|
84
|
+
currency: o.currency,
|
|
85
|
+
total: money(total(o), o.currency),
|
|
86
|
+
email: o._buyer?.email ?? null,
|
|
87
|
+
items: units(o),
|
|
88
|
+
shipments: [shipmentRef(o)],
|
|
89
|
+
archived: m.archived_at !== null,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
function address(o) {
|
|
93
|
+
const d = destination(o);
|
|
94
|
+
if (!Object.keys(d).length)
|
|
95
|
+
return null;
|
|
96
|
+
return {
|
|
97
|
+
name: [d.first_name, d.last_name].filter(Boolean).join(' ') || null,
|
|
98
|
+
company: null,
|
|
99
|
+
line_1: d.street_address ?? null,
|
|
100
|
+
line_2: d.extended_address ?? null,
|
|
101
|
+
city: d.address_locality ?? null,
|
|
102
|
+
zip_code: d.postal_code ?? null,
|
|
103
|
+
state_code: d.address_region ?? null,
|
|
104
|
+
country_code: d.address_country ?? null,
|
|
105
|
+
phone: o._buyer?.phone_number ?? null,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
function detail(o, r) {
|
|
109
|
+
const m = merchant(o);
|
|
110
|
+
const cur = o.currency;
|
|
111
|
+
const amount = (type) => o.totals.filter((t) => t.type === type).reduce((s, t) => s + t.amount, 0);
|
|
112
|
+
const captured = sum(o, 'capture') - sum(o, 'refund');
|
|
113
|
+
return {
|
|
114
|
+
...row(o),
|
|
115
|
+
line_items: o.line_items.map((l) => ({
|
|
116
|
+
item_id: l.item?.id,
|
|
117
|
+
sku: l.item?.id,
|
|
118
|
+
name: l.item?.title,
|
|
119
|
+
quantity: l.quantity?.total ?? l.quantity,
|
|
120
|
+
unit_price: money(l.item?.price ?? 0, cur),
|
|
121
|
+
total: money(l.totals?.find((t) => t.type === 'total')?.amount ?? 0, cur),
|
|
122
|
+
})),
|
|
123
|
+
totals: { subtotal: money(amount('subtotal'), cur), discount: money(amount('discount'), cur), shipping: money(amount('fulfillment'), cur), taxes: money(amount('tax'), cur), total: money(total(o), cur), codes: o._codes ?? [] },
|
|
124
|
+
shipping_address: address(o),
|
|
125
|
+
billing_address: address(o),
|
|
126
|
+
shipping_method: o.fulfillment?.expectations?.[0]?.description ?? null,
|
|
127
|
+
payment: { method: 'Sandbox', source: 'sandbox wire transfer', status: paymentStatus(o), captured: money(Math.max(0, sum(o, 'capture')), cur), refundable: money(Math.max(0, captured), cur) },
|
|
128
|
+
transactions: m.transactions.map((t) => ({ ...t, amount: money(t.amount, cur) })),
|
|
129
|
+
language: o._language ?? baseLanguage(r),
|
|
130
|
+
archived_at: m.archived_at,
|
|
131
|
+
ucp: { id: o.id, permalink_url: o.permalink_url },
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
const orders = (r) => Object.values(r.ucp.orders).slice().reverse();
|
|
135
|
+
/** An order by id, number, #number, or the checkout or cart it came from. */
|
|
136
|
+
function findOrder(r, wanted) {
|
|
137
|
+
const o = orders(r).find((x) => x.id === wanted || x.label === wanted || x.label === `#${wanted}` || x.checkout_id === wanted || x._cart_id === wanted);
|
|
138
|
+
if (!o)
|
|
139
|
+
throw new OrderError(404, 'not_found', `There is no order ${wanted}.`, 'Run `c11r orders list` to see the orders.');
|
|
140
|
+
return o;
|
|
141
|
+
}
|
|
142
|
+
/** The commands an order allows now, as the API names them in its hints. */
|
|
143
|
+
function orderCommands(o) {
|
|
144
|
+
const m = merchant(o);
|
|
145
|
+
const n = number(o);
|
|
146
|
+
const pay = paymentStatus(o);
|
|
147
|
+
const out = [];
|
|
148
|
+
if (m.status === 'placed')
|
|
149
|
+
out.push(`c11r orders approve ${n} [--capture]`);
|
|
150
|
+
if (m.status === 'approved' && pay === 'authorized')
|
|
151
|
+
out.push(`c11r orders capture ${n}`);
|
|
152
|
+
if (m.status !== 'cancelled' && !['shipped', 'delivered'].includes(m.shipment.status) && ['authorized', 'free'].includes(pay))
|
|
153
|
+
out.push(`c11r orders cancel ${n}`);
|
|
154
|
+
if (['paid', 'partially_refunded'].includes(pay))
|
|
155
|
+
out.push(`c11r orders refund ${n} [--amount <major>]`);
|
|
156
|
+
out.push(m.archived_at ? `c11r orders unarchive ${n}` : `c11r orders archive ${n}`);
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
const refuse = (what, allowed) => {
|
|
160
|
+
throw new OrderError(422, 'invalid_transition', what, `Allowed now: ${allowed.join('; ')}.`);
|
|
161
|
+
};
|
|
162
|
+
export function listOrders(r, q) {
|
|
163
|
+
const list = (name, allowed, fallback) => {
|
|
164
|
+
const raw = q.get(name);
|
|
165
|
+
if (!raw)
|
|
166
|
+
return fallback;
|
|
167
|
+
const vs = raw.split(',').map((v) => v.trim());
|
|
168
|
+
if (vs.includes('all'))
|
|
169
|
+
return allowed;
|
|
170
|
+
for (const v of vs)
|
|
171
|
+
if (!allowed.includes(v))
|
|
172
|
+
throw new OrderError(422, 'invalid_argument', `${name}=${v} is not one of ${allowed.join(', ')}, all.`, `Use --${name} with one of ${allowed.join(', ')}, or all.`);
|
|
173
|
+
return vs;
|
|
174
|
+
};
|
|
175
|
+
const statuses = list('status', ORDER_STATUSES, ['placed', 'approved']);
|
|
176
|
+
const payments = list('payment', PAYMENT_STATUSES, PAYMENT_STATUSES);
|
|
177
|
+
const fulfillments = list('fulfillment', FULFILLMENT_STATUSES, FULFILLMENT_STATUSES);
|
|
178
|
+
const archived = q.get('archived') ?? 'false';
|
|
179
|
+
if (!['true', 'false', 'all'].includes(archived))
|
|
180
|
+
throw new OrderError(422, 'invalid_argument', `archived=${archived} is not true, false or all.`, 'Use --archived for only archived orders, --archived all for every order.');
|
|
181
|
+
const limit = q.get('limit') === null ? 25 : Number(q.get('limit'));
|
|
182
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 100)
|
|
183
|
+
throw new OrderError(422, 'invalid_argument', `limit=${q.get('limit')} is not between 1 and 100.`, 'Use --limit with a number from 1 to 100.');
|
|
184
|
+
const rows = orders(r)
|
|
185
|
+
.map(row)
|
|
186
|
+
// Archived orders are listed only when asked for, whatever their status.
|
|
187
|
+
.filter((x) => (archived === 'all' ? true : archived === 'true' ? x.archived : !x.archived))
|
|
188
|
+
.filter((x) => (archived === 'true' && !q.get('status') ? true : statuses.includes(x.status)))
|
|
189
|
+
.filter((x) => payments.includes(x.payment_status) && fulfillments.includes(x.fulfillment_status));
|
|
190
|
+
return { orders: rows.slice(0, limit) };
|
|
191
|
+
}
|
|
192
|
+
export const showOrder = (r, id) => detail(findOrder(r, id), r);
|
|
193
|
+
export function orderAction(r, id, action, body) {
|
|
194
|
+
const o = findOrder(r, id);
|
|
195
|
+
const m = merchant(o);
|
|
196
|
+
const at = now();
|
|
197
|
+
const capture = () => {
|
|
198
|
+
m.transactions.push({ type: 'capture', amount: total(o) - sum(o, 'capture'), succeeded: true, at });
|
|
199
|
+
};
|
|
200
|
+
switch (action) {
|
|
201
|
+
case 'approve':
|
|
202
|
+
if (m.status !== 'placed')
|
|
203
|
+
refuse(`Order ${number(o)} is ${m.status}: only a placed order can be approved.`, orderCommands(o));
|
|
204
|
+
m.status = 'approved';
|
|
205
|
+
if (body?.capture === true && paymentStatus(o) === 'authorized')
|
|
206
|
+
capture();
|
|
207
|
+
break;
|
|
208
|
+
case 'capture':
|
|
209
|
+
if (m.status !== 'approved' || paymentStatus(o) !== 'authorized')
|
|
210
|
+
refuse(`Order ${number(o)} is ${m.status} with payment ${paymentStatus(o)}: only an approved order with an authorized payment can be captured.`, orderCommands(o));
|
|
211
|
+
capture();
|
|
212
|
+
break;
|
|
213
|
+
case 'cancel':
|
|
214
|
+
if (m.status === 'cancelled')
|
|
215
|
+
refuse(`Order ${number(o)} is already cancelled.`, orderCommands(o));
|
|
216
|
+
if (['shipped', 'delivered'].includes(m.shipment.status))
|
|
217
|
+
refuse(`Order ${number(o)} has shipped: it cannot be cancelled.`, orderCommands(o));
|
|
218
|
+
if (!['authorized', 'free'].includes(paymentStatus(o)))
|
|
219
|
+
refuse(`Order ${number(o)} has payment ${paymentStatus(o)}: refund it to cancel it.`, orderCommands(o));
|
|
220
|
+
if (paymentStatus(o) === 'authorized')
|
|
221
|
+
m.transactions.push({ type: 'void', amount: total(o), succeeded: true, at });
|
|
222
|
+
m.status = 'cancelled';
|
|
223
|
+
m.shipment.status = 'cancelled';
|
|
224
|
+
break;
|
|
225
|
+
case 'refund': {
|
|
226
|
+
if (!['paid', 'partially_refunded'].includes(paymentStatus(o)))
|
|
227
|
+
refuse(`Order ${number(o)} has payment ${paymentStatus(o)}: only a captured payment can be refunded.`, orderCommands(o));
|
|
228
|
+
const refundable = sum(o, 'capture') - sum(o, 'refund');
|
|
229
|
+
let amount = refundable;
|
|
230
|
+
if (body?.amount !== undefined) {
|
|
231
|
+
if (typeof body.amount !== 'number' || !Number.isFinite(body.amount) || body.amount <= 0)
|
|
232
|
+
throw new OrderError(422, 'invalid_argument', `amount ${body.amount} is not a positive number.`, 'Give --amount in major units, for example --amount 4.50, or leave it out to refund everything still refundable.');
|
|
233
|
+
amount = Math.round(body.amount * minorUnits(o.currency));
|
|
234
|
+
if (amount > refundable)
|
|
235
|
+
throw new OrderError(422, 'refund_exceeds', `${money(amount, o.currency).display} is more than the ${money(refundable, o.currency).display} still refundable on order ${number(o)}.`, `Refund at most ${money(refundable, o.currency).major}, or leave --amount out to refund all of it.`);
|
|
236
|
+
}
|
|
237
|
+
m.transactions.push({ type: 'refund', amount, succeeded: true, at });
|
|
238
|
+
// As in Commerce Layer: refunding the last of the payment cancels the order.
|
|
239
|
+
if (sum(o, 'refund') >= sum(o, 'capture')) {
|
|
240
|
+
m.status = 'cancelled';
|
|
241
|
+
if (OPEN_SHIPMENTS.includes(m.shipment.status))
|
|
242
|
+
m.shipment.status = 'cancelled';
|
|
243
|
+
}
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
case 'archive':
|
|
247
|
+
m.archived_at ??= at;
|
|
248
|
+
break;
|
|
249
|
+
case 'unarchive':
|
|
250
|
+
m.archived_at = null;
|
|
251
|
+
break;
|
|
252
|
+
default:
|
|
253
|
+
throw new OrderError(404, 'not_found', `No order action ${action}.`, 'Use approve, capture, cancel, refund, archive or unarchive.');
|
|
254
|
+
}
|
|
255
|
+
return detail(o, r);
|
|
256
|
+
}
|
|
257
|
+
// ---------- shipments
|
|
258
|
+
function shipmentRow(o) {
|
|
259
|
+
const s = merchant(o).shipment;
|
|
260
|
+
return {
|
|
261
|
+
id: s.id,
|
|
262
|
+
number: s.number,
|
|
263
|
+
status: s.status,
|
|
264
|
+
order: { id: o.id, number: number(o), status: merchant(o).status },
|
|
265
|
+
country: destination(o).address_country ?? null,
|
|
266
|
+
shipping_method: o.fulfillment?.expectations?.[0]?.description ?? null,
|
|
267
|
+
items: units(o),
|
|
268
|
+
tracking: s.tracking,
|
|
269
|
+
dates: { ...s.dates },
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
/** A shipment by id or number, or the one shipment of an order by its id or number. */
|
|
273
|
+
function findShipment(r, wanted) {
|
|
274
|
+
const o = orders(r).find((x) => merchant(x).shipment.id === wanted || merchant(x).shipment.number === wanted) ?? orders(r).find((x) => x.id === wanted || x.label === wanted || x.label === `#${wanted}`);
|
|
275
|
+
if (!o)
|
|
276
|
+
throw new OrderError(404, 'not_found', `There is no shipment ${wanted}.`, 'Run `c11r fulfillment list` to see the shipments.');
|
|
277
|
+
return o;
|
|
278
|
+
}
|
|
279
|
+
function shipmentCommands(o) {
|
|
280
|
+
const s = merchant(o).shipment;
|
|
281
|
+
const approved = merchant(o).status === 'approved';
|
|
282
|
+
const n = s.number;
|
|
283
|
+
const out = [];
|
|
284
|
+
const can = (cmd, ok) => ok && out.push(`c11r fulfillment ${cmd} ${n}`);
|
|
285
|
+
can('hold', ['upcoming', 'picking', 'packing', 'ready_to_ship'].includes(s.status) && merchant(o).status !== 'cancelled');
|
|
286
|
+
can('pick', approved && ['upcoming', 'on_hold', 'packing', 'ready_to_ship'].includes(s.status));
|
|
287
|
+
can('pack', approved && ['on_hold', 'picking', 'ready_to_ship'].includes(s.status));
|
|
288
|
+
can('ready', approved && ['on_hold', 'picking', 'packing'].includes(s.status));
|
|
289
|
+
can('ship', approved && OPEN_SHIPMENTS.includes(s.status));
|
|
290
|
+
can('deliver', s.status === 'shipped');
|
|
291
|
+
if (!out.length && merchant(o).status === 'placed')
|
|
292
|
+
out.push(`c11r orders approve ${number(o)} [--capture]`);
|
|
293
|
+
return out;
|
|
294
|
+
}
|
|
295
|
+
export function listShipments(r, q) {
|
|
296
|
+
const raw = q.get('status');
|
|
297
|
+
let statuses = [...OPEN_SHIPMENTS];
|
|
298
|
+
if (raw) {
|
|
299
|
+
const vs = raw.split(',').map((v) => v.trim());
|
|
300
|
+
if (vs.includes('all'))
|
|
301
|
+
statuses = SHIPMENT_STATUSES;
|
|
302
|
+
else {
|
|
303
|
+
for (const v of vs)
|
|
304
|
+
if (!SHIPMENT_STATUSES.includes(v))
|
|
305
|
+
throw new OrderError(422, 'invalid_argument', `status=${v} is not one of ${SHIPMENT_STATUSES.join(', ')}, all.`, `Use --status with one of ${SHIPMENT_STATUSES.join(', ')}, or all.`);
|
|
306
|
+
statuses = vs;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
const limit = q.get('limit') === null ? 25 : Number(q.get('limit'));
|
|
310
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 100)
|
|
311
|
+
throw new OrderError(422, 'invalid_argument', `limit=${q.get('limit')} is not between 1 and 100.`, 'Use --limit with a number from 1 to 100.');
|
|
312
|
+
return { shipments: orders(r).filter((o) => statuses.includes(merchant(o).shipment.status)).map(shipmentRow).slice(0, limit) };
|
|
313
|
+
}
|
|
314
|
+
export function showShipment(r, id) {
|
|
315
|
+
const o = findShipment(r, id);
|
|
316
|
+
return { ...shipmentRow(o), line_items: o.line_items.map((l) => ({ sku: l.item?.id, name: l.item?.title, quantity: l.quantity?.total ?? l.quantity })), address: address(o), parcels: [] };
|
|
317
|
+
}
|
|
318
|
+
const URL_RE = /^https:\/\/\S+$/;
|
|
319
|
+
export function shipmentAction(r, id, action, body) {
|
|
320
|
+
const o = findShipment(r, id);
|
|
321
|
+
const m = merchant(o);
|
|
322
|
+
const s = m.shipment;
|
|
323
|
+
const at = now();
|
|
324
|
+
const move = (to, from, needsApproved) => {
|
|
325
|
+
if (!from.includes(s.status) || (needsApproved && m.status !== 'approved') || m.status === 'cancelled') {
|
|
326
|
+
const why = needsApproved && m.status !== 'approved' ? `its order ${number(o)} is ${m.status}` : `it is ${s.status}`;
|
|
327
|
+
refuse(`Shipment ${s.number} cannot go ${to.replace(/_/g, ' ')}: ${why}.`, shipmentCommands(o));
|
|
328
|
+
}
|
|
329
|
+
s.status = to;
|
|
330
|
+
s.dates[`${to}_at`] = at;
|
|
331
|
+
};
|
|
332
|
+
switch (action) {
|
|
333
|
+
case 'hold':
|
|
334
|
+
return move('on_hold', ['upcoming', 'picking', 'packing', 'ready_to_ship'], false), showShipment(r, s.id);
|
|
335
|
+
case 'pick':
|
|
336
|
+
return move('picking', ['upcoming', 'on_hold', 'packing', 'ready_to_ship'], true), showShipment(r, s.id);
|
|
337
|
+
case 'pack':
|
|
338
|
+
return move('packing', ['on_hold', 'picking', 'ready_to_ship'], true), showShipment(r, s.id);
|
|
339
|
+
case 'ready':
|
|
340
|
+
return move('ready_to_ship', ['on_hold', 'picking', 'packing'], true), showShipment(r, s.id);
|
|
341
|
+
case 'ship': {
|
|
342
|
+
for (const k of ['carrier', 'tracking', 'tracking_url']) {
|
|
343
|
+
const v = body?.[k];
|
|
344
|
+
if (v !== undefined && (typeof v !== 'string' || !v.trim() || v.length > 255))
|
|
345
|
+
throw new OrderError(422, 'invalid_argument', `${k} must be text of 1 to 255 characters.`, `Give --${k.replace('_', '-')} a value, or leave it out.`);
|
|
346
|
+
}
|
|
347
|
+
if (body?.tracking_url !== undefined && !URL_RE.test(body.tracking_url))
|
|
348
|
+
throw new OrderError(422, 'invalid_argument', `tracking_url ${body.tracking_url} is not an https URL.`, 'Give --tracking-url the carrier\'s tracking page, starting with https://.');
|
|
349
|
+
move('shipped', OPEN_SHIPMENTS, true);
|
|
350
|
+
if (body?.carrier || body?.tracking || body?.tracking_url)
|
|
351
|
+
s.tracking = { carrier: body.carrier, number: body.tracking, url: body.tracking_url };
|
|
352
|
+
return showShipment(r, s.id);
|
|
353
|
+
}
|
|
354
|
+
case 'deliver':
|
|
355
|
+
return move('delivered', ['shipped'], false), showShipment(r, s.id);
|
|
356
|
+
default:
|
|
357
|
+
throw new OrderError(404, 'not_found', `No shipment action ${action}.`, 'Use hold, pick, pack, ready, ship or deliver.');
|
|
358
|
+
}
|
|
359
|
+
}
|