@commercelayer/agent-cli 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,13 +15,13 @@ npx -y @commercelayer/agent-cli@latest init --name "The Blue Brand" # runs c11
15
15
  | Command | Does |
16
16
  | --- | --- |
17
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`. Reuses the store if they exist. |
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; 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. |
18
+ | `plan` | Validate `store.yml` and the product files, and list what `apply` would create, update or delete: markets, products, promotions, images, and the store's name, policies and theme; `warnings` flags more than 10 active promotions without a coupon. `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 and promotions 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
- | `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 and every step. |
24
+ | `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
25
  | `orders list` | The orders placed in the store, newest first, as UCP order entities. |
26
26
  | `orders show <id or number>` | One order. |
27
27
  | `ucp profile [--agent]` | The store's UCP business profile (`/.well-known/ucp`); with `--agent`, this CLI's own platform profile. |
@@ -58,6 +58,16 @@ theme:
58
58
  logo: img/logo.svg # or an https:// URL; default: the name as a wordmark
59
59
  hero: img/hero.jpg # the home page's wide image; default: a band in the accent
60
60
  tagline: Small-batch coffee, roasted in Milan # at most 120 characters; default: "Ships to <countries>"
61
+ promotions:
62
+ # Discounts: rules (when, all must match) and actions (what). The id is stable.
63
+ - id: welcome10
64
+ name: Welcome, 10% off
65
+ rules: [{ coupon: WELCOME10 }]
66
+ actions: [{ percentage: 10 }]
67
+ - id: free-shipping-50
68
+ market: eu
69
+ rules: [{ order_amount: { min: 50 } }]
70
+ actions: [free_shipping]
61
71
  policies:
62
72
  # Pages shopping agents show buyers before they pay. Without them the store
63
73
  # serves default sandbox pages at <preview_url>/policies/<name>.
@@ -65,6 +75,15 @@ policies:
65
75
  terms_of_service: https://example.com/terms
66
76
  returns: https://example.com/returns
