@commercelayer/agent-cli 0.3.1 → 0.5.1

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.
@@ -0,0 +1,359 @@
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.
8
+ import { money } from '../../ucp/shop.js';
9
+ import { minorUnits } from '../../ucp/platform.js';
10
+ import { baseLanguage } from './locale.js';
11
+ export const ORDER_STATUSES = ['placed', 'approved', 'cancelled', 'editing'];
12
+ export const PAYMENT_STATUSES = ['unpaid', 'authorized', 'partially_authorized', 'paid', 'partially_paid', 'voided', 'partially_voided', 'refunded', 'partially_refunded', 'free'];
13
+ export const FULFILLMENT_STATUSES = ['unfulfilled', 'in_progress', 'fulfilled', 'not_required'];
14
+ export const SHIPMENT_STATUSES = ['upcoming', 'on_hold', 'picking', 'packing', 'ready_to_ship', 'shipped', 'delivered', 'cancelled'];
15
+ const OPEN_SHIPMENTS = ['upcoming', 'on_hold', 'picking', 'packing', 'ready_to_ship'];
16
+ const WORKING = ['on_hold', 'picking', 'packing', 'ready_to_ship'];
17
+ export class OrderError extends Error {
18
+ status;
19
+ code;
20
+ hint;
21
+ constructor(status, code, message, hint) {
22
+ super(message);
23
+ this.status = status;
24
+ this.code = code;
25
+ this.hint = hint;
26
+ }
27
+ }
28
+ const now = () => new Date().toISOString();
29
+ const number = (o) => o.label.replace(/^#/, '');
30
+ const total = (o) => o.totals.find((t) => t.type === 'total')?.amount ?? 0;
31
+ const sum = (o, type) => merchant(o).transactions.filter((t) => t.type === type && t.succeeded).reduce((s, t) => s + t.amount, 0);
32
+ /** The merchant state of an order, created on first use as Commerce Layer has it after placement. */
33
+ function merchant(o) {
34
+ if (!o._merchant) {
35
+ const at = o._placed_at ?? now();
36
+ o._merchant = {
37
+ status: 'placed',
38
+ placed_at: at,
39
+ archived_at: null,
40
+ transactions: [{ type: 'authorization', amount: total(o), succeeded: true, at }],
41
+ 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 } },
42
+ };
43
+ }
44
+ return o._merchant;
45
+ }
46
+ function paymentStatus(o) {
47
+ if (total(o) === 0)
48
+ return 'free';
49
+ const captured = sum(o, 'capture');
50
+ const refunded = sum(o, 'refund');
51
+ if (sum(o, 'void') > 0)
52
+ return 'voided';
53
+ if (refunded > 0)
54
+ return refunded >= captured ? 'refunded' : 'partially_refunded';
55
+ if (captured > 0)
56
+ return captured >= total(o) ? 'paid' : 'partially_paid';
57
+ return 'authorized';
58
+ }
59
+ function fulfillmentStatus(o) {
60
+ const s = merchant(o).shipment.status;
61
+ if (s === 'shipped' || s === 'delivered')
62
+ return 'fulfilled';
63
+ if (WORKING.includes(s))
64
+ return 'in_progress';
65
+ return 'unfulfilled';
66
+ }
67
+ const destination = (o) => o.fulfillment?.expectations?.[0]?.destination ?? {};
68
+ const units = (o) => o.line_items.reduce((s, l) => s + (l.quantity?.total ?? l.quantity ?? 0), 0);
69
+ function shipmentRef(o) {
70
+ const s = merchant(o).shipment;
71
+ return { id: s.id, number: s.number, status: s.status, tracking: s.tracking };
72
+ }
73
+ function row(o) {
74
+ const m = merchant(o);
75
+ return {
76
+ id: o.id,
77
+ number: number(o),
78
+ status: m.status,
79
+ payment_status: paymentStatus(o),
80
+ fulfillment_status: fulfillmentStatus(o),
81
+ placed_at: m.placed_at,
82
+ market: o._market ?? null,
83
+ country: destination(o).address_country ?? null,
84
+ currency: o.currency,
85
+ total: money(total(o), o.currency),
86
+ email: o._buyer?.email ?? null,
87
+ items: units(o),
88
+ shipments: [shipmentRef(o)],
89
+ archived: m.archived_at !== null,
90
+ };
91
+ }
92
+ function address(o) {
93
+ const d = destination(o);
94
+ if (!Object.keys(d).length)
95
+ return null;
96
+ return {
97
+ name: [d.first_name, d.last_name].filter(Boolean).join(' ') || null,
98
+ company: null,
99
+ line_1: d.street_address ?? null,
100
+ line_2: d.extended_address ?? null,
101
+ city: d.address_locality ?? null,
102
+ zip_code: d.postal_code ?? null,
103
+ state_code: d.address_region ?? null,
104
+ country_code: d.address_country ?? null,
105
+ phone: o._buyer?.phone_number ?? null,
106
+ };
107
+ }
108
+ function detail(o, r) {
109
+ const m = merchant(o);
110
+ const cur = o.currency;
111
+ const amount = (type) => o.totals.filter((t) => t.type === type).reduce((s, t) => s + t.amount, 0);
112
+ const captured = sum(o, 'capture') - sum(o, 'refund');
113
+ return {
114
+ ...row(o),
115
+ line_items: o.line_items.map((l) => ({
116
+ item_id: l.item?.id,
117
+ sku: l.item?.id,
118
+ name: l.item?.title,
119
+ quantity: l.quantity?.total ?? l.quantity,
120
+ unit_price: money(l.item?.price ?? 0, cur),
121
+ total: money(l.totals?.find((t) => t.type === 'total')?.amount ?? 0, cur),
122
+ })),
123
+ 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
+ shipping_address: address(o),
125
+ billing_address: address(o),
126
+ shipping_method: o.fulfillment?.expectations?.[0]?.description ?? null,
127
+ 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
+ transactions: m.transactions.map((t) => ({ ...t, amount: money(t.amount, cur) })),
129
+ language: o._language ?? baseLanguage(r),
130
+ archived_at: m.archived_at,
131
+ ucp: { id: o.id, permalink_url: o.permalink_url },
132
+ };
133
+ }
134
+ const orders = (r) => Object.values(r.ucp.orders).slice().reverse();
135
+ /** An order by id, number, #number, or the checkout or cart it came from. */
136
+ function findOrder(r, wanted) {
137
+ const o = orders(r).find((x) => x.id === wanted || x.label === wanted || x.label === `#${wanted}` || x.checkout_id === wanted || x._cart_id === wanted);
138
+ if (!o)
139
+ throw new OrderError(404, 'not_found', `There is no order ${wanted}.`, 'Run `c11r orders list` to see the orders.');
140
+ return o;
141
+ }
142
+ /** The commands an order allows now, as the API names them in its hints. */
143
+ function orderCommands(o) {
144
+ const m = merchant(o);
145
+ const n = number(o);
146
+ const pay = paymentStatus(o);
147
+ const out = [];
148
+ if (m.status === 'placed')
149
+ out.push(`c11r orders approve ${n} [--capture]`);
150
+ if (m.status === 'approved' && pay === 'authorized')
151
+ out.push(`c11r orders capture ${n}`);
152
+ if (m.status !== 'cancelled' && !['shipped', 'delivered'].includes(m.shipment.status) && ['authorized', 'free'].includes(pay))
153
+ out.push(`c11r orders cancel ${n}`);
154
+ if (['paid', 'partially_refunded'].includes(pay))
155
+ out.push(`c11r orders refund ${n} [--amount <major>]`);
156
+ out.push(m.archived_at ? `c11r orders unarchive ${n}` : `c11r orders archive ${n}`);
157
+ return out;
158
+ }
159
+ const refuse = (what, allowed) => {
160
+ throw new OrderError(422, 'invalid_transition', what, `Allowed now: ${allowed.join('; ')}.`);
161
+ };
162
+ export function listOrders(r, q) {
163
+ const list = (name, allowed, fallback) => {
164
+ const raw = q.get(name);
165
+ if (!raw)
166
+ return fallback;
167
+ const vs = raw.split(',').map((v) => v.trim());
168
+ if (vs.includes('all'))
169
+ return allowed;
170
+ for (const v of vs)
171
+ if (!allowed.includes(v))
172
+ 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;
174
+ };
175
+ const statuses = list('status', ORDER_STATUSES, ['placed', 'approved']);
176
+ const payments = list('payment', PAYMENT_STATUSES, PAYMENT_STATUSES);
177
+ const fulfillments = list('fulfillment', FULFILLMENT_STATUSES, FULFILLMENT_STATUSES);
178
+ const archived = q.get('archived') ?? 'false';
179
+ if (!['true', 'false', 'all'].includes(archived))
180
+ throw new OrderError(422, 'invalid_argument', `archived=${archived} is not true, false or all.`, 'Use --archived for only archived orders, --archived all for every order.');
181
+ const limit = q.get('limit') === null ? 25 : Number(q.get('limit'));
182
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100)
183
+ throw new OrderError(422, 'invalid_argument', `limit=${q.get('limit')} is not between 1 and 100.`, 'Use --limit with a number from 1 to 100.');
184
+ const rows = orders(r)
185
+ .map(row)
186
+ // Archived orders are listed only when asked for, whatever their status.
187
+ .filter((x) => (archived === 'all' ? true : archived === 'true' ? x.archived : !x.archived))
188
+ .filter((x) => (archived === 'true' && !q.get('status') ? true : statuses.includes(x.status)))
189
+ .filter((x) => payments.includes(x.payment_status) && fulfillments.includes(x.fulfillment_status));
190
+ return { orders: rows.slice(0, limit) };
191
+ }
192
+ export const showOrder = (r, id) => detail(findOrder(r, id), r);
193
+ export function orderAction(r, id, action, body) {
194
+ const o = findOrder(r, id);
195
+ const m = merchant(o);
196
+ const at = now();
197
+ const capture = () => {
198
+ m.transactions.push({ type: 'capture', amount: total(o) - sum(o, 'capture'), succeeded: true, at });
199
+ };
200
+ switch (action) {
201
+ case 'approve':
202
+ if (m.status !== 'placed')
203
+ refuse(`Order ${number(o)} is ${m.status}: only a placed order can be approved.`, orderCommands(o));
204
+ m.status = 'approved';
205
+ if (body?.capture === true && paymentStatus(o) === 'authorized')
206
+ capture();
207
+ break;
208
+ case 'capture':
209
+ if (m.status !== 'approved' || paymentStatus(o) !== 'authorized')
210
+ refuse(`Order ${number(o)} is ${m.status} with payment ${paymentStatus(o)}: only an approved order with an authorized payment can be captured.`, orderCommands(o));
211
+ capture();
212
+ break;
213
+ case 'cancel':
214
+ if (m.status === 'cancelled')
215
+ refuse(`Order ${number(o)} is already cancelled.`, orderCommands(o));
216
+ if (['shipped', 'delivered'].includes(m.shipment.status))
217
+ refuse(`Order ${number(o)} has shipped: it cannot be cancelled.`, orderCommands(o));
218
+ if (!['authorized', 'free'].includes(paymentStatus(o)))
219
+ refuse(`Order ${number(o)} has payment ${paymentStatus(o)}: refund it to cancel it.`, orderCommands(o));
220
+ if (paymentStatus(o) === 'authorized')
221
+ m.transactions.push({ type: 'void', amount: total(o), succeeded: true, at });
222
+ m.status = 'cancelled';
223
+ m.shipment.status = 'cancelled';
224
+ break;
225
+ case 'refund': {
226
+ if (!['paid', 'partially_refunded'].includes(paymentStatus(o)))
227
+ refuse(`Order ${number(o)} has payment ${paymentStatus(o)}: only a captured payment can be refunded.`, orderCommands(o));
228
+ const refundable = sum(o, 'capture') - sum(o, 'refund');
229
+ let amount = refundable;
230
+ if (body?.amount !== undefined) {
231
+ if (typeof body.amount !== 'number' || !Number.isFinite(body.amount) || body.amount <= 0)
232
+ 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.');
233
+ amount = Math.round(body.amount * minorUnits(o.currency));
234
+ if (amount > refundable)
235
+ 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.`);
236
+ }
237
+ m.transactions.push({ type: 'refund', amount, succeeded: true, at });
238
+ // As in Commerce Layer: refunding the last of the payment cancels the order.
239
+ if (sum(o, 'refund') >= sum(o, 'capture')) {
240
+ m.status = 'cancelled';
241
+ if (OPEN_SHIPMENTS.includes(m.shipment.status))
242
+ m.shipment.status = 'cancelled';
243
+ }
244
+ break;
245
+ }
246
+ case 'archive':
247
+ m.archived_at ??= at;
248
+ break;
249
+ case 'unarchive':
250
+ m.archived_at = null;
251
+ break;
252
+ default:
253
+ throw new OrderError(404, 'not_found', `No order action ${action}.`, 'Use approve, capture, cancel, refund, archive or unarchive.');
254
+ }
255
+ return detail(o, r);
256
+ }
257
+ // ---------- shipments
258
+ function shipmentRow(o) {
259
+ const s = merchant(o).shipment;
260
+ return {
261
+ id: s.id,
262
+ number: s.number,
263
+ status: s.status,
264
+ order: { id: o.id, number: number(o), status: merchant(o).status },
265
+ country: destination(o).address_country ?? null,
266
+ shipping_method: o.fulfillment?.expectations?.[0]?.description ?? null,
267
+ items: units(o),
268
+ tracking: s.tracking,
269
+ dates: { ...s.dates },
270
+ };
271
+ }
272
+ /** A shipment by id or number, or the one shipment of an order by its id or number. */
273
+ function findShipment(r, wanted) {
274
+ 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}`);
275
+ if (!o)
276
+ throw new OrderError(404, 'not_found', `There is no shipment ${wanted}.`, 'Run `c11r fulfillment list` to see the shipments.');
277
+ return o;
278
+ }
279
+ function shipmentCommands(o) {
280
+ const s = merchant(o).shipment;
281
+ const approved = merchant(o).status === 'approved';
282
+ const n = s.number;
283
+ const out = [];
284
+ const can = (cmd, ok) => ok && out.push(`c11r fulfillment ${cmd} ${n}`);
285
+ can('hold', ['upcoming', 'picking', 'packing', 'ready_to_ship'].includes(s.status) && merchant(o).status !== 'cancelled');
286
+ can('pick', approved && ['upcoming', 'on_hold', 'packing', 'ready_to_ship'].includes(s.status));
287
+ can('pack', approved && ['on_hold', 'picking', 'ready_to_ship'].includes(s.status));
288
+ can('ready', approved && ['on_hold', 'picking', 'packing'].includes(s.status));
289
+ can('ship', approved && OPEN_SHIPMENTS.includes(s.status));
290
+ can('deliver', s.status === 'shipped');
291
+ if (!out.length && merchant(o).status === 'placed')
292
+ out.push(`c11r orders approve ${number(o)} [--capture]`);
293
+ return out;
294
+ }
295
+ export function listShipments(r, q) {
296
+ const raw = q.get('status');
297
+ let statuses = [...OPEN_SHIPMENTS];
298
+ if (raw) {
299
+ const vs = raw.split(',').map((v) => v.trim());
300
+ if (vs.includes('all'))
301
+ statuses = SHIPMENT_STATUSES;
302
+ else {
303
+ for (const v of vs)
304
+ if (!SHIPMENT_STATUSES.includes(v))
305
+ 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.`);
306
+ statuses = vs;
307
+ }
308
+ }
309
+ const limit = q.get('limit') === null ? 25 : Number(q.get('limit'));
310
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100)
311
+ throw new OrderError(422, 'invalid_argument', `limit=${q.get('limit')} is not between 1 and 100.`, 'Use --limit with a number from 1 to 100.');
312
+ return { shipments: orders(r).filter((o) => statuses.includes(merchant(o).shipment.status)).map(shipmentRow).slice(0, limit) };
313
+ }
314
+ export function showShipment(r, id) {
315
+ const o = findShipment(r, id);
316
+ 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: [] };
317
+ }
318
+ const URL_RE = /^https:\/\/\S+$/;
319
+ export function shipmentAction(r, id, action, body) {
320
+ const o = findShipment(r, id);
321
+ const m = merchant(o);
322
+ const s = m.shipment;
323
+ const at = now();
324
+ const move = (to, from, needsApproved) => {
325
+ if (!from.includes(s.status) || (needsApproved && m.status !== 'approved') || m.status === 'cancelled') {
326
+ const why = needsApproved && m.status !== 'approved' ? `its order ${number(o)} is ${m.status}` : `it is ${s.status}`;
327
+ refuse(`Shipment ${s.number} cannot go ${to.replace(/_/g, ' ')}: ${why}.`, shipmentCommands(o));
328
+ }
329
+ s.status = to;
330
+ s.dates[`${to}_at`] = at;
331
+ };
332
+ switch (action) {
333
+ case 'hold':
334
+ return move('on_hold', ['upcoming', 'picking', 'packing', 'ready_to_ship'], false), showShipment(r, s.id);
335
+ case 'pick':
336
+ return move('picking', ['upcoming', 'on_hold', 'packing', 'ready_to_ship'], true), showShipment(r, s.id);
337
+ case 'pack':
338
+ return move('packing', ['on_hold', 'picking', 'ready_to_ship'], true), showShipment(r, s.id);
339
+ case 'ready':
340
+ return move('ready_to_ship', ['on_hold', 'picking', 'packing'], true), showShipment(r, s.id);
341
+ case 'ship': {
342
+ for (const k of ['carrier', 'tracking', 'tracking_url']) {
343
+ const v = body?.[k];
344
+ if (v !== undefined && (typeof v !== 'string' || !v.trim() || v.length > 255))
345
+ throw new OrderError(422, 'invalid_argument', `${k} must be text of 1 to 255 characters.`, `Give --${k.replace('_', '-')} a value, or leave it out.`);
346
+ }
347
+ if (body?.tracking_url !== undefined && !URL_RE.test(body.tracking_url))
348
+ throw new OrderError(422, 'invalid_argument', `tracking_url ${body.tracking_url} is not an https URL.`, 'Give --tracking-url the carrier\'s tracking page, starting with https://.');
349
+ move('shipped', OPEN_SHIPMENTS, true);
350
+ if (body?.carrier || body?.tracking || body?.tracking_url)
351
+ s.tracking = { carrier: body.carrier, number: body.tracking, url: body.tracking_url };
352
+ return showShipment(r, s.id);
353
+ }
354
+ case 'deliver':
355
+ return move('delivered', ['shipped'], false), showShipment(r, s.id);
356
+ default:
357
+ throw new OrderError(404, 'not_found', `No shipment action ${action}.`, 'Use hold, pick, pack, ready, ship or deliver.');
358
+ }
359
+ }
@@ -0,0 +1,78 @@
1
+ // A copy of the store Worker's search (commercelayer-ai-store src/store/search.ts), so the
2
+ // stub ranks as the store does. Keep it in step with the store's; only the import differs.
3
+ //
4
+ // Catalog search over what apply sent: words in any order, ranked. A query is folded
5
+ // (case and accents aside), split into words, stripped of the stop words of the theme's
6
+ // languages and of plurals; each product scores by where its words appear (title over
7
+ // category over description, in its base text or any translation) and by how many of
8
+ // the query's words it has. Products with every word come first, then those with some;
9
+ // ties go by sku. No embeddings: what a word means is not known here, only that it is there.
10
+ const WEIGHT = { sku: 8, title: 4, category: 2, description: 1 };
11
+ /** Lower case, without accents: "Caffè" and "caffe" compare equal. */
12
+ export const fold = (t) => t.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
13
+ // The theme's languages: en, it, fr, de, es. Words that say nothing about a product.
14
+ const STOP = new Set(`a an and any are as at be best but by can do does for from get good have i in into is it its looking me my need of on or our please some something show that the their them these this those to want with without you your
15
+ il lo la i gli le un uno una di da del della dei delle per con su e o che qualcosa cerco vorrei voglio mi un po
16
+ le la les un une des de du d et ou pour avec sur dans au aux en quelque chose je veux cherche
17
+ der die das ein eine einen einem und oder fur mit auf im in zu etwas ich suche mochte
18
+ el la los las un una unos unas de del y o para con en algo busco quiero`.split(/\s+/));
19
+ /** A word in one spelling: folded, and singular for the common plurals (beans, berries, boxes). */
20
+ function stem(word) {
21
+ if (word.length > 4 && word.endsWith('ies'))
22
+ return `${word.slice(0, -3)}y`;
23
+ if (word.length > 4 && /(ches|shes|sses|xes)$/.test(word))
24
+ return word.slice(0, -2);
25
+ if (word.length > 3 && word.endsWith('s') && !/(ss|us|is)$/.test(word))
26
+ return word.slice(0, -1);
27
+ return word;
28
+ }
29
+ const wordsIn = (text) => (text ? fold(text).split(/[^\p{L}\p{N}]+/u).filter(Boolean).map(stem) : []);
30
+ /** The words a query searches for: no stop words, no repeats, in the order given. */
31
+ export function queryWords(text) {
32
+ return [...new Set(wordsIn(text).filter((w) => !STOP.has(w)))];
33
+ }
34
+ /** A product's words by field, its base text and every translation together. */
35
+ function fieldsOf(p) {
36
+ const texts = [p, ...Object.values(p.translations ?? {})];
37
+ return {
38
+ sku: new Set([fold(p.sku), ...wordsIn(p.sku)]),
39
+ title: new Set(texts.flatMap((t) => wordsIn(t.name))),
40
+ category: new Set(texts.flatMap((t) => wordsIn(t.category))),
41
+ description: new Set(texts.flatMap((t) => wordsIn(t.description))),
42
+ };
43
+ }
44
+ /** A word is in a field when the field has it, or (for words of four letters or more) a word that starts with it: "filt" finds "filter". */
45
+ const has = (field, word) => field.has(word) || (word.length >= 4 && [...field].some((w) => w.startsWith(word)));
46
+ /** How well a product matches the query's words, or null when none of them is in it. */
47
+ export function scoreOf(p, words) {
48
+ if (!words.length)
49
+ return { score: 0, words: [], matched: [], all: true };
50
+ const fields = fieldsOf(p);
51
+ const found = [];
52
+ const matched = new Set();
53
+ let score = 0;
54
+ for (const w of words) {
55
+ const where = Object.keys(WEIGHT).filter((f) => has(fields[f], w));
56
+ if (!where.length)
57
+ continue;
58
+ found.push(w);
59
+ where.forEach((f) => matched.add(f));
60
+ score += WEIGHT[where[0]];
61
+ }
62
+ if (!found.length)
63
+ return null;
64
+ const all = found.length === words.length;
65
+ // Every word beats some words; more words beat better places.
66
+ score += found.length * 10 + (all ? 100 : 0);
67
+ return { score, words: found, matched: Object.keys(WEIGHT).filter((f) => matched.has(f)), all };
68
+ }
69
+ /** The products matching a query, best first; ties by sku. An empty query (or only stop words) matches everything, by sku. */
70
+ export function rank(products, text) {
71
+ const words = queryWords(text);
72
+ return products
73
+ .flatMap((product) => {
74
+ const match = scoreOf(product, words);
75
+ return match ? [{ product, match }] : [];
76
+ })
77
+ .sort((a, b) => b.match.score - a.match.score || (a.product.sku < b.product.sku ? -1 : a.product.sku > b.product.sku ? 1 : 0));
78
+ }
@@ -3,6 +3,9 @@
3
3
  // schemas; src/ucp/platform.ts holds the constants shared with the client.
4
4
  import crypto from 'node:crypto';
5
5
  import { SANDBOX_CREDENTIAL_TYPE, SANDBOX_HANDLER, SANDBOX_HANDLER_ID, SANDBOX_TOKEN_DECLINED, SANDBOX_TOKEN_SUCCESS, toMinor, UCP_VERSION } from '../../ucp/platform.js';
6
+ import { baseLanguage, marketLocale, texts } from './locale.js';
7
+ import { queryWords, rank } from './search.js';
8
+ const lang = (ctx) => ctx.language ?? baseLanguage(ctx.record);
6
9
  /** The discount code every sandbox accepts: 10% off the items. */
7
10
  const SANDBOX_CODE = 'SANDBOX10';
8
11
  const CONSENT_PURPOSES = {
@@ -88,62 +91,123 @@ function returnPolicy(ctx) {
88
91
  // ---------- catalog
89
92
  /** A path in the storefront (img/eth.jpg) is served from the store host: shopping agents always get a full URL,
90
93
  * and for a product without an image, its tile. */
91
- const imageUrl = (ctx, p) => new URL(p.image_url ?? `tiles/${encodeURIComponent(p.sku)}.svg`, `${ctx.origin}/`).href;
92
- function variant(ctx, p, market) {
94
+ const imageUrl = (ctx, p) => new URL(p.image_url ?? `tiles/${encodeURIComponent(p.sku)}.svg${lang(ctx) === baseLanguage(ctx.record) ? '' : `?lang=${lang(ctx)}`}`, `${ctx.origin}/`).href;
95
+ /** A product page: under the locale of the buyer's country when it is in the market, else the market's first. */
96
+ function productUrl(ctx, p, market) {
97
+ if (ctx.record.storefront_mode === 'agent-only')
98
+ return undefined;
99
+ const l = marketLocale(ctx.record, market.code, ctx.country);
100
+ return `${ctx.origin}${l ? `/${l.path}` : ''}/products/${encodeURIComponent(p.sku)}`;
101
+ }
102
+ /** asked: the country the buyer asked about (filters.fulfills_to, else context), whose shipping the variant answers for. */
103
+ function variant(ctx, p, market, asked) {
93
104
  const price = { amount: toMinor(p.prices[market.currency], market.currency), currency: market.currency };
94
105
  const v = {
95
106
  id: p.sku,
96
107
  sku: p.sku,
97
- title: p.name,
98
- description: { plain: p.description ?? p.name },
108
+ title: texts(p, lang(ctx)).name,
109
+ description: { plain: texts(p, lang(ctx)).description ?? texts(p, lang(ctx)).name },
99
110
  // Agent-only stores have no product pages to link to.
100
- url: ctx.record.storefront_mode === 'agent-only' ? undefined : `${ctx.origin}/products/${encodeURIComponent(p.sku)}`,
111
+ url: productUrl(ctx, p, market),
101
112
  price,
102
113
  // Inventory lives in Commerce Layer; the sandbox catalog has no stock, so everything is in stock.
103
114
  availability: { available: true, status: 'in_stock' },
115
+ // Like the store: which market the price is for. tax_included comes from a Commerce Layer price list: none here.
116
+ metadata: { prices: { market: market.code, market_name: market.name, currency: market.currency, countries: market.countries } },
104
117
  };
105
118
  v.media = [{ type: 'image', url: imageUrl(ctx, p) }];
106
119
  if (ctx.shared.has('dev.ucp.shopping.fulfillment')) {
107
120
  const o = shippingOption(market);
108
- v.fulfillment = { methods: [{ type: 'shipping', description: { plain: `Ships to ${market.countries.join(', ')}` }, availability: v.availability, options: [{ id: o.id, title: o.title, description: o.description }] }] };
121
+ // days is "3" or "3-5": the earliest and latest arrival, at noon UTC.
122
+ const [first, last = first] = String(market.shipping?.days ?? '0').split('-').map((d) => Number(d.trim()) || 0);
123
+ const day = (n) => {
124
+ const at = new Date(Date.now() + n * 86_400_000);
125
+ at.setUTCHours(12, 0, 0, 0);
126
+ return at.toISOString().replace('.000', '');
127
+ };
128
+ const ships = !asked || market.countries.includes(asked.toUpperCase());
129
+ const availability = ships ? v.availability : { available: false, status: 'out_of_stock' };
130
+ const method = {
131
+ type: 'shipping',
132
+ description: { plain: `Ships to ${market.countries.join(', ')}` },
133
+ ships_to: market.countries,
134
+ availability,
135
+ options: ships ? [{ id: o.id, title: o.title, description: o.description, totals: [{ type: 'fulfillment', display_text: 'Shipping', amount: o.totals[0].amount }], earliest_fulfillment_time: day(first), latest_fulfillment_time: day(last) }] : [],
136
+ };
137
+ if (asked)
138
+ method.destination = { address_country: asked.toUpperCase(), ships };
139
+ // Like the store: the variant stays available; the method says it does not ship there.
140
+ v.fulfillment = { methods: [method] };
109
141
  }
110
142
  return v;
111
143
  }
112
- function product(ctx, p, market) {
113
- const v = variant(ctx, p, market);
144
+ function product(ctx, p, market, asked, match) {
145
+ const v = variant(ctx, p, market, asked);
114
146
  const out = {
115
147
  id: `prod_${p.sku}`,
116
148
  handle: p.sku.toLowerCase(),
117
- title: p.name,
118
- description: { plain: p.description ?? p.name },
149
+ title: v.title,
150
+ description: v.description,
119
151
  url: v.url,
120
152
  price_range: { min: v.price, max: v.price },
121
153
  variants: [v],
122
154
  };
123
155
  if (v.media)
124
156
  out.media = v.media;
157
+ // Like the store: the base language's path, which filters.categories matches.
158
+ if (p.category)
159
+ out.categories = [{ value: p.category, taxonomy: 'merchant' }];
160
+ if (match && match.words.length)
161
+ out.metadata = { search: { score: match.score, all_words: match.all, words: match.words, matched_on: match.matched } };
125
162
  return out;
126
163
  }
164
+ /** A category path folded as the store folds it: case and spacing, not accents. */
165
+ const categoryKey = (c) => c.split('>').map((l) => l.trim().toLowerCase().replace(/\s+/g, ' ')).join(' > ');
127
166
  export function searchCatalog(ctx, body) {
128
167
  const market = pickMarket(ctx, body.context);
129
168
  if (!market)
130
169
  return { ucp: envelope(ctx, 'catalog.search'), products: [], pagination: { has_next_page: false, total_count: 0 } };
131
- const q = String(body.query ?? '').toLowerCase().trim();
170
+ const q = String(body.query ?? '').trim();
132
171
  const max = body.filters?.price?.max;
133
172
  const min = body.filters?.price?.min;
173
+ const asked = body.filters?.fulfills_to?.address_country ?? body.context?.address_country;
174
+ // Like the store: filters.fulfills_to restricts the results to what ships there (context alone only seeds availability).
175
+ const shipsTo = body.filters?.fulfills_to?.address_country?.toUpperCase();
176
+ if (shipsTo && !market.countries.includes(shipsTo)) {
177
+ const other = markets(ctx).find((m) => m.countries.includes(shipsTo));
178
+ const content = other
179
+ ? `The ${market.name} market ships to ${market.countries.join(', ')}, not to ${shipsTo}. The ${other.name} market (${other.currency}) does: send context.address_country ${shipsTo} for its prices.`
180
+ : `The store does not ship to ${shipsTo}.`;
181
+ return { ucp: envelope(ctx, 'catalog.search'), products: [], pagination: { has_next_page: false, total_count: 0 }, messages: [{ type: 'info', code: 'no_results', path: '$.filters.fulfills_to.address_country', content }] };
182
+ }
134
183
  let all = Object.values(applied(ctx).products).filter((p) => p.prices[market.currency] !== undefined);
135
- if (q)
136
- all = all.filter((p) => `${p.name} ${p.description ?? ''} ${p.sku}`.toLowerCase().includes(q));
184
+ // Like the store: a category matches itself and the paths below it, by whole levels.
185
+ const wanted = (body.filters?.categories ?? []).map((c) => categoryKey(String(c)));
186
+ if (wanted.length)
187
+ all = all.filter((p) => p.category && wanted.some((w) => categoryKey(p.category) === w || categoryKey(p.category).startsWith(`${w} > `)));
137
188
  all = all.filter((p) => (min === undefined || toMinor(p.prices[market.currency], market.currency) >= min) && (max === undefined || toMinor(p.prices[market.currency], market.currency) <= max));
189
+ // Like the store: words in any order, ranked; every word before some of them.
190
+ const ranked = rank(all, q);
138
191
  const limit = Math.max(1, Number(body.pagination?.limit ?? 10));
139
192
  const offset = Number(body.pagination?.cursor ?? 0) || 0;
140
- const page = all.slice(offset, offset + limit);
141
- const hasNext = offset + limit < all.length;
193
+ const page = ranked.slice(offset, offset + limit);
194
+ const hasNext = offset + limit < ranked.length;
142
195
  const out = {
143
196
  ucp: envelope(ctx, 'catalog.search'),
144
- products: page.map((p) => product(ctx, p, market)),
145
- pagination: { has_next_page: hasNext, total_count: all.length, ...(hasNext ? { cursor: String(offset + limit) } : {}) },
197
+ products: page.map(({ product: p, match }) => product(ctx, p, market, asked, match)),
198
+ pagination: { has_next_page: hasNext, total_count: ranked.length, ...(hasNext ? { cursor: String(offset + limit) } : {}) },
146
199
  };
200
+ const categories = [...new Set(Object.values(applied(ctx).products).map((p) => p.category).filter(Boolean))].sort().join('; ');
201
+ const messages = [];
202
+ const words = queryWords(q);
203
+ if (!ranked.length)
204
+ messages.push({ type: 'info', code: 'no_results', content: `No products match${words.length ? `: searched the words "${words.join(' ')}" in titles, categories and descriptions, in any order` : ''}.${categories ? ` The store's categories: ${categories}.` : ''} Retry with fewer or other words, or with one of them in filters.categories.` });
205
+ else if (words.length > 1 && !ranked[0].match.all)
206
+ messages.push({ type: 'info', code: 'partial_match', content: `No product has every word of "${q}"; these have some of them (${words.join(', ')}), best first.${categories ? ` The store's categories: ${categories}.` : ''}` });
207
+ if (!body.context?.address_country && !body.context?.currency)
208
+ messages.push({ type: 'info', code: 'prices', content: `Prices are in ${market.currency} for the ${market.name} market (${market.countries.join(', ')}). Send context.address_country for another country's prices and shipping.` });
209
+ if (messages.length)
210
+ out.messages = messages;
147
211
  const policies = returnPolicy(ctx);
148
212
  if (policies.length)
149
213
  out.policies = policies;
@@ -202,7 +266,7 @@ function buildLines(ctx, market, requested, existing = []) {
202
266
  const price = toMinor(p.prices[market.currency], market.currency);
203
267
  const qty = quantity;
204
268
  const prev = existing.find((e) => e.id === l.id) ?? existing.find((e) => e.item.id === p.sku);
205
- const item = { id: p.sku, title: p.name, price };
269
+ const item = { id: p.sku, title: texts(p, lang(ctx)).name, price };
206
270
  item.image_url = imageUrl(ctx, p);
207
271
  lines.push({ id: prev?.id ?? `li_${++n}`, item, quantity: qty, totals: [{ type: 'subtotal', amount: price * qty }, { type: 'total', amount: price * qty }] });
208
272
  });
@@ -570,6 +634,8 @@ function placeOrder(ctx, ck) {
570
634
  order.attribution = ck.attribution;
571
635
  if (ck.buyer)
572
636
  order._buyer = ck.buyer;
637
+ // What the merchant view of the order reads (stub/orders.ts): it lands placed, payment authorized.
638
+ Object.assign(order, { _placed_at: new Date().toISOString(), _market: ck._market, _cart_id: ck._cart_id, _codes: ck.discounts?.codes ?? [], _language: lang(ctx) });
573
639
  orders[orderId] = order;
574
640
  const webhook = platformWebhook(ctx);
575
641
  if (webhook)