@commercelayer/agent-cli 0.5.2 → 0.8.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.
@@ -9,7 +9,7 @@
9
9
  import { money } from '../../ucp/shop.js';
10
10
  import { minorUnits } from '../../ucp/platform.js';
11
11
  import { baseLanguage } from './locale.js';
12
- export const ORDER_STATUSES = ['placed', 'approved', 'cancelled', 'editing'];
12
+ export const ORDER_STATUSES = ['pending', 'placed', 'approved', 'cancelled', 'editing'];
13
13
  export const PAYMENT_STATUSES = ['unpaid', 'authorized', 'partially_authorized', 'paid', 'partially_paid', 'voided', 'partially_voided', 'refunded', 'partially_refunded', 'free'];
14
14
  export const FULFILLMENT_STATUSES = ['unfulfilled', 'in_progress', 'fulfilled', 'not_required'];
15
15
  export const SHIPMENT_STATUSES = ['upcoming', 'on_hold', 'picking', 'packing', 'ready_to_ship', 'shipped', 'delivered', 'cancelled'];
@@ -19,11 +19,13 @@ export class OrderError extends Error {
19
19
  status;
20
20
  code;
21
21
  hint;
22
- constructor(status, code, message, hint) {
22
+ path;
23
+ constructor(status, code, message, hint, path) {
23
24
  super(message);
24
25
  this.status = status;
25
26
  this.code = code;
26
27
  this.hint = hint;
28
+ this.path = path;
27
29
  }
28
30
  }
29
31
  const now = () => new Date().toISOString();
@@ -47,6 +49,9 @@ function merchant(o) {
47
49
  function paymentStatus(o) {
48
50
  if (total(o) === 0)
49
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';
50
55
  const captured = sum(o, 'capture');
51
56
  const refunded = sum(o, 'refund');
52
57
  if (sum(o, 'void') > 0)
@@ -71,6 +76,14 @@ function shipmentRef(o) {
71
76
  const s = merchant(o).shipment;
72
77
  return { id: s.id, number: s.number, status: s.status, tracking: s.tracking };
73
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
+ }
74
87
  function row(o) {
75
88
  const m = merchant(o);
76
89
  return {
@@ -80,8 +93,14 @@ function row(o) {
80
93
  payment_status: paymentStatus(o),
81
94
  fulfillment_status: fulfillmentStatus(o),
82
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),
83
100
  market: o._market ?? null,
101
+ // The shipping country, as Commerce Layer's country_code follows the shipping address.
84
102
  country: destination(o).address_country ?? null,
103
+ channel: channel(o),
85
104
  currency: o.currency,
86
105
  total: money(total(o), o.currency),
87
106
  email: o._buyer?.email ?? null,
@@ -90,20 +109,19 @@ function row(o) {
90
109
  archived: m.archived_at !== null,
91
110
  };
92
111
  }
93
- function address(o) {
94
- const d = destination(o);
112
+ function address(o, d = destination(o)) {
95
113
  if (!Object.keys(d).length)
96
114
  return null;
97
115
  return {
98
116
  name: [d.first_name, d.last_name].filter(Boolean).join(' ') || null,
99
- company: null,
117
+ company: d.company ?? null,
100
118
  line_1: d.street_address ?? null,
101
119
  line_2: d.extended_address ?? null,
102
120
  city: d.address_locality ?? null,
103
121
  zip_code: d.postal_code ?? null,
104
122
  state_code: d.address_region ?? null,
105
123
  country_code: d.address_country ?? null,
106
- phone: o._buyer?.phone_number ?? null,
124
+ phone: d.phone ?? o._buyer?.phone_number ?? null,
107
125
  };
108
126
  }
109
127
  function detail(o, r) {
@@ -123,13 +141,14 @@ function detail(o, r) {
123
141
  })),
124
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 ?? [] },
125
143
  shipping_address: address(o),
126
- billing_address: address(o),
144
+ billing_address: o._bill_to ? address(o, o._bill_to) : address(o),
127
145
  shipping_method: o.fulfillment?.expectations?.[0]?.description ?? null,
128
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) },
129
147
  transactions: m.transactions.map((t) => ({ ...t, amount: money(t.amount, cur) })),
130
148
  language: o._language ?? baseLanguage(r),
131
149
  archived_at: m.archived_at,
132
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 } : {}),
133
152
  };
134
153
  }
135
154
  const orders = (r) => Object.values(r.ucp.orders).slice().reverse();
