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