@commercelayer/agent-cli 0.8.0 → 1.0.0-dev.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.
Files changed (67) hide show
  1. package/README.md +95 -215
  2. package/dist/argv.js +214 -0
  3. package/dist/bin.js +15 -178
  4. package/dist/builtins/auth.js +169 -0
  5. package/dist/builtins/extensions.js +123 -0
  6. package/dist/builtins/index.js +8 -0
  7. package/dist/builtins/loops.js +215 -0
  8. package/dist/builtins/sandbox.js +186 -0
  9. package/dist/config.js +211 -0
  10. package/dist/confirm.js +56 -0
  11. package/dist/engine.js +173 -0
  12. package/dist/errors.js +146 -0
  13. package/dist/help.js +78 -0
  14. package/dist/http.js +117 -0
  15. package/dist/login.js +133 -0
  16. package/dist/resources.js +41 -0
  17. package/dist/schema.js +152 -0
  18. package/dist/schemas.js +72 -0
  19. package/dist/secrets.js +189 -0
  20. package/dist/version.js +4 -8
  21. package/package.json +4 -9
  22. package/dist/api/client.js +0 -190
  23. package/dist/api/server.js +0 -26
  24. package/dist/api/stub/db.js +0 -37
  25. package/dist/api/stub/http.js +0 -18
  26. package/dist/api/stub/index.js +0 -52
  27. package/dist/api/stub/locale.js +0 -52
  28. package/dist/api/stub/management.js +0 -409
  29. package/dist/api/stub/messages.js +0 -126
  30. package/dist/api/stub/orders.js +0 -625
  31. package/dist/api/stub/search.js +0 -78
  32. package/dist/api/stub/shopping.js +0 -673
  33. package/dist/api/stub/theme.js +0 -197
  34. package/dist/api/stub/ucp.js +0 -377
  35. package/dist/api/transport.js +0 -57
  36. package/dist/api/types.js +0 -4
  37. package/dist/assets.js +0 -100
  38. package/dist/catalog.js +0 -366
  39. package/dist/check.js +0 -187
  40. package/dist/commands.js +0 -1241
  41. package/dist/cursor.js +0 -31
  42. package/dist/files.js +0 -98
  43. package/dist/inbox/cursor.js +0 -11
  44. package/dist/inbox/fake.js +0 -95
  45. package/dist/inbox/imap.js +0 -139
  46. package/dist/inbox/mailbox.js +0 -25
  47. package/dist/inbox/message.js +0 -88
  48. package/dist/inspect.js +0 -230
  49. package/dist/languages.js +0 -49
  50. package/dist/links.js +0 -118
  51. package/dist/locales.js +0 -43
  52. package/dist/ops.js +0 -190
  53. package/dist/orderRequest.js +0 -141
  54. package/dist/output.js +0 -39
  55. package/dist/products.js +0 -151
  56. package/dist/promotions.js +0 -291
  57. package/dist/store.js +0 -379
  58. package/dist/storefront.js +0 -285
  59. package/dist/ucp/buy.js +0 -90
  60. package/dist/ucp/check.js +0 -360
  61. package/dist/ucp/client.js +0 -129
  62. package/dist/ucp/images.js +0 -62
  63. package/dist/ucp/platform.js +0 -90
  64. package/dist/ucp/shop.js +0 -357
  65. package/dist/ucp/signing.js +0 -67
  66. package/dist/web.js +0 -316
  67. package/dist/website.js +0 -390
