@commercelayer/agent-cli 0.5.2 → 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.
- package/README.md +46 -3
- package/dist/api/client.js +39 -1
- package/dist/api/stub/http.js +2 -2
- package/dist/api/stub/management.js +19 -2
- package/dist/api/stub/messages.js +126 -0
- package/dist/api/stub/orders.js +286 -24
- package/dist/bin.js +26 -4
- package/dist/commands.js +462 -4
- package/dist/cursor.js +31 -0
- package/dist/files.js +5 -3
- package/dist/inbox/cursor.js +11 -0
- package/dist/inbox/fake.js +95 -0
- package/dist/inbox/imap.js +139 -0
- package/dist/inbox/mailbox.js +25 -0
- package/dist/inbox/message.js +88 -0
- package/dist/ops.js +190 -0
- package/dist/orderRequest.js +141 -0
- package/dist/output.js +7 -3
- package/package.json +3 -1
package/dist/api/stub/orders.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
183
|
-
if (
|
|
184
|
-
|
|
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.
|
|
190
|
-
.filter((x) => payments.includes(x.payment_status) && fulfillments.includes(x.fulfillment_status))
|
|
191
|
-
|
|
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
|
-
|
|
314
|
-
|
|
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']);
|
|
9
9
|
const argv = process.argv.slice(2);
|
|
10
10
|
const flags = new Set();
|
|
11
11
|
const values = {};
|
|
@@ -93,13 +93,15 @@ async function run() {
|
|
|
93
93
|
case 'orders':
|
|
94
94
|
if (sub === 'test')
|
|
95
95
|
return cmd.ordersTest(flags, values);
|
|
96
|
+
if (sub === 'create')
|
|
97
|
+
return cmd.ordersCreate(values, lists, flags);
|
|
96
98
|
if (sub === 'list')
|
|
97
99
|
return cmd.ordersList(values, flags);
|
|
98
100
|
if (sub === 'show')
|
|
99
101
|
return cmd.ordersShow(args[2]);
|
|
100
|
-
if (sub === 'approve' || sub === 'capture' || sub === 'cancel' || sub === 'refund' || sub === 'archive' || sub === 'unarchive')
|
|
102
|
+
if (sub === 'place' || sub === 'approve' || sub === 'capture' || sub === 'cancel' || sub === 'refund' || sub === 'archive' || sub === 'unarchive')
|
|
101
103
|
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`.');
|
|
104
|
+
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
105
|
case 'fulfillment':
|
|
104
106
|
if (sub === 'list')
|
|
105
107
|
return cmd.fulfillmentList(values);
|
|
@@ -108,6 +110,26 @@ async function run() {
|
|
|
108
110
|
if (sub === 'hold' || sub === 'pick' || sub === 'pack' || sub === 'ready' || sub === 'ship' || sub === 'deliver')
|
|
109
111
|
return cmd.fulfillmentAct(sub, args[2], values);
|
|
110
112
|
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`.');
|
|
113
|
+
case 'ops':
|
|
114
|
+
if (sub === 'check')
|
|
115
|
+
return cmd.opsCheck(values);
|
|
116
|
+
if (sub === 'run')
|
|
117
|
+
return cmd.opsRun(flags);
|
|
118
|
+
if (sub === 'digest')
|
|
119
|
+
return cmd.opsDigest(values);
|
|
120
|
+
throw new CliError('unknown_command', `There is no command "ops ${sub ?? ''}".`, 'Use `c11r ops check`, `ops run [--dry-run]` or `ops digest [--since 24h]`.');
|
|
121
|
+
case 'insights':
|
|
122
|
+
return cmd.insights(values);
|
|
123
|
+
case 'inbox':
|
|
124
|
+
if (sub === 'check')
|
|
125
|
+
return cmd.inboxCheck();
|
|
126
|
+
if (sub === 'list')
|
|
127
|
+
return cmd.inboxList(values, flags);
|
|
128
|
+
if (sub === 'pull')
|
|
129
|
+
return cmd.inboxPull(args[2], values, flags);
|
|
130
|
+
if (sub === 'tag')
|
|
131
|
+
return cmd.inboxTag(args[2], lists, values, flags);
|
|
132
|
+
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
133
|
case 'ucp':
|
|
112
134
|
if (sub === 'profile')
|
|
113
135
|
return cmd.ucpProfile(flags);
|
|
@@ -148,7 +170,7 @@ run()
|
|
|
148
170
|
})
|
|
149
171
|
.catch((e) => {
|
|
150
172
|
if (e instanceof CliError) {
|
|
151
|
-
print({ error: { code: e.code, message: e.message, hint: e.hint } });
|
|
173
|
+
print({ error: { code: e.code, message: e.message, hint: e.hint, ...(e.path ? { path: e.path } : {}) } });
|
|
152
174
|
}
|
|
153
175
|
else {
|
|
154
176
|
const message = e instanceof Error ? e.message : String(e);
|