@commercelayer/agent-cli 0.0.0-stage → 0.1.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,543 @@
1
+ // The dev.ucp.shopping service of a store: catalog, carts, checkouts, orders and
2
+ // ask, computed from the applied store.yml. Shapes follow the UCP 2026-08-25
3
+ // schemas; src/ucp/platform.ts holds the constants shared with the client.
4
+ import crypto from 'node:crypto';
5
+ import { SANDBOX_CREDENTIAL_TYPE, SANDBOX_HANDLER, SANDBOX_HANDLER_ID, SANDBOX_TOKEN_DECLINED, SANDBOX_TOKEN_SUCCESS, toMinor, UCP_VERSION } from '../../ucp/platform.js';
6
+ /** The discount code every sandbox accepts: 10% off the items. */
7
+ const SANDBOX_CODE = 'SANDBOX10';
8
+ const CONSENT_PURPOSES = {
9
+ 'dev.ucp.consent.marketing': 'Marketing messages about new products and offers.',
10
+ 'dev.ucp.consent.analytics': 'Analytics to improve the store.',
11
+ 'dev.ucp.consent.preferences': 'Remembering your preferences.',
12
+ 'dev.ucp.consent.sale_or_sharing': 'Sharing your data with partners.',
13
+ };
14
+ const id = (prefix) => `${prefix}_${crypto.randomBytes(6).toString('hex')}`;
15
+ const now = () => new Date();
16
+ const later = (hours) => new Date(Date.now() + hours * 3600 * 1000).toISOString();
17
+ const RELEVANT = {
18
+ 'catalog.search': ['dev.ucp.shopping.catalog.search', 'dev.ucp.shopping.fulfillment', 'dev.ucp.shopping.policy.return'],
19
+ 'catalog.lookup': ['dev.ucp.shopping.catalog.lookup', 'dev.ucp.shopping.fulfillment', 'dev.ucp.shopping.policy.return'],
20
+ cart: ['dev.ucp.shopping.cart', 'dev.ucp.shopping.discount', 'dev.ucp.shopping.buyer_consent', 'dev.ucp.shopping.policy.return'],
21
+ checkout: ['dev.ucp.shopping.checkout', 'dev.ucp.shopping.fulfillment', 'dev.ucp.shopping.discount', 'dev.ucp.shopping.buyer_consent', 'dev.ucp.shopping.policy.return'],
22
+ order: ['dev.ucp.shopping.order', 'dev.ucp.shopping.policy.return'],
23
+ ask: ['dev.ucp.common.ask'],
24
+ };
25
+ export function envelope(ctx, op, status = 'success') {
26
+ const capabilities = {};
27
+ for (const name of RELEVANT[op])
28
+ if (ctx.shared.has(name))
29
+ capabilities[name] = [{ version: UCP_VERSION }];
30
+ const ucp = { version: UCP_VERSION, status, capabilities };
31
+ if (op === 'checkout')
32
+ ucp.payment_handlers = { [SANDBOX_HANDLER]: [{ id: SANDBOX_HANDLER_ID, version: UCP_VERSION, available_instruments: [{ type: 'card' }], config: { environment: 'sandbox' } }] };
33
+ return ucp;
34
+ }
35
+ export function errorResponse(ctx, op, code, content, extra = {}) {
36
+ return { ucp: envelope(ctx, op, 'error'), messages: [{ type: 'error', code, content, severity: 'unrecoverable' }], ...extra };
37
+ }
38
+ const err = (code, content, path, severity = 'recoverable') => ({ type: 'error', code, content, severity, ...(path ? { path } : {}) });
39
+ const warn = (code, content, path) => ({ type: 'warning', code, content, ...(path ? { path } : {}) });
40
+ // ---------- the store
41
+ function applied(ctx) {
42
+ return ctx.record.applied ?? { markets: {}, products: {} };
43
+ }
44
+ function markets(ctx) {
45
+ return Object.values(applied(ctx).markets);
46
+ }
47
+ /** The market a buyer is in: by country, then by currency, else the first. */
48
+ export function pickMarket(ctx, context) {
49
+ const all = markets(ctx);
50
+ if (all.length === 0)
51
+ return null;
52
+ const country = context?.address_country?.toUpperCase();
53
+ const currency = context?.currency?.toUpperCase();
54
+ return all.find((m) => country && m.countries.includes(country)) ?? all.find((m) => currency && m.currency === currency) ?? all[0];
55
+ }
56
+ function findProduct(ctx, itemId) {
57
+ const products = applied(ctx).products;
58
+ return products[itemId] ?? Object.values(products).find((p) => `prod_${p.sku}` === itemId);
59
+ }
60
+ function shippingOption(market) {
61
+ const s = market.shipping ?? { method: 'Standard', price: 0 };
62
+ const plain = s.price === 0 ? `Free shipping${s.days ? `, arrives in ${s.days} business days` : ''}` : s.days ? `Arrives in ${s.days} business days` : s.method;
63
+ return { id: `opt_${market.code}_std`, title: s.method, description: { plain }, totals: [{ type: 'total', amount: toMinor(s.price, market.currency) }] };
64
+ }
65
+ function policyLinks(ctx) {
66
+ const p = ctx.record.policies ?? {};
67
+ const url = (name, key) => p[key] ?? `${ctx.origin}/policies/${name}`;
68
+ return [
69
+ { type: 'terms_of_service', url: url('terms', 'terms_of_service') },
70
+ { type: 'privacy_policy', url: url('privacy', 'privacy_policy') },
71
+ { type: 'refund_policy', url: url('returns', 'returns') },
72
+ ];
73
+ }
74
+ function returnPolicy(ctx) {
75
+ if (!ctx.shared.has('dev.ucp.shopping.policy.return'))
76
+ return [];
77
+ return [
78
+ {
79
+ type: 'dev.ucp.shopping.policy.return',
80
+ description: { plain: '30-day returns from delivery, refunded to the original payment method.' },
81
+ eligibility: { type: 'window', duration: 'P30D', anchor: 'delivered' },
82
+ supported_resolutions: ['original_payment_method'],
83
+ methods: ['by_mail'],
84
+ url: policyLinks(ctx)[2].url,
85
+ },
86
+ ];
87
+ }
88
+ // ---------- catalog
89
+ function variant(ctx, p, market) {
90
+ const price = { amount: toMinor(p.prices[market.currency], market.currency), currency: market.currency };
91
+ const v = {
92
+ id: p.sku,
93
+ sku: p.sku,
94
+ title: p.name,
95
+ description: { plain: p.description ?? p.name },
96
+ url: `${ctx.origin}/products/${encodeURIComponent(p.sku)}`,
97
+ price,
98
+ // Inventory lives in Commerce Layer; the sandbox catalog has no stock, so everything is in stock.
99
+ availability: { available: true, status: 'in_stock' },
100
+ };
101
+ if (p.image_url)
102
+ v.media = [{ type: 'image', url: p.image_url }];
103
+ if (ctx.shared.has('dev.ucp.shopping.fulfillment')) {
104
+ const o = shippingOption(market);
105
+ 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 }] }] };
106
+ }
107
+ return v;
108
+ }
109
+ function product(ctx, p, market) {
110
+ const v = variant(ctx, p, market);
111
+ const out = {
112
+ id: `prod_${p.sku}`,
113
+ handle: p.sku.toLowerCase(),
114
+ title: p.name,
115
+ description: { plain: p.description ?? p.name },
116
+ url: v.url,
117
+ price_range: { min: v.price, max: v.price },
118
+ variants: [v],
119
+ };
120
+ if (v.media)
121
+ out.media = v.media;
122
+ return out;
123
+ }
124
+ export function searchCatalog(ctx, body) {
125
+ const market = pickMarket(ctx, body.context);
126
+ if (!market)
127
+ return { ucp: envelope(ctx, 'catalog.search'), products: [], pagination: { has_next_page: false, total_count: 0 } };
128
+ const q = String(body.query ?? '').toLowerCase().trim();
129
+ const max = body.filters?.price?.max;
130
+ const min = body.filters?.price?.min;
131
+ let all = Object.values(applied(ctx).products).filter((p) => p.prices[market.currency] !== undefined);
132
+ if (q)
133
+ all = all.filter((p) => `${p.name} ${p.description ?? ''} ${p.sku}`.toLowerCase().includes(q));
134
+ all = all.filter((p) => (min === undefined || toMinor(p.prices[market.currency], market.currency) >= min) && (max === undefined || toMinor(p.prices[market.currency], market.currency) <= max));
135
+ const limit = Math.max(1, Number(body.pagination?.limit ?? 10));
136
+ const offset = Number(body.pagination?.cursor ?? 0) || 0;
137
+ const page = all.slice(offset, offset + limit);
138
+ const hasNext = offset + limit < all.length;
139
+ const out = {
140
+ ucp: envelope(ctx, 'catalog.search'),
141
+ products: page.map((p) => product(ctx, p, market)),
142
+ pagination: { has_next_page: hasNext, total_count: all.length, ...(hasNext ? { cursor: String(offset + limit) } : {}) },
143
+ };
144
+ const policies = returnPolicy(ctx);
145
+ if (policies.length)
146
+ out.policies = policies;
147
+ return out;
148
+ }
149
+ export function lookupCatalog(ctx, body) {
150
+ const market = pickMarket(ctx, body.context);
151
+ const ids = [...new Set((body.ids ?? []))];
152
+ const products = [];
153
+ const messages = [];
154
+ const seen = new Set();
155
+ for (const requested of ids) {
156
+ const p = market ? findProduct(ctx, requested) : undefined;
157
+ if (!p || !market) {
158
+ messages.push({ type: 'info', code: 'not_found', content: `No product or variant ${requested}.`, path: `$.ids[${ids.indexOf(requested)}]` });
159
+ continue;
160
+ }
161
+ if (seen.has(p.sku)) {
162
+ products.find((x) => x.id === `prod_${p.sku}`).variants[0].inputs.push({ id: requested, match: 'exact' });
163
+ continue;
164
+ }
165
+ seen.add(p.sku);
166
+ const out = product(ctx, p, market);
167
+ out.variants[0].inputs = [{ id: requested, match: 'exact' }];
168
+ products.push(out);
169
+ }
170
+ const res = { ucp: envelope(ctx, 'catalog.lookup'), products };
171
+ if (messages.length)
172
+ res.messages = messages;
173
+ return res;
174
+ }
175
+ export function getProduct(ctx, body) {
176
+ const market = pickMarket(ctx, body.context);
177
+ const p = market && body.id ? findProduct(ctx, String(body.id)) : undefined;
178
+ if (!p || !market)
179
+ return errorResponse(ctx, 'catalog.lookup', 'not_found', `No product or variant ${body.id ?? ''}.`);
180
+ return { ucp: envelope(ctx, 'catalog.lookup'), product: product(ctx, p, market) };
181
+ }
182
+ /** Line items from a request, priced in the market. Unknown or sold-out items are reported, not priced. */
183
+ function buildLines(ctx, market, requested, existing = []) {
184
+ const lines = [];
185
+ const messages = [];
186
+ let n = existing.length;
187
+ (requested ?? []).forEach((l, i) => {
188
+ const itemId = String(l?.item?.id ?? '');
189
+ const quantity = Number(l?.quantity ?? 0);
190
+ const p = findProduct(ctx, itemId);
191
+ if (!p || p.prices[market.currency] === undefined) {
192
+ messages.push(err('item_unavailable', `${itemId || 'An item'} is not sold here.`, `$.line_items[${i}]`));
193
+ return;
194
+ }
195
+ if (!Number.isInteger(quantity) || quantity < 1) {
196
+ messages.push(err('invalid_quantity', `Quantity must be a positive integer.`, `$.line_items[${i}].quantity`));
197
+ return;
198
+ }
199
+ const price = toMinor(p.prices[market.currency], market.currency);
200
+ const qty = quantity;
201
+ const prev = existing.find((e) => e.id === l.id) ?? existing.find((e) => e.item.id === p.sku);
202
+ const item = { id: p.sku, title: p.name, price };
203
+ if (p.image_url)
204
+ item.image_url = p.image_url;
205
+ lines.push({ id: prev?.id ?? `li_${++n}`, item, quantity: qty, totals: [{ type: 'subtotal', amount: price * qty }, { type: 'total', amount: price * qty }] });
206
+ });
207
+ return { lines, messages };
208
+ }
209
+ /** Apply discount codes: the sandbox code is 10% off the items; anything else is rejected with a warning. */
210
+ function discounts(ctx, body, lines, messages) {
211
+ if (!ctx.shared.has('dev.ucp.shopping.discount') || body.discounts === undefined)
212
+ return undefined;
213
+ const codes = (body.discounts.codes ?? []).map((c) => String(c));
214
+ const subtotal = lines.reduce((s, l) => s + l.totals[0].amount, 0);
215
+ const appliedList = [];
216
+ codes.forEach((code, i) => {
217
+ if (code.toUpperCase() === SANDBOX_CODE) {
218
+ if (appliedList.length)
219
+ messages.push(warn('discount_code_already_applied', `${code} is already applied.`, `$.discounts.codes[${i}]`));
220
+ else
221
+ appliedList.push({ code: SANDBOX_CODE, title: '10% off', amount: Math.floor(subtotal / 10), method: 'across' });
222
+ }
223
+ else
224
+ messages.push(warn('discount_code_invalid', `${code} is not a valid code.`, `$.discounts.codes[${i}]`));
225
+ });
226
+ return { codes, applied: appliedList };
227
+ }
228
+ function consent(ctx, buyer) {
229
+ if (!ctx.shared.has('dev.ucp.shopping.buyer_consent') || !buyer?.consent)
230
+ return undefined;
231
+ const out = {};
232
+ for (const [purpose, given] of Object.entries(buyer.consent)) {
233
+ out[purpose] = { granted: Boolean(given?.granted), source: given?.source === 'business' ? 'business' : 'platform', description: CONSENT_PURPOSES[purpose] ?? purpose };
234
+ }
235
+ return out;
236
+ }
237
+ function totals(lines, discount, shipping) {
238
+ const subtotal = lines.reduce((s, l) => s + l.totals[0].amount, 0);
239
+ const out = [{ type: 'subtotal', display_text: 'Subtotal', amount: subtotal }];
240
+ const off = discount?.applied?.reduce((s, d) => s + d.amount, 0) ?? 0;
241
+ if (off > 0)
242
+ out.push({ type: 'discount', display_text: 'Discount', amount: -off });
243
+ if (shipping !== undefined)
244
+ out.push({ type: 'fulfillment', display_text: 'Shipping', amount: shipping });
245
+ out.push({ type: 'total', display_text: shipping === undefined ? 'Estimated total' : 'Total', amount: subtotal - off + (shipping ?? 0) });
246
+ return out;
247
+ }
248
+ // ---------- carts
249
+ function cartFrom(ctx, body, existing) {
250
+ const market = pickMarket(ctx, body.context ?? existing?.context);
251
+ const { lines, messages } = buildLines(ctx, market, body.line_items, existing?.line_items);
252
+ const discount = discounts(ctx, body, lines, messages);
253
+ const cart = {
254
+ ucp: envelope(ctx, 'cart'),
255
+ id: existing?.id ?? id('cart'),
256
+ currency: market.currency,
257
+ line_items: lines,
258
+ _market: market.code,
259
+ _platform: ctx.platform,
260
+ };
261
+ for (const k of ['context', 'buyer', 'signals', 'attribution'])
262
+ if (body[k] !== undefined)
263
+ cart[k] = body[k];
264
+ const c = consent(ctx, body.buyer);
265
+ if (c)
266
+ cart.buyer = { ...(cart.buyer ?? {}), consent: c };
267
+ if (discount)
268
+ cart.discounts = discount;
269
+ cart.totals = totals(lines, discount, undefined);
270
+ if (messages.length)
271
+ cart.messages = messages;
272
+ cart.links = policyLinks(ctx);
273
+ const policies = returnPolicy(ctx);
274
+ if (policies.length)
275
+ cart.policies = policies;
276
+ cart.continue_url = `${ctx.origin}/carts/${cart.id}`;
277
+ cart.expires_at = existing?.expires_at ?? later(24 * 7);
278
+ return cart;
279
+ }
280
+ export function createCart(ctx, body) {
281
+ if (!pickMarket(ctx, body.context))
282
+ return { status: 200, body: errorResponse(ctx, 'cart', 'item_unavailable', 'The store has no markets yet.') };
283
+ const cart = cartFrom(ctx, body);
284
+ if (cart.line_items.length === 0 && (body.line_items ?? []).length > 0) {
285
+ return { status: 200, body: errorResponse(ctx, 'cart', 'item_unavailable', 'None of the items can be sold here.', { continue_url: ctx.origin }) };
286
+ }
287
+ ctx.record.ucp.carts[cart.id] = cart;
288
+ return { status: 201, body: cart };
289
+ }
290
+ export function getCart(ctx, cartId) {
291
+ return ctx.record.ucp.carts[cartId] ?? errorResponse(ctx, 'cart', 'not_found', `No cart ${cartId}.`);
292
+ }
293
+ export function updateCart(ctx, cartId, body) {
294
+ const existing = ctx.record.ucp.carts[cartId];
295
+ if (!existing)
296
+ return errorResponse(ctx, 'cart', 'not_found', `No cart ${cartId}.`);
297
+ const cart = cartFrom(ctx, body, existing);
298
+ ctx.record.ucp.carts[cartId] = cart;
299
+ return cart;
300
+ }
301
+ export function cancelCart(ctx, cartId) {
302
+ const existing = ctx.record.ucp.carts[cartId];
303
+ if (!existing)
304
+ return errorResponse(ctx, 'cart', 'not_found', `No cart ${cartId}.`);
305
+ delete ctx.record.ucp.carts[cartId];
306
+ return existing;
307
+ }
308
+ // ---------- checkouts
309
+ /** Recompute everything the business decides on a checkout: lines, fulfillment, totals, status. */
310
+ function evaluate(ctx, ck, requested) {
311
+ const market = markets(ctx).find((m) => m.code === ck._market);
312
+ const { lines, messages } = buildLines(ctx, market, requested.line_items ?? ck.line_items, ck.line_items);
313
+ ck.line_items = lines;
314
+ if (lines.length === 0)
315
+ messages.push(err('item_unavailable', 'The checkout has no purchasable items.', '$.line_items'));
316
+ const discount = discounts(ctx, requested, lines, messages);
317
+ if (discount)
318
+ ck.discounts = discount;
319
+ else
320
+ delete ck.discounts;
321
+ if (!ck.buyer?.email)
322
+ messages.push(err('missing', 'An email address is needed for the order confirmation.', '$.buyer.email'));
323
+ let shipping;
324
+ if (ctx.shared.has('dev.ucp.shopping.fulfillment')) {
325
+ const sent = requested.fulfillment?.methods?.[0] ?? {};
326
+ const method = { id: 'ful_ship', type: 'shipping', line_item_ids: lines.map((l) => l.id) };
327
+ const destinations = (sent.destinations ?? ck.fulfillment?.methods?.[0]?.destinations ?? []).map((d, i) => ({ type: 'shipping_address', ...d, id: d.id ?? `dest_${i + 1}`, type_: undefined }));
328
+ for (const d of destinations)
329
+ delete d.type_;
330
+ const selectedId = sent.selected_destination_id ?? ck.fulfillment?.methods?.[0]?.selected_destination_id ?? null;
331
+ method.destinations = destinations;
332
+ method.selected_destination_id = selectedId;
333
+ const selected = destinations.find((d) => d.id === selectedId);
334
+ if (!selected) {
335
+ method.groups = [];
336
+ messages.push(err('missing', 'A shipping address is needed.', '$.fulfillment.methods[0].selected_destination_id'));
337
+ }
338
+ else if (!market.countries.includes(String(selected.address_country ?? '').toUpperCase())) {
339
+ method.groups = [];
340
+ messages.push(err('address_undeliverable', `This store ships to ${market.countries.join(', ')} in the ${market.name} market.`, `$.fulfillment.methods[0].destinations[${destinations.indexOf(selected)}].address_country`));
341
+ }
342
+ else {
343
+ const option = shippingOption(market);
344
+ const wanted = sent.groups?.[0]?.selected_option_id;
345
+ if (wanted && wanted !== option.id)
346
+ messages.push(warn('fulfillment_changed', `Option ${wanted} is not offered; ${option.title} selected.`, '$.fulfillment.methods[0].groups[0].selected_option_id'));
347
+ method.groups = [{ id: 'grp_1', line_item_ids: method.line_item_ids, options: [option], selected_option_id: option.id }];
348
+ shipping = option.totals[0].amount;
349
+ }
350
+ ck.fulfillment = { methods: [method], available_methods: [{ type: 'shipping', line_item_ids: method.line_item_ids, fulfillable_on: 'now' }] };
351
+ }
352
+ else
353
+ shipping = 0;
354
+ ck.totals = totals(lines, discount, shipping);
355
+ const blocking = messages.some((m) => m.type === 'error');
356
+ ck.status = blocking ? 'incomplete' : 'ready_for_complete';
357
+ if (messages.length)
358
+ ck.messages = messages;
359
+ else
360
+ delete ck.messages;
361
+ ck.continue_url = `${ctx.origin}/checkout-sessions/${ck.id}`;
362
+ ck.links = policyLinks(ctx);
363
+ const policies = returnPolicy(ctx);
364
+ if (policies.length)
365
+ ck.policies = policies;
366
+ else
367
+ delete ck.policies;
368
+ }
369
+ function copyInputs(ctx, ck, body) {
370
+ for (const k of ['buyer', 'context', 'signals', 'attribution']) {
371
+ if (body[k] !== undefined)
372
+ ck[k] = body[k];
373
+ }
374
+ const c = consent(ctx, body.buyer);
375
+ if (c)
376
+ ck.buyer = { ...(ck.buyer ?? {}), consent: c };
377
+ }
378
+ export function createCheckout(ctx, body) {
379
+ const checkouts = ctx.record.ucp.checkouts;
380
+ let seed = body;
381
+ let cartId;
382
+ if (body.cart_id !== undefined) {
383
+ if (!ctx.shared.has('dev.ucp.shopping.cart'))
384
+ return { status: 200, body: errorResponse(ctx, 'checkout', 'capabilities_incompatible', 'cart_id needs the cart capability, which is not negotiated.') };
385
+ const cart = ctx.record.ucp.carts[body.cart_id];
386
+ if (!cart)
387
+ return { status: 200, body: errorResponse(ctx, 'checkout', 'not_found', `No cart ${body.cart_id}.`) };
388
+ const open = Object.values(checkouts).find((c) => c._cart_id === body.cart_id && c.status === 'incomplete');
389
+ if (open)
390
+ return { status: 200, body: open };
391
+ cartId = cart.id;
392
+ seed = { ...cart, ...body, line_items: cart.line_items.map((l) => ({ item: { id: l.item.id }, quantity: l.quantity })), discounts: cart.discounts ? { codes: cart.discounts.codes } : body.discounts, context: body.context ?? cart.context, buyer: body.buyer ?? cart.buyer };
393
+ }
394
+ else if (!Array.isArray(body.line_items)) {
395
+ return { status: 200, body: errorResponse(ctx, 'checkout', 'missing', 'A checkout needs line_items or a cart_id.') };
396
+ }
397
+ const market = pickMarket(ctx, seed.context);
398
+ if (!market)
399
+ return { status: 200, body: errorResponse(ctx, 'checkout', 'item_unavailable', 'The store has no markets yet.') };
400
+ const ck = { ucp: envelope(ctx, 'checkout'), id: id('chk'), status: 'incomplete', currency: market.currency, line_items: [], _market: market.code, _platform: ctx.platform, _created: now().toISOString() };
401
+ if (cartId)
402
+ ck._cart_id = cartId;
403
+ copyInputs(ctx, ck, seed);
404
+ evaluate(ctx, ck, seed);
405
+ if (ck.line_items.length === 0)
406
+ return { status: 200, body: errorResponse(ctx, 'checkout', 'item_unavailable', 'None of the items can be sold here.', { continue_url: ctx.origin }) };
407
+ ck.expires_at = later(6);
408
+ checkouts[ck.id] = ck;
409
+ return { status: 201, body: ck };
410
+ }
411
+ export function getCheckout(ctx, ckId) {
412
+ return ctx.record.ucp.checkouts[ckId] ?? errorResponse(ctx, 'checkout', 'not_found', `No checkout ${ckId}.`);
413
+ }
414
+ export function updateCheckout(ctx, ckId, body) {
415
+ const ck = ctx.record.ucp.checkouts[ckId];
416
+ if (!ck)
417
+ return errorResponse(ctx, 'checkout', 'not_found', `No checkout ${ckId}.`);
418
+ if (ck.status === 'completed' || ck.status === 'canceled') {
419
+ return { ...ck, messages: [err('not_allowed', `The checkout is ${ck.status} and cannot change.`, '$.status', 'unrecoverable')] };
420
+ }
421
+ if (ck.status === 'complete_in_progress')
422
+ return { ...ck, messages: [err('in_progress', 'Completion is in progress; wait for it.', '$.status')] };
423
+ if (!Array.isArray(body.line_items))
424
+ return { ...ck, messages: [err('missing', 'Update Checkout replaces the whole checkout: send line_items.', '$.line_items')] };
425
+ copyInputs(ctx, ck, body);
426
+ evaluate(ctx, ck, body);
427
+ return ck;
428
+ }
429
+ export function cancelCheckout(ctx, ckId) {
430
+ const ck = ctx.record.ucp.checkouts[ckId];
431
+ if (!ck)
432
+ return errorResponse(ctx, 'checkout', 'not_found', `No checkout ${ckId}.`);
433
+ if (ck.status === 'completed' || ck.status === 'canceled')
434
+ return { ...ck, messages: [err('not_allowed', `The checkout is ${ck.status}.`, '$.status', 'unrecoverable')] };
435
+ ck.status = 'canceled';
436
+ delete ck.continue_url;
437
+ delete ck.messages;
438
+ return ck;
439
+ }
440
+ export function completeCheckout(ctx, ckId, body) {
441
+ const ck = ctx.record.ucp.checkouts[ckId];
442
+ if (!ck)
443
+ return errorResponse(ctx, 'checkout', 'not_found', `No checkout ${ckId}.`);
444
+ if (ck.status === 'completed')
445
+ return ck;
446
+ if (ck.status !== 'ready_for_complete') {
447
+ return { ...ck, messages: [...(ck.messages ?? []), err('not_ready', `The checkout is ${ck.status}; resolve its messages first.`, '$.status', ck.status === 'canceled' ? 'unrecoverable' : 'recoverable')] };
448
+ }
449
+ const instruments = body.payment?.instruments ?? [];
450
+ const reject = (content, path = '$.payment.instruments') => ({ ...ck, messages: [err('payment_failed', content, path)] });
451
+ if (instruments.length !== 1)
452
+ return reject('Exactly one payment instrument is needed.');
453
+ const [pi] = instruments;
454
+ if (pi.handler_id !== SANDBOX_HANDLER_ID)
455
+ return reject(`Unknown payment handler ${pi.handler_id}; this store accepts ${SANDBOX_HANDLER_ID}.`, '$.payment.instruments[0].handler_id');
456
+ if (pi.type !== 'card')
457
+ return reject(`Instrument type ${pi.type} is not accepted; use card.`, '$.payment.instruments[0].type');
458
+ if (pi.credential?.type !== SANDBOX_CREDENTIAL_TYPE)
459
+ return reject(`Credential type must be ${SANDBOX_CREDENTIAL_TYPE}.`, '$.payment.instruments[0].credential.type');
460
+ if (pi.credential.token === SANDBOX_TOKEN_DECLINED)
461
+ return reject('The card was declined.', '$.payment.instruments[0]');
462
+ if (pi.credential.token !== SANDBOX_TOKEN_SUCCESS)
463
+ return reject(`Unknown sandbox token; use ${SANDBOX_TOKEN_SUCCESS} or ${SANDBOX_TOKEN_DECLINED}.`, '$.payment.instruments[0].credential.token');
464
+ if (body.signals)
465
+ ck.signals = body.signals;
466
+ if (body.attribution)
467
+ ck.attribution = body.attribution;
468
+ const { token: _t, ...credential } = pi.credential;
469
+ ck.payment = { instruments: [{ ...pi, selected: true, credential }] };
470
+ const order = placeOrder(ctx, ck);
471
+ ck.status = 'completed';
472
+ ck.order = { id: order.id, label: order.label, permalink_url: order.permalink_url };
473
+ delete ck.continue_url;
474
+ delete ck.expires_at;
475
+ delete ck.messages;
476
+ return ck;
477
+ }
478
+ function placeOrder(ctx, ck) {
479
+ const orders = ctx.record.ucp.orders;
480
+ const orderId = id('ord');
481
+ const method = ck.fulfillment?.methods?.[0];
482
+ const destination = method?.destinations?.find((d) => d.id === method.selected_destination_id);
483
+ const option = method?.groups?.[0]?.options?.find((o) => o.id === method.groups[0].selected_option_id);
484
+ const { type: _type, id: _id, ...address } = destination ?? {};
485
+ const order = {
486
+ ucp: envelope(ctx, 'order'),
487
+ id: orderId,
488
+ label: `#${1001 + Object.keys(orders).length}`,
489
+ checkout_id: ck.id,
490
+ permalink_url: `${ctx.origin}/orders/${orderId}`,
491
+ currency: ck.currency,
492
+ line_items: ck.line_items.map((l) => ({ id: l.id, item: l.item, quantity: { original: l.quantity, total: l.quantity, fulfilled: 0 }, totals: l.totals, status: 'processing' })),
493
+ fulfillment: {
494
+ expectations: destination
495
+ ? [{ id: 'exp_1', line_items: ck.line_items.map((l) => ({ id: l.id, quantity: l.quantity })), method_type: 'shipping', destination: address, description: option?.description?.plain ?? option?.title, fulfillable_on: 'now' }]
496
+ : [],
497
+ events: [],
498
+ },
499
+ adjustments: [],
500
+ totals: ck.totals,
501
+ _platform: ck._platform,
502
+ };
503
+ if (ck.policies)
504
+ order.policies = ck.policies;
505
+ if (ck.attribution)
506
+ order.attribution = ck.attribution;
507
+ if (ck.buyer)
508
+ order._buyer = ck.buyer;
509
+ orders[orderId] = order;
510
+ const webhook = platformWebhook(ctx);
511
+ if (webhook)
512
+ ctx.record.ucp.webhooks.push({ id: crypto.randomUUID(), timestamp: Math.floor(Date.now() / 1000), url: webhook, order_id: orderId, status: 'pending' });
513
+ return order;
514
+ }
515
+ function platformWebhook(ctx) {
516
+ return ctx.webhookUrl ?? null;
517
+ }
518
+ export function getOrder(ctx, orderId) {
519
+ return ctx.record.ucp.orders[orderId] ?? null;
520
+ }
521
+ // ---------- ask
522
+ export function ask(ctx, body) {
523
+ const market = pickMarket(ctx, body.context);
524
+ const products = Object.values(applied(ctx).products);
525
+ const name = ctx.record.name ?? ctx.record.org;
526
+ const q = String(body.query ?? '').toLowerCase();
527
+ const price = (p) => (market ? `${p.prices[market.currency]} ${market.currency}` : '');
528
+ let plain;
529
+ if (products.length === 0)
530
+ plain = `${name} has no products yet.`;
531
+ else {
532
+ const hits = products.filter((p) => q && `${p.name} ${p.description ?? ''}`.toLowerCase().split(/\W+/).some((w) => w.length > 2 && q.includes(w)));
533
+ const list = (hits.length ? hits : products).map((p) => `${p.name} (${p.sku}, ${price(p)})`).join('; ');
534
+ plain = hits.length ? `${name} sells ${list}.` : `${name} sells ${products.length} products: ${list}. Ships to ${markets(ctx).flatMap((m) => m.countries).join(', ')}.`;
535
+ }
536
+ const out = { ucp: envelope(ctx, 'ask'), answer: { plain }, links: policyLinks(ctx).map((l) => ({ ...l, title: l.type.replace(/_/g, ' ') })) };
537
+ if (body.conversation?.id)
538
+ out.conversation = { id: body.conversation.id, expires_at: later(1) };
539
+ if (/\b(buy|order|cancel|refund|add to cart|checkout|pay)\b/.test(q)) {
540
+ out.messages = [{ type: 'info', code: 'operation_not_performed', content: 'Ask answers questions only; use the cart and checkout capabilities to buy.' }];
541
+ }
542
+ return out;
543
+ }