67
77
  ```
78
+ ### Promotions
79
+ Each entry under `promotions:` has an `id` (lowercase letters, digits and dashes; the store identifies the promotion by it) and optionally `name`, `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` and `enabled`.
80
+
81
+ - 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` (resolved to its products' skus by the CLI).
82
+ - 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.
83
+ - `flex: { rules: [...] }` holds a Commerce Layer Rules Engine promotion as written (amounts in cents), in place of `rules` and `actions`. It needs the Rules Engine: sandboxes do not have it, and `apply` stops with `flex_unavailable`, applying nothing.
84
+
85
+ `plan` lists them as `promotion` changes, and warns when more than 10 promotion actions without a coupon are active (Commerce Layer applies 10). Removing one needs `--allow-delete`. `orders test --code <CODE>` proves one: its `discounts` lists what was taken off, and a code that applied nothing is a `discount_code_invalid` warning in `messages`.
86
+
68
87
  ### The products folder
69
88
  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
89
  - `<name>.yml`: a list of products in the shape above (one file per collection, or one per product, as you like).
@@ -96,7 +115,7 @@ One rule for images: `img/<file>` anywhere in `store.yml` (product `image_url`,
96
115
 
97
116
  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
117
 
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`.
118
+ Error codes: `invalid_theme`, `invalid_promotion`, `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
119
  ## The store speaks UCP
101
120
  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
121
  | What | Where |
@@ -7,10 +7,11 @@ import { publicOrders, readDb, SANDBOX_DOMAIN, writeDb } from './db.js';
7
7
  import { bearer, error, json, readJson } from './http.js';
8
8
  function publicState(r) {
9
9
  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, assets: r.assets ?? {}, apply: r.apply ?? null, orders: publicOrders(r) };
10
+ return { ...state, name: r.name ?? null, policies: r.policies ?? null, theme: r.theme ?? null, assets: r.assets ?? {}, promotions: r.promotions ?? {}, apply: r.apply ?? null, orders: publicOrders(r) };
11
11
  }
12
- /** Like Commerce Layer refusing a sku in an import. Only the stub does this, so tests can exercise apply_failed. */
12
+ /** Like Commerce Layer refusing a sku or a promotion in an import. Only the stub does this, so tests can exercise apply_failed. */
13
13
  const REFUSED = /^REFUSED-/;
14
+ const REFUSED_PROMOTION = /^refused-/;
14
15
  /** C11R_STUB_BUSY_READS=n: the first n reads of a running import answer 503 commercelayer_busy, as when Commerce Layer rate-limits. Per process. */
15
16
  let busyReads = Number(process.env.C11R_STUB_BUSY_READS) || 0;
16
17
  /** Items an import gets through per read: small, so the CLI polls a few times. */
@@ -28,13 +29,19 @@ function advance(r) {
28
29
  return;
29
30
  job.step = 'done';
30
31
  job.finished_at = new Date().toISOString();
31
- if (r.pending.refused.length) {
32
+ const refusedPromotions = r.pending.refused_promotions ?? [];
33
+ if (r.pending.refused.length || refusedPromotions.length) {
32
34
  job.status = 'failed';
33
- job.errors = r.pending.refused.slice(0, 50).map((sku) => ({ sku, message: 'code is invalid' }));
35
+ job.errors = [
36
+ ...r.pending.refused.map((sku) => ({ sku, message: 'code is invalid' })),
37
+ ...refusedPromotions.map((id) => ({ sku: null, promotion: id, message: 'is invalid' })),
38
+ ].slice(0, 50);
34
39
  }
35
40
  else {
36
41
  job.status = 'completed';
37
42
  r.applied = r.pending.applied;
43
+ if (r.pending.promotions)
44
+ r.promotions = r.pending.promotions;
38
45
  r.applied_hash = job.hash;
39
46
  }
40
47
  delete r.pending;
@@ -98,11 +105,21 @@ export function handleManagement(req) {
98
105
  writeDb(db);
99
106
  if (record.apply?.status === 'in_progress')
100
107
  return error(409, 'apply_in_progress', 'Another apply is still running.', 'Run `c11r status` to follow it, then apply again.');
101
- const { applied, hash, name, policies, theme, assets, allow_delete, full } = readJson(req);
108
+ const { applied, hash, name, policies, theme, promotions, assets, allow_delete, full } = readJson(req);
102
109
  const before = record.applied?.products ?? {};
103
110
  const removed = Object.keys(before).filter((sku) => !(sku in applied.products));
104
- if (removed.length && !allow_delete)
105
- 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`.');
111
+ // No promotions field (older CLIs): the promotions stay as they are.
112
+ const promosBefore = record.promotions ?? {};
113
+ const promos = promotions ? Object.fromEntries(promotions.map((p) => [p.id, p])) : promosBefore;
114
+ const promosRemoved = Object.keys(promosBefore).filter((id) => !(id in promos));
115
+ const promosChanged = Object.keys(promos).filter((id) => !(id in promosBefore) || canonical(promosBefore[id]) !== canonical(promos[id]));
116
+ const doomed = [...removed.map((sku) => `product ${sku}`), ...promosRemoved.map((id) => `promotion ${id}`)];
117
+ if (doomed.length && !allow_delete)
118
+ return error(409, 'deletes_need_approval', `Apply would delete ${doomed.join(', ')}.`, 'Ask the human whether to delete these. If they agree, run `c11r apply --allow-delete`.');
119
+ // Sandboxes have no Rules Engine: a new or changed flex promotion is refused before anything applies.
120
+ const flexAt = (promotions ?? []).findIndex((p) => p.flex && promosChanged.includes(p.id));
121
+ if (flexAt >= 0)
122
+ return error(422, 'flex_unavailable', `promotions[${flexAt}] (${promotions[flexAt].id}): 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, or remove it, then apply again.');
106
123
  // The name, policies and markets apply in the request; products go through an
107
124
  // import job (SKUs, prices, then cleanups for removals) when any changed.
108
125
  record.name = name;
@@ -119,8 +136,10 @@ export function handleManagement(req) {
119
136
  }
120
137
  }
121
138
  const changed = Object.keys(applied.products).filter((sku) => full || !(sku in before) || canonical(before[sku]) !== canonical(applied.products[sku]));
122
- if (changed.length === 0 && removed.length === 0) {
139
+ const refusedPromotions = promosChanged.filter((id) => REFUSED_PROMOTION.test(id));
140
+ if (changed.length === 0 && removed.length === 0 && refusedPromotions.length === 0) {
123
141
  record.applied = applied;
142
+ record.promotions = promos;
124
143
  record.applied_hash = hash;
125
144
  writeDb(db);
126
145
  return json(200, publicState(record));
@@ -131,12 +150,12 @@ export function handleManagement(req) {
131
150
  step: 'skus',
132
151
  hash,
133
152
  processed: 0,
134
- total: changed.length + removed.length,
153
+ total: changed.length + removed.length + promosChanged.length + promosRemoved.length,
135
154
  started_at: new Date().toISOString(),
136
155
  finished_at: null,
137
156
  errors: [],
138
157
  };
139
- record.pending = { applied, refused: changed.filter((sku) => REFUSED.test(sku)) };
158
+ record.pending = { applied, refused: changed.filter((sku) => REFUSED.test(sku)), promotions: promos, refused_promotions: refusedPromotions };
140
159
  writeDb(db);
141
160
  return json(202, publicState(record));
142
161
  }
@@ -208,23 +208,85 @@ function buildLines(ctx, market, requested, existing = []) {
208
208
  });
209
209
  return { lines, messages };
210
210
  }
211
- /** Apply discount codes: the sandbox code is 10% off the items; anything else is rejected with a warning. */
212
- function discounts(ctx, body, lines, messages) {
213
- if (!ctx.shared.has('dev.ucp.shopping.discount') || body.discounts === undefined)
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 appliedList = [];
218
- codes.forEach((code, i) => {
219
- if (code.toUpperCase() === SANDBOX_CODE) {
220
- if (appliedList.length)
221
- messages.push(warn('discount_code_already_applied', `${code} is already applied.`, `$.discounts.codes[${i}]`));
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
- appliedList.push({ code: SANDBOX_CODE, title: '10% off', amount: Math.floor(subtotal / 10), method: 'across' });
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
- else
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/commands.js CHANGED
@@ -10,6 +10,7 @@ import { diff, hashStore, loadStore, storefrontPath, template, toApplied } from
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 } from './promotions.js';
13
14
  import { storefrontCheck } from './check.js';
14
15
  import { buy } from './ucp/buy.js';
15
16
  import { check } from './ucp/check.js';
@@ -19,13 +20,13 @@ import { agentProfile, AGENT_PROFILE_URL } from './ucp/platform.js';
19
20
  export const VERSION = createRequire(import.meta.url)('../package.json').version;
20
21
  export const HELP = {
21
22
  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.',
22
- 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
- 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.',
23
+ plan: 'Show what apply would create, update or delete: markets, products, promotions, images, and the store\'s name, policies and theme; warnings flags more than 10 active promotions without a coupon (Commerce Layer applies 10); 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).',
24
+ apply: 'Make the store match .commercelayer/store.yml and .commercelayer/products/: markets, products, promotions, 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 (products, promotions) unless --allow-delete is given. Flex promotions need the Rules Engine: on an organization without it apply stops with flex_unavailable and applies nothing. 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
25
  '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
26
  '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
27
  '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
28
  '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
- '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.',
29
+ '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
30
  'orders list': 'List the orders placed in the store, newest first, as UCP order entities.',
30
31
  'orders show': 'Show one order: c11r orders show <id or number>.',
31
32
  'ucp profile': 'Show the store\'s UCP business profile (/.well-known/ucp). With --agent, this CLI\'s own platform profile instead.',
@@ -79,6 +80,16 @@ export async function init(values) {
79
80
  next: 'Describe the store in .commercelayer/store.yml (products there or in .commercelayer/products/*.yml|csv, the theme under theme:, images in .commercelayer/img/), then run c11r plan and c11r apply: the website is live at preview_url.',
80
81
  };
81
82
  }
83
+ /** Warnings plan and apply give about promotions: the active limit, and an API without them. */
84
+ function promotionWarnings(state, desired) {
85
+ const out = [];
86
+ if (desired.promotions.length && state.promotions === undefined)
87
+ out.push('This API does not apply promotions yet: apply will refuse while store.yml has any.');
88
+ const active = activeWithoutCoupon(desired.promotions);
89
+ if (active.count > ACTIVE_LIMIT)
90
+ out.push(`${active.count} promotions without a coupon are active (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, end dates, or enabled: false.`);
91
+ return out.length ? out : undefined;
92
+ }
82
93
  export async function plan() {
83
94
  const api = createApi(requireCredentials());
84
95
  const desired = loadStore();
@@ -90,6 +101,7 @@ export async function plan() {
90
101
  changes,
91
102
  deletes: changes.filter((c) => c.action === 'delete' && c.type !== 'asset').length,
92
103
  ...imageNotes(assets),
104
+ warnings: promotionWarnings(state, desired),
93
105
  next: assets.missing.length ? 'Put the missing images in .commercelayer/img/ (img/<file> in store.yml is .commercelayer/img/<file>), or fix the references: apply refuses until they are there.' : undefined,
94
106
  };
95
107
  }
@@ -148,6 +160,9 @@ export async function apply(flags) {
148
160
  if (assets.missing.length) {
149
161
  fail('missing_images', `The store references images that are not in .commercelayer/img/: ${assets.missing.join('; ')}.`, 'Put the files there (img/<file> in store.yml is .commercelayer/img/<file>), or fix or remove the references, then apply again.');
150
162
  }
163
+ if (desired.promotions.length && state.promotions === undefined) {
164
+ fail('promotions_unsupported', 'This API does not apply promotions yet.', 'Remove promotions: from store.yml to apply the rest, or update c11r (npx @commercelayer/agent-cli@latest) and try again.');
165
+ }
151
166
  // Images first, so the theme and products never point at a file the store host does not have yet.
152
167
  const uploaded = assets.supported ? await uploadAssets(api, assets) : 0;
153
168
  const body = {
@@ -156,6 +171,7 @@ export async function apply(flags) {
156
171
  name: desired.name,
157
172
  policies: desired.policies,
158
173
  theme: desired.theme ?? {},
174
+ promotions: desired.promotions,
159
175
  ...(assets.supported && { assets: assets.keep }),
160
176
  ...(flags.has('--allow-delete') && { allow_delete: true }),
161
177
  ...(flags.has('--full') && { full: true }),
@@ -165,9 +181,10 @@ export async function apply(flags) {
165
181
  const done = job ? await follow(api, started) : started;
166
182
  if (job && done.apply?.id === job.id && done.apply.status === 'failed') {
167
183
  const errors = done.apply.errors;
168
- const list = errors.map((e) => (e.sku ? `${e.sku}: ${e.message}` : e.message)).join('; ');
169
- const what = errors.length === 1 ? '1 product was refused' : `${errors.length} products were refused`;
170
- fail('apply_failed', errors.length ? `${what}: ${list}` : 'The apply failed in Commerce Layer.', 'Fix those products in store.yml or the catalog source and run `c11r apply` again; the rest of the store is unchanged.');
184
+ const list = errors.map((e) => (e.sku ? `${e.sku}: ${e.message}` : e.promotion && !e.message.startsWith(`promotion ${e.promotion}:`) ? `promotion ${e.promotion}: ${e.message}` : e.message)).join('; ');
185
+ const promos = errors.filter((e) => e.promotion && !e.sku).length;
186
+ const what = errors.length === 1 ? (promos ? '1 promotion was refused' : '1 product was refused') : promos === errors.length ? `${errors.length} promotions were refused` : promos ? `${errors.length} items were refused` : `${errors.length} products were refused`;
187
+ fail('apply_failed', errors.length ? `${what}: ${list}` : 'The apply failed in Commerce Layer.', `Fix ${promos ? 'them' : 'those products'} in store.yml${promos ? '' : ' or the catalog source'} and run \`c11r apply\` again; the rest of the store is unchanged.`);
171
188
  }
172
189
  return {
173
190
  status: 'applied',
@@ -175,6 +192,8 @@ export async function apply(flags) {
175
192
  preview_url: done.preview_url,
176
193
  markets: desired.markets.length,
177
194
  products: desired.products.length,
195
+ promotions: desired.promotions.length || undefined,
196
+ warnings: promotionWarnings(state, desired),
178
197
  payment_gateway: 'sandbox (test payments only)',
179
198
  images_uploaded: uploaded || undefined,
180
199
  unused_images: assets.unused.length ? assets.unused : undefined,
@@ -254,8 +273,10 @@ export async function ordersTest(flags, values) {
254
273
  return {
255
274
  status: p.checkout.status === 'completed' ? 'paid' : p.checkout.status,
256
275
  order: p.order,
276
+ // What the promotions took off, in minor units like the totals; a code that applied nothing is in messages.
277
+ discounts: (p.checkout.discounts ?? p.ready.discounts)?.applied?.length ? (p.checkout.discounts ?? p.ready.discounts).applied : undefined,
257
278
  checkout_id: p.checkout.id,
258
- messages: p.checkout.messages,
279
+ messages: p.checkout.messages ?? p.ready.messages,
259
280
  payment_handler: 'ai.commercelayer.sandbox (test payments only)',
260
281
  steps: p.steps,
261
282
  };
@@ -0,0 +1,262 @@
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
+ /** Validate promotions: and normalize them to the wire shape, or fail invalid_promotion with the path. */
33
+ export function checkPromotions(raw, markets, products) {
34
+ if (raw === undefined || raw === null)
35
+ return [];
36
+ if (!Array.isArray(raw))
37
+ fail('invalid_promotion', 'promotions in store.yml is not a list.', `Write promotions as a YAML list under \`promotions:\`, one entry per promotion. ${SHAPE_HINT}`);
38
+ const skus = new Set(products.map((p) => p.sku));
39
+ const currencies = new Set(markets.map((m) => m.currency));
40
+ const ids = new Set();
41
+ const out = [];
42
+ raw.forEach((p, i) => {
43
+ let at = `promotions[${i}]`;
44
+ const bad = (msg, hint, path = at) => fail('invalid_promotion', `${path}: ${msg}`, hint);
45
+ if (!isObj(p))
46
+ bad('is not a map.', `Each promotion is a map with id, rules and actions. ${SHAPE_HINT}`);
47
+ const pr = p;
48
+ if (typeof pr.id !== 'string' || !/^[a-z0-9][a-z0-9-]{0,62}$/.test(pr.id))
49
+ bad(`id is ${pr.id === undefined ? 'missing' : `"${String(pr.id)}"`}.`, 'Give every promotion an id of lowercase letters, digits and dashes, such as welcome10. The store identifies the promotion by it: keep it stable.');
50
+ const id = pr.id;
51
+ if (ids.has(id))
52
+ bad(`two promotions have id ${id}.`, 'Give every promotion a unique id.');
53
+ ids.add(id);
54
+ at = `promotions[${i}] (${id})`;
55
+ for (const k of Object.keys(pr))
56
+ if (!KEYS.includes(k))
57
+ bad(`unknown key ${k}.`, `A promotion's keys are ${KEYS.join(', ')}.`);
58
+ const promo = { id, name: pr.name === undefined ? id : String(pr.name), starts: DEFAULT_STARTS, ends: DEFAULT_ENDS, exclusive: false, enabled: true };
59
+ if (!promo.name.trim() || promo.name.length > 255)
60
+ bad('name is empty or longer than 255 characters.', 'Write a short name the human will recognize in the dashboard.');
61
+ let currency;
62
+ if (pr.market !== undefined) {
63
+ const m = markets.find((x) => x.code === pr.market);
64
+ if (!m)
65
+ 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(', ')}.`);
66
+ promo.market = m.code;
67
+ currency = m.currency;
68
+ }
69
+ if (pr.starts !== undefined)
70
+ promo.starts = isoDate(pr.starts, false, (m, h) => bad(`starts ${m}`, h));
71
+ if (pr.ends !== undefined)
72
+ promo.ends = isoDate(pr.ends, true, (m, h) => bad(`ends ${m}`, h));
73
+ if (Date.parse(promo.ends) <= Date.parse(promo.starts))
74
+ bad('ends is not after starts.', 'Make the end date later than the start date.');
75
+ if (pr.limit !== undefined) {
76
+ if (!isInt(pr.limit, 1))
77
+ bad(`limit is ${String(pr.limit)}.`, 'limit is the total number of uses, a whole number of 1 or more.');
78
+ promo.limit = pr.limit;
79
+ }
80
+ if (pr.priority !== undefined) {
81
+ if (!isInt(pr.priority, 1))
82
+ bad(`priority is ${String(pr.priority)}.`, 'priority is a whole number of 1 or more; the lowest runs first.');
83
+ promo.priority = pr.priority;
84
+ }
85
+ for (const k of ['exclusive', 'enabled']) {
86
+ if (pr[k] !== undefined && typeof pr[k] !== 'boolean')
87
+ bad(`${k} is ${String(pr[k])}.`, `${k} is true or false.`);
88
+ if (pr[k] !== undefined)
89
+ promo[k] = pr[k];
90
+ }
91
+ if (pr.flex !== undefined) {
92
+ if (pr.rules !== undefined || pr.actions !== undefined)
93
+ 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.');
94
+ const f = pr.flex;
95
+ if (!isObj(f) || !Array.isArray(f.rules) || f.rules.length === 0)
96
+ bad('flex has no rules.', 'Write flex: { rules: [...] }, each rule with conditions and actions. See docs.commercelayer.io/rules-engine.');
97
+ f.rules.forEach((r, j) => {
98
+ const path = `${at}.flex.rules[${j}]`;
99
+ if (!isObj(r) || !Array.isArray(r.conditions) || !r.conditions.length || !Array.isArray(r.actions) || !r.actions.length)
100
+ 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);
101
+ });
102
+ promo.flex = f;
103
+ out.push(promo);
104
+ return;
105
+ }
106
+ // Rules: a list of single-key maps; none means every order.
107
+ const rules = {};
108
+ let ruleSkus;
109
+ const seen = new Set();
110
+ const resolve = (v, kind, path) => {
111
+ if (kind === 'category') {
112
+ const name = String(v).split('>').map((s) => s.trim()).filter(Boolean).join(' > ');
113
+ const found = products.filter((x) => x.category === name || x.category?.startsWith(`${name} > `)).map((x) => x.sku);
114
+ if (!name || !found.length)
115
+ 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);
116
+ return found.sort();
117
+ }
118
+ const l = list(v).map(String);
119
+ if (!l.length)
120
+ bad('skus is empty.', 'List one or more SKUs.', path);
121
+ for (const s of l)
122
+ if (!skus.has(s))
123
+ bad(`${s} is not a product.`, 'Use SKUs of the store\'s products.', path);
124
+ return [...new Set(l)].sort();
125
+ };
126
+ const rawRules = pr.rules === undefined ? [] : pr.rules;
127
+ if (!Array.isArray(rawRules))
128
+ bad('rules is not a list.', `Write rules as a list: rules: [{ coupon: CODE }]. ${SHAPE_HINT}`);
129
+ rawRules.forEach((r, j) => {
130
+ const path = `${at}.rules[${j}]`;
131
+ if (!isObj(r) || Object.keys(r).length !== 1)
132
+ bad('is not a single rule.', `Each rule is one of ${RULES.join(', ')}, written as a one-key map: { coupon: CODE }.`, path);
133
+ const [k, v] = Object.entries(r)[0];
134
+ if (!RULES.includes(k))
135
+ bad(`unknown rule ${k}.`, `Rules are ${RULES.join(', ')}. For anything else, write a flex promotion.`, path);
136
+ const kind = k === 'category' ? 'skus' : k;
137
+ if (seen.has(kind))
138
+ 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);
139
+ seen.add(kind);
140
+ if (k === 'coupon') {
141
+ const l = list(v).map(String);
142
+ for (const c of l) {
143
+ if (!/^[A-Za-z0-9_-]{8,40}$/.test(c))
144
+ bad(`coupon "${c}" is not 8 to 40 letters, digits, - or _.`, 'Commerce Layer codes have 8 to 40 characters: WELCOME10, not WELCOME.', path);
145
+ }
146
+ if (!l.length)
147
+ bad('coupon has no code.', 'Write coupon: CODE, or a list of codes.', path);
148
+ rules.coupons = [...new Set(l)];
149
+ }
150
+ else if (k === 'order_amount') {
151
+ if (isObj(v) && v.max !== undefined)
152
+ bad('order_amount has a max.', 'Commerce Layer promotions match a minimum order amount only: remove max, or write a flex promotion.', path);
153
+ if (!isObj(v) || Object.keys(v).some((x) => x !== 'min') || !isNum(v.min) || v.min <= 0)
154
+ 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);
155
+ rules.order_amount = { min: v.min };
156
+ }
157
+ else {
158
+ ruleSkus = resolve(v, k, path);
159
+ rules.skus = ruleSkus;
160
+ }
161
+ });
162
+ // Actions: one or more, a single-key map or the bare free_shipping.
163
+ if (!Array.isArray(pr.actions) || pr.actions.length === 0)
164
+ bad('has no actions.', `Add actions: what the promotion does, such as actions: [{ percentage: 10 }]. ${SHAPE_HINT}`);
165
+ const actions = [];
166
+ const types = new Set();
167
+ let needsCurrency = Boolean(rules.order_amount);
168
+ pr.actions.forEach((a, j) => {
169
+ const path = `${at}.actions[${j}]`;
170
+ const entry = a === 'free_shipping' ? { free_shipping: true } : a;
171
+ if (!isObj(entry))
172
+ bad('is not an action.', `Each action is one of ${ACTIONS.join(', ')}: { percentage: 10 }, or free_shipping.`, path);
173
+ const [k, v] = Object.entries(entry)[0] ?? [];
174
+ if (!ACTIONS.includes(k))
175
+ bad(`unknown action ${k ?? '(none)'}.`, `Actions are ${ACTIONS.join(', ')}. For anything else, write a flex promotion.`, path);
176
+ if (types.has(k))
177
+ bad(`has two ${k} actions.`, 'Use each action once per promotion.', path);
178
+ types.add(k);
179
+ const extra = Object.keys(entry).filter((x) => x !== k);
180
+ // Targets: skus or category next to the action, or inside its map; else the rule's.
181
+ const target = (o, required) => {
182
+ if (o.skus !== undefined && o.category !== undefined)
183
+ bad('has skus and category.', 'Use one of them.', path);
184
+ if (o.skus !== undefined)
185
+ return resolve(o.skus, 'skus', path);
186
+ if (o.category !== undefined)
187
+ return resolve(o.category, 'category', path);
188
+ if (required && !ruleSkus)
189
+ bad(`${k} needs the SKUs it applies to.`, `Add skus: [...] or category: ... to the action, or a skus or category rule.`, path);
190
+ return ruleSkus;
191
+ };
192
+ const only = (o, allowed) => {
193
+ for (const x of Object.keys(o))
194
+ if (!allowed.includes(x))
195
+ bad(`unknown key ${x} in ${k}.`, `${k} takes ${allowed.join(', ')}.`, path);
196
+ };
197
+ if (k === 'percentage') {
198
+ only(entry, ['percentage', 'skus', 'category']);
199
+ if (!isNum(v) || v <= 0 || v > 100)
200
+ bad(`percentage is ${String(v)}.`, 'percentage is a number above 0 and up to 100: 10 is 10% off. To make shipping free, use free_shipping.', path);
201
+ const t = target(entry, false);
202
+ actions.push({ type: 'percentage', value: v, ...(t ? { skus: t } : {}) });
203
+ }
204
+ else if (k === 'fixed_amount') {
205
+ only(entry, ['fixed_amount']);
206
+ if (!isNum(v) || v <= 0)
207
+ bad(`fixed_amount is ${String(v)}.`, 'fixed_amount is the amount off the order, in major units: 5 is 5 euros.', path);
208
+ actions.push({ type: 'fixed_amount', value: v });
209
+ needsCurrency = true;
210
+ }
211
+ else if (k === 'free_shipping') {
212
+ if (extra.length || (v !== true && v !== null && !(isObj(v) && !Object.keys(v).length)))
213
+ bad('free_shipping takes no settings.', 'Write free_shipping on its own.', path);
214
+ actions.push({ type: 'free_shipping' });
215
+ }
216
+ else {
217
+ if (extra.length)
218
+ bad(`${k} has keys next to it: ${extra.join(', ')}.`, `Put them inside: ${k}: { ... }.`, path);
219
+ if (!isObj(v))
220
+ bad(`${k} is not a map.`, SHAPE_HINT, path);
221
+ const o = v;
222
+ if (k === 'fixed_price') {
223
+ only(o, ['price', 'skus', 'category']);
224
+ if (!isNum(o.price) || o.price <= 0)
225
+ bad(`fixed_price.price is ${String(o.price)}.`, 'price is the price the SKUs sell at, in major units: 9.90.', path);
226
+ actions.push({ type: 'fixed_price', price: o.price, skus: target(o, true) });
227
+ needsCurrency = true;
228
+ }
229
+ else if (k === 'free_gift') {
230
+ only(o, ['skus', 'category', 'quantity']);
231
+ if (o.skus === undefined && o.category === undefined)
232
+ bad('free_gift needs the SKUs given away.', 'Write free_gift: { skus: [GIFT-SKU], quantity: 1 }.', path);
233
+ if (o.quantity !== undefined && !isInt(o.quantity, 1))
234
+ bad(`free_gift.quantity is ${String(o.quantity)}.`, 'quantity is a whole number of 1 or more.', path);
235
+ actions.push({ type: 'free_gift', skus: target(o, true), quantity: o.quantity ?? 1 });
236
+ }
237
+ else {
238
+ only(o, ['x', 'y', 'cheapest_free', 'skus', 'category']);
239
+ if (!isInt(o.x, 2) || !isInt(o.y, 1) || o.y >= o.x)
240
+ 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);
241
+ if (o.cheapest_free !== undefined && typeof o.cheapest_free !== 'boolean')
242
+ bad('cheapest_free is not true or false.', 'cheapest_free is true or false.', path);
243
+ actions.push({ type: 'buy_x_pay_y', x: o.x, y: o.y, cheapest_free: o.cheapest_free ?? false, skus: target(o, true) });
244
+ }
245
+ }
246
+ });
247
+ if (needsCurrency && !currency && currencies.size > 1)
248
+ 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.');
249
+ promo.rules = rules;
250
+ promo.actions = actions;
251
+ out.push(promo);
252
+ });
253
+ return out;
254
+ }
255
+ /**
256
+ * Commerce Layer promotions active without a coupon: each action of an enabled promotion that
257
+ * has not ended is one (the API makes one per action). Commerce Layer applies at most 10.
258
+ */
259
+ export function activeWithoutCoupon(promotions, now = Date.now()) {
260
+ const live = promotions.filter((p) => p.enabled && !p.flex && !p.rules?.coupons && Date.parse(p.ends) > now);
261
+ return { count: live.reduce((n, p) => n + (p.actions?.length ?? 1), 0), ids: live.map((p) => p.id) };
262
+ }
package/dist/store.js CHANGED
@@ -8,6 +8,7 @@ import YAML from 'yaml';
8
8
  import { fail } from './output.js';
9
9
  import { paths } from './files.js';
10
10
  import { productFiles, readProductFile } from './products.js';
11
+ import { checkPromotions } from './promotions.js';
11
12
  /** The keys a product may have. Stock is not one: inventory lives in Commerce Layer. */
12
13
  export const PRODUCT_KEYS = ['sku', 'name', 'description', 'image_url', 'category', 'prices'];
13
14
  export const THEME_KEYS = ['color', 'logo', 'hero', 'tagline'];
@@ -61,6 +62,18 @@ theme:
61
62
  # hero: img/hero.jpg # the home page's wide image; without it a band in the accent
62
63
  # tagline: Small-batch coffee, roasted in Milan # at most 120 characters
63
64
 
65
+ promotions:
66
+ # Discount codes and automatic discounts: rules (when) and actions (what).
67
+ # The id is unique and stable. Amounts are in the market currency, like prices.
68
+ # - id: welcome10
69
+ # name: Welcome, 10% off
70
+ # rules: [{ coupon: WELCOME10 }] # codes have 8 to 40 characters
71
+ # actions: [{ percentage: 10 }]
72
+ # - id: free-shipping-50
73
+ # market: eu
74
+ # rules: [{ order_amount: { min: 50 } }]
75
+ # actions: [free_shipping]
76
+
64
77
  storefront:
65
78
  # Only for a custom storefront (your own web app in this repo, published with
66
79
  # c11r storefront publish): it replaces the theme. Its folder, from the repo root.
@@ -144,7 +157,8 @@ export function loadStore() {
144
157
  const theme = checkTheme(doc.theme);
145
158
  const currencies = [...new Set(markets.map((m) => m.currency))];
146
159
  const products = checkProducts(sourced, currencies);
147
- return { name: doc.name, markets, products, storefront: doc.storefront, policies, theme };
160
+ const promotions = checkPromotions(doc.promotions, markets, products);
161
+ return { name: doc.name, markets, products, storefront: doc.storefront, policies, theme, promotions };
148
162
  }
149
163
  /** The product rules, shared by loadStore and catalog pull: errors name each product's source. */
150
164
  export function checkProducts(sourced, currencies) {
@@ -231,7 +245,15 @@ export function diff(state, desired) {
231
245
  changes.push({ action: 'update', type: 'store', id: 'name' });
232
246
  if (state.policies !== undefined && canonical(state.policies) !== canonical(desired.policies))
233
247
  changes.push({ action: 'update', type: 'store', id: 'policies' });
234
- if (state.theme !== undefined && canonical(state.theme) !== canonical(desired.theme))
248
+ }
249
+ // The theme is listed like a resource: a create when the store has none yet (first apply
250
+ // included), an update when it changes. An API that does not know themes (undefined) is skipped.
251
+ const hasTheme = (t) => Boolean(t && Object.keys(t).length);
252
+ if (!applied || state.theme !== undefined) {
253
+ const before = applied ? state.theme : null;
254
+ if (!hasTheme(before) && hasTheme(desired.theme))
255
+ changes.push({ action: 'create', type: 'store', id: 'theme' });
256
+ else if (hasTheme(before) && canonical(before) !== canonical(desired.theme))
235
257
  changes.push({ action: 'update', type: 'store', id: 'theme' });
236
258
  }
237
259
  const after = toApplied(desired);
@@ -248,5 +270,19 @@ export function diff(state, desired) {
248
270
  if (!(id in after[kind]))
249
271
  changes.push({ action: 'delete', type, id });
250
272
  }
273
+ // Promotions, by id. An API that does not know them (undefined) is skipped: apply refuses instead.
274
+ if (!applied || state.promotions !== undefined) {
275
+ const before = (applied ? state.promotions : undefined) ?? {};
276
+ const want = Object.fromEntries((desired.promotions ?? []).map((p) => [p.id, p]));
277
+ for (const id of Object.keys(want)) {
278
+ if (!(id in before))
279
+ changes.push({ action: 'create', type: 'promotion', id });
280
+ else if (canonical(before[id]) !== canonical(want[id]))
281
+ changes.push({ action: 'update', type: 'promotion', id });
282
+ }
283
+ for (const id of Object.keys(before))
284
+ if (!(id in want))
285
+ changes.push({ action: 'delete', type: 'promotion', id });
286
+ }
251
287
  return changes;
252
288
  }
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.0",
3
+ "version": "0.2.2",
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": [