@@ -146,6 +165,8 @@ function orderCommands(o) {
146
165
  const n = number(o);
147
166
  const pay = paymentStatus(o);
148
167
  const out = [];
168
+ if (m.status === 'pending')
169
+ return [`c11r orders place ${n}`, `c11r orders cancel ${n}`];
149
170
  if (m.status === 'placed')
150
171
  out.push(`c11r orders approve ${n} [--capture]`);
151
172
  if (m.status !== 'cancelled' && pay === 'authorized')
@@ -160,35 +181,73 @@ function orderCommands(o) {
160
181
  const refuse = (what, allowed) => {
161
182
  throw new OrderError(422, 'invalid_transition', what, `Allowed now: ${allowed.join('; ')}.`);
162
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
+ }
163
218
  export function listOrders(r, q) {
164
- const list = (name, allowed, fallback) => {
219
+ const list = (name, allowed, fallback, all = allowed) => {
165
220
  const raw = q.get(name);
166
221
  if (!raw)
167
222
  return fallback;
168
223
  const vs = raw.split(',').map((v) => v.trim());
169
- if (vs.includes('all'))
170
- return allowed;
171
224
  for (const v of vs)
172
- if (!allowed.includes(v))
225
+ if (v !== 'all' && !allowed.includes(v))
173
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.`);
174
- return vs;
227
+ return [...new Set(vs.flatMap((v) => (v === 'all' ? all : [v])))];
175
228
  };
176
- 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'));
177
231
  const payments = list('payment', PAYMENT_STATUSES, PAYMENT_STATUSES);
178
232
  const fulfillments = list('fulfillment', FULFILLMENT_STATUSES, FULFILLMENT_STATUSES);
179
233
  const archived = q.get('archived') ?? 'false';
180
234
  if (!['true', 'false', 'all'].includes(archived))
181
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.');
182
- const limit = q.get('limit') === null ? 25 : Number(q.get('limit'));
183
- if (!Number.isInteger(limit) || limit < 1 || limit > 100)
184
- 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]));
185
243
  const rows = orders(r)
186
244
  .map(row)
187
245
  // Archived orders are listed only when asked for, whatever their status.
188
246
  .filter((x) => (archived === 'all' ? true : archived === 'true' ? x.archived : !x.archived))
189
- .filter((x) => (archived === 'true' && !q.get('status') ? true : statuses.includes(x.status)))
190
- .filter((x) => payments.includes(x.payment_status) && fulfillments.includes(x.fulfillment_status));
191
- 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');
192
251
  }
193
252
  export const showOrder = (r, id) => detail(findOrder(r, id), r);
194
253
  export function orderAction(r, id, action, body) {
@@ -199,10 +258,19 @@ export function orderAction(r, id, action, body) {
199
258
  m.transactions.push({ type: 'capture', amount: total(o) - sum(o, 'capture'), succeeded: true, at });
200
259
  };
201
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;
202
269
  case 'approve':
203
270
  if (m.status !== 'placed')
204
271
  refuse(`Order ${number(o)} is ${m.status}: only a placed order can be approved.`, orderCommands(o));
205
272
  m.status = 'approved';
273
+ m.approved_at = at;
206
274
  if (body?.capture === true && paymentStatus(o) === 'authorized')
207
275
  capture();
208
276
  break;
@@ -215,6 +283,15 @@ export function orderAction(r, id, action, body) {
215
283
  case 'cancel':
216
284
  if (m.status === 'cancelled')
217
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
+ }
218
295
  if (['shipped', 'delivered'].includes(m.shipment.status))
219
296
  refuse(`Order ${number(o)} has shipped: it cannot be cancelled.`, orderCommands(o));
220
297
  // Commerce Layer voids an authorized payment and refunds a captured one.
@@ -224,6 +301,7 @@ export function orderAction(r, id, action, body) {
224
301
  if (left > 0)
225
302
  m.transactions.push({ type: 'refund', amount: left, succeeded: true, at });
226
303
  m.status = 'cancelled';
304
+ m.cancelled_at = at;
227
305
  m.shipment.status = 'cancelled';
228
306
  break;
229
307
  case 'refund': {
@@ -242,6 +320,7 @@ export function orderAction(r, id, action, body) {
242
320
  // As in Commerce Layer: refunding the last of the payment cancels the order.
243
321
  if (sum(o, 'refund') >= sum(o, 'capture')) {
244
322
  m.status = 'cancelled';
323
+ m.cancelled_at = at;
245
324
  if (OPEN_SHIPMENTS.includes(m.shipment.status))
246
325
  m.shipment.status = 'cancelled';
247
326
  }
@@ -254,11 +333,21 @@ export function orderAction(r, id, action, body) {
254
333
  m.archived_at = null;
255
334
  break;
256
335
  default:
257
- 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.');
258
337
  }
338
+ m.updated_at = at;
259
339
  return detail(o, r);
260
340
  }
261
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
+ }
262
351
  function shipmentRow(o) {
263
352
  const s = merchant(o).shipment;
264
353
  return {
@@ -270,6 +359,8 @@ function shipmentRow(o) {
270
359
  shipping_method: o.fulfillment?.expectations?.[0]?.description ?? null,
271
360
  items: units(o),
272
361
  tracking: s.tracking,
362
+ status_changed_at: statusChangedAt(o),
363
+ updated_at: s.updated_at ?? s.dates.created_at,
273
364
  dates: { ...s.dates },
274
365
  };
275
366
  }
@@ -310,10 +401,8 @@ export function listShipments(r, q) {
310
401
  statuses = vs;
311
402
  }
312
403
  }
313
- const limit = q.get('limit') === null ? 25 : Number(q.get('limit'));
314
- if (!Number.isInteger(limit) || limit < 1 || limit > 100)
315
- 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.');
316
- 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');
317
406
  }
318
407
  export function showShipment(r, id) {
319
408
  const o = findShipment(r, id);
@@ -332,6 +421,7 @@ export function shipmentAction(r, id, action, body) {
332
421
  }
333
422
  s.status = to;
334
423
  s.dates[`${to}_at`] = at;
424
+ s.updated_at = at;
335
425
  };
336
426
  switch (action) {
337
427
  case 'hold':
@@ -361,3 +451,175 @@ export function shipmentAction(r, id, action, body) {
361
451
  throw new OrderError(404, 'not_found', `No shipment action ${action}.`, 'Use hold, pick, pack, ready, ship or deliver.');
362
452
  }
363
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
+ }
package/dist/bin.js CHANGED
@@ -5,7 +5,7 @@
5
5
  import * as cmd from './commands.js';
6
6
  import { CliError, print } from './output.js';
7
7
  import { LATEST_HINT, NPX } from './version.js';
8
- const VALUE_OPTIONS = new Set(['--name', '--sku', '--quantity', '--country', '--code', '--file', '--store', '--item', '--email', '--first-name', '--last-name', '--phone', '--street', '--city', '--postal-code', '--region', '--shipping', '--category', '--min-price', '--max-price', '--status', '--payment', '--fulfillment', '--limit', '--archived', '--amount', '--carrier', '--tracking', '--tracking-url']);
8
+ const VALUE_OPTIONS = new Set(['--name', '--sku', '--quantity', '--country', '--code', '--file', '--store', '--item', '--email', '--first-name', '--last-name', '--phone', '--street', '--city', '--postal-code', '--region', '--shipping', '--category', '--min-price', '--max-price', '--status', '--payment', '--fulfillment', '--limit', '--archived', '--amount', '--carrier', '--tracking', '--tracking-url', '--since', '--until', '--by', '--company', '--line-2', '--query', '--reference', '--notes', '--language', '--source', '--channel', '--label', '--unlabel', '--move', '--currency']);
9
9
  const argv = process.argv.slice(2);
10
10
  const flags = new Set();
11
11
  const values = {};
@@ -54,7 +54,7 @@ async function run() {
54
54
  return cmd.claim();
55
55
  case 'catalog':
56
56
  if (sub === 'pull')
57
- return cmd.catalogPull();
57
+ return cmd.catalogPull(values);
58
58
  throw new CliError('unknown_command', `There is no command "catalog ${sub ?? ''}".`, 'Use `c11r catalog pull`.');
59
59
  case 'storefront':
60
60
  if (sub === 'init')
@@ -63,7 +63,9 @@ async function run() {
63
63
  return cmd.storefrontPublish();
64
64
  if (sub === 'check')
65
65
  return cmd.storefrontCheckCmd();
66
- throw new CliError('unknown_command', `There is no command "storefront ${sub ?? ''}".`, 'Use `c11r storefront init`, `storefront publish` or `storefront check`.');
66
+ if (sub === 'inspect')
67
+ return cmd.storefrontInspect(args[2]);
68
+ throw new CliError('unknown_command', `There is no command "storefront ${sub ?? ''}".`, 'Use `c11r storefront init`, `storefront publish`, `storefront check` or `storefront inspect <url>`.');
67
69
  case 'promotions':
68
70
  if (sub === 'list')
69
71
  return cmd.promotionsList();
@@ -93,13 +95,15 @@ async function run() {
93
95
  case 'orders':
94
96
  if (sub === 'test')
95
97
  return cmd.ordersTest(flags, values);
98
+ if (sub === 'create')
99
+ return cmd.ordersCreate(values, lists, flags);
96
100
  if (sub === 'list')
97
101
  return cmd.ordersList(values, flags);
98
102
  if (sub === 'show')
99
103
  return cmd.ordersShow(args[2]);
100
- if (sub === 'approve' || sub === 'capture' || sub === 'cancel' || sub === 'refund' || sub === 'archive' || sub === 'unarchive')
104
+ if (sub === 'place' || sub === 'approve' || sub === 'capture' || sub === 'cancel' || sub === 'refund' || sub === 'archive' || sub === 'unarchive')
101
105
  return cmd.ordersAct(sub, args[2], values, flags);
102
- throw new CliError('unknown_command', `There is no command "orders ${sub ?? ''}".`, 'Use `c11r orders list`, `show <number>`, `approve <number> [--capture]`, `capture`, `cancel`, `refund <number> [--amount <n>]`, `archive`, `unarchive`, or `orders test`.');
106
+ throw new CliError('unknown_command', `There is no command "orders ${sub ?? ''}".`, 'Use `c11r orders list`, `show <number>`, `create`, `place <number>`, `approve <number> [--capture]`, `capture`, `cancel`, `refund <number> [--amount <n>]`, `archive`, `unarchive`, or `orders test`.');
103
107
  case 'fulfillment':
104
108
  if (sub === 'list')
105
109
  return cmd.fulfillmentList(values);
@@ -108,6 +112,26 @@ async function run() {
108
112
  if (sub === 'hold' || sub === 'pick' || sub === 'pack' || sub === 'ready' || sub === 'ship' || sub === 'deliver')
109
113
  return cmd.fulfillmentAct(sub, args[2], values);
110
114
  throw new CliError('unknown_command', `There is no command "fulfillment ${sub ?? ''}".`, 'Use `c11r fulfillment list`, `show <shipment>`, `hold`, `pick`, `pack`, `ready`, `ship <shipment> [--carrier --tracking --tracking-url]` or `deliver`.');
115
+ case 'ops':
116
+ if (sub === 'check')
117
+ return cmd.opsCheck(values);
118
+ if (sub === 'run')
119
+ return cmd.opsRun(flags);
120
+ if (sub === 'digest')
121
+ return cmd.opsDigest(values);
122
+ throw new CliError('unknown_command', `There is no command "ops ${sub ?? ''}".`, 'Use `c11r ops check`, `ops run [--dry-run]` or `ops digest [--since 24h]`.');
123
+ case 'insights':
124
+ return cmd.insights(values);
125
+ case 'inbox':
126
+ if (sub === 'check')
127
+ return cmd.inboxCheck();
128
+ if (sub === 'list')
129
+ return cmd.inboxList(values, flags);
130
+ if (sub === 'pull')
131
+ return cmd.inboxPull(args[2], values, flags);
132
+ if (sub === 'tag')
133
+ return cmd.inboxTag(args[2], lists, values, flags);
134
+ throw new CliError('unknown_command', `There is no command "inbox ${sub ?? ''}".`, sub === 'reply' ? 'The CLI does not send email: replies stay with the human or their mail app.' : 'Use `c11r inbox check`, `list [--unseen] [--since 24h] [--limit 20]`, `pull <id> | --unseen` or `tag <id> --label <name> | --unlabel <name> | --move <folder> | --seen`.');
111
135
  case 'ucp':
112
136
  if (sub === 'profile')
113
137
  return cmd.ucpProfile(flags);
@@ -148,7 +172,7 @@ run()
148
172
  })
149
173
  .catch((e) => {
150
174
  if (e instanceof CliError) {
151
- print({ error: { code: e.code, message: e.message, hint: e.hint } });
175
+ print({ error: { code: e.code, message: e.message, hint: e.hint, ...(e.path ? { path: e.path } : {}) } });
152
176
  }
153
177
  else {
154
178
  const message = e instanceof Error ? e.message : String(e);