@commercelayer/agent-cli 0.2.1 → 0.2.3
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 +25 -4
- package/dist/api/client.js +32 -1
- package/dist/api/stub/management.js +71 -0
- package/dist/api/stub/shopping.js +80 -18
- package/dist/bin.js +19 -1
- package/dist/commands.js +104 -5
- package/dist/files.js +21 -0
- package/dist/promotions.js +291 -0
- package/dist/store.js +2 -0
- package/dist/ucp/buy.js +3 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -14,14 +14,17 @@ npx -y @commercelayer/agent-cli@latest init --name "The Blue Brand" # runs c11
|
|
|
14
14
|
## Commands
|
|
15
15
|
| Command | Does |
|
|
16
16
|
| --- | --- |
|
|
17
|
-
| `init [--name <business name>]` | Create a sandbox store, with no login. The name (the folder name when absent) names the organization; the org slug and store hostname derive from it, numbered when taken. Writes `store.yml` (a commented template) and `credentials
|
|
17
|
+
| `init [--name <business name>]` | Create a sandbox store, with no login. The name (the folder name when absent) names the organization; the org slug and store hostname derive from it, numbered when taken. Writes `store.yml` (a commented template) and `credentials`, plus a short `AGENTS.md` and `CLAUDE.md` section (appended to an existing file, never twice) that sends later agents in the repo to [commercelayer.ai/skills.md](https://commercelayer.ai/skills.md). Reuses the store if they exist. |
|
|
18
18
|
| `plan` | Validate `store.yml` and the product files, and list what `apply` would create, update or delete: markets, products, images, and the store's name, policies and theme. `missing_images` lists `img/<file>` references with no file in `.commercelayer/img/` (`apply` refuses them); `unused_images` lists files there that nothing references. |
|
|
19
|
-
| `apply [--allow-delete] [--full]` | Make the store match the files, the website included: the theme settings, and the images in `.commercelayer/img/` that the theme and products reference, uploaded when new or changed (unused ones are dropped from the store host). Refuses deletes without `--allow-delete`. Product changes go through a Commerce Layer import: `apply` waits for it, with progress as JSON lines on stderr, and prints one result. `apply_failed` lists the products Commerce Layer refused
|
|
19
|
+
| `apply [--allow-delete] [--full]` | Make the store match the files, the website included: the theme settings, and the images in `.commercelayer/img/` that the theme and products reference, uploaded when new or changed (unused ones are dropped from the store host). Refuses deletes without `--allow-delete`. Product changes go through a Commerce Layer import: `apply` waits for it, with progress as JSON lines on stderr, and prints one result. `apply_failed` lists the products Commerce Layer refused. While Commerce Layer is busy or unavailable, `apply` keeps reading (backing off on busy) and writes a `retrying` line. `apply_pending` means the import keeps going after 10 minutes, and `c11r status` follows it. An apply still running is finished before a new one starts. `--full` re-imports every product and price, after edits in the Commerce Layer dashboard. |
|
|
20
20
|
| `catalog pull` | Read the products from the catalog source in `store.yml` (a Supabase table, a Google Sheet or a Google Merchant Center feed) into `.commercelayer/products/catalog.csv`. Then `plan` and `apply` as usual. |
|
|
21
21
|
| `storefront init [--force]` | Custom storefronts only. Write a static storefront on [drop-in.js](https://commercelayer.github.io/drop-in.js/), no build step, wired to the sandbox's sales channel. `index.html`: the config block, the products grouped by top-level category with a navigation, a minicart that slides in on add to cart, the hosted cart and checkout, and a product page at `#/p/<sku>` built from `<template id="product-template">` and the `#products` data. `styles.css`: theme tokens first (`--paper`, `--ink`, `--muted`, `--line`, `--accent`, `--on-accent`, `--radius`, `--font`), which also restyle drop-in.js's prices, buttons and links, so a dark theme is a token change; then the drop-in.js overrides, then the page. `app.js`: the market switch, the category links and the product page. Product images are the store's, at `/img/<file>` (from `.commercelayer/img/`, uploaded by `apply`); products with no image get a neutral tile. The site's own files go in `storefront/assets/`. Refuses to overwrite without the flag; with it, rewrites the three files. |
|
|
22
22
|
| `storefront publish` | Custom storefronts only. Upload the folder at `storefront.path` (default `storefront`) to the store host, where it replaces the hosted theme, leaving out dotfiles. `/img/` on the store host is always the store's images, so files under the folder's `img/` are not served: publish lists them as `not_served`. |
|
|
23
23
|
| `storefront check` | Custom storefronts only. Read the published storefront at the preview URL and check it: drop-in.js and its config block wired to this store, the drop-in elements, an add to cart for every product and none for unknown skus, every image loading, alt text, stylesheets after drop-in.css, the theme tokens and drop-in overrides (no dead `::part` rules), title, `lang`, viewport, description, and whether the folder changed since publish. Prints `{ status, summary, checks: [{ id, kind, status: pass\|warn\|fail, message, fix, items }], render }` and exits 1 when a check fails; warnings do not fail. `kind` is `static` today; a later rendering phase adds `render` checks and fills `render`. |
|
|
24
|
-
| `
|
|
24
|
+
| `promotions list` · `show <id>` | The store's promotions, live in Commerce Layer: id, name, status, rules, actions, uses, dates; `managed: false` for ones made in the dashboard. |
|
|
25
|
+
| `promotions create` · `update <id>` | Create a promotion, or replace one, from a spec in YAML or JSON on stdin or `-f <file>` (see [Promotions](#promotions)). Prints the promotion and the `orders test` call that checks it. |
|
|
26
|
+
| `promotions enable <id>` · `disable <id>` · `delete <id>` | Turn a promotion on or off, or delete it (ask the human first). |
|
|
27
|
+
| `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. |
|
|
25
28
|
| `orders list` | The orders placed in the store, newest first, as UCP order entities. |
|
|
26
29
|
| `orders show <id or number>` | One order. |
|
|
27
30
|
| `ucp profile [--agent]` | The store's UCP business profile (`/.well-known/ucp`); with `--agent`, this CLI's own platform profile. |
|
|
@@ -65,6 +68,24 @@ policies:
|
|
|
65
68
|
terms_of_service: https://example.com/terms
|
|
66
69
|
returns: https://example.com/returns
|
|
67
70
|
```
|
|
71
|
+
### Promotions
|
|
72
|
+
Promotions are live settings in Commerce Layer, not store files: the `promotions` commands create and change them directly, and nothing about them is committed. A spec, in YAML or JSON on stdin or with `-f <file>`:
|
|
73
|
+
|
|
74
|
+
```yaml
|
|
75
|
+
id: welcome10 # the handle: lowercase letters, digits, dashes; unique
|
|
76
|
+
name: Welcome, 10% off
|
|
77
|
+
rules: [{ coupon: WELCOME10 }]
|
|
78
|
+
actions: [{ percentage: 10 }]
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Optional: `market` (a market code; without it, every market), `starts` and `ends` (a day, or a UTC time; a bare `ends` day runs to 23:59:59Z; without them the promotion is always on), `limit` (total uses), `priority` (lowest first), `exclusive`, `enabled`.
|
|
82
|
+
|
|
83
|
+
- Rules, one of each kind at most, all matching: `coupon: CODE` or a list (8 to 40 letters, digits, `-`, `_`), `order_amount: { min }` (the items total), `skus: [...]` or `category: A > B`. A category becomes the skus of its products in store.yml when the command runs: after adding products to it, run `update` again.
|
|
84
|
+
- Actions: `percentage: 10` (with `skus` or `category` to limit it), `fixed_amount: 5`, `free_shipping`, `fixed_price: { price, skus|category }`, `free_gift: { skus|category, quantity }`, `buy_x_pay_y: { x, y, cheapest_free?, skus|category }`. Actions without their own skus take the rule's. Amounts are in major units, like prices; in a store with several currencies, a promotion with an amount names its `market`. Commerce Layer gets one promotion per action.
|
|
85
|
+
- `flex: { rules: [...] }` holds a Commerce Layer Rules Engine promotion as written (amounts in cents), in place of `rules` and `actions`. Sandboxes have no Rules Engine: `create` answers `flex_unavailable` and creates nothing.
|
|
86
|
+
|
|
87
|
+
`list` and `show` print each promotion in the normalized form plus `status` (`active`, `scheduled`, `expired`, `disabled`, `used_up`) and `uses`; that output can be edited and sent back to `update`. Promotions made outside c11r (the dashboard, the sandbox's `SANDBOX10`) are listed with `managed: false` and Commerce Layer's id: `enable`, `disable` and `delete` work on them, `update` answers `promotion_not_managed`. `percentage` is a whole number from 1 to 100; a promotion takes at most 20 codes, a free gift at most 25 SKUs. More than 10 active promotion actions without a coupon gives a `warnings` entry (Commerce Layer applies 10). `orders test --code <CODE>` proves one: its `discounts` lists what was taken off, automatic promotions with `automatic: true`, and a code that applied nothing is a `discount_code_invalid` warning in `messages`.
|
|
88
|
+
|
|
68
89
|
### The products folder
|
|
69
90
|
Beyond a handful of products, put the catalog in `.commercelayer/products/` and leave `products` out of `store.yml`. Every file in the folder is merged into the catalog by sku, in file-name order:
|
|
70
91
|
- `<name>.yml`: a list of products in the shape above (one file per collection, or one per product, as you like).
|
|
@@ -96,7 +117,7 @@ One rule for images: `img/<file>` anywhere in `store.yml` (product `image_url`,
|
|
|
96
117
|
|
|
97
118
|
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/`.
|
|
98
119
|
|
|
99
|
-
Error codes: `invalid_theme`, `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`.
|
|
120
|
+
Error codes: `invalid_theme`, `invalid_promotion`, `promotion_exists`, `promotion_not_managed`, `flex_unavailable`, `promotions_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`.
|
|
100
121
|
## The store speaks UCP
|
|
101
122
|
Each store is a UCP business at `https://<org>.sandbox.commercelayer.store` (live stores: `<slug>.commercelayer.store`), implementing release **2026-08-25** over REST:
|
|
102
123
|
| What | Where |
|
package/dist/api/client.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// The client for the management API at api.commercelayer.ai.
|
|
2
|
-
import { fail } from '../output.js';
|
|
2
|
+
import { CliError, fail } from '../output.js';
|
|
3
3
|
import { apiUrl, DEFAULT_API_URL, send } from './transport.js';
|
|
4
4
|
export function createApi(auth) {
|
|
5
5
|
const base = apiUrl() === 'stub' ? DEFAULT_API_URL : apiUrl().replace(/\/$/, '');
|
|
@@ -27,7 +27,38 @@ export function createApi(auth) {
|
|
|
27
27
|
fail(err.code, err.message, err.hint);
|
|
28
28
|
fail('api_error', `The API answered ${res.status}.`, 'Try again. If it keeps happening, tell the human, with the output of `c11r status`.');
|
|
29
29
|
};
|
|
30
|
+
// Promotions: on an API older than them, the collection route is an unknown route (404 not_found).
|
|
31
|
+
const promotions = () => `${org()}/promotions`;
|
|
32
|
+
const one = (id) => `${promotions()}/${encodeURIComponent(id)}`;
|
|
33
|
+
const unsupported = async (call) => {
|
|
34
|
+
try {
|
|
35
|
+
return await call;
|
|
36
|
+
}
|
|
37
|
+
catch (e) {
|
|
38
|
+
if (e instanceof CliError && (e.code === 'not_found' || (e.code === 'api_error' && /\b404\b/.test(e.message))))
|
|
39
|
+
fail('promotions_unsupported', 'This API does not manage promotions yet.', 'Try again later; tell the human if it persists.');
|
|
40
|
+
throw e;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
const found = async (id, call) => {
|
|
44
|
+
try {
|
|
45
|
+
return await call;
|
|
46
|
+
}
|
|
47
|
+
catch (e) {
|
|
48
|
+
if (e instanceof CliError && e.code === 'not_found')
|
|
49
|
+
fail('not_found', `There is no promotion ${id}.`, 'Run `c11r promotions list` for the ids, or `c11r promotions create` to make it.');
|
|
50
|
+
throw e;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
30
53
|
return {
|
|
54
|
+
listPromotions: () => unsupported(request('GET', promotions())),
|
|
55
|
+
createPromotion: (spec) => unsupported(request('POST', promotions(), spec)),
|
|
56
|
+
getPromotion: (id) => found(id, request('GET', one(id))),
|
|
57
|
+
updatePromotion: (id, spec) => found(id, request('PUT', one(id), spec)),
|
|
58
|
+
setPromotionEnabled: (id, enabled) => found(id, request('POST', `${one(id)}/${enabled ? 'enable' : 'disable'}`)),
|
|
59
|
+
deletePromotion: async (id) => {
|
|
60
|
+
await found(id, request('DELETE', one(id)));
|
|
61
|
+
},
|
|
31
62
|
createSandbox: (name) => request('POST', '/sandboxes', { name }),
|
|
32
63
|
getStore: () => request('GET', org()),
|
|
33
64
|
applyStore: (body) => request('PUT', `${org()}/resources`, body),
|
|
@@ -39,6 +39,26 @@ function advance(r) {
|
|
|
39
39
|
}
|
|
40
40
|
delete r.pending;
|
|
41
41
|
}
|
|
42
|
+
function withDefaults(spec) {
|
|
43
|
+
return { ...spec, starts: spec.starts ?? '2000-01-01T00:00:00Z', ends: spec.ends ?? '2100-01-01T00:00:00Z', exclusive: spec.exclusive ?? false, enabled: spec.enabled ?? true };
|
|
44
|
+
}
|
|
45
|
+
/** As the API lists it: the spec plus status and uses. */
|
|
46
|
+
function listed(p) {
|
|
47
|
+
const now = Date.now();
|
|
48
|
+
const status = !p.enabled ? 'disabled' : p.limit !== undefined && p.uses >= p.limit ? 'used_up' : Date.parse(p.ends) <= now ? 'expired' : Date.parse(p.starts) > now ? 'scheduled' : 'active';
|
|
49
|
+
return { ...p, status, managed: true };
|
|
50
|
+
}
|
|
51
|
+
/** The API checks skus against the last applied catalog. */
|
|
52
|
+
function unknownSkus(r, spec) {
|
|
53
|
+
const known = r.applied?.products ?? {};
|
|
54
|
+
const where = [['rules.skus', spec.rules?.skus], ...(spec.actions ?? []).map((a, i) => [`actions[${i}].skus`, 'skus' in a ? a.skus : undefined])];
|
|
55
|
+
for (const [path, list] of where) {
|
|
56
|
+
const i = (list ?? []).findIndex((sku) => !(sku in known));
|
|
57
|
+
if (i >= 0)
|
|
58
|
+
return error(422, 'invalid_promotion', `${path}[${i}]: ${list[i]} is not a product of the store.`, 'Use skus of applied products: run `c11r apply` first if you just added them.');
|
|
59
|
+
}
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
42
62
|
export function handleManagement(req) {
|
|
43
63
|
const db = readDb();
|
|
44
64
|
const path = req.url.pathname;
|
|
@@ -140,6 +160,57 @@ export function handleManagement(req) {
|
|
|
140
160
|
writeDb(db);
|
|
141
161
|
return json(202, publicState(record));
|
|
142
162
|
}
|
|
163
|
+
// Promotions: live, synchronous, keyed by id. Sandboxes have no Rules Engine.
|
|
164
|
+
if (rest === '/promotions' || rest.startsWith('/promotions/')) {
|
|
165
|
+
const store = (record.promotions ??= {});
|
|
166
|
+
const [, , rawId, verb] = rest.split('/');
|
|
167
|
+
const id = rawId === undefined ? undefined : decodeURIComponent(rawId);
|
|
168
|
+
const save = () => writeDb(db);
|
|
169
|
+
const flexRefused = (spec) => (spec.flex ? error(422, 'flex_unavailable', 'Flex promotions need the Rules Engine, which this organization does not have.', 'Tell the human flex promotions need a Commerce Layer plan with the Rules Engine; build the promotion from the standard rules and actions if you can.') : undefined);
|
|
170
|
+
if (id === undefined) {
|
|
171
|
+
if (req.method === 'GET')
|
|
172
|
+
return json(200, { promotions: Object.keys(store).sort().map((k) => listed(store[k])) });
|
|
173
|
+
if (req.method === 'POST') {
|
|
174
|
+
const spec = readJson(req);
|
|
175
|
+
if (store[spec.id])
|
|
176
|
+
return error(409, 'promotion_exists', `There is already a promotion ${spec.id}.`, 'Use `c11r promotions update <id>` to change it, or pick another id.');
|
|
177
|
+
const refused = flexRefused(spec) ?? unknownSkus(record, spec);
|
|
178
|
+
if (refused)
|
|
179
|
+
return refused;
|
|
180
|
+
store[spec.id] = { ...withDefaults(spec), uses: 0 };
|
|
181
|
+
save();
|
|
182
|
+
return json(201, listed(store[spec.id]));
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
const p = store[id];
|
|
187
|
+
if (!p)
|
|
188
|
+
return error(404, 'not_found', `There is no promotion ${id}.`, 'Run `c11r promotions list`.');
|
|
189
|
+
if (verb === undefined && req.method === 'GET')
|
|
190
|
+
return json(200, listed(p));
|
|
191
|
+
if (verb === undefined && req.method === 'PUT') {
|
|
192
|
+
const spec = readJson(req);
|
|
193
|
+
if (spec.id !== id)
|
|
194
|
+
return error(422, 'invalid_promotion', `id: the body has ${spec.id}, the path ${id}.`, 'An id never changes.');
|
|
195
|
+
const refused = flexRefused(spec) ?? unknownSkus(record, spec);
|
|
196
|
+
if (refused)
|
|
197
|
+
return refused;
|
|
198
|
+
store[id] = { ...withDefaults(spec), uses: p.uses };
|
|
199
|
+
save();
|
|
200
|
+
return json(200, listed(store[id]));
|
|
201
|
+
}
|
|
202
|
+
if (verb === undefined && req.method === 'DELETE') {
|
|
203
|
+
delete store[id];
|
|
204
|
+
save();
|
|
205
|
+
return { status: 204, headers: {}, body: '' };
|
|
206
|
+
}
|
|
207
|
+
if ((verb === 'enable' || verb === 'disable') && req.method === 'POST') {
|
|
208
|
+
p.enabled = verb === 'enable';
|
|
209
|
+
save();
|
|
210
|
+
return json(200, listed(p));
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
143
214
|
if (req.method === 'PUT' && rest === '/assets') {
|
|
144
215
|
const { files } = readJson(req);
|
|
145
216
|
let total = 0;
|
|
@@ -208,23 +208,85 @@ function buildLines(ctx, market, requested, existing = []) {
|
|
|
208
208
|
});
|
|
209
209
|
return { lines, messages };
|
|
210
210
|
}
|
|
211
|
-
/**
|
|
212
|
-
function
|
|
213
|
-
|
|
214
|
-
return undefined;
|
|
215
|
-
const codes = (body.discounts.codes ?? []).map((c) => String(c));
|
|
211
|
+
/** The promotions that match these lines and codes, as Commerce Layer applies them: by priority, an exclusive one alone. */
|
|
212
|
+
function promotionDiscounts(ctx, market, lines, codes, shipping) {
|
|
213
|
+
const now = Date.now();
|
|
216
214
|
const subtotal = lines.reduce((s, l) => s + l.totals[0].amount, 0);
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
215
|
+
const upper = codes.map((c) => c.toUpperCase());
|
|
216
|
+
const used = new Set();
|
|
217
|
+
const matching = [];
|
|
218
|
+
for (const p of Object.values(ctx.record.promotions ?? {})) {
|
|
219
|
+
if (!p.enabled || p.flex || !p.actions || Date.parse(p.starts) > now || Date.parse(p.ends) <= now)
|
|
220
|
+
continue;
|
|
221
|
+
if (p.market && p.market !== market.code)
|
|
222
|
+
continue;
|
|
223
|
+
const r = p.rules ?? {};
|
|
224
|
+
const code = r.coupons?.find((c) => upper.includes(c.toUpperCase()));
|
|
225
|
+
if (r.coupons && !code)
|
|
226
|
+
continue;
|
|
227
|
+
if (code)
|
|
228
|
+
used.add(code.toUpperCase());
|
|
229
|
+
if (r.order_amount && subtotal < toMinor(r.order_amount.min, market.currency))
|
|
230
|
+
continue;
|
|
231
|
+
if (r.skus && !lines.some((l) => r.skus.includes(l.item.id)))
|
|
232
|
+
continue;
|
|
233
|
+
matching.push({ p, code });
|
|
234
|
+
}
|
|
235
|
+
matching.sort((a, b) => (a.p.priority ?? Infinity) - (b.p.priority ?? Infinity));
|
|
236
|
+
const exclusive = matching.find((m) => m.p.exclusive);
|
|
237
|
+
const applied = [];
|
|
238
|
+
for (const { p, code } of exclusive ? [exclusive] : matching) {
|
|
239
|
+
let amount = 0;
|
|
240
|
+
for (const a of p.actions) {
|
|
241
|
+
const on = (skus) => lines.filter((l) => !skus || skus.includes(l.item.id));
|
|
242
|
+
const units = (skus) => on(skus).flatMap((l) => Array(l.quantity).fill(l.item.price)).sort((x, y) => x - y);
|
|
243
|
+
if (a.type === 'percentage')
|
|
244
|
+
amount += Math.floor((on(a.skus).reduce((s, l) => s + l.totals[0].amount, 0) * a.value) / 100);
|
|
245
|
+
else if (a.type === 'fixed_amount')
|
|
246
|
+
amount += Math.min(toMinor(a.value, market.currency), subtotal);
|
|
247
|
+
else if (a.type === 'free_shipping')
|
|
248
|
+
amount += shipping ?? 0;
|
|
249
|
+
else if (a.type === 'fixed_price')
|
|
250
|
+
amount += on(a.skus).reduce((s, l) => s + Math.max(0, l.item.price - toMinor(a.price, market.currency)) * l.quantity, 0);
|
|
251
|
+
else if (a.type === 'free_gift')
|
|
252
|
+
amount += units(a.skus).slice(0, a.quantity).reduce((s, x) => s + x, 0);
|
|
253
|
+
else if (a.cheapest_free) {
|
|
254
|
+
const all = units(a.skus);
|
|
255
|
+
amount += all.slice(0, Math.floor(all.length / a.x) * (a.x - a.y)).reduce((s, x) => s + x, 0);
|
|
256
|
+
}
|
|
222
257
|
else
|
|
223
|
-
|
|
258
|
+
amount += on(a.skus).reduce((s, l) => s + Math.floor(l.quantity / a.x) * (a.x - a.y) * l.item.price, 0);
|
|
224
259
|
}
|
|
225
|
-
|
|
260
|
+
if (amount > 0)
|
|
261
|
+
applied.push({ ...(code ? { code } : {}), title: p.name, amount, ...(code ? {} : { automatic: true }), method: 'across' });
|
|
262
|
+
}
|
|
263
|
+
return { applied, used };
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Discounts: the store's promotions (automatic ones, and those whose code was entered) plus the
|
|
267
|
+
* sandbox code, 10% off the items. A code that applies nothing is reported with a warning.
|
|
268
|
+
*/
|
|
269
|
+
function discounts(ctx, body, lines, messages, market, shipping) {
|
|
270
|
+
if (!ctx.shared.has('dev.ucp.shopping.discount'))
|
|
271
|
+
return undefined;
|
|
272
|
+
const codes = (body.discounts?.codes ?? []).map((c) => String(c));
|
|
273
|
+
const subtotal = lines.reduce((s, l) => s + l.totals[0].amount, 0);
|
|
274
|
+
const { applied: appliedList, used } = promotionDiscounts(ctx, market, lines, codes, shipping);
|
|
275
|
+
const seen = new Set();
|
|
276
|
+
codes.forEach((code, i) => {
|
|
277
|
+
const key = code.toUpperCase();
|
|
278
|
+
if (seen.has(key))
|
|
279
|
+
messages.push(warn('discount_code_already_applied', `${code} is already applied.`, `$.discounts.codes[${i}]`));
|
|
280
|
+
else if (key === SANDBOX_CODE)
|
|
281
|
+
appliedList.push({ code: SANDBOX_CODE, title: '10% off', amount: Math.floor(subtotal / 10), method: 'across' });
|
|
282
|
+
else if (!used.has(key))
|
|
226
283
|
messages.push(warn('discount_code_invalid', `${code} is not a valid code.`, `$.discounts.codes[${i}]`));
|
|
284
|
+
else if (!appliedList.some((d) => d.code?.toUpperCase() === key))
|
|
285
|
+
messages.push(warn('discount_code_invalid', `${code} does not apply to this order.`, `$.discounts.codes[${i}]`));
|
|
286
|
+
seen.add(key);
|
|
227
287
|
});
|
|
288
|
+
if (body.discounts === undefined && !appliedList.length)
|
|
289
|
+
return undefined;
|
|
228
290
|
return { codes, applied: appliedList };
|
|
229
291
|
}
|
|
230
292
|
function consent(ctx, buyer) {
|
|
@@ -251,7 +313,7 @@ function totals(lines, discount, shipping) {
|
|
|
251
313
|
function cartFrom(ctx, body, existing) {
|
|
252
314
|
const market = pickMarket(ctx, body.context ?? existing?.context);
|
|
253
315
|
const { lines, messages } = buildLines(ctx, market, body.line_items, existing?.line_items);
|
|
254
|
-
const discount = discounts(ctx, body, lines, messages);
|
|
316
|
+
const discount = discounts(ctx, body, lines, messages, market);
|
|
255
317
|
const cart = {
|
|
256
318
|
ucp: envelope(ctx, 'cart'),
|
|
257
319
|
id: existing?.id ?? id('cart'),
|
|
@@ -315,11 +377,6 @@ function evaluate(ctx, ck, requested) {
|
|
|
315
377
|
ck.line_items = lines;
|
|
316
378
|
if (lines.length === 0)
|
|
317
379
|
messages.push(err('item_unavailable', 'The checkout has no purchasable items.', '$.line_items'));
|
|
318
|
-
const discount = discounts(ctx, requested, lines, messages);
|
|
319
|
-
if (discount)
|
|
320
|
-
ck.discounts = discount;
|
|
321
|
-
else
|
|
322
|
-
delete ck.discounts;
|
|
323
380
|
if (!ck.buyer?.email)
|
|
324
381
|
messages.push(err('missing', 'An email address is needed for the order confirmation.', '$.buyer.email'));
|
|
325
382
|
let shipping;
|
|
@@ -353,6 +410,11 @@ function evaluate(ctx, ck, requested) {
|
|
|
353
410
|
}
|
|
354
411
|
else
|
|
355
412
|
shipping = 0;
|
|
413
|
+
const discount = discounts(ctx, requested, lines, messages, market, shipping);
|
|
414
|
+
if (discount)
|
|
415
|
+
ck.discounts = discount;
|
|
416
|
+
else
|
|
417
|
+
delete ck.discounts;
|
|
356
418
|
ck.totals = totals(lines, discount, shipping);
|
|
357
419
|
const blocking = messages.some((m) => m.type === 'error');
|
|
358
420
|
ck.status = blocking ? 'incomplete' : 'ready_for_complete';
|
package/dist/bin.js
CHANGED
|
@@ -4,13 +4,17 @@
|
|
|
4
4
|
// Output is always JSON; exit code 1 on an error or a failed check.
|
|
5
5
|
import * as cmd from './commands.js';
|
|
6
6
|
import { CliError, print } from './output.js';
|
|
7
|
-
const VALUE_OPTIONS = new Set(['--name', '--sku', '--quantity', '--country', '--code']);
|
|
7
|
+
const VALUE_OPTIONS = new Set(['--name', '--sku', '--quantity', '--country', '--code', '--file']);
|
|
8
8
|
const argv = process.argv.slice(2);
|
|
9
9
|
const flags = new Set();
|
|
10
10
|
const values = {};
|
|
11
11
|
const args = [];
|
|
12
12
|
for (let i = 0; i < argv.length; i++) {
|
|
13
13
|
const a = argv[i];
|
|
14
|
+
if (a === '-f' && i + 1 < argv.length) {
|
|
15
|
+
values.file = argv[++i];
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
14
18
|
if (!a.startsWith('--')) {
|
|
15
19
|
args.push(a);
|
|
16
20
|
continue;
|
|
@@ -53,6 +57,20 @@ async function run() {
|
|
|
53
57
|
if (sub === 'check')
|
|
54
58
|
return cmd.storefrontCheckCmd();
|
|
55
59
|
throw new CliError('unknown_command', `There is no command "storefront ${sub ?? ''}".`, 'Use `c11r storefront init`, `storefront publish` or `storefront check`.');
|
|
60
|
+
case 'promotions':
|
|
61
|
+
if (sub === 'list')
|
|
62
|
+
return cmd.promotionsList();
|
|
63
|
+
if (sub === 'show')
|
|
64
|
+
return cmd.promotionsShow(args[2]);
|
|
65
|
+
if (sub === 'create')
|
|
66
|
+
return cmd.promotionsCreate(values);
|
|
67
|
+
if (sub === 'update')
|
|
68
|
+
return cmd.promotionsUpdate(args[2], values);
|
|
69
|
+
if (sub === 'enable' || sub === 'disable')
|
|
70
|
+
return cmd.promotionsEnable(args[2], sub === 'enable');
|
|
71
|
+
if (sub === 'delete')
|
|
72
|
+
return cmd.promotionsDelete(args[2]);
|
|
73
|
+
throw new CliError('unknown_command', `There is no command "promotions ${sub ?? ''}".`, 'Use `c11r promotions list`, `show <id>`, `create`, `update <id>`, `enable <id>`, `disable <id>` or `delete <id>`.');
|
|
56
74
|
case 'orders':
|
|
57
75
|
if (sub === 'test')
|
|
58
76
|
return cmd.ordersTest(flags, values);
|
package/dist/commands.js
CHANGED
|
@@ -4,12 +4,14 @@ import fs from 'node:fs';
|
|
|
4
4
|
import { createRequire } from 'node:module';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { createApi } from './api/client.js';
|
|
7
|
-
import { ignoreCredentials, paths, readCredentials, requireCredentials, writeCredentials } from './files.js';
|
|
7
|
+
import { ignoreCredentials, paths, readCredentials, requireCredentials, writeAgentNotes, writeCredentials } from './files.js';
|
|
8
8
|
import { CliError, fail, progress } from './output.js';
|
|
9
9
|
import { diff, hashStore, loadStore, storefrontPath, template, toApplied } from './store.js';
|
|
10
10
|
import { storefrontFiles } from './storefront.js';
|
|
11
11
|
import { imageNotes, planAssets, uploadAssets } from './assets.js';
|
|
12
12
|
import { pull } from './catalog.js';
|
|
13
|
+
import { ACTIVE_LIMIT, activeWithoutCoupon, checkPromotion } from './promotions.js';
|
|
14
|
+
import YAML from 'yaml';
|
|
13
15
|
import { storefrontCheck } from './check.js';
|
|
14
16
|
import { buy } from './ucp/buy.js';
|
|
15
17
|
import { check } from './ucp/check.js';
|
|
@@ -18,14 +20,21 @@ import { agentProfile, AGENT_PROFILE_URL } from './ucp/platform.js';
|
|
|
18
20
|
/** The package version, read from package.json so a release bumps it in one place. */
|
|
19
21
|
export const VERSION = createRequire(import.meta.url)('../package.json').version;
|
|
20
22
|
export const HELP = {
|
|
21
|
-
init: 'Create a sandbox store for this repo, with no login: c11r init --name "<business name>" (the folder name when --name is absent). The name becomes the organization\'s name, and the org slug and store hostname derive from it. Writes .commercelayer/store.yml and .commercelayer/credentials. Reuses the store if store.yml already exists.',
|
|
23
|
+
init: 'Create a sandbox store for this repo, with no login: c11r init --name "<business name>" (the folder name when --name is absent). The name becomes the organization\'s name, and the org slug and store hostname derive from it. Writes .commercelayer/store.yml and .commercelayer/credentials, and a section in AGENTS.md and CLAUDE.md (created, or appended once) that points later agents in this repo to the skills: commit them. Reuses the store if store.yml already exists.',
|
|
22
24
|
plan: 'Show what apply would create, update or delete: markets, products, images, and the store\'s name, policies and theme; missing_images lists references with no file in .commercelayer/img/ (apply refuses them), unused_images files nothing references, from .commercelayer/store.yml and the product files under .commercelayer/products/ (*.yml lists, *.csv rows, merged by sku).',
|
|
23
25
|
apply: 'Make the store match .commercelayer/store.yml and .commercelayer/products/: markets, products, the theme (its settings, and the images in .commercelayer/img/ that the theme and products reference, uploaded when new or changed), name and policies. The store\'s website is live at preview_url after apply. Refuses deletes unless --allow-delete is given. Product changes go through a Commerce Layer import: apply waits for it (progress as JSON lines on stderr) and prints one result; if it stops with apply_pending the import keeps going, so run `c11r status` to follow it. While Commerce Layer is busy or unavailable it keeps reading, with a retrying line. An apply still running is finished first. --full re-imports every product and price, after edits in the Commerce Layer dashboard.',
|
|
24
26
|
'catalog pull': 'Read the products from the catalog source in store.yml (catalog: { source: supabase | sheets | merchant, url, table }) and write them to .commercelayer/products/catalog.csv; then plan and apply as usual. Columns: sku, name, description, image_url, category and one price column per market currency, in lowercase (eur, usd). Supabase reads C11R_SUPABASE_URL and C11R_SUPABASE_KEY (the anon key) from .commercelayer/credentials or the environment; a Google Sheet needs its CSV link or a link anyone can open; a Merchant Center feed is RSS/XML or TSV.',
|
|
25
27
|
'storefront init': 'Custom storefronts only: the hosted theme needs none of this (set theme: in store.yml and apply). Write a static storefront on drop-in.js, no build step: storefront/index.html (the products by category, a minicart that opens on add to cart, the hosted cart and checkout, and a product page at #/p/<sku>), styles.css (theme tokens first) and app.js. Product images are the store\'s, at /img/ (.commercelayer/img/, uploaded by apply); the site\'s own files go in storefront/assets/. Refuses to overwrite unless --force is given.',
|
|
26
28
|
'storefront check': 'Custom storefronts only. Check the published storefront at the preview URL: drop-in.js and its config block wired to this store, the drop-in elements, an add to cart for every product, every image loading, alt text, stylesheets after drop-in.css, the theme tokens and drop-in overrides, title, lang, viewport, description, and whether the folder changed since publish. Prints { status, summary, checks: [{ id, kind, status: pass|warn|fail, message, fix, items }] }; exits 1 when a check fails.',
|
|
27
29
|
'storefront publish': 'Custom storefronts only: upload the storefront folder (store.yml storefront.path, default storefront) to the preview URL, where it replaces the hosted theme.',
|
|
28
|
-
'
|
|
30
|
+
'promotions list': 'The store\'s promotions, live in Commerce Layer (not in store files): id, name, status (active, scheduled, expired, disabled, used_up), rules, actions, uses, starts, ends; managed: false marks one made in the dashboard (its id is Commerce Layer\'s), which can be enabled, disabled or deleted but not updated. warnings flags more than 10 active promotion actions without a coupon (Commerce Layer applies 10).',
|
|
31
|
+
'promotions show': 'One promotion by id, as list shows it: c11r promotions show <id>.',
|
|
32
|
+
'promotions create': "Create a promotion from a spec in YAML or JSON, on stdin (c11r promotions create <<'EOF' ... EOF) or with -f <file>: { id, name, market, starts, ends, limit, priority, exclusive, enabled, rules: [{ coupon | order_amount: { min } | skus | category }], actions: [{ percentage | fixed_amount | free_shipping | fixed_price | free_gift | buy_x_pay_y }] } or flex: { rules } in place of rules and actions. id is the handle, unique. Amounts are in the market currency, in major units. A category becomes the skus of its products in store.yml now: after adding products to it, run update again. Errors: invalid_promotion (the message starts with the path in the spec), promotion_exists, flex_unavailable (no Rules Engine: nothing is created). Check it with orders test --code.",
|
|
33
|
+
'promotions update': 'Replace a promotion with a whole spec, same as create, on stdin or -f <file>: c11r promotions update <id>. The id stays the same. What show prints can be edited and sent back. A promotion made in the dashboard (managed: false) cannot be updated: promotion_not_managed; enable, disable and delete work on it.',
|
|
34
|
+
'promotions enable': 'Turn a promotion on: c11r promotions enable <id>.',
|
|
35
|
+
'promotions disable': 'Turn a promotion off without deleting it: c11r promotions disable <id>.',
|
|
36
|
+
'promotions delete': 'Delete a promotion from Commerce Layer: c11r promotions delete <id>. Ask the human first; to pause one, disable it.',
|
|
37
|
+
'orders test': 'Buy from the store over UCP as a shopping agent would, paying with the sandbox handler: discover, search, checkout, address, pay, read the order. Options: --sku <id> --quantity <n> --country <CC> --code <discount> --via-cart --decline. discounts lists what promotions and codes took off; a code that applied nothing is a discount_code_invalid warning in messages.',
|
|
29
38
|
'orders list': 'List the orders placed in the store, newest first, as UCP order entities.',
|
|
30
39
|
'orders show': 'Show one order: c11r orders show <id or number>.',
|
|
31
40
|
'ucp profile': 'Show the store\'s UCP business profile (/.well-known/ucp). With --agent, this CLI\'s own platform profile instead.',
|
|
@@ -53,7 +62,8 @@ export async function init(values) {
|
|
|
53
62
|
const existing = readCredentials();
|
|
54
63
|
if (existing && fs.existsSync(paths.store)) {
|
|
55
64
|
const s = await createApi(existing).getStore();
|
|
56
|
-
|
|
65
|
+
const notes = writeAgentNotes();
|
|
66
|
+
return { status: 'reused', org: s.org, claim_url: s.claim_url, expires_at: s.expires_at, preview_url: s.preview_url, files: notes.length ? notes : undefined };
|
|
57
67
|
}
|
|
58
68
|
// The business name becomes the organization's name; the platform derives the
|
|
59
69
|
// org slug (and so the store hostname) from it. The folder name is the fallback.
|
|
@@ -68,6 +78,7 @@ export async function init(values) {
|
|
|
68
78
|
const files = [paths.store, paths.credentials];
|
|
69
79
|
if (ignoreCredentials())
|
|
70
80
|
files.push('.gitignore');
|
|
81
|
+
files.push(...writeAgentNotes());
|
|
71
82
|
return {
|
|
72
83
|
status: 'created',
|
|
73
84
|
org: sandbox.org,
|
|
@@ -254,8 +265,10 @@ export async function ordersTest(flags, values) {
|
|
|
254
265
|
return {
|
|
255
266
|
status: p.checkout.status === 'completed' ? 'paid' : p.checkout.status,
|
|
256
267
|
order: p.order,
|
|
268
|
+
// What the promotions took off, in minor units like the totals; a code that applied nothing is in messages.
|
|
269
|
+
discounts: (p.checkout.discounts ?? p.ready.discounts)?.applied?.length ? (p.checkout.discounts ?? p.ready.discounts).applied : undefined,
|
|
257
270
|
checkout_id: p.checkout.id,
|
|
258
|
-
messages: p.checkout.messages,
|
|
271
|
+
messages: p.checkout.messages ?? p.ready.messages,
|
|
259
272
|
payment_handler: 'ai.commercelayer.sandbox (test payments only)',
|
|
260
273
|
steps: p.steps,
|
|
261
274
|
};
|
|
@@ -334,3 +347,89 @@ export async function claim() {
|
|
|
334
347
|
next: 'Ask the human to open verification_url and enter user_code, then run `c11r status`.',
|
|
335
348
|
};
|
|
336
349
|
}
|
|
350
|
+
// ---------- promotions: live settings in Commerce Layer, not store files
|
|
351
|
+
/** The fields list, show and the writes print, in a stable order. */
|
|
352
|
+
const READ_ONLY = ['status', 'uses', 'managed'];
|
|
353
|
+
/** The spec from -f <file> or stdin, YAML or JSON, validated against store.yml's markets and products. */
|
|
354
|
+
async function readSpec(values) {
|
|
355
|
+
let text = "";
|
|
356
|
+
if (values.file) {
|
|
357
|
+
if (!fs.existsSync(values.file))
|
|
358
|
+
fail('missing_argument', `There is no file ${values.file}.`, 'Give the promotion as YAML or JSON with -f <file>, or on stdin.');
|
|
359
|
+
text = fs.readFileSync(values.file, 'utf8');
|
|
360
|
+
}
|
|
361
|
+
else if (!process.stdin.isTTY) {
|
|
362
|
+
// A stream read: readFileSync(0) fails with EAGAIN on a pipe whose writer is still running.
|
|
363
|
+
const chunks = [];
|
|
364
|
+
for await (const chunk of process.stdin)
|
|
365
|
+
chunks.push(chunk);
|
|
366
|
+
text = Buffer.concat(chunks).toString('utf8');
|
|
367
|
+
}
|
|
368
|
+
else
|
|
369
|
+
fail('missing_argument', 'No promotion given.', "Give the promotion as YAML or JSON on stdin (c11r promotions create <<'EOF' ... EOF) or with -f <file>.");
|
|
370
|
+
if (!text.trim())
|
|
371
|
+
fail('missing_argument', 'No promotion given: the input is empty.', "Give the promotion as YAML or JSON on stdin (c11r promotions create <<'EOF' ... EOF) or with -f <file>.");
|
|
372
|
+
let raw;
|
|
373
|
+
try {
|
|
374
|
+
raw = YAML.parse(text);
|
|
375
|
+
}
|
|
376
|
+
catch (e) {
|
|
377
|
+
fail('invalid_promotion', `promotion: not valid YAML or JSON: ${e.message.split('\n')[0]}`, 'Fix the syntax and run the command again.');
|
|
378
|
+
}
|
|
379
|
+
// What show prints can be edited and sent back: the read-only fields are dropped.
|
|
380
|
+
if (raw && typeof raw === 'object' && !Array.isArray(raw))
|
|
381
|
+
for (const k of READ_ONLY)
|
|
382
|
+
delete raw[k];
|
|
383
|
+
const store = loadStore();
|
|
384
|
+
return checkPromotion(raw, store.markets, store.products);
|
|
385
|
+
}
|
|
386
|
+
/** Warn when more promotion actions without a coupon are live than Commerce Layer applies. */
|
|
387
|
+
async function activeWarning(api) {
|
|
388
|
+
const { promotions } = await api.listPromotions();
|
|
389
|
+
const live = promotions.filter((p) => p.status === 'active' || p.status === 'scheduled');
|
|
390
|
+
const active = activeWithoutCoupon(live);
|
|
391
|
+
return active.count > ACTIVE_LIMIT ? [`${active.count} promotions without a coupon are active or scheduled (one per action, in ${active.ids.join(', ')}): Commerce Layer applies at most ${ACTIVE_LIMIT}, the ones with the lowest priority number. Give the rest a coupon, an end date, or disable them.`] : undefined;
|
|
392
|
+
}
|
|
393
|
+
const checkNext = (p) => p.rules?.coupons?.length ? `Check it: c11r orders test --code ${p.rules.coupons[0]}, and read discounts.` : p.flex ? undefined : 'Check it: c11r orders test with an order that matches the rules (--sku, --quantity), and read discounts.';
|
|
394
|
+
export async function promotionsList() {
|
|
395
|
+
const api = createApi(requireCredentials());
|
|
396
|
+
const { promotions } = await api.listPromotions();
|
|
397
|
+
return { count: promotions.length, promotions, warnings: await activeWarning(api) };
|
|
398
|
+
}
|
|
399
|
+
export async function promotionsShow(id) {
|
|
400
|
+
if (!id)
|
|
401
|
+
fail('missing_argument', 'Which promotion?', 'Use `c11r promotions show <id>`; `c11r promotions list` has the ids.');
|
|
402
|
+
return createApi(requireCredentials()).getPromotion(id);
|
|
403
|
+
}
|
|
404
|
+
export async function promotionsCreate(values) {
|
|
405
|
+
const api = createApi(requireCredentials());
|
|
406
|
+
const spec = await readSpec(values);
|
|
407
|
+
const created = await api.createPromotion(spec);
|
|
408
|
+
return { status: 'created', promotion: created, warnings: await activeWarning(api), next: checkNext(created) };
|
|
409
|
+
}
|
|
410
|
+
export async function promotionsUpdate(id, values) {
|
|
411
|
+
if (!id)
|
|
412
|
+
fail('missing_argument', 'Which promotion?', 'Use `c11r promotions update <id>` with the whole promotion on stdin or -f <file>.');
|
|
413
|
+
const api = createApi(requireCredentials());
|
|
414
|
+
// A promotion made outside c11r has Commerce Layer's id, which is no spec id: say what it is first.
|
|
415
|
+
if ((await api.getPromotion(id)).managed === false)
|
|
416
|
+
fail('promotion_not_managed', `Promotion ${id} was made outside c11r, in the dashboard.`, 'Create a c11r promotion with `c11r promotions create` and delete this one with `c11r promotions delete`, or edit it in the dashboard.');
|
|
417
|
+
const spec = await readSpec(values);
|
|
418
|
+
if (spec.id !== id)
|
|
419
|
+
fail('invalid_promotion', `id: the spec has id ${spec.id}, the command ${id}.`, 'An id never changes: use the same id, or create a new promotion and delete the old one.');
|
|
420
|
+
const updated = await api.updatePromotion(id, spec);
|
|
421
|
+
return { status: 'updated', promotion: updated, warnings: await activeWarning(api), next: checkNext(updated) };
|
|
422
|
+
}
|
|
423
|
+
export async function promotionsEnable(id, enabled) {
|
|
424
|
+
if (!id)
|
|
425
|
+
fail('missing_argument', 'Which promotion?', `Use \`c11r promotions ${enabled ? 'enable' : 'disable'} <id>\`.`);
|
|
426
|
+
const api = createApi(requireCredentials());
|
|
427
|
+
const p = await api.setPromotionEnabled(id, enabled);
|
|
428
|
+
return { status: enabled ? 'enabled' : 'disabled', promotion: p, warnings: enabled ? await activeWarning(api) : undefined };
|
|
429
|
+
}
|
|
430
|
+
export async function promotionsDelete(id) {
|
|
431
|
+
if (!id)
|
|
432
|
+
fail('missing_argument', 'Which promotion?', 'Use `c11r promotions delete <id>`, after asking the human.');
|
|
433
|
+
await createApi(requireCredentials()).deletePromotion(id);
|
|
434
|
+
return { status: 'deleted', id };
|
|
435
|
+
}
|
package/dist/files.js
CHANGED
|
@@ -61,3 +61,24 @@ export function ignoreCredentials() {
|
|
|
61
61
|
fs.appendFileSync('.gitignore', `${sep}${paths.credentials}\n`);
|
|
62
62
|
return true;
|
|
63
63
|
}
|
|
64
|
+
/** The note that sends a future agent in this repo to the skills. No skill names: it never goes stale. */
|
|
65
|
+
const AGENT_NOTE_HEADING = '# Store on Commerce Layer';
|
|
66
|
+
export const AGENT_NOTE = `${AGENT_NOTE_HEADING}
|
|
67
|
+
This store is managed with \`c11r\` (\`npx @commercelayer/agent-cli\`), configured in \`.commercelayer/store.yml\`. Before any task about the store (products, prices, markets, storefront, orders, promotions, agents), fetch \`https://commercelayer.ai/skills.md\` with \`curl -fsSL\` and load the skill for the task from \`skills/<name>.md\`; follow it rather than calling the Commerce Layer API directly. If \`.commercelayer/store.yml\` is missing, start from \`https://commercelayer.ai/start.md\`.
|
|
68
|
+
`;
|
|
69
|
+
/**
|
|
70
|
+
* Write the note to AGENTS.md and CLAUDE.md (Claude Code reads that name) in the repo root:
|
|
71
|
+
* created when absent, appended as a section when the file has other content, left alone
|
|
72
|
+
* when the section is already there. Returns the files written.
|
|
73
|
+
*/
|
|
74
|
+
export function writeAgentNotes() {
|
|
75
|
+
const written = [];
|
|
76
|
+
for (const file of ['AGENTS.md', 'CLAUDE.md']) {
|
|
77
|
+
const before = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
|
|
78
|
+
if (before.split('\n').some((line) => line.trim() === AGENT_NOTE_HEADING))
|
|
79
|
+
continue;
|
|
80
|
+
fs.writeFileSync(file, before.trim() ? `${before.replace(/\s*$/, '')}\n\n${AGENT_NOTE}` : AGENT_NOTE);
|
|
81
|
+
written.push(file);
|
|
82
|
+
}
|
|
83
|
+
return written;
|
|
84
|
+
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
// promotions: in store.yml. The CLI validates the YAML sugar (single-key rules and actions,
|
|
2
|
+
// categories, date-only days) and sends one normalized object per promotion in PUT /resources;
|
|
3
|
+
// the API maps each to Commerce Layer promotions. Amounts stay in major units, like prices;
|
|
4
|
+
// flex rules pass through as written (Rules Engine syntax, cents).
|
|
5
|
+
import { fail } from './output.js';
|
|
6
|
+
/** Commerce Layer needs both dates: without them the promotion runs from 2000 to 2100. */
|
|
7
|
+
export const DEFAULT_STARTS = '2000-01-01T00:00:00Z';
|
|
8
|
+
export const DEFAULT_ENDS = '2100-01-01T00:00:00Z';
|
|
9
|
+
/** Promotions without a coupon that can be active at once: Commerce Layer's limit. */
|
|
10
|
+
export const ACTIVE_LIMIT = 10;
|
|
11
|
+
const KEYS = ['id', 'name', 'market', 'starts', 'ends', 'limit', 'priority', 'exclusive', 'enabled', 'rules', 'actions', 'flex'];
|
|
12
|
+
const RULES = ['coupon', 'order_amount', 'skus', 'category'];
|
|
13
|
+
const ACTIONS = ['percentage', 'fixed_amount', 'free_shipping', 'fixed_price', 'free_gift', 'buy_x_pay_y'];
|
|
14
|
+
const SHAPE_HINT = 'See the promotions skill for the shape: https://commercelayer.ai/skills/promotions.md';
|
|
15
|
+
const isObj = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v);
|
|
16
|
+
const isNum = (v) => typeof v === 'number' && Number.isFinite(v);
|
|
17
|
+
const isInt = (v, min) => Number.isInteger(v) && v >= min;
|
|
18
|
+
const list = (v) => (Array.isArray(v) ? v : [v]);
|
|
19
|
+
/** A date or a date-time, as ISO UTC. A bare day starts at 00:00:00Z and ends at 23:59:59Z. */
|
|
20
|
+
function isoDate(v, end, bad) {
|
|
21
|
+
const s = v instanceof Date ? v.toISOString().replace(/\.000Z$/, 'Z') : String(v);
|
|
22
|
+
const hint = 'Write a day, 2026-12-01, or a time in UTC, 2026-12-01T09:00:00Z.';
|
|
23
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) {
|
|
24
|
+
if (Number.isNaN(Date.parse(s)))
|
|
25
|
+
bad(`"${s}" is not a date.`, hint);
|
|
26
|
+
return `${s}T${end ? '23:59:59' : '00:00:00'}Z`;
|
|
27
|
+
}
|
|
28
|
+
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:\d{2})$/.test(s) || Number.isNaN(Date.parse(s)))
|
|
29
|
+
bad(`"${s}" is not a date.`, hint);
|
|
30
|
+
return new Date(s).toISOString().replace(/\.000Z$/, 'Z');
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The normalized shape (what list and show print) back to the YAML shorthand, so a promotion
|
|
34
|
+
* read with show can be edited and sent to update: rules as an object, actions with a type.
|
|
35
|
+
*/
|
|
36
|
+
function fromListed(p) {
|
|
37
|
+
const out = { ...p };
|
|
38
|
+
if (isObj(p.rules)) {
|
|
39
|
+
const r = p.rules;
|
|
40
|
+
out.rules = [
|
|
41
|
+
...(r.coupons !== undefined ? [{ coupon: r.coupons }] : []),
|
|
42
|
+
...(r.order_amount !== undefined ? [{ order_amount: r.order_amount }] : []),
|
|
43
|
+
...(r.skus !== undefined ? [{ skus: r.skus }] : []),
|
|
44
|
+
...Object.keys(r).filter((k) => !['coupons', 'order_amount', 'skus'].includes(k)).map((k) => ({ [k]: r[k] })),
|
|
45
|
+
];
|
|
46
|
+
}
|
|
47
|
+
if (Array.isArray(p.actions)) {
|
|
48
|
+
out.actions = p.actions.map((a) => {
|
|
49
|
+
if (!isObj(a) || typeof a.type !== 'string')
|
|
50
|
+
return a;
|
|
51
|
+
const { type, ...rest } = a;
|
|
52
|
+
if (type === 'free_shipping')
|
|
53
|
+
return 'free_shipping';
|
|
54
|
+
if (type === 'percentage' || type === 'fixed_amount') {
|
|
55
|
+
const { value, ...targets } = rest;
|
|
56
|
+
return { [type]: value, ...targets };
|
|
57
|
+
}
|
|
58
|
+
return { [type]: rest };
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Validate one promotion spec (YAML or JSON, as given to promotions create or update) and
|
|
65
|
+
* normalize it to the wire shape, or fail invalid_promotion naming the path inside the spec.
|
|
66
|
+
* Categories become their products' skus as they are now, from store.yml.
|
|
67
|
+
*/
|
|
68
|
+
export function checkPromotion(raw, markets, products) {
|
|
69
|
+
const skus = new Set(products.map((p) => p.sku));
|
|
70
|
+
const currencies = new Set(markets.map((m) => m.currency));
|
|
71
|
+
const at = 'promotion';
|
|
72
|
+
const bad = (msg, hint, path = at) => fail('invalid_promotion', `${path}: ${msg}`, hint);
|
|
73
|
+
{
|
|
74
|
+
const p = raw;
|
|
75
|
+
if (!isObj(p))
|
|
76
|
+
bad('is not a map.', `A promotion is a map with id, rules and actions. ${SHAPE_HINT}`);
|
|
77
|
+
const pr = fromListed(p);
|
|
78
|
+
if (typeof pr.id !== 'string' || !/^[a-z0-9][a-z0-9-]{0,62}$/.test(pr.id))
|
|
79
|
+
bad(`id is ${pr.id === undefined ? 'missing' : `"${String(pr.id)}"`}.`, 'Give the promotion an id of lowercase letters, digits and dashes, such as welcome10: the handle for show, update, enable, disable and delete.');
|
|
80
|
+
const id = pr.id;
|
|
81
|
+
for (const k of Object.keys(pr))
|
|
82
|
+
if (!KEYS.includes(k))
|
|
83
|
+
bad(`unknown key ${k}.`, `A promotion's keys are ${KEYS.join(', ')}.`);
|
|
84
|
+
const promo = { id, name: pr.name === undefined ? id : String(pr.name), starts: DEFAULT_STARTS, ends: DEFAULT_ENDS, exclusive: false, enabled: true };
|
|
85
|
+
if (!promo.name.trim() || promo.name.length > 255)
|
|
86
|
+
bad('name is empty or longer than 255 characters.', 'Write a short name the human will recognize in the dashboard.');
|
|
87
|
+
let currency;
|
|
88
|
+
if (pr.market !== undefined) {
|
|
89
|
+
const m = markets.find((x) => x.code === pr.market);
|
|
90
|
+
if (!m)
|
|
91
|
+
bad(`market ${String(pr.market)} is not in markets.`, `Use the code of one of the store's markets: ${markets.map((x) => x.code).join(', ')}.`);
|
|
92
|
+
promo.market = m.code;
|
|
93
|
+
currency = m.currency;
|
|
94
|
+
}
|
|
95
|
+
if (pr.starts !== undefined)
|
|
96
|
+
promo.starts = isoDate(pr.starts, false, (m, h) => bad(`starts ${m}`, h));
|
|
97
|
+
if (pr.ends !== undefined)
|
|
98
|
+
promo.ends = isoDate(pr.ends, true, (m, h) => bad(`ends ${m}`, h));
|
|
99
|
+
if (Date.parse(promo.ends) <= Date.parse(promo.starts))
|
|
100
|
+
bad('ends is not after starts.', 'Make the end date later than the start date.');
|
|
101
|
+
if (pr.limit !== undefined) {
|
|
102
|
+
if (!isInt(pr.limit, 1))
|
|
103
|
+
bad(`limit is ${String(pr.limit)}.`, 'limit is the total number of uses, a whole number of 1 or more.');
|
|
104
|
+
promo.limit = pr.limit;
|
|
105
|
+
}
|
|
106
|
+
if (pr.priority !== undefined) {
|
|
107
|
+
if (!isInt(pr.priority, 1))
|
|
108
|
+
bad(`priority is ${String(pr.priority)}.`, 'priority is a whole number of 1 or more; the lowest runs first.');
|
|
109
|
+
promo.priority = pr.priority;
|
|
110
|
+
}
|
|
111
|
+
for (const k of ['exclusive', 'enabled']) {
|
|
112
|
+
if (pr[k] !== undefined && typeof pr[k] !== 'boolean')
|
|
113
|
+
bad(`${k} is ${String(pr[k])}.`, `${k} is true or false.`);
|
|
114
|
+
if (pr[k] !== undefined)
|
|
115
|
+
promo[k] = pr[k];
|
|
116
|
+
}
|
|
117
|
+
if (pr.flex !== undefined) {
|
|
118
|
+
if (pr.rules !== undefined || pr.actions !== undefined)
|
|
119
|
+
bad('has flex and rules or actions.', 'A flex promotion keeps its rules and actions inside flex: remove the other rules and actions, or remove flex.');
|
|
120
|
+
const f = pr.flex;
|
|
121
|
+
if (!isObj(f) || !Array.isArray(f.rules) || f.rules.length === 0)
|
|
122
|
+
bad('flex has no rules.', 'Write flex: { rules: [...] }, each rule with conditions and actions. See docs.commercelayer.io/rules-engine.');
|
|
123
|
+
f.rules.forEach((r, j) => {
|
|
124
|
+
const path = `flex.rules[${j}]`;
|
|
125
|
+
if (!isObj(r) || !Array.isArray(r.conditions) || !r.conditions.length || !Array.isArray(r.actions) || !r.actions.length)
|
|
126
|
+
bad('needs a list of conditions and a list of actions.', 'Each flex rule has conditions (field, matcher, value) and actions (type, selector, value).', path);
|
|
127
|
+
});
|
|
128
|
+
promo.flex = f;
|
|
129
|
+
return promo;
|
|
130
|
+
}
|
|
131
|
+
// Rules: a list of single-key maps; none means every order.
|
|
132
|
+
const rules = {};
|
|
133
|
+
let ruleSkus;
|
|
134
|
+
const seen = new Set();
|
|
135
|
+
const resolve = (v, kind, path) => {
|
|
136
|
+
if (kind === 'category') {
|
|
137
|
+
const name = String(v).split('>').map((s) => s.trim()).filter(Boolean).join(' > ');
|
|
138
|
+
const found = products.filter((x) => x.category === name || x.category?.startsWith(`${name} > `)).map((x) => x.sku);
|
|
139
|
+
if (!name || !found.length)
|
|
140
|
+
bad(`no product is in category "${String(v)}".`, `Use a category of the store's products, written as in the products: ${[...new Set(products.map((x) => x.category).filter(Boolean))].join(', ') || 'none has a category yet'}.`, path);
|
|
141
|
+
return found.sort();
|
|
142
|
+
}
|
|
143
|
+
const l = list(v).map(String);
|
|
144
|
+
if (!l.length)
|
|
145
|
+
bad('skus is empty.', 'List one or more SKUs.', path);
|
|
146
|
+
for (const s of l)
|
|
147
|
+
if (!skus.has(s))
|
|
148
|
+
bad(`${s} is not a product.`, 'Use SKUs of the store\'s products.', path);
|
|
149
|
+
return [...new Set(l)].sort();
|
|
150
|
+
};
|
|
151
|
+
const rawRules = pr.rules === undefined ? [] : pr.rules;
|
|
152
|
+
if (!Array.isArray(rawRules))
|
|
153
|
+
bad('rules is not a list.', `Write rules as a list: rules: [{ coupon: CODE }]. ${SHAPE_HINT}`);
|
|
154
|
+
rawRules.forEach((r, j) => {
|
|
155
|
+
const path = `rules[${j}]`;
|
|
156
|
+
if (!isObj(r) || Object.keys(r).length !== 1)
|
|
157
|
+
bad('is not a single rule.', `Each rule is one of ${RULES.join(', ')}, written as a one-key map: { coupon: CODE }.`, path);
|
|
158
|
+
const [k, v] = Object.entries(r)[0];
|
|
159
|
+
if (!RULES.includes(k))
|
|
160
|
+
bad(`unknown rule ${k}.`, `Rules are ${RULES.join(', ')}. For anything else, write a flex promotion.`, path);
|
|
161
|
+
const kind = k === 'category' ? 'skus' : k;
|
|
162
|
+
if (seen.has(kind))
|
|
163
|
+
bad(`has two ${kind === 'skus' ? 'skus or category' : k} rules.`, kind === 'skus' ? 'Use skus or category once per promotion: list every SKU in one rule, or make two promotions.' : `Use one ${k} rule per promotion; a coupon rule takes a list of codes.`, path);
|
|
164
|
+
seen.add(kind);
|
|
165
|
+
if (k === 'coupon') {
|
|
166
|
+
const l = list(v).map(String);
|
|
167
|
+
for (const c of l) {
|
|
168
|
+
if (!/^[A-Za-z0-9_-]{8,40}$/.test(c))
|
|
169
|
+
bad(`coupon "${c}" is not 8 to 40 letters, digits, - or _.`, 'Commerce Layer codes have 8 to 40 characters: WELCOME10, not WELCOME.', path);
|
|
170
|
+
}
|
|
171
|
+
if (!l.length)
|
|
172
|
+
bad('coupon has no code.', 'Write coupon: CODE, or a list of codes.', path);
|
|
173
|
+
if (new Set(l).size > 20)
|
|
174
|
+
bad(`coupon has ${new Set(l).size} codes.`, 'A promotion takes at most 20 codes: split them across promotions.', path);
|
|
175
|
+
rules.coupons = [...new Set(l)];
|
|
176
|
+
}
|
|
177
|
+
else if (k === 'order_amount') {
|
|
178
|
+
if (isObj(v) && v.max !== undefined)
|
|
179
|
+
bad('order_amount has a max.', 'Commerce Layer promotions match a minimum order amount only: remove max, or write a flex promotion.', path);
|
|
180
|
+
if (!isObj(v) || Object.keys(v).some((x) => x !== 'min') || !isNum(v.min) || v.min <= 0)
|
|
181
|
+
bad('order_amount needs min, a positive amount.', 'Write order_amount: { min: 50 }, the minimum items total, in the market currency, in major units.', path);
|
|
182
|
+
rules.order_amount = { min: v.min };
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
ruleSkus = resolve(v, k, path);
|
|
186
|
+
rules.skus = ruleSkus;
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
// Actions: one or more, a single-key map or the bare free_shipping.
|
|
190
|
+
if (!Array.isArray(pr.actions) || pr.actions.length === 0)
|
|
191
|
+
bad('has no actions.', `Add actions: what the promotion does, such as actions: [{ percentage: 10 }]. ${SHAPE_HINT}`);
|
|
192
|
+
const actions = [];
|
|
193
|
+
const types = new Set();
|
|
194
|
+
let needsCurrency = Boolean(rules.order_amount);
|
|
195
|
+
pr.actions.forEach((a, j) => {
|
|
196
|
+
const path = `actions[${j}]`;
|
|
197
|
+
const entry = a === 'free_shipping' ? { free_shipping: true } : a;
|
|
198
|
+
if (!isObj(entry))
|
|
199
|
+
bad('is not an action.', `Each action is one of ${ACTIONS.join(', ')}: { percentage: 10 }, or free_shipping.`, path);
|
|
200
|
+
const [k, v] = Object.entries(entry)[0] ?? [];
|
|
201
|
+
if (!ACTIONS.includes(k))
|
|
202
|
+
bad(`unknown action ${k ?? '(none)'}.`, `Actions are ${ACTIONS.join(', ')}. For anything else, write a flex promotion.`, path);
|
|
203
|
+
if (types.has(k))
|
|
204
|
+
bad(`has two ${k} actions.`, 'Use each action once per promotion.', path);
|
|
205
|
+
types.add(k);
|
|
206
|
+
const extra = Object.keys(entry).filter((x) => x !== k);
|
|
207
|
+
// Targets: skus or category next to the action, or inside its map; else the rule's.
|
|
208
|
+
const target = (o, required) => {
|
|
209
|
+
if (o.skus !== undefined && o.category !== undefined)
|
|
210
|
+
bad('has skus and category.', 'Use one of them.', path);
|
|
211
|
+
if (o.skus !== undefined)
|
|
212
|
+
return resolve(o.skus, 'skus', path);
|
|
213
|
+
if (o.category !== undefined)
|
|
214
|
+
return resolve(o.category, 'category', path);
|
|
215
|
+
if (required && !ruleSkus)
|
|
216
|
+
bad(`${k} needs the SKUs it applies to.`, `Add skus: [...] or category: ... to the action, or a skus or category rule.`, path);
|
|
217
|
+
return ruleSkus;
|
|
218
|
+
};
|
|
219
|
+
const only = (o, allowed) => {
|
|
220
|
+
for (const x of Object.keys(o))
|
|
221
|
+
if (!allowed.includes(x))
|
|
222
|
+
bad(`unknown key ${x} in ${k}.`, `${k} takes ${allowed.join(', ')}.`, path);
|
|
223
|
+
};
|
|
224
|
+
if (k === 'percentage') {
|
|
225
|
+
only(entry, ['percentage', 'skus', 'category']);
|
|
226
|
+
if (!isInt(v, 1) || v > 100)
|
|
227
|
+
bad(`percentage is ${String(v)}.`, 'percentage is a whole number from 1 to 100: 10 is 10% off. To make shipping free, use free_shipping.', path);
|
|
228
|
+
const t = target(entry, false);
|
|
229
|
+
actions.push({ type: 'percentage', value: v, ...(t ? { skus: t } : {}) });
|
|
230
|
+
}
|
|
231
|
+
else if (k === 'fixed_amount') {
|
|
232
|
+
only(entry, ['fixed_amount']);
|
|
233
|
+
if (!isNum(v) || v <= 0)
|
|
234
|
+
bad(`fixed_amount is ${String(v)}.`, 'fixed_amount is the amount off the order, in major units: 5 is 5 euros.', path);
|
|
235
|
+
actions.push({ type: 'fixed_amount', value: v });
|
|
236
|
+
needsCurrency = true;
|
|
237
|
+
}
|
|
238
|
+
else if (k === 'free_shipping') {
|
|
239
|
+
if (extra.length || (v !== true && v !== null && !(isObj(v) && !Object.keys(v).length)))
|
|
240
|
+
bad('free_shipping takes no settings.', 'Write free_shipping on its own.', path);
|
|
241
|
+
actions.push({ type: 'free_shipping' });
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
if (extra.length)
|
|
245
|
+
bad(`${k} has keys next to it: ${extra.join(', ')}.`, `Put them inside: ${k}: { ... }.`, path);
|
|
246
|
+
if (!isObj(v))
|
|
247
|
+
bad(`${k} is not a map.`, SHAPE_HINT, path);
|
|
248
|
+
const o = v;
|
|
249
|
+
if (k === 'fixed_price') {
|
|
250
|
+
only(o, ['price', 'skus', 'category']);
|
|
251
|
+
if (!isNum(o.price) || o.price <= 0)
|
|
252
|
+
bad(`fixed_price.price is ${String(o.price)}.`, 'price is the price the SKUs sell at, in major units: 9.90.', path);
|
|
253
|
+
actions.push({ type: 'fixed_price', price: o.price, skus: target(o, true) });
|
|
254
|
+
needsCurrency = true;
|
|
255
|
+
}
|
|
256
|
+
else if (k === 'free_gift') {
|
|
257
|
+
only(o, ['skus', 'category', 'quantity']);
|
|
258
|
+
if (o.skus === undefined && o.category === undefined)
|
|
259
|
+
bad('free_gift needs the SKUs given away.', 'Write free_gift: { skus: [GIFT-SKU], quantity: 1 }.', path);
|
|
260
|
+
if (o.quantity !== undefined && !isInt(o.quantity, 1))
|
|
261
|
+
bad(`free_gift.quantity is ${String(o.quantity)}.`, 'quantity is a whole number of 1 or more.', path);
|
|
262
|
+
const gifts = target(o, true);
|
|
263
|
+
if (gifts.length > 25)
|
|
264
|
+
bad(`free_gift has ${gifts.length} skus.`, 'A free gift takes at most 25 SKUs: narrow the list or the category.', path);
|
|
265
|
+
actions.push({ type: 'free_gift', skus: gifts, quantity: o.quantity ?? 1 });
|
|
266
|
+
}
|
|
267
|
+
else {
|
|
268
|
+
only(o, ['x', 'y', 'cheapest_free', 'skus', 'category']);
|
|
269
|
+
if (!isInt(o.x, 2) || !isInt(o.y, 1) || o.y >= o.x)
|
|
270
|
+
bad(`buy_x_pay_y is x ${String(o.x)}, y ${String(o.y)}.`, 'x and y are whole numbers with x above y and y at least 1: { x: 3, y: 2 } is 3 for 2.', path);
|
|
271
|
+
if (o.cheapest_free !== undefined && typeof o.cheapest_free !== 'boolean')
|
|
272
|
+
bad('cheapest_free is not true or false.', 'cheapest_free is true or false.', path);
|
|
273
|
+
actions.push({ type: 'buy_x_pay_y', x: o.x, y: o.y, cheapest_free: o.cheapest_free ?? false, skus: target(o, true) });
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
if (needsCurrency && !currency && currencies.size > 1)
|
|
278
|
+
bad('has an amount but no market, in a store with several currencies.', 'Set market: so the amount has a currency, or use a percentage.');
|
|
279
|
+
promo.rules = rules;
|
|
280
|
+
promo.actions = actions;
|
|
281
|
+
return promo;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Commerce Layer promotions active without a coupon: each action of an enabled promotion that
|
|
286
|
+
* has not ended is one (the API makes one per action). Commerce Layer applies at most 10.
|
|
287
|
+
*/
|
|
288
|
+
export function activeWithoutCoupon(promotions, now = Date.now()) {
|
|
289
|
+
const live = promotions.filter((p) => p.enabled && !p.flex && !p.rules?.coupons && Date.parse(p.ends) > now);
|
|
290
|
+
return { count: live.reduce((n, p) => n + (p.actions?.length ?? 1), 0), ids: live.map((p) => p.id) };
|
|
291
|
+
}
|
package/dist/store.js
CHANGED
|
@@ -136,6 +136,8 @@ export function loadStore() {
|
|
|
136
136
|
}
|
|
137
137
|
}
|
|
138
138
|
}
|
|
139
|
+
if (doc.promotions !== undefined)
|
|
140
|
+
fail('invalid_store', 'store.yml has promotions:, which is not part of the store files.', 'Promotions are live settings in Commerce Layer: create each one with `c11r promotions create -f <file>` (the same entry as YAML or JSON), then remove promotions: from store.yml.');
|
|
139
141
|
const policies = (doc.policies ?? {});
|
|
140
142
|
for (const [key, url] of Object.entries(policies)) {
|
|
141
143
|
if (url && !/^https?:\/\//.test(String(url)))
|
package/dist/ucp/buy.js
CHANGED
|
@@ -68,6 +68,8 @@ export async function buy(origin, opts = {}) {
|
|
|
68
68
|
const why = (checkout.messages ?? []).filter((m) => m.type === 'error').map((m) => `${m.code} at ${m.path}: ${m.content}`).join('; ');
|
|
69
69
|
fail('checkout_not_ready', `The checkout is ${checkout.status}: ${why}`, 'Fix the store (markets, countries, prices) and run the command again.');
|
|
70
70
|
}
|
|
71
|
+
// The checkout as it was before paying: its warnings (a code that applied nothing) and discounts.
|
|
72
|
+
const ready = checkout;
|
|
71
73
|
const completeCall = await shopping.completeCheckout(store, checkout.id, { payment: { instruments: [sandboxInstrument(opts.decline)] }, signals: TEST_SIGNALS });
|
|
72
74
|
checkout = note(steps, 'pay', completeCall, '');
|
|
73
75
|
steps[steps.length - 1].result = checkout.status === 'completed' ? `completed, order ${checkout.order.id}` : `${checkout.status}: ${(checkout.messages ?? []).map((m) => m.code).join(', ')}`;
|
|
@@ -76,7 +78,7 @@ export async function buy(origin, opts = {}) {
|
|
|
76
78
|
order = note(steps, 'order', await shopping.getOrder(store, checkout.order.id), '');
|
|
77
79
|
steps[steps.length - 1].result = `${order.label}, ${order.line_items.length} line item(s), ${order.totals.at(-1).amount} ${order.currency}`;
|
|
78
80
|
}
|
|
79
|
-
return { store, steps, checkout, order };
|
|
81
|
+
return { store, steps, checkout, ready, order };
|
|
80
82
|
}
|
|
81
83
|
catch (e) {
|
|
82
84
|
if (e instanceof UcpError) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commercelayer/agent-cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"description": "c11r: the Commerce Layer CLI for coding agents. Builds a sandbox store that people and shopping agents can buy from.",
|
|
5
5
|
"homepage": "https://commercelayer.ai",
|
|
6
6
|
"keywords": [
|