@@ -1,625 +0,0 @@
1
- // Orders and shipments as the merchant manages them, the API's view=merchant and
2
- // /shipments routes: the stub keeps the Commerce Layer state machine on each UCP
3
- // order, in _merchant, so the tests cover the same transitions as the platform.
4
- //
5
- // An order lands placed with its payment authorized. approve (optionally with
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; as in
8
- // Commerce Layer, an upcoming shipment ships only once it is picking (or past it).
9
- import { money } from '../../ucp/shop.js';
10
- import { minorUnits } from '../../ucp/platform.js';
11
- import { baseLanguage } from './locale.js';
12
- export const ORDER_STATUSES = ['pending', 'placed', 'approved', 'cancelled', 'editing'];
13
- export const PAYMENT_STATUSES = ['unpaid', 'authorized', 'partially_authorized', 'paid', 'partially_paid', 'voided', 'partially_voided', 'refunded', 'partially_refunded', 'free'];
14
- export const FULFILLMENT_STATUSES = ['unfulfilled', 'in_progress', 'fulfilled', 'not_required'];
15
- export const SHIPMENT_STATUSES = ['upcoming', 'on_hold', 'picking', 'packing', 'ready_to_ship', 'shipped', 'delivered', 'cancelled'];
16
- const OPEN_SHIPMENTS = ['upcoming', 'on_hold', 'picking', 'packing', 'ready_to_ship'];
17
- const WORKING = ['on_hold', 'picking', 'packing', 'ready_to_ship'];
18
- export class OrderError extends Error {
19
- status;
20
- code;
21
- hint;
22
- path;
23
- constructor(status, code, message, hint, path) {
24
- super(message);
25
- this.status = status;
26
- this.code = code;
27
- this.hint = hint;
28
- this.path = path;
29
- }
30
- }
31
- const now = () => new Date().toISOString();
32
- const number = (o) => o.label.replace(/^#/, '');
33
- const total = (o) => o.totals.find((t) => t.type === 'total')?.amount ?? 0;
34
- const sum = (o, type) => merchant(o).transactions.filter((t) => t.type === type && t.succeeded).reduce((s, t) => s + t.amount, 0);
35
- /** The merchant state of an order, created on first use as Commerce Layer has it after placement. */
36
- function merchant(o) {
37
- if (!o._merchant) {
38
- const at = o._placed_at ?? now();
39
- o._merchant = {
40
- status: 'placed',
41
- placed_at: at,
42
- archived_at: null,
43
- transactions: [{ type: 'authorization', amount: total(o), succeeded: true, at }],
44
- shipment: { id: `shp_${o.id.replace(/^ord_/, '')}`, number: `${number(o)}/S/001`, status: 'upcoming', tracking: null, dates: { created_at: at, on_hold_at: null, picking_at: null, packing_at: null, ready_to_ship_at: null, shipped_at: null, delivered_at: null } },
45
- };
46
- }
47
- return o._merchant;
48
- }
49
- function paymentStatus(o) {
50
- if (total(o) === 0)
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';
55
- const captured = sum(o, 'capture');
56
- const refunded = sum(o, 'refund');
57
- if (sum(o, 'void') > 0)
58
- return 'voided';
59
- if (refunded > 0)
60
- return refunded >= captured ? 'refunded' : 'partially_refunded';
61
- if (captured > 0)
62
- return captured >= total(o) ? 'paid' : 'partially_paid';
63
- return 'authorized';
64
- }
65
- function fulfillmentStatus(o) {
66
- const s = merchant(o).shipment.status;
67
- if (s === 'shipped' || s === 'delivered')
68
- return 'fulfilled';
69
- if (WORKING.includes(s))
70
- return 'in_progress';
71
- return 'unfulfilled';
72
- }
73
- const destination = (o) => o.fulfillment?.expectations?.[0]?.destination ?? {};
74
- const units = (o) => o.line_items.reduce((s, l) => s + (l.quantity?.total ?? l.quantity ?? 0), 0);
75
- function shipmentRef(o) {
76
- const s = merchant(o).shipment;
77
- return { id: s.id, number: s.number, status: s.status, tracking: s.tracking };
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
- }
87
- function row(o) {
88
- const m = merchant(o);
89
- return {
90
- id: o.id,
91
- number: number(o),
92
- status: m.status,
93
- payment_status: paymentStatus(o),
94
- fulfillment_status: fulfillmentStatus(o),
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),
100
- market: o._market ?? null,
101
- // The shipping country, as Commerce Layer's country_code follows the shipping address.
102
- country: destination(o).address_country ?? null,
103
- channel: channel(o),
104
- currency: o.currency,
105
- total: money(total(o), o.currency),
106
- email: o._buyer?.email ?? null,
107
- items: units(o),
108
- shipments: [shipmentRef(o)],
109
- archived: m.archived_at !== null,
110
- };
111
- }
112
- function address(o, d = destination(o)) {
113
- if (!Object.keys(d).length)
114
- return null;
115
- return {
116
- name: [d.first_name, d.last_name].filter(Boolean).join(' ') || null,
117
- company: d.company ?? null,
118
- line_1: d.street_address ?? null,
119
- line_2: d.extended_address ?? null,
120
- city: d.address_locality ?? null,
121
- zip_code: d.postal_code ?? null,
122
- state_code: d.address_region ?? null,
123
- country_code: d.address_country ?? null,
124
- phone: d.phone ?? o._buyer?.phone_number ?? null,
125
- };
126
- }
127
- function detail(o, r) {
128
- const m = merchant(o);
129
- const cur = o.currency;
130
- const amount = (type) => o.totals.filter((t) => t.type === type).reduce((s, t) => s + t.amount, 0);
131
- const captured = sum(o, 'capture') - sum(o, 'refund');
132
- return {
133
- ...row(o),
134
- line_items: o.line_items.map((l) => ({
135
- item_id: l.item?.id,
136
- sku: l.item?.id,
137
- name: l.item?.title,
138
- quantity: l.quantity?.total ?? l.quantity,
139
- unit_price: money(l.item?.price ?? 0, cur),
140
- total: money(l.totals?.find((t) => t.type === 'total')?.amount ?? 0, cur),
141
- })),
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 ?? [] },
143
- shipping_address: address(o),
144
- billing_address: o._bill_to ? address(o, o._bill_to) : address(o),
145
- shipping_method: o.fulfillment?.expectations?.[0]?.description ?? null,
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) },
147
- transactions: m.transactions.map((t) => ({ ...t, amount: money(t.amount, cur) })),
148
- language: o._language ?? baseLanguage(r),
149
- archived_at: m.archived_at,
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 } : {}),
152
- };
153
- }
154
- const orders = (r) => Object.values(r.ucp.orders).slice().reverse();
155
- /** An order by id, number, #number, or the checkout or cart it came from. */
156
- function findOrder(r, wanted) {
157
- const o = orders(r).find((x) => x.id === wanted || x.label === wanted || x.label === `#${wanted}` || x.checkout_id === wanted || x._cart_id === wanted);
158
- if (!o)
159
- throw new OrderError(404, 'not_found', `There is no order ${wanted}.`, 'Run `c11r orders list` to see the orders.');
160
- return o;
161
- }
162
- /** The commands an order allows now, as the API names them in its hints. */
163
- function orderCommands(o) {
164
- const m = merchant(o);
165
- const n = number(o);
166
- const pay = paymentStatus(o);
167
- const out = [];
168
- if (m.status === 'pending')
169
- return [`c11r orders place ${n}`, `c11r orders cancel ${n}`];
170
- if (m.status === 'placed')
171
- out.push(`c11r orders approve ${n} [--capture]`);
172
- if (m.status !== 'cancelled' && pay === 'authorized')
173
- out.push(`c11r orders capture ${n}`);
174
- if (m.status !== 'cancelled' && !['shipped', 'delivered'].includes(m.shipment.status))
175
- out.push(`c11r orders cancel ${n}`);
176
- if (['paid', 'partially_refunded'].includes(pay))
177
- out.push(`c11r orders refund ${n} [--amount <major>]`);
178
- out.push(m.archived_at ? `c11r orders unarchive ${n}` : `c11r orders archive ${n}`);
179
- return out;
180
- }
181
- const refuse = (what, allowed) => {
182
- throw new OrderError(422, 'invalid_transition', what, `Allowed now: ${allowed.join('; ')}.`);
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
- }
218
- export function listOrders(r, q) {
219
- const list = (name, allowed, fallback, all = allowed) => {
220
- const raw = q.get(name);
221
- if (!raw)
222
- return fallback;
223
- const vs = raw.split(',').map((v) => v.trim());
224
- for (const v of vs)
225
- if (v !== 'all' && !allowed.includes(v))
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.`);
227
- return [...new Set(vs.flatMap((v) => (v === 'all' ? all : [v])))];
228
- };
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'));
231
- const payments = list('payment', PAYMENT_STATUSES, PAYMENT_STATUSES);
232
- const fulfillments = list('fulfillment', FULFILLMENT_STATUSES, FULFILLMENT_STATUSES);
233
- const archived = q.get('archived') ?? 'false';
234
- if (!['true', 'false', 'all'].includes(archived))
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.');
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]));
243
- const rows = orders(r)
244
- .map(row)
245
- // Archived orders are listed only when asked for, whatever their status.
246
- .filter((x) => (archived === 'all' ? true : archived === 'true' ? x.archived : !x.archived))
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');
251
- }
252
- export const showOrder = (r, id) => detail(findOrder(r, id), r);
253
- export function orderAction(r, id, action, body) {
254
- const o = findOrder(r, id);
255
- const m = merchant(o);
256
- const at = now();
257
- const capture = () => {
258
- m.transactions.push({ type: 'capture', amount: total(o) - sum(o, 'capture'), succeeded: true, at });
259
- };
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;
269
- case 'approve':
270
- if (m.status !== 'placed')
271
- refuse(`Order ${number(o)} is ${m.status}: only a placed order can be approved.`, orderCommands(o));
272
- m.status = 'approved';
273
- m.approved_at = at;
274
- if (body?.capture === true && paymentStatus(o) === 'authorized')
275
- capture();
276
- break;
277
- case 'capture':
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));
281
- capture();
282
- break;
283
- case 'cancel':
284
- if (m.status === 'cancelled')
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
- }
295
- if (['shipped', 'delivered'].includes(m.shipment.status))
296
- refuse(`Order ${number(o)} has shipped: it cannot be cancelled.`, orderCommands(o));
297
- // Commerce Layer voids an authorized payment and refunds a captured one.
298
- if (paymentStatus(o) === 'authorized')
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 });
303
- m.status = 'cancelled';
304
- m.cancelled_at = at;
305
- m.shipment.status = 'cancelled';
306
- break;
307
- case 'refund': {
308
- if (!['paid', 'partially_refunded'].includes(paymentStatus(o)))
309
- refuse(`Order ${number(o)} has payment ${paymentStatus(o)}: only a captured payment can be refunded.`, orderCommands(o));
310
- const refundable = sum(o, 'capture') - sum(o, 'refund');
311
- let amount = refundable;
312
- if (body?.amount !== undefined) {
313
- if (typeof body.amount !== 'number' || !Number.isFinite(body.amount) || body.amount <= 0)
314
- throw new OrderError(422, 'invalid_argument', `amount ${body.amount} is not a positive number.`, 'Give --amount in major units, for example --amount 4.50, or leave it out to refund everything still refundable.');
315
- amount = Math.round(body.amount * minorUnits(o.currency));
316
- if (amount > refundable)
317
- throw new OrderError(422, 'refund_exceeds', `${money(amount, o.currency).display} is more than the ${money(refundable, o.currency).display} still refundable on order ${number(o)}.`, `Refund at most ${money(refundable, o.currency).major}, or leave --amount out to refund all of it.`);
318
- }
319
- m.transactions.push({ type: 'refund', amount, succeeded: true, at });
320
- // As in Commerce Layer: refunding the last of the payment cancels the order.
321
- if (sum(o, 'refund') >= sum(o, 'capture')) {
322
- m.status = 'cancelled';
323
- m.cancelled_at = at;
324
- if (OPEN_SHIPMENTS.includes(m.shipment.status))
325
- m.shipment.status = 'cancelled';
326
- }
327
- break;
328
- }
329
- case 'archive':
330
- m.archived_at ??= at;
331
- break;
332
- case 'unarchive':
333
- m.archived_at = null;
334
- break;
335
- default:
336
- throw new OrderError(404, 'not_found', `No order action ${action}.`, 'Use place, approve, capture, cancel, refund, archive or unarchive.');
337
- }
338
- m.updated_at = at;
339
- return detail(o, r);
340
- }
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
- }
351
- function shipmentRow(o) {
352
- const s = merchant(o).shipment;
353
- return {
354
- id: s.id,
355
- number: s.number,
356
- status: s.status,
357
- order: { id: o.id, number: number(o), status: merchant(o).status },
358
- country: destination(o).address_country ?? null,
359
- shipping_method: o.fulfillment?.expectations?.[0]?.description ?? null,
360
- items: units(o),
361
- tracking: s.tracking,
362
- status_changed_at: statusChangedAt(o),
363
- updated_at: s.updated_at ?? s.dates.created_at,
364
- dates: { ...s.dates },
365
- };
366
- }
367
- /** A shipment by id or number, or the one shipment of an order by its id or number. */
368
- function findShipment(r, wanted) {
369
- const o = orders(r).find((x) => merchant(x).shipment.id === wanted || merchant(x).shipment.number === wanted) ?? orders(r).find((x) => x.id === wanted || x.label === wanted || x.label === `#${wanted}`);
370
- if (!o)
371
- throw new OrderError(404, 'not_found', `There is no shipment ${wanted}.`, 'Run `c11r fulfillment list` to see the shipments.');
372
- return o;
373
- }
374
- function shipmentCommands(o) {
375
- const s = merchant(o).shipment;
376
- const approved = merchant(o).status === 'approved';
377
- const n = s.number;
378
- const out = [];
379
- const can = (cmd, ok) => ok && out.push(`c11r fulfillment ${cmd} ${n}`);
380
- can('hold', ['upcoming', 'picking', 'packing', 'ready_to_ship'].includes(s.status) && merchant(o).status !== 'cancelled');
381
- can('pick', approved && ['upcoming', 'on_hold', 'packing', 'ready_to_ship'].includes(s.status));
382
- can('pack', approved && ['on_hold', 'picking', 'ready_to_ship'].includes(s.status));
383
- can('ready', approved && ['on_hold', 'picking', 'packing'].includes(s.status));
384
- can('ship', approved && WORKING.includes(s.status));
385
- can('deliver', s.status === 'shipped');
386
- if (!out.length && merchant(o).status === 'placed')
387
- out.push(`c11r orders approve ${number(o)} [--capture]`);
388
- return out;
389
- }
390
- export function listShipments(r, q) {
391
- const raw = q.get('status');
392
- let statuses = [...OPEN_SHIPMENTS];
393
- if (raw) {
394
- const vs = raw.split(',').map((v) => v.trim());
395
- if (vs.includes('all'))
396
- statuses = SHIPMENT_STATUSES;
397
- else {
398
- for (const v of vs)
399
- if (!SHIPMENT_STATUSES.includes(v))
400
- throw new OrderError(422, 'invalid_argument', `status=${v} is not one of ${SHIPMENT_STATUSES.join(', ')}, all.`, `Use --status with one of ${SHIPMENT_STATUSES.join(', ')}, or all.`);
401
- statuses = vs;
402
- }
403
- }
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');
406
- }
407
- export function showShipment(r, id) {
408
- const o = findShipment(r, id);
409
- return { ...shipmentRow(o), line_items: o.line_items.map((l) => ({ sku: l.item?.id, name: l.item?.title, quantity: l.quantity?.total ?? l.quantity })), address: address(o), parcels: [] };
410
- }
411
- const URL_RE = /^https:\/\/\S+$/;
412
- export function shipmentAction(r, id, action, body) {
413
- const o = findShipment(r, id);
414
- const m = merchant(o);
415
- const s = m.shipment;
416
- const at = now();
417
- const move = (to, from, needsApproved) => {
418
- if (!from.includes(s.status) || (needsApproved && m.status !== 'approved') || m.status === 'cancelled') {
419
- const why = needsApproved && m.status !== 'approved' ? `its order ${number(o)} is ${m.status}` : `it is ${s.status}`;
420
- refuse(`Shipment ${s.number} cannot go ${to.replace(/_/g, ' ')}: ${why}.`, shipmentCommands(o));
421
- }
422
- s.status = to;
423
- s.dates[`${to}_at`] = at;
424
- s.updated_at = at;
425
- };
426
- switch (action) {
427
- case 'hold':
428
- return move('on_hold', ['upcoming', 'picking', 'packing', 'ready_to_ship'], false), showShipment(r, s.id);
429
- case 'pick':
430
- return move('picking', ['upcoming', 'on_hold', 'packing', 'ready_to_ship'], true), showShipment(r, s.id);
431
- case 'pack':
432
- return move('packing', ['on_hold', 'picking', 'ready_to_ship'], true), showShipment(r, s.id);
433
- case 'ready':
434
- return move('ready_to_ship', ['on_hold', 'picking', 'packing'], true), showShipment(r, s.id);
435
- case 'ship': {
436
- for (const k of ['carrier', 'tracking', 'tracking_url']) {
437
- const v = body?.[k];
438
- if (v !== undefined && (typeof v !== 'string' || !v.trim() || v.length > 255))
439
- throw new OrderError(422, 'invalid_argument', `${k} must be text of 1 to 255 characters.`, `Give --${k.replace('_', '-')} a value, or leave it out.`);
440
- }
441
- if (body?.tracking_url !== undefined && !URL_RE.test(body.tracking_url))
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://.');
443
- move('shipped', WORKING, true);
444
- if (body?.carrier || body?.tracking || body?.tracking_url)
445
- s.tracking = { carrier: body.carrier, number: body.tracking, url: body.tracking_url };
446
- return showShipment(r, s.id);
447
- }
448
- case 'deliver':
449
- return move('delivered', ['shipped'], false), showShipment(r, s.id);
450
- default:
451
- throw new OrderError(404, 'not_found', `No shipment action ${action}.`, 'Use hold, pick, pack, ready, ship or deliver.');
452
- }
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
- }