@commercelayer/agent-cli 0.5.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,11 +4,12 @@
4
4
  //
5
5
  // An order lands placed with its payment authorized. approve (optionally with
6
6
  // capture), capture, cancel, refund, archive and unarchive move it; its one shipment
7
- // goes upcoming → on_hold | picking | packing | ready_to_ship → shipped → delivered.
7
+ // goes upcoming → on_hold | picking → packing → ready_to_ship → shipped → delivered; as in
8
+ // Commerce Layer, an upcoming shipment ships only once it is picking (or past it).
8
9
  import { money } from '../../ucp/shop.js';
9
10
  import { minorUnits } from '../../ucp/platform.js';
10
11
  import { baseLanguage } from './locale.js';
11
- export const ORDER_STATUSES = ['placed', 'approved', 'cancelled', 'editing'];
12
+ export const ORDER_STATUSES = ['pending', 'placed', 'approved', 'cancelled', 'editing'];
12
13
  export const PAYMENT_STATUSES = ['unpaid', 'authorized', 'partially_authorized', 'paid', 'partially_paid', 'voided', 'partially_voided', 'refunded', 'partially_refunded', 'free'];
13
14
  export const FULFILLMENT_STATUSES = ['unfulfilled', 'in_progress', 'fulfilled', 'not_required'];
14
15
  export const SHIPMENT_STATUSES = ['upcoming', 'on_hold', 'picking', 'packing', 'ready_to_ship', 'shipped', 'delivered', 'cancelled'];
@@ -18,11 +19,13 @@ export class OrderError extends Error {
18
19
  status;
19
20
  code;
20
21
  hint;
21
- constructor(status, code, message, hint) {
22
+ path;
23
+ constructor(status, code, message, hint, path) {
22
24
  super(message);
23
25
  this.status = status;
24
26
  this.code = code;
25
27
  this.hint = hint;
28
+ this.path = path;
26
29
  }
27
30
  }
28
31
  const now = () => new Date().toISOString();
@@ -46,6 +49,9 @@ function merchant(o) {
46
49
  function paymentStatus(o) {
47
50
  if (total(o) === 0)
48
51
  return 'free';
52
+ // A pending order has no payment yet: placing it authorizes one.
53
+ if (!merchant(o).transactions.some((t) => t.type === 'authorization'))
54
+ return 'unpaid';
49
55
  const captured = sum(o, 'capture');
50
56
  const refunded = sum(o, 'refund');
51
57
  if (sum(o, 'void') > 0)
@@ -70,6 +76,14 @@ function shipmentRef(o) {
70
76
  const s = merchant(o).shipment;
71
77
  return { id: s.id, number: s.number, status: s.status, tracking: s.tracking };
72
78
  }
79
+ /** Who placed it: a shopping agent over UCP, a person through a checkout link, the store's own checkout, or orders create from an email or a message. */
80
+ const channel = (o) => (o._platform === 'message' ? 'message' : !o._platform ? 'web' : /permalink|\/buy\b/.test(o._platform) ? 'link' : 'agent');
81
+ /** The latest change to the order, its payment or its shipment. */
82
+ function updatedAt(o) {
83
+ const m = merchant(o);
84
+ const all = [m.created_at, m.placed_at, m.updated_at, m.shipment.updated_at, ...m.transactions.map((t) => t.at), ...Object.values(m.shipment.dates)].filter((x) => !!x);
85
+ return all.reduce((a, b) => (Date.parse(b) > Date.parse(a) ? b : a));
86
+ }
73
87
  function row(o) {
74
88
  const m = merchant(o);
75
89
  return {
@@ -79,8 +93,14 @@ function row(o) {
79
93
  payment_status: paymentStatus(o),
80
94
  fulfillment_status: fulfillmentStatus(o),
81
95
  placed_at: m.placed_at,
96
+ created_at: m.created_at ?? m.placed_at,
97
+ approved_at: m.approved_at ?? null,
98
+ cancelled_at: m.cancelled_at ?? null,
99
+ updated_at: updatedAt(o),
82
100
  market: o._market ?? null,
101
+ // The shipping country, as Commerce Layer's country_code follows the shipping address.
83
102
  country: destination(o).address_country ?? null,
103
+ channel: channel(o),
84
104
  currency: o.currency,
85
105
  total: money(total(o), o.currency),
86
106
  email: o._buyer?.email ?? null,
@@ -89,20 +109,19 @@ function row(o) {
89
109
  archived: m.archived_at !== null,
90
110
  };
91
111
  }
92
- function address(o) {
93
- const d = destination(o);
112
+ function address(o, d = destination(o)) {
94
113
  if (!Object.keys(d).length)
95
114
  return null;
96
115
  return {
97
116
  name: [d.first_name, d.last_name].filter(Boolean).join(' ') || null,
98
- company: null,
117
+ company: d.company ?? null,
99
118
  line_1: d.street_address ?? null,
100
119
  line_2: d.extended_address ?? null,
101
120
  city: d.address_locality ?? null,
102
121
  zip_code: d.postal_code ?? null,
103
122
  state_code: d.address_region ?? null,
104
123
  country_code: d.address_country ?? null,
105
- phone: o._buyer?.phone_number ?? null,
124
+ phone: d.phone ?? o._buyer?.phone_number ?? null,
106
125
  };
107
126
  }
108
127
  function detail(o, r) {
@@ -122,13 +141,14 @@ function detail(o, r) {
122
141
  })),
123
142
  totals: { subtotal: money(amount('subtotal'), cur), discount: money(amount('discount'), cur), shipping: money(amount('fulfillment'), cur), taxes: money(amount('tax'), cur), total: money(total(o), cur), codes: o._codes ?? [] },
124
143
  shipping_address: address(o),
125
- billing_address: address(o),
144
+ billing_address: o._bill_to ? address(o, o._bill_to) : address(o),
126
145
  shipping_method: o.fulfillment?.expectations?.[0]?.description ?? null,
127
146
  payment: { method: 'Sandbox', source: 'sandbox wire transfer', status: paymentStatus(o), captured: money(Math.max(0, sum(o, 'capture')), cur), refundable: money(Math.max(0, captured), cur) },
128
147
  transactions: m.transactions.map((t) => ({ ...t, amount: money(t.amount, cur) })),
129
148
  language: o._language ?? baseLanguage(r),
130
149
  archived_at: m.archived_at,
131
150
  ucp: { id: o.id, permalink_url: o.permalink_url },
151
+ ...(o._platform === 'message' ? { source: o._source ?? null, notes: o._notes ?? null, customer_reference: o._reference ?? null } : {}),
132
152
  };
133
153
  }
134
154
  const orders = (r) => Object.values(r.ucp.orders).slice().reverse();
@@ -145,11 +165,13 @@ function orderCommands(o) {
145
165
  const n = number(o);
146
166
  const pay = paymentStatus(o);
147
167
  const out = [];
168
+ if (m.status === 'pending')
169
+ return [`c11r orders place ${n}`, `c11r orders cancel ${n}`];
148
170
  if (m.status === 'placed')
149
171
  out.push(`c11r orders approve ${n} [--capture]`);
150
- if (m.status === 'approved' && pay === 'authorized')
172
+ if (m.status !== 'cancelled' && pay === 'authorized')
151
173
  out.push(`c11r orders capture ${n}`);
152
- if (m.status !== 'cancelled' && !['shipped', 'delivered'].includes(m.shipment.status) && ['authorized', 'free'].includes(pay))
174
+ if (m.status !== 'cancelled' && !['shipped', 'delivered'].includes(m.shipment.status))
153
175
  out.push(`c11r orders cancel ${n}`);
154
176
  if (['paid', 'partially_refunded'].includes(pay))
155
177
  out.push(`c11r orders refund ${n} [--amount <major>]`);
@@ -159,35 +181,73 @@ function orderCommands(o) {
159
181
  const refuse = (what, allowed) => {
160
182
  throw new OrderError(422, 'invalid_transition', what, `Allowed now: ${allowed.join('; ')}.`);
161
183
  };
184
+ /** since / updated_since, sort, direction, limit and page, as the API reads them: { page, limit, total, has_more }. */
185
+ function paged(q, rows, sorts, defaultSort, kind) {
186
+ const time = (name) => {
187
+ const raw = q.get(name);
188
+ if (raw === null)
189
+ return undefined;
190
+ const t = Date.parse(raw);
191
+ if (!/^\d{4}-\d{2}-\d{2}/.test(raw) || Number.isNaN(t))
192
+ throw new OrderError(422, 'invalid_argument', `${name}=${raw} is not an ISO 8601 date.`, `Give ${name} as an ISO 8601 date or time.`);
193
+ return t;
194
+ };
195
+ // Shipments take updated_since only.
196
+ const since = kind === 'orders' ? time('since') : undefined;
197
+ const updated = time('updated_since');
198
+ const sort = q.get('sort') ?? defaultSort;
199
+ if (!sorts.includes(sort))
200
+ throw new OrderError(422, 'invalid_argument', `sort=${sort} is not one of ${sorts.join(', ')}.`, `Use sort with one of ${sorts.join(', ')}.`);
201
+ const direction = q.get('direction') ?? 'desc';
202
+ if (!['asc', 'desc'].includes(direction))
203
+ throw new OrderError(422, 'invalid_argument', `direction=${direction} is not asc or desc.`, 'Use direction=asc or desc.');
204
+ const limit = q.get('limit') === null ? 25 : Number(q.get('limit'));
205
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100)
206
+ throw new OrderError(422, 'invalid_argument', `limit=${q.get('limit')} is not between 1 and 100.`, 'Use --limit with a number from 1 to 100.');
207
+ const page = q.get('page') === null ? 1 : Number(q.get('page'));
208
+ if (!Number.isInteger(page) || page < 1)
209
+ throw new OrderError(422, 'invalid_argument', `page=${q.get('page')} is not a page number from 1.`, 'Use page=1, 2, …');
210
+ const at = (x) => Date.parse(sort === 'updated_at' ? x.updated_at : sort === 'placed_at' ? (x.placed_at ?? x.created_at) : x.dates.created_at);
211
+ const kept = rows
212
+ // A pending order has no placed_at: since is its created_at.
213
+ .filter((x) => since === undefined || Date.parse(x.placed_at ?? x.created_at) >= since)
214
+ .filter((x) => updated === undefined || Date.parse(x.updated_at) >= updated)
215
+ .sort((a, b) => (direction === 'asc' ? at(a) - at(b) : at(b) - at(a)) || (direction === 'asc' ? a.number.localeCompare(b.number) : b.number.localeCompare(a.number)));
216
+ return { [kind]: kept.slice((page - 1) * limit, page * limit), page, limit, total: kept.length, has_more: page * limit < kept.length };
217
+ }
162
218
  export function listOrders(r, q) {
163
- const list = (name, allowed, fallback) => {
219
+ const list = (name, allowed, fallback, all = allowed) => {
164
220
  const raw = q.get(name);
165
221
  if (!raw)
166
222
  return fallback;
167
223
  const vs = raw.split(',').map((v) => v.trim());
168
- if (vs.includes('all'))
169
- return allowed;
170
224
  for (const v of vs)
171
- if (!allowed.includes(v))
225
+ if (v !== 'all' && !allowed.includes(v))
172
226
  throw new OrderError(422, 'invalid_argument', `${name}=${v} is not one of ${allowed.join(', ')}, all.`, `Use --${name} with one of ${allowed.join(', ')}, or all.`);
173
- return vs;
227
+ return [...new Set(vs.flatMap((v) => (v === 'all' ? all : [v])))];
174
228
  };
175
- const statuses = list('status', ORDER_STATUSES, ['placed', 'approved']);
229
+ // all is every status but pending: pending orders are listed only when asked for by name.
230
+ const statuses = list('status', ORDER_STATUSES, ['placed', 'approved'], ORDER_STATUSES.filter((x) => x !== 'pending'));
176
231
  const payments = list('payment', PAYMENT_STATUSES, PAYMENT_STATUSES);
177
232
  const fulfillments = list('fulfillment', FULFILLMENT_STATUSES, FULFILLMENT_STATUSES);
178
233
  const archived = q.get('archived') ?? 'false';
179
234
  if (!['true', 'false', 'all'].includes(archived))
180
235
  throw new OrderError(422, 'invalid_argument', `archived=${archived} is not true, false or all.`, 'Use --archived for only archived orders, --archived all for every order.');
181
- const limit = q.get('limit') === null ? 25 : Number(q.get('limit'));
182
- if (!Number.isInteger(limit) || limit < 1 || limit > 100)
183
- throw new OrderError(422, 'invalid_argument', `limit=${q.get('limit')} is not between 1 and 100.`, 'Use --limit with a number from 1 to 100.');
236
+ const ch = q.get('channel');
237
+ if (ch)
238
+ for (const v of ch.split(','))
239
+ if (!['message', 'link'].includes(v))
240
+ throw new OrderError(422, 'invalid_argument', `channel=${v} is not message or link.`, 'Use --channel message or link (the markers Commerce Layer can match); for agent and web, see c11r insights --by channel.');
241
+ // A cancelled pending order lists with the pending ones (archived), as the API does.
242
+ const listed = new Map(orders(r).map((o) => [o.id, merchant(o).cancelled_pending ? 'pending' : merchant(o).status]));
184
243
  const rows = orders(r)
185
244
  .map(row)
186
245
  // Archived orders are listed only when asked for, whatever their status.
187
246
  .filter((x) => (archived === 'all' ? true : archived === 'true' ? x.archived : !x.archived))
188
- .filter((x) => (archived === 'true' && !q.get('status') ? true : statuses.includes(x.status)))
189
- .filter((x) => payments.includes(x.payment_status) && fulfillments.includes(x.fulfillment_status));
190
- return { orders: rows.slice(0, limit) };
247
+ .filter((x) => (archived === 'true' && !q.get('status') ? true : statuses.includes(listed.get(x.id))))
248
+ .filter((x) => payments.includes(x.payment_status) && fulfillments.includes(x.fulfillment_status))
249
+ .filter((x) => !ch || ch.split(',').includes(x.channel));
250
+ return paged(q, rows, ['placed_at', 'updated_at'], 'placed_at', 'orders');
191
251
  }
192
252
  export const showOrder = (r, id) => detail(findOrder(r, id), r);
193
253
  export function orderAction(r, id, action, body) {
@@ -198,28 +258,50 @@ export function orderAction(r, id, action, body) {
198
258
  m.transactions.push({ type: 'capture', amount: total(o) - sum(o, 'capture'), succeeded: true, at });
199
259
  };
200
260
  switch (action) {
261
+ case 'place':
262
+ if (m.status !== 'pending' || channel(o) !== 'message')
263
+ refuse(`Order ${number(o)} is ${m.status}: only a pending order can be placed.`, orderCommands(o));
264
+ // As the API does: a wire transfer, then _place; Commerce Layer authorizes it.
265
+ m.status = 'placed';
266
+ m.placed_at = at;
267
+ m.transactions.push({ type: 'authorization', amount: total(o), succeeded: true, at });
268
+ break;
201
269
  case 'approve':
202
270
  if (m.status !== 'placed')
203
271
  refuse(`Order ${number(o)} is ${m.status}: only a placed order can be approved.`, orderCommands(o));
204
272
  m.status = 'approved';
273
+ m.approved_at = at;
205
274
  if (body?.capture === true && paymentStatus(o) === 'authorized')
206
275
  capture();
207
276
  break;
208
277
  case 'capture':
209
- if (m.status !== 'approved' || paymentStatus(o) !== 'authorized')
210
- refuse(`Order ${number(o)} is ${m.status} with payment ${paymentStatus(o)}: only an approved order with an authorized payment can be captured.`, orderCommands(o));
278
+ // As in Commerce Layer: a placed order can be captured before it is approved.
279
+ if (m.status === 'cancelled' || paymentStatus(o) !== 'authorized')
280
+ refuse(`Order ${number(o)} is ${m.status} with payment ${paymentStatus(o)}: only an authorized payment can be captured.`, orderCommands(o));
211
281
  capture();
212
282
  break;
213
283
  case 'cancel':
214
284
  if (m.status === 'cancelled')
215
285
  refuse(`Order ${number(o)} is already cancelled.`, orderCommands(o));
286
+ if (m.status === 'pending') {
287
+ // Commerce Layer refuses _cancel and DELETE on a pending order: the API archives it and marks it cancelled.
288
+ m.status = 'cancelled';
289
+ m.cancelled_pending = true;
290
+ m.cancelled_at = at;
291
+ m.archived_at = at;
292
+ m.shipment.status = 'cancelled';
293
+ break;
294
+ }
216
295
  if (['shipped', 'delivered'].includes(m.shipment.status))
217
296
  refuse(`Order ${number(o)} has shipped: it cannot be cancelled.`, orderCommands(o));
218
- if (!['authorized', 'free'].includes(paymentStatus(o)))
219
- refuse(`Order ${number(o)} has payment ${paymentStatus(o)}: refund it to cancel it.`, orderCommands(o));
297
+ // Commerce Layer voids an authorized payment and refunds a captured one.
220
298
  if (paymentStatus(o) === 'authorized')
221
299
  m.transactions.push({ type: 'void', amount: total(o), succeeded: true, at });
300
+ const left = sum(o, 'capture') - sum(o, 'refund');
301
+ if (left > 0)
302
+ m.transactions.push({ type: 'refund', amount: left, succeeded: true, at });
222
303
  m.status = 'cancelled';
304
+ m.cancelled_at = at;
223
305
  m.shipment.status = 'cancelled';
224
306
  break;
225
307
  case 'refund': {
@@ -238,6 +320,7 @@ export function orderAction(r, id, action, body) {
238
320
  // As in Commerce Layer: refunding the last of the payment cancels the order.
239
321
  if (sum(o, 'refund') >= sum(o, 'capture')) {
240
322
  m.status = 'cancelled';
323
+ m.cancelled_at = at;
241
324
  if (OPEN_SHIPMENTS.includes(m.shipment.status))
242
325
  m.shipment.status = 'cancelled';
243
326
  }
@@ -250,11 +333,21 @@ export function orderAction(r, id, action, body) {
250
333
  m.archived_at = null;
251
334
  break;
252
335
  default:
253
- throw new OrderError(404, 'not_found', `No order action ${action}.`, 'Use approve, capture, cancel, refund, archive or unarchive.');
336
+ throw new OrderError(404, 'not_found', `No order action ${action}.`, 'Use place, approve, capture, cancel, refund, archive or unarchive.');
254
337
  }
338
+ m.updated_at = at;
255
339
  return detail(o, r);
256
340
  }
257
341
  // ---------- shipments
342
+ /** When the shipment entered its status: that status's own date, created_at while upcoming, updated_at once cancelled. */
343
+ function statusChangedAt(o) {
344
+ const s = merchant(o).shipment;
345
+ if (s.status === 'upcoming')
346
+ return s.dates.created_at;
347
+ if (s.status === 'cancelled')
348
+ return s.updated_at ?? s.dates.created_at;
349
+ return s.dates[`${s.status}_at`] ?? s.dates.created_at;
350
+ }
258
351
  function shipmentRow(o) {
259
352
  const s = merchant(o).shipment;
260
353
  return {
@@ -266,6 +359,8 @@ function shipmentRow(o) {
266
359
  shipping_method: o.fulfillment?.expectations?.[0]?.description ?? null,
267
360
  items: units(o),
268
361
  tracking: s.tracking,
362
+ status_changed_at: statusChangedAt(o),
363
+ updated_at: s.updated_at ?? s.dates.created_at,
269
364
  dates: { ...s.dates },
270
365
  };
271
366
  }
@@ -286,7 +381,7 @@ function shipmentCommands(o) {
286
381
  can('pick', approved && ['upcoming', 'on_hold', 'packing', 'ready_to_ship'].includes(s.status));
287
382
  can('pack', approved && ['on_hold', 'picking', 'ready_to_ship'].includes(s.status));
288
383
  can('ready', approved && ['on_hold', 'picking', 'packing'].includes(s.status));
289
- can('ship', approved && OPEN_SHIPMENTS.includes(s.status));
384
+ can('ship', approved && WORKING.includes(s.status));
290
385
  can('deliver', s.status === 'shipped');
291
386
  if (!out.length && merchant(o).status === 'placed')
292
387
  out.push(`c11r orders approve ${number(o)} [--capture]`);
@@ -306,10 +401,8 @@ export function listShipments(r, q) {
306
401
  statuses = vs;
307
402
  }
308
403
  }
309
- const limit = q.get('limit') === null ? 25 : Number(q.get('limit'));
310
- if (!Number.isInteger(limit) || limit < 1 || limit > 100)
311
- throw new OrderError(422, 'invalid_argument', `limit=${q.get('limit')} is not between 1 and 100.`, 'Use --limit with a number from 1 to 100.');
312
- return { shipments: orders(r).filter((o) => statuses.includes(merchant(o).shipment.status)).map(shipmentRow).slice(0, limit) };
404
+ // A pending order's shipment is a draft: not something to fulfil yet.
405
+ return paged(q, orders(r).filter((o) => merchant(o).status !== 'pending' && statuses.includes(merchant(o).shipment.status)).map(shipmentRow), ['created_at', 'updated_at'], 'created_at', 'shipments');
313
406
  }
314
407
  export function showShipment(r, id) {
315
408
  const o = findShipment(r, id);
@@ -328,6 +421,7 @@ export function shipmentAction(r, id, action, body) {
328
421
  }
329
422
  s.status = to;
330
423
  s.dates[`${to}_at`] = at;
424
+ s.updated_at = at;
331
425
  };
332
426
  switch (action) {
333
427
  case 'hold':
@@ -346,7 +440,7 @@ export function shipmentAction(r, id, action, body) {
346
440
  }
347
441
  if (body?.tracking_url !== undefined && !URL_RE.test(body.tracking_url))
348
442
  throw new OrderError(422, 'invalid_argument', `tracking_url ${body.tracking_url} is not an https URL.`, 'Give --tracking-url the carrier\'s tracking page, starting with https://.');
349
- move('shipped', OPEN_SHIPMENTS, true);
443
+ move('shipped', WORKING, true);
350
444
  if (body?.carrier || body?.tracking || body?.tracking_url)
351
445
  s.tracking = { carrier: body.carrier, number: body.tracking, url: body.tracking_url };
352
446
  return showShipment(r, s.id);
@@ -357,3 +451,175 @@ export function shipmentAction(r, id, action, body) {
357
451
  throw new OrderError(404, 'not_found', `No shipment action ${action}.`, 'Use hold, pick, pack, ready, ship or deliver.');
358
452
  }
359
453
  }
454
+ // ---------- the ops snapshot and insights (README "Operations and insights" of the API)
455
+ const DAY_MS = 86_400_000;
456
+ const SNAPSHOT_CAP = 50;
457
+ /** GET /stores/:org/ops: what the merchant agent's loop polls, oldest first, 50 a list. */
458
+ export function opsSnapshot(r, q) {
459
+ const days = (name, fallback) => {
460
+ const raw = q.get(name);
461
+ if (raw === null)
462
+ return fallback;
463
+ const n = Number(raw);
464
+ if (!Number.isFinite(n) || n <= 0 || n > 365)
465
+ throw new OrderError(422, 'invalid_argument', `${name}=${raw} is not a number of days above 0 and up to 365.`, `Give ${name} in days, for example ${fallback}.`);
466
+ return n;
467
+ };
468
+ const thresholds = { stuck_days: days('stuck_days', 2), transit_days: days('transit_days', 7) };
469
+ const asOf = Date.now();
470
+ const rows = orders(r)
471
+ .filter((o) => merchant(o).archived_at === null)
472
+ .map(row)
473
+ .sort((a, b) => Date.parse(a.placed_at ?? a.created_at) - Date.parse(b.placed_at ?? b.created_at) || a.number.localeCompare(b.number));
474
+ const lists = {
475
+ placed_unapproved: rows.filter((x) => x.status === 'placed'),
476
+ approved_uncaptured: rows.filter((x) => x.status === 'approved' && ['authorized', 'partially_authorized'].includes(x.payment_status)),
477
+ awaiting_payment: rows.filter((x) => ['placed', 'approved'].includes(x.status) && x.payment_status === 'unpaid'),
478
+ };
479
+ const stuck = orders(r)
480
+ .map(shipmentRow)
481
+ .map((x) => ({ ...x, since_days: Math.round(((asOf - Date.parse(x.status_changed_at)) / DAY_MS) * 10) / 10 }))
482
+ .filter((x) => (['picking', 'packing', 'ready_to_ship'].includes(x.status) && x.since_days > thresholds.stuck_days) || (x.status === 'shipped' && x.since_days > thresholds.transit_days))
483
+ .sort((a, b) => b.since_days - a.since_days);
484
+ const cap = (l) => l.slice(0, SNAPSHOT_CAP);
485
+ return {
486
+ as_of: new Date(asOf).toISOString(),
487
+ thresholds,
488
+ orders: { placed_unapproved: cap(lists.placed_unapproved), approved_uncaptured: cap(lists.approved_uncaptured), awaiting_payment: cap(lists.awaiting_payment) },
489
+ shipments: { stuck: cap(stuck), returns: null },
490
+ counts: { placed_unapproved: lists.placed_unapproved.length, approved_uncaptured: lists.approved_uncaptured.length, awaiting_payment: lists.awaiting_payment.length, stuck: stuck.length },
491
+ has_more: { placed_unapproved: lists.placed_unapproved.length > SNAPSHOT_CAP, approved_uncaptured: lists.approved_uncaptured.length > SNAPSHOT_CAP, awaiting_payment: lists.awaiting_payment.length > SNAPSHOT_CAP, stuck: stuck.length > SNAPSHOT_CAP },
492
+ };
493
+ }
494
+ const INSIGHTS_BY = ['product', 'day', 'market', 'country', 'channel', 'promotion'];
495
+ const CHANNEL_LABELS = { agent: 'Shopping agents (UCP)', link: 'Checkout links', web: 'Store checkout', message: 'Email and messages' };
496
+ /** Money per currency, sorted by currency, never summed across currencies. */
497
+ const perCurrency = (list) => {
498
+ const by = new Map();
499
+ for (const [amount, cur] of list)
500
+ by.set(cur, (by.get(cur) ?? 0) + amount);
501
+ return [...by].sort(([a], [b]) => a.localeCompare(b)).map(([cur, amount]) => money(amount, cur));
502
+ };
503
+ /** "24h", "7d" (any <n>h or <n>d) or an ISO date: the instant, as the API reads since. */
504
+ function spanOrTime(raw, name, now) {
505
+ const rel = raw.match(/^(\d+)([hd])$/);
506
+ if (rel)
507
+ return now - Number(rel[1]) * (rel[2] === 'h' ? 3_600_000 : DAY_MS);
508
+ const t = Date.parse(raw);
509
+ if (!/^\d{4}-\d{2}-\d{2}/.test(raw) || Number.isNaN(t))
510
+ throw new OrderError(422, 'invalid_argument', `${name}=${raw} is not a span (24h, 7d) or an ISO 8601 date.`, `Give ${name} as 24h, 7d, 30d or an ISO date.`);
511
+ return t;
512
+ }
513
+ const regionName = (code) => {
514
+ try {
515
+ return new Intl.DisplayNames(['en'], { type: 'region' }).of(code) ?? code;
516
+ }
517
+ catch {
518
+ return code;
519
+ }
520
+ };
521
+ /**
522
+ * GET /stores/:org/insights: the orders placed in the period that are placed or approved now.
523
+ * Revenue is their totals; refunds and cancellations are reported apart.
524
+ */
525
+ export function insights(r, q) {
526
+ const now = Date.now();
527
+ const until = q.get('until') === null ? now : spanOrTime(q.get('until'), 'until', now);
528
+ const since = spanOrTime(q.get('since') ?? '30d', 'since', now);
529
+ if (until <= since)
530
+ throw new OrderError(422, 'invalid_argument', 'until is not after since.', 'Give an until later than since.');
531
+ if (until - since > 366 * DAY_MS)
532
+ throw new OrderError(422, 'invalid_argument', 'The period is longer than 366 days.', 'Ask for at most 366 days.');
533
+ const by = q.get('by');
534
+ if (by !== null && !INSIGHTS_BY.includes(by))
535
+ throw new OrderError(422, 'invalid_argument', `by=${by} is not one of ${INSIGHTS_BY.join(', ')}.`, `Use --by with one of ${INSIGHTS_BY.join(', ')}.`);
536
+ const period = orders(r).filter((o) => {
537
+ const t = Date.parse(merchant(o).placed_at ?? '');
538
+ return t >= since && t < until;
539
+ });
540
+ const counted = period.filter((o) => ['placed', 'approved'].includes(merchant(o).status));
541
+ const lineTotal = (l) => l.totals?.find((t) => t.type === 'total')?.amount ?? 0;
542
+ const rows = new Map();
543
+ const add = (key, label, o, u, amount) => {
544
+ const x = rows.get(key) ?? { key, label, orders: new Set(), units: 0, revenue: [] };
545
+ x.orders.add(o.id);
546
+ x.units += u;
547
+ x.revenue.push([amount, o.currency]);
548
+ rows.set(key, x);
549
+ return x;
550
+ };
551
+ const products = new Map();
552
+ for (const o of counted) {
553
+ for (const l of o.line_items) {
554
+ const sku = l.item?.id;
555
+ const x = products.get(sku) ?? { key: sku, label: l.item?.title, orders: new Set(), units: 0, revenue: [] };
556
+ x.orders.add(o.id);
557
+ x.units += l.quantity?.total ?? l.quantity ?? 0;
558
+ x.revenue.push([lineTotal(l), o.currency]);
559
+ products.set(sku, x);
560
+ }
561
+ const m = merchant(o);
562
+ if (by === 'day')
563
+ add(m.placed_at.slice(0, 10), m.placed_at.slice(0, 10), o, units(o), total(o));
564
+ else if (by === 'market')
565
+ add(o._market ?? 'none', (o._market ? r.applied?.markets?.[o._market]?.name : undefined) ?? o._market ?? 'none', o, units(o), total(o));
566
+ else if (by === 'country') {
567
+ const cc = destination(o).address_country ?? 'none';
568
+ add(cc, cc === 'none' ? 'none' : regionName(cc), o, units(o), total(o));
569
+ }
570
+ else if (by === 'channel')
571
+ add(channel(o), CHANNEL_LABELS[channel(o)], o, units(o), total(o));
572
+ else if (by === 'promotion') {
573
+ const discount = o.totals.filter((t) => t.type === 'discount').reduce((sum_, t) => sum_ + t.amount, 0);
574
+ for (const code of o._codes?.length ? o._codes : []) {
575
+ const x = add(code, code, o, units(o), total(o));
576
+ (x.discount ??= []).push([-Math.abs(discount), o.currency]);
577
+ }
578
+ }
579
+ }
580
+ if (by === 'product')
581
+ for (const [k, v] of products)
582
+ rows.set(k, v);
583
+ // Every channel, empty ones included, as the API answers.
584
+ if (by === 'channel')
585
+ for (const [key, label] of Object.entries(CHANNEL_LABELS))
586
+ if (!rows.has(key))
587
+ rows.set(key, { key, label, orders: new Set(), units: 0, revenue: [] });
588
+ if (by === 'day') {
589
+ // Every day of the period, empty ones included.
590
+ for (let d = Date.parse(new Date(since).toISOString().slice(0, 10)); d < until; d += DAY_MS) {
591
+ const key = new Date(d).toISOString().slice(0, 10);
592
+ if (!rows.has(key))
593
+ rows.set(key, { key, label: key, orders: new Set(), units: 0, revenue: [] });
594
+ }
595
+ }
596
+ const view = (x) => ({ key: x.key, label: x.label, orders: x.orders.size, units: x.units, revenue: perCurrency(x.revenue), ...(x.discount ? { discount: perCurrency(x.discount) } : {}) });
597
+ const out = [...rows.values()].map(view);
598
+ out.sort(by === 'day' ? (a, b) => a.key.localeCompare(b.key) : (a, b) => b.units - a.units || a.key.localeCompare(b.key));
599
+ const revenue = perCurrency(counted.map((o) => [total(o), o.currency]));
600
+ const count = new Map();
601
+ for (const o of counted)
602
+ count.set(o.currency, (count.get(o.currency) ?? 0) + 1);
603
+ const channels = { agent: 0, link: 0, web: 0, message: 0, partial: false };
604
+ for (const o of counted)
605
+ channels[channel(o)]++;
606
+ return {
607
+ since: new Date(since).toISOString(),
608
+ until: new Date(until).toISOString(),
609
+ by,
610
+ as_of: new Date(now).toISOString(),
611
+ source: 'stub',
612
+ totals: {
613
+ orders: counted.length,
614
+ units: counted.reduce((n, o) => n + units(o), 0),
615
+ revenue,
616
+ average_order: revenue.map((t) => money(Math.round(t.amount / (count.get(t.currency) || 1)), t.currency)),
617
+ refunds: perCurrency(period.filter((o) => sum(o, 'refund')).map((o) => [sum(o, 'refund'), o.currency])),
618
+ cancellations: period.filter((o) => merchant(o).status === 'cancelled').length,
619
+ },
620
+ rows: by ? out : [],
621
+ top_products: [...products.values()].sort((a, b) => b.units - a.units || a.key.localeCompare(b.key)).slice(0, 5).map((x) => ({ sku: x.key, name: x.label, orders: x.orders.size, units: x.units, revenue: perCurrency(x.revenue) })),
622
+ channel: channels,
623
+ partial: false,
624
+ };
625
+ }
@@ -155,8 +155,23 @@ function renderPage(r, path, search, locale) {
155
155
  return page(r, { lang: locale ? tag(locale) : 'en', title, head: locale ? alternates(localesOf(r), path) : '', header, body, description: tagline, home: `${prefix}/${keep}` });
156
156
  }
157
157
  const xml = (t) => String(t ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;' })[c]);
158
- /** /tiles/<sku>.svg and /tiles/hero.svg: a plain stand-in for the store host's generated tiles. */
158
+ /** A 1×1 PNG: the stand-in for a generated PNG tile. */
159
+ const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkqF9fDwAEAQG/jvkFNQAAAABJRU5ErkJggg==', 'base64');
160
+ /** /tiles/<sku>.svg, /tiles/<sku>.png and /tiles/hero.svg: a plain stand-in for the store host's generated tiles. */
159
161
  export function renderTile(r, path, search) {
162
+ const png = path.match(/^\/tiles\/([^/]+)\.png$/);
163
+ if (png) {
164
+ let sku;
165
+ try {
166
+ sku = decodeURIComponent(png[1]);
167
+ }
168
+ catch {
169
+ return null;
170
+ }
171
+ if (!r.applied?.products[sku])
172
+ return null;
173
+ return { status: 200, headers: { 'content-type': 'image/png', 'cache-control': 'public, max-age=300', 'x-content-type-options': 'nosniff' }, body: PNG };
174
+ }
160
175
  const m = path.match(/^\/tiles\/([^/]+)\.svg$/);
161
176
  if (!m)
162
177
  return null;
@@ -68,7 +68,7 @@ function negotiate(platformUrl) {
68
68
  // ---------- the REST service
69
69
  const protocolError = (status, code, content, headers = {}) => json(status, { code, content }, headers);
70
70
  function withUcpHeaders(r, res) {
71
- const digest = contentDigest(res.body);
71
+ const digest = contentDigest(res.body.toString());
72
72
  const values = { '@status': String(res.status), 'content-digest': digest, 'content-type': res.headers['content-type'] ?? 'application/json' };
73
73
  const sig = signHeaders('sig1', values, { components: ['@status', 'content-digest', 'content-type'], keyid: r.ucp.kid, created: Math.floor(Date.now() / 1000) }, r.ucp.private_jwk);
74
74
  return { ...res, headers: { ...res.headers, 'content-digest': digest, ...sig } };
@@ -114,7 +114,7 @@ function handleService(req, r, origin, path) {
114
114
  const res = dispatch(ctx, req.method, path, parsed);
115
115
  res.headers['content-language'] = ctx.language;
116
116
  if (mutating && key)
117
- r.ucp.idempotency[`${req.method} ${path} ${key}`] = { digest, status: res.status, headers: res.headers, body: res.body };
117
+ r.ucp.idempotency[`${req.method} ${path} ${key}`] = { digest, status: res.status, headers: res.headers, body: res.body.toString() };
118
118
  return res;
119
119
  }
120
120
  function dispatch(ctx, method, path, body) {
@@ -8,6 +8,16 @@ import { handle } from './stub/index.js';
8
8
  import { USER_AGENT } from '../version.js';
9
9
  export const DEFAULT_API_URL = 'https://api.commercelayer.ai';
10
10
  export const apiUrl = () => process.env.C11R_API_URL ?? DEFAULT_API_URL;
11
+ /** Where a request goes: to a local API, other hosts through its /-/<host>/ prefix. */
12
+ function targetOf(url) {
13
+ const base = apiUrl();
14
+ const baseUrl = new URL(base);
15
+ const targetUrl = new URL(url);
16
+ if (base !== DEFAULT_API_URL && targetUrl.host !== baseUrl.host && !url.startsWith(base)) {
17
+ return `${base.replace(/\/$/, '')}/-/${targetUrl.host}${targetUrl.pathname}${targetUrl.search}`;
18
+ }
19
+ return url;
20
+ }
11
21
  export async function send(method, url, headers = {}, body) {
12
22
  const base = apiUrl();
13
23
  const lower = {};
@@ -17,14 +27,11 @@ export async function send(method, url, headers = {}, body) {
17
27
  lower['user-agent'] ??= USER_AGENT;
18
28
  if (body !== undefined && !lower['content-type'])
19
29
  lower['content-type'] = 'application/json';
20
- if (base === 'stub')
21
- return handle({ method, url: new URL(url), headers: lower, body });
22
- let target = url;
23
- const baseUrl = new URL(base);
24
- const targetUrl = new URL(url);
25
- if (base !== DEFAULT_API_URL && targetUrl.host !== baseUrl.host && !url.startsWith(base)) {
26
- target = `${base.replace(/\/$/, '')}/-/${targetUrl.host}${targetUrl.pathname}${targetUrl.search}`;
30
+ if (base === 'stub') {
31
+ const r = handle({ method, url: new URL(url), headers: lower, body });
32
+ return { ...r, body: r.body.toString() };
27
33
  }
34
+ const target = targetOf(url);
28
35
  let res;
29
36
  try {
30
37
  // Never follow redirects: UCP forbids it for profiles, and the 302/303 of
@@ -38,3 +45,13 @@ export async function send(method, url, headers = {}, body) {
38
45
  res.headers.forEach((v, k) => (resHeaders[k] = v));
39
46
  return { status: res.status, headers: resHeaders, body: await res.text() };
40
47
  }
48
+ /** A file over GET, as bytes, within timeoutMs; redirects are followed (images sit behind CDNs). Throws on failure. */
49
+ export async function download(url, timeoutMs) {
50
+ const headers = { 'user-agent': USER_AGENT, accept: 'image/*' };
51
+ if (apiUrl() === 'stub') {
52
+ const r = handle({ method: 'GET', url: new URL(url), headers });
53
+ return { status: r.status, type: r.headers['content-type'] ?? '', bytes: Buffer.from(r.body) };
54
+ }
55
+ const res = await fetch(targetOf(url), { headers, signal: AbortSignal.timeout(timeoutMs) });
56
+ return { status: res.status, type: res.headers.get('content-type') ?? '', bytes: Buffer.from(await res.arrayBuffer()) };
57
+ }