@commercelayer/agent-cli 0.8.1 → 1.0.0-dev.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.
- package/README.md +98 -215
- package/dist/argv.js +227 -0
- package/dist/bin.js +15 -178
- package/dist/builtins/auth.js +169 -0
- package/dist/builtins/extensions.js +107 -0
- package/dist/builtins/index.js +8 -0
- package/dist/builtins/loops.js +215 -0
- package/dist/builtins/sandbox.js +186 -0
- package/dist/config.js +238 -0
- package/dist/confirm.js +56 -0
- package/dist/engine.js +205 -0
- package/dist/errors.js +146 -0
- package/dist/help.js +81 -0
- package/dist/http.js +117 -0
- package/dist/inputs.js +112 -0
- package/dist/login.js +136 -0
- package/dist/resources.js +42 -0
- package/dist/schema.js +182 -0
- package/dist/schemas.js +72 -0
- package/dist/secrets.js +204 -0
- package/dist/version.js +4 -8
- package/package.json +4 -9
- package/dist/api/client.js +0 -190
- package/dist/api/server.js +0 -26
- package/dist/api/stub/db.js +0 -37
- package/dist/api/stub/http.js +0 -18
- package/dist/api/stub/index.js +0 -52
- package/dist/api/stub/locale.js +0 -52
- package/dist/api/stub/management.js +0 -409
- package/dist/api/stub/messages.js +0 -126
- package/dist/api/stub/orders.js +0 -625
- package/dist/api/stub/search.js +0 -78
- package/dist/api/stub/shopping.js +0 -673
- package/dist/api/stub/theme.js +0 -197
- package/dist/api/stub/ucp.js +0 -377
- package/dist/api/transport.js +0 -57
- package/dist/api/types.js +0 -4
- package/dist/assets.js +0 -100
- package/dist/catalog.js +0 -372
- package/dist/check.js +0 -187
- package/dist/commands.js +0 -1241
- package/dist/cursor.js +0 -31
- package/dist/files.js +0 -98
- package/dist/inbox/cursor.js +0 -11
- package/dist/inbox/fake.js +0 -95
- package/dist/inbox/imap.js +0 -139
- package/dist/inbox/mailbox.js +0 -25
- package/dist/inbox/message.js +0 -88
- package/dist/inspect.js +0 -237
- package/dist/languages.js +0 -49
- package/dist/links.js +0 -118
- package/dist/locales.js +0 -43
- package/dist/ops.js +0 -190
- package/dist/orderRequest.js +0 -141
- package/dist/output.js +0 -39
- package/dist/products.js +0 -151
- package/dist/promotions.js +0 -291
- package/dist/store.js +0 -379
- package/dist/storefront.js +0 -285
- package/dist/ucp/buy.js +0 -90
- package/dist/ucp/check.js +0 -362
- package/dist/ucp/client.js +0 -129
- package/dist/ucp/images.js +0 -62
- package/dist/ucp/platform.js +0 -90
- package/dist/ucp/shop.js +0 -357
- package/dist/ucp/signing.js +0 -67
- package/dist/web.js +0 -316
- package/dist/website.js +0 -390
|
@@ -1,673 +0,0 @@
|
|
|
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
|
-
import { baseLanguage, marketLocale, texts } from './locale.js';
|
|
7
|
-
import { queryWords, rank } from './search.js';
|
|
8
|
-
const lang = (ctx) => ctx.language ?? baseLanguage(ctx.record);
|
|
9
|
-
/** The discount code every sandbox accepts: 10% off the items. */
|
|
10
|
-
const SANDBOX_CODE = 'SANDBOX10';
|
|
11
|
-
const CONSENT_PURPOSES = {
|
|
12
|
-
'dev.ucp.consent.marketing': 'Marketing messages about new products and offers.',
|
|
13
|
-
'dev.ucp.consent.analytics': 'Analytics to improve the store.',
|
|
14
|
-
'dev.ucp.consent.preferences': 'Remembering your preferences.',
|
|
15
|
-
'dev.ucp.consent.sale_or_sharing': 'Sharing your data with partners.',
|
|
16
|
-
};
|
|
17
|
-
const id = (prefix) => `${prefix}_${crypto.randomBytes(6).toString('hex')}`;
|
|
18
|
-
const now = () => new Date();
|
|
19
|
-
const later = (hours) => new Date(Date.now() + hours * 3600 * 1000).toISOString();
|
|
20
|
-
const RELEVANT = {
|
|
21
|
-
'catalog.search': ['dev.ucp.shopping.catalog.search', 'dev.ucp.shopping.fulfillment', 'dev.ucp.shopping.policy.return'],
|
|
22
|
-
'catalog.lookup': ['dev.ucp.shopping.catalog.lookup', 'dev.ucp.shopping.fulfillment', 'dev.ucp.shopping.policy.return'],
|
|
23
|
-
cart: ['dev.ucp.shopping.cart', 'dev.ucp.shopping.discount', 'dev.ucp.shopping.buyer_consent', 'dev.ucp.shopping.policy.return'],
|
|
24
|
-
checkout: ['dev.ucp.shopping.checkout', 'dev.ucp.shopping.fulfillment', 'dev.ucp.shopping.discount', 'dev.ucp.shopping.buyer_consent', 'dev.ucp.shopping.policy.return'],
|
|
25
|
-
order: ['dev.ucp.shopping.order', 'dev.ucp.shopping.policy.return'],
|
|
26
|
-
ask: ['dev.ucp.common.ask'],
|
|
27
|
-
};
|
|
28
|
-
export function envelope(ctx, op, status = 'success') {
|
|
29
|
-
const capabilities = {};
|
|
30
|
-
for (const name of RELEVANT[op])
|
|
31
|
-
if (ctx.shared.has(name))
|
|
32
|
-
capabilities[name] = [{ version: UCP_VERSION }];
|
|
33
|
-
const ucp = { version: UCP_VERSION, status, capabilities };
|
|
34
|
-
if (op === 'checkout')
|
|
35
|
-
ucp.payment_handlers = { [SANDBOX_HANDLER]: [{ id: SANDBOX_HANDLER_ID, version: UCP_VERSION, available_instruments: [{ type: 'card' }], config: { environment: 'sandbox' } }] };
|
|
36
|
-
return ucp;
|
|
37
|
-
}
|
|
38
|
-
export function errorResponse(ctx, op, code, content, extra = {}) {
|
|
39
|
-
return { ucp: envelope(ctx, op, 'error'), messages: [{ type: 'error', code, content, severity: 'unrecoverable' }], ...extra };
|
|
40
|
-
}
|
|
41
|
-
const err = (code, content, path, severity = 'recoverable') => ({ type: 'error', code, content, severity, ...(path ? { path } : {}) });
|
|
42
|
-
const warn = (code, content, path) => ({ type: 'warning', code, content, ...(path ? { path } : {}) });
|
|
43
|
-
// ---------- the store
|
|
44
|
-
function applied(ctx) {
|
|
45
|
-
return ctx.record.applied ?? { markets: {}, products: {} };
|
|
46
|
-
}
|
|
47
|
-
function markets(ctx) {
|
|
48
|
-
return Object.values(applied(ctx).markets);
|
|
49
|
-
}
|
|
50
|
-
/** The market a buyer is in: by country, then by currency, else the first. */
|
|
51
|
-
export function pickMarket(ctx, context) {
|
|
52
|
-
const all = markets(ctx);
|
|
53
|
-
if (all.length === 0)
|
|
54
|
-
return null;
|
|
55
|
-
const country = context?.address_country?.toUpperCase();
|
|
56
|
-
const currency = context?.currency?.toUpperCase();
|
|
57
|
-
return all.find((m) => country && m.countries.includes(country)) ?? all.find((m) => currency && m.currency === currency) ?? all[0];
|
|
58
|
-
}
|
|
59
|
-
function findProduct(ctx, itemId) {
|
|
60
|
-
const products = applied(ctx).products;
|
|
61
|
-
return products[itemId] ?? Object.values(products).find((p) => `prod_${p.sku}` === itemId);
|
|
62
|
-
}
|
|
63
|
-
function shippingOption(market) {
|
|
64
|
-
const s = market.shipping ?? { method: 'Standard', price: 0 };
|
|
65
|
-
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;
|
|
66
|
-
return { id: `opt_${market.code}_std`, title: s.method, description: { plain }, totals: [{ type: 'total', amount: toMinor(s.price, market.currency) }] };
|
|
67
|
-
}
|
|
68
|
-
function policyLinks(ctx) {
|
|
69
|
-
const p = ctx.record.policies ?? {};
|
|
70
|
-
const url = (name, key) => p[key] ?? `${ctx.origin}/policies/${name}`;
|
|
71
|
-
return [
|
|
72
|
-
{ type: 'terms_of_service', url: url('terms', 'terms_of_service') },
|
|
73
|
-
{ type: 'privacy_policy', url: url('privacy', 'privacy_policy') },
|
|
74
|
-
{ type: 'refund_policy', url: url('returns', 'returns') },
|
|
75
|
-
];
|
|
76
|
-
}
|
|
77
|
-
function returnPolicy(ctx) {
|
|
78
|
-
if (!ctx.shared.has('dev.ucp.shopping.policy.return'))
|
|
79
|
-
return [];
|
|
80
|
-
return [
|
|
81
|
-
{
|
|
82
|
-
type: 'dev.ucp.shopping.policy.return',
|
|
83
|
-
description: { plain: '30-day returns from delivery, refunded to the original payment method.' },
|
|
84
|
-
eligibility: { type: 'window', duration: 'P30D', anchor: 'delivered' },
|
|
85
|
-
supported_resolutions: ['original_payment_method'],
|
|
86
|
-
methods: ['by_mail'],
|
|
87
|
-
url: policyLinks(ctx)[2].url,
|
|
88
|
-
},
|
|
89
|
-
];
|
|
90
|
-
}
|
|
91
|
-
// ---------- catalog
|
|
92
|
-
/** A path in the storefront (img/eth.jpg) is served from the store host: shopping agents always get a full URL,
|
|
93
|
-
* and for a product without an image, its tile. */
|
|
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) {
|
|
104
|
-
const price = { amount: toMinor(p.prices[market.currency], market.currency), currency: market.currency };
|
|
105
|
-
const v = {
|
|
106
|
-
id: p.sku,
|
|
107
|
-
sku: p.sku,
|
|
108
|
-
title: texts(p, lang(ctx)).name,
|
|
109
|
-
description: { plain: texts(p, lang(ctx)).description ?? texts(p, lang(ctx)).name },
|
|
110
|
-
// Agent-only stores have no product pages to link to.
|
|
111
|
-
url: productUrl(ctx, p, market),
|
|
112
|
-
price,
|
|
113
|
-
// Inventory lives in Commerce Layer; the sandbox catalog has no stock, so everything is in stock.
|
|
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 } },
|
|
117
|
-
};
|
|
118
|
-
v.media = [{ type: 'image', url: imageUrl(ctx, p) }];
|
|
119
|
-
if (ctx.shared.has('dev.ucp.shopping.fulfillment')) {
|
|
120
|
-
const o = shippingOption(market);
|
|
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] };
|
|
141
|
-
}
|
|
142
|
-
return v;
|
|
143
|
-
}
|
|
144
|
-
function product(ctx, p, market, asked, match) {
|
|
145
|
-
const v = variant(ctx, p, market, asked);
|
|
146
|
-
const out = {
|
|
147
|
-
id: `prod_${p.sku}`,
|
|
148
|
-
handle: p.sku.toLowerCase(),
|
|
149
|
-
title: v.title,
|
|
150
|
-
description: v.description,
|
|
151
|
-
url: v.url,
|
|
152
|
-
price_range: { min: v.price, max: v.price },
|
|
153
|
-
variants: [v],
|
|
154
|
-
};
|
|
155
|
-
if (v.media)
|
|
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 } };
|
|
162
|
-
return out;
|
|
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(' > ');
|
|
166
|
-
export function searchCatalog(ctx, body) {
|
|
167
|
-
const market = pickMarket(ctx, body.context);
|
|
168
|
-
if (!market)
|
|
169
|
-
return { ucp: envelope(ctx, 'catalog.search'), products: [], pagination: { has_next_page: false, total_count: 0 } };
|
|
170
|
-
const q = String(body.query ?? '').trim();
|
|
171
|
-
const max = body.filters?.price?.max;
|
|
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
|
-
}
|
|
183
|
-
let all = Object.values(applied(ctx).products).filter((p) => p.prices[market.currency] !== undefined);
|
|
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} > `)));
|
|
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);
|
|
191
|
-
const limit = Math.max(1, Number(body.pagination?.limit ?? 10));
|
|
192
|
-
const offset = Number(body.pagination?.cursor ?? 0) || 0;
|
|
193
|
-
const page = ranked.slice(offset, offset + limit);
|
|
194
|
-
const hasNext = offset + limit < ranked.length;
|
|
195
|
-
const out = {
|
|
196
|
-
ucp: envelope(ctx, 'catalog.search'),
|
|
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) } : {}) },
|
|
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;
|
|
211
|
-
const policies = returnPolicy(ctx);
|
|
212
|
-
if (policies.length)
|
|
213
|
-
out.policies = policies;
|
|
214
|
-
return out;
|
|
215
|
-
}
|
|
216
|
-
export function lookupCatalog(ctx, body) {
|
|
217
|
-
const market = pickMarket(ctx, body.context);
|
|
218
|
-
const ids = [...new Set((body.ids ?? []))];
|
|
219
|
-
const products = [];
|
|
220
|
-
const messages = [];
|
|
221
|
-
const seen = new Set();
|
|
222
|
-
for (const requested of ids) {
|
|
223
|
-
const p = market ? findProduct(ctx, requested) : undefined;
|
|
224
|
-
if (!p || !market) {
|
|
225
|
-
messages.push({ type: 'info', code: 'not_found', content: `No product or variant ${requested}.`, path: `$.ids[${ids.indexOf(requested)}]` });
|
|
226
|
-
continue;
|
|
227
|
-
}
|
|
228
|
-
if (seen.has(p.sku)) {
|
|
229
|
-
products.find((x) => x.id === `prod_${p.sku}`).variants[0].inputs.push({ id: requested, match: 'exact' });
|
|
230
|
-
continue;
|
|
231
|
-
}
|
|
232
|
-
seen.add(p.sku);
|
|
233
|
-
const out = product(ctx, p, market);
|
|
234
|
-
out.variants[0].inputs = [{ id: requested, match: 'exact' }];
|
|
235
|
-
products.push(out);
|
|
236
|
-
}
|
|
237
|
-
const res = { ucp: envelope(ctx, 'catalog.lookup'), products };
|
|
238
|
-
if (messages.length)
|
|
239
|
-
res.messages = messages;
|
|
240
|
-
return res;
|
|
241
|
-
}
|
|
242
|
-
export function getProduct(ctx, body) {
|
|
243
|
-
const market = pickMarket(ctx, body.context);
|
|
244
|
-
const p = market && body.id ? findProduct(ctx, String(body.id)) : undefined;
|
|
245
|
-
if (!p || !market)
|
|
246
|
-
return errorResponse(ctx, 'catalog.lookup', 'not_found', `No product or variant ${body.id ?? ''}.`);
|
|
247
|
-
return { ucp: envelope(ctx, 'catalog.lookup'), product: product(ctx, p, market) };
|
|
248
|
-
}
|
|
249
|
-
/** Line items from a request, priced in the market. Unknown or sold-out items are reported, not priced. */
|
|
250
|
-
function buildLines(ctx, market, requested, existing = []) {
|
|
251
|
-
const lines = [];
|
|
252
|
-
const messages = [];
|
|
253
|
-
let n = existing.length;
|
|
254
|
-
(requested ?? []).forEach((l, i) => {
|
|
255
|
-
const itemId = String(l?.item?.id ?? '');
|
|
256
|
-
const quantity = Number(l?.quantity ?? 0);
|
|
257
|
-
const p = findProduct(ctx, itemId);
|
|
258
|
-
if (!p || p.prices[market.currency] === undefined) {
|
|
259
|
-
messages.push(err('item_unavailable', `${itemId || 'An item'} is not sold here.`, `$.line_items[${i}]`));
|
|
260
|
-
return;
|
|
261
|
-
}
|
|
262
|
-
if (!Number.isInteger(quantity) || quantity < 1) {
|
|
263
|
-
messages.push(err('invalid_quantity', `Quantity must be a positive integer.`, `$.line_items[${i}].quantity`));
|
|
264
|
-
return;
|
|
265
|
-
}
|
|
266
|
-
const price = toMinor(p.prices[market.currency], market.currency);
|
|
267
|
-
const qty = quantity;
|
|
268
|
-
const prev = existing.find((e) => e.id === l.id) ?? existing.find((e) => e.item.id === p.sku);
|
|
269
|
-
const item = { id: p.sku, title: texts(p, lang(ctx)).name, price };
|
|
270
|
-
item.image_url = imageUrl(ctx, p);
|
|
271
|
-
lines.push({ id: prev?.id ?? `li_${++n}`, item, quantity: qty, totals: [{ type: 'subtotal', amount: price * qty }, { type: 'total', amount: price * qty }] });
|
|
272
|
-
});
|
|
273
|
-
return { lines, messages };
|
|
274
|
-
}
|
|
275
|
-
/** The promotions that match these lines and codes, as Commerce Layer applies them: by priority, an exclusive one alone. */
|
|
276
|
-
function promotionDiscounts(ctx, market, lines, codes, shipping) {
|
|
277
|
-
const now = Date.now();
|
|
278
|
-
const subtotal = lines.reduce((s, l) => s + l.totals[0].amount, 0);
|
|
279
|
-
const upper = codes.map((c) => c.toUpperCase());
|
|
280
|
-
const used = new Set();
|
|
281
|
-
const matching = [];
|
|
282
|
-
for (const p of Object.values(ctx.record.promotions ?? {})) {
|
|
283
|
-
if (!p.enabled || p.flex || !p.actions || Date.parse(p.starts) > now || Date.parse(p.ends) <= now)
|
|
284
|
-
continue;
|
|
285
|
-
if (p.market && p.market !== market.code)
|
|
286
|
-
continue;
|
|
287
|
-
const r = p.rules ?? {};
|
|
288
|
-
const code = r.coupons?.find((c) => upper.includes(c.toUpperCase()));
|
|
289
|
-
if (r.coupons && !code)
|
|
290
|
-
continue;
|
|
291
|
-
if (code)
|
|
292
|
-
used.add(code.toUpperCase());
|
|
293
|
-
if (r.order_amount && subtotal < toMinor(r.order_amount.min, market.currency))
|
|
294
|
-
continue;
|
|
295
|
-
if (r.skus && !lines.some((l) => r.skus.includes(l.item.id)))
|
|
296
|
-
continue;
|
|
297
|
-
matching.push({ p, code });
|
|
298
|
-
}
|
|
299
|
-
matching.sort((a, b) => (a.p.priority ?? Infinity) - (b.p.priority ?? Infinity));
|
|
300
|
-
const exclusive = matching.find((m) => m.p.exclusive);
|
|
301
|
-
const applied = [];
|
|
302
|
-
for (const { p, code } of exclusive ? [exclusive] : matching) {
|
|
303
|
-
let amount = 0;
|
|
304
|
-
for (const a of p.actions) {
|
|
305
|
-
const on = (skus) => lines.filter((l) => !skus || skus.includes(l.item.id));
|
|
306
|
-
const units = (skus) => on(skus).flatMap((l) => Array(l.quantity).fill(l.item.price)).sort((x, y) => x - y);
|
|
307
|
-
if (a.type === 'percentage')
|
|
308
|
-
amount += Math.floor((on(a.skus).reduce((s, l) => s + l.totals[0].amount, 0) * a.value) / 100);
|
|
309
|
-
else if (a.type === 'fixed_amount')
|
|
310
|
-
amount += Math.min(toMinor(a.value, market.currency), subtotal);
|
|
311
|
-
else if (a.type === 'free_shipping')
|
|
312
|
-
amount += shipping ?? 0;
|
|
313
|
-
else if (a.type === 'fixed_price')
|
|
314
|
-
amount += on(a.skus).reduce((s, l) => s + Math.max(0, l.item.price - toMinor(a.price, market.currency)) * l.quantity, 0);
|
|
315
|
-
else if (a.type === 'free_gift')
|
|
316
|
-
amount += units(a.skus).slice(0, a.quantity).reduce((s, x) => s + x, 0);
|
|
317
|
-
else if (a.cheapest_free) {
|
|
318
|
-
const all = units(a.skus);
|
|
319
|
-
amount += all.slice(0, Math.floor(all.length / a.x) * (a.x - a.y)).reduce((s, x) => s + x, 0);
|
|
320
|
-
}
|
|
321
|
-
else
|
|
322
|
-
amount += on(a.skus).reduce((s, l) => s + Math.floor(l.quantity / a.x) * (a.x - a.y) * l.item.price, 0);
|
|
323
|
-
}
|
|
324
|
-
if (amount > 0)
|
|
325
|
-
applied.push({ ...(code ? { code } : {}), title: p.name, amount, ...(code ? {} : { automatic: true }), method: 'across' });
|
|
326
|
-
}
|
|
327
|
-
return { applied, used };
|
|
328
|
-
}
|
|
329
|
-
/**
|
|
330
|
-
* Discounts: the store's promotions (automatic ones, and those whose code was entered) plus the
|
|
331
|
-
* sandbox code, 10% off the items. A code that applies nothing is reported with a warning.
|
|
332
|
-
*/
|
|
333
|
-
function discounts(ctx, body, lines, messages, market, shipping) {
|
|
334
|
-
if (!ctx.shared.has('dev.ucp.shopping.discount'))
|
|
335
|
-
return undefined;
|
|
336
|
-
const codes = (body.discounts?.codes ?? []).map((c) => String(c));
|
|
337
|
-
const subtotal = lines.reduce((s, l) => s + l.totals[0].amount, 0);
|
|
338
|
-
const { applied: appliedList, used } = promotionDiscounts(ctx, market, lines, codes, shipping);
|
|
339
|
-
const seen = new Set();
|
|
340
|
-
codes.forEach((code, i) => {
|
|
341
|
-
const key = code.toUpperCase();
|
|
342
|
-
if (seen.has(key))
|
|
343
|
-
messages.push(warn('discount_code_already_applied', `${code} is already applied.`, `$.discounts.codes[${i}]`));
|
|
344
|
-
else if (key === SANDBOX_CODE)
|
|
345
|
-
appliedList.push({ code: SANDBOX_CODE, title: '10% off', amount: Math.floor(subtotal / 10), method: 'across' });
|
|
346
|
-
else if (!used.has(key))
|
|
347
|
-
messages.push(warn('discount_code_invalid', `${code} is not a valid code.`, `$.discounts.codes[${i}]`));
|
|
348
|
-
else if (!appliedList.some((d) => d.code?.toUpperCase() === key))
|
|
349
|
-
messages.push(warn('discount_code_invalid', `${code} does not apply to this order.`, `$.discounts.codes[${i}]`));
|
|
350
|
-
seen.add(key);
|
|
351
|
-
});
|
|
352
|
-
if (body.discounts === undefined && !appliedList.length)
|
|
353
|
-
return undefined;
|
|
354
|
-
return { codes, applied: appliedList };
|
|
355
|
-
}
|
|
356
|
-
function consent(ctx, buyer) {
|
|
357
|
-
if (!ctx.shared.has('dev.ucp.shopping.buyer_consent') || !buyer?.consent)
|
|
358
|
-
return undefined;
|
|
359
|
-
const out = {};
|
|
360
|
-
for (const [purpose, given] of Object.entries(buyer.consent)) {
|
|
361
|
-
out[purpose] = { granted: Boolean(given?.granted), source: given?.source === 'business' ? 'business' : 'platform', description: CONSENT_PURPOSES[purpose] ?? purpose };
|
|
362
|
-
}
|
|
363
|
-
return out;
|
|
364
|
-
}
|
|
365
|
-
function totals(lines, discount, shipping) {
|
|
366
|
-
const subtotal = lines.reduce((s, l) => s + l.totals[0].amount, 0);
|
|
367
|
-
const out = [{ type: 'subtotal', display_text: 'Subtotal', amount: subtotal }];
|
|
368
|
-
const off = discount?.applied?.reduce((s, d) => s + d.amount, 0) ?? 0;
|
|
369
|
-
if (off > 0)
|
|
370
|
-
out.push({ type: 'discount', display_text: 'Discount', amount: -off });
|
|
371
|
-
if (shipping !== undefined)
|
|
372
|
-
out.push({ type: 'fulfillment', display_text: 'Shipping', amount: shipping });
|
|
373
|
-
out.push({ type: 'total', display_text: shipping === undefined ? 'Estimated total' : 'Total', amount: subtotal - off + (shipping ?? 0) });
|
|
374
|
-
return out;
|
|
375
|
-
}
|
|
376
|
-
// ---------- carts
|
|
377
|
-
function cartFrom(ctx, body, existing) {
|
|
378
|
-
const market = pickMarket(ctx, body.context ?? existing?.context);
|
|
379
|
-
const { lines, messages } = buildLines(ctx, market, body.line_items, existing?.line_items);
|
|
380
|
-
const discount = discounts(ctx, body, lines, messages, market);
|
|
381
|
-
const cart = {
|
|
382
|
-
ucp: envelope(ctx, 'cart'),
|
|
383
|
-
id: existing?.id ?? id('cart'),
|
|
384
|
-
currency: market.currency,
|
|
385
|
-
line_items: lines,
|
|
386
|
-
_market: market.code,
|
|
387
|
-
_platform: ctx.platform,
|
|
388
|
-
};
|
|
389
|
-
for (const k of ['context', 'buyer', 'signals', 'attribution'])
|
|
390
|
-
if (body[k] !== undefined)
|
|
391
|
-
cart[k] = body[k];
|
|
392
|
-
const c = consent(ctx, body.buyer);
|
|
393
|
-
if (c)
|
|
394
|
-
cart.buyer = { ...(cart.buyer ?? {}), consent: c };
|
|
395
|
-
if (discount)
|
|
396
|
-
cart.discounts = discount;
|
|
397
|
-
cart.totals = totals(lines, discount, undefined);
|
|
398
|
-
if (messages.length)
|
|
399
|
-
cart.messages = messages;
|
|
400
|
-
cart.links = policyLinks(ctx);
|
|
401
|
-
const policies = returnPolicy(ctx);
|
|
402
|
-
if (policies.length)
|
|
403
|
-
cart.policies = policies;
|
|
404
|
-
cart.continue_url = `${ctx.origin}/carts/${cart.id}`;
|
|
405
|
-
cart.expires_at = existing?.expires_at ?? later(24 * 7);
|
|
406
|
-
return cart;
|
|
407
|
-
}
|
|
408
|
-
export function createCart(ctx, body) {
|
|
409
|
-
if (!pickMarket(ctx, body.context))
|
|
410
|
-
return { status: 200, body: errorResponse(ctx, 'cart', 'item_unavailable', 'The store has no markets yet.') };
|
|
411
|
-
const cart = cartFrom(ctx, body);
|
|
412
|
-
if (cart.line_items.length === 0 && (body.line_items ?? []).length > 0) {
|
|
413
|
-
return { status: 200, body: errorResponse(ctx, 'cart', 'item_unavailable', 'None of the items can be sold here.', { continue_url: ctx.origin }) };
|
|
414
|
-
}
|
|
415
|
-
ctx.record.ucp.carts[cart.id] = cart;
|
|
416
|
-
return { status: 201, body: cart };
|
|
417
|
-
}
|
|
418
|
-
export function getCart(ctx, cartId) {
|
|
419
|
-
return ctx.record.ucp.carts[cartId] ?? errorResponse(ctx, 'cart', 'not_found', `No cart ${cartId}.`);
|
|
420
|
-
}
|
|
421
|
-
export function updateCart(ctx, cartId, body) {
|
|
422
|
-
const existing = ctx.record.ucp.carts[cartId];
|
|
423
|
-
if (!existing)
|
|
424
|
-
return errorResponse(ctx, 'cart', 'not_found', `No cart ${cartId}.`);
|
|
425
|
-
const cart = cartFrom(ctx, body, existing);
|
|
426
|
-
ctx.record.ucp.carts[cartId] = cart;
|
|
427
|
-
return cart;
|
|
428
|
-
}
|
|
429
|
-
export function cancelCart(ctx, cartId) {
|
|
430
|
-
const existing = ctx.record.ucp.carts[cartId];
|
|
431
|
-
if (!existing)
|
|
432
|
-
return errorResponse(ctx, 'cart', 'not_found', `No cart ${cartId}.`);
|
|
433
|
-
delete ctx.record.ucp.carts[cartId];
|
|
434
|
-
return existing;
|
|
435
|
-
}
|
|
436
|
-
// ---------- checkouts
|
|
437
|
-
/** Recompute everything the business decides on a checkout: lines, fulfillment, totals, status. */
|
|
438
|
-
function evaluate(ctx, ck, requested) {
|
|
439
|
-
const market = markets(ctx).find((m) => m.code === ck._market);
|
|
440
|
-
const { lines, messages } = buildLines(ctx, market, requested.line_items ?? ck.line_items, ck.line_items);
|
|
441
|
-
ck.line_items = lines;
|
|
442
|
-
if (lines.length === 0)
|
|
443
|
-
messages.push(err('item_unavailable', 'The checkout has no purchasable items.', '$.line_items'));
|
|
444
|
-
if (!ck.buyer?.email)
|
|
445
|
-
messages.push(err('missing', 'An email address is needed for the order confirmation.', '$.buyer.email'));
|
|
446
|
-
let shipping;
|
|
447
|
-
if (ctx.shared.has('dev.ucp.shopping.fulfillment')) {
|
|
448
|
-
const sent = requested.fulfillment?.methods?.[0] ?? {};
|
|
449
|
-
const method = { id: 'ful_ship', type: 'shipping', line_item_ids: lines.map((l) => l.id) };
|
|
450
|
-
const destinations = (sent.destinations ?? ck.fulfillment?.methods?.[0]?.destinations ?? []).map((d, i) => ({ type: 'shipping_address', ...d, id: d.id ?? `dest_${i + 1}`, type_: undefined }));
|
|
451
|
-
for (const d of destinations)
|
|
452
|
-
delete d.type_;
|
|
453
|
-
const selectedId = sent.selected_destination_id ?? ck.fulfillment?.methods?.[0]?.selected_destination_id ?? null;
|
|
454
|
-
method.destinations = destinations;
|
|
455
|
-
method.selected_destination_id = selectedId;
|
|
456
|
-
const selected = destinations.find((d) => d.id === selectedId);
|
|
457
|
-
if (!selected) {
|
|
458
|
-
method.groups = [];
|
|
459
|
-
messages.push(err('missing', 'A shipping address is needed.', '$.fulfillment.methods[0].selected_destination_id'));
|
|
460
|
-
}
|
|
461
|
-
else if (!market.countries.includes(String(selected.address_country ?? '').toUpperCase())) {
|
|
462
|
-
method.groups = [];
|
|
463
|
-
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`));
|
|
464
|
-
}
|
|
465
|
-
else {
|
|
466
|
-
const option = shippingOption(market);
|
|
467
|
-
const wanted = sent.groups?.[0]?.selected_option_id;
|
|
468
|
-
if (wanted && wanted !== option.id)
|
|
469
|
-
messages.push(warn('fulfillment_changed', `Option ${wanted} is not offered; ${option.title} selected.`, '$.fulfillment.methods[0].groups[0].selected_option_id'));
|
|
470
|
-
method.groups = [{ id: 'grp_1', line_item_ids: method.line_item_ids, options: [option], selected_option_id: option.id }];
|
|
471
|
-
shipping = option.totals[0].amount;
|
|
472
|
-
}
|
|
473
|
-
ck.fulfillment = { methods: [method], available_methods: [{ type: 'shipping', line_item_ids: method.line_item_ids, fulfillable_on: 'now' }] };
|
|
474
|
-
}
|
|
475
|
-
else
|
|
476
|
-
shipping = 0;
|
|
477
|
-
const discount = discounts(ctx, requested, lines, messages, market, shipping);
|
|
478
|
-
if (discount)
|
|
479
|
-
ck.discounts = discount;
|
|
480
|
-
else
|
|
481
|
-
delete ck.discounts;
|
|
482
|
-
ck.totals = totals(lines, discount, shipping);
|
|
483
|
-
const blocking = messages.some((m) => m.type === 'error');
|
|
484
|
-
ck.status = blocking ? 'incomplete' : 'ready_for_complete';
|
|
485
|
-
if (messages.length)
|
|
486
|
-
ck.messages = messages;
|
|
487
|
-
else
|
|
488
|
-
delete ck.messages;
|
|
489
|
-
ck.continue_url = `${ctx.origin}/checkout-sessions/${ck.id}`;
|
|
490
|
-
ck.links = policyLinks(ctx);
|
|
491
|
-
const policies = returnPolicy(ctx);
|
|
492
|
-
if (policies.length)
|
|
493
|
-
ck.policies = policies;
|
|
494
|
-
else
|
|
495
|
-
delete ck.policies;
|
|
496
|
-
}
|
|
497
|
-
function copyInputs(ctx, ck, body) {
|
|
498
|
-
for (const k of ['buyer', 'context', 'signals', 'attribution']) {
|
|
499
|
-
if (body[k] !== undefined)
|
|
500
|
-
ck[k] = body[k];
|
|
501
|
-
}
|
|
502
|
-
const c = consent(ctx, body.buyer);
|
|
503
|
-
if (c)
|
|
504
|
-
ck.buyer = { ...(ck.buyer ?? {}), consent: c };
|
|
505
|
-
}
|
|
506
|
-
export function createCheckout(ctx, body) {
|
|
507
|
-
const checkouts = ctx.record.ucp.checkouts;
|
|
508
|
-
let seed = body;
|
|
509
|
-
let cartId;
|
|
510
|
-
if (body.cart_id !== undefined) {
|
|
511
|
-
if (!ctx.shared.has('dev.ucp.shopping.cart'))
|
|
512
|
-
return { status: 200, body: errorResponse(ctx, 'checkout', 'capabilities_incompatible', 'cart_id needs the cart capability, which is not negotiated.') };
|
|
513
|
-
const cart = ctx.record.ucp.carts[body.cart_id];
|
|
514
|
-
if (!cart)
|
|
515
|
-
return { status: 200, body: errorResponse(ctx, 'checkout', 'not_found', `No cart ${body.cart_id}.`) };
|
|
516
|
-
const open = Object.values(checkouts).find((c) => c._cart_id === body.cart_id && c.status === 'incomplete');
|
|
517
|
-
if (open)
|
|
518
|
-
return { status: 200, body: open };
|
|
519
|
-
cartId = cart.id;
|
|
520
|
-
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 };
|
|
521
|
-
}
|
|
522
|
-
else if (!Array.isArray(body.line_items)) {
|
|
523
|
-
return { status: 200, body: errorResponse(ctx, 'checkout', 'missing', 'A checkout needs line_items or a cart_id.') };
|
|
524
|
-
}
|
|
525
|
-
const market = pickMarket(ctx, seed.context);
|
|
526
|
-
if (!market)
|
|
527
|
-
return { status: 200, body: errorResponse(ctx, 'checkout', 'item_unavailable', 'The store has no markets yet.') };
|
|
528
|
-
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() };
|
|
529
|
-
if (cartId)
|
|
530
|
-
ck._cart_id = cartId;
|
|
531
|
-
copyInputs(ctx, ck, seed);
|
|
532
|
-
evaluate(ctx, ck, seed);
|
|
533
|
-
if (ck.line_items.length === 0)
|
|
534
|
-
return { status: 200, body: errorResponse(ctx, 'checkout', 'item_unavailable', 'None of the items can be sold here.', { continue_url: ctx.origin }) };
|
|
535
|
-
ck.expires_at = later(6);
|
|
536
|
-
checkouts[ck.id] = ck;
|
|
537
|
-
return { status: 201, body: ck };
|
|
538
|
-
}
|
|
539
|
-
export function getCheckout(ctx, ckId) {
|
|
540
|
-
return ctx.record.ucp.checkouts[ckId] ?? errorResponse(ctx, 'checkout', 'not_found', `No checkout ${ckId}.`);
|
|
541
|
-
}
|
|
542
|
-
export function updateCheckout(ctx, ckId, body) {
|
|
543
|
-
const ck = ctx.record.ucp.checkouts[ckId];
|
|
544
|
-
if (!ck)
|
|
545
|
-
return errorResponse(ctx, 'checkout', 'not_found', `No checkout ${ckId}.`);
|
|
546
|
-
if (ck.status === 'completed' || ck.status === 'canceled') {
|
|
547
|
-
return { ...ck, messages: [err('not_allowed', `The checkout is ${ck.status} and cannot change.`, '$.status', 'unrecoverable')] };
|
|
548
|
-
}
|
|
549
|
-
if (ck.status === 'complete_in_progress')
|
|
550
|
-
return { ...ck, messages: [err('in_progress', 'Completion is in progress; wait for it.', '$.status')] };
|
|
551
|
-
if (!Array.isArray(body.line_items))
|
|
552
|
-
return { ...ck, messages: [err('missing', 'Update Checkout replaces the whole checkout: send line_items.', '$.line_items')] };
|
|
553
|
-
copyInputs(ctx, ck, body);
|
|
554
|
-
evaluate(ctx, ck, body);
|
|
555
|
-
return ck;
|
|
556
|
-
}
|
|
557
|
-
export function cancelCheckout(ctx, ckId) {
|
|
558
|
-
const ck = ctx.record.ucp.checkouts[ckId];
|
|
559
|
-
if (!ck)
|
|
560
|
-
return errorResponse(ctx, 'checkout', 'not_found', `No checkout ${ckId}.`);
|
|
561
|
-
if (ck.status === 'completed' || ck.status === 'canceled')
|
|
562
|
-
return { ...ck, messages: [err('not_allowed', `The checkout is ${ck.status}.`, '$.status', 'unrecoverable')] };
|
|
563
|
-
ck.status = 'canceled';
|
|
564
|
-
delete ck.continue_url;
|
|
565
|
-
delete ck.messages;
|
|
566
|
-
return ck;
|
|
567
|
-
}
|
|
568
|
-
export function completeCheckout(ctx, ckId, body) {
|
|
569
|
-
const ck = ctx.record.ucp.checkouts[ckId];
|
|
570
|
-
if (!ck)
|
|
571
|
-
return errorResponse(ctx, 'checkout', 'not_found', `No checkout ${ckId}.`);
|
|
572
|
-
if (ck.status === 'completed')
|
|
573
|
-
return ck;
|
|
574
|
-
if (ck.status !== 'ready_for_complete') {
|
|
575
|
-
return { ...ck, messages: [...(ck.messages ?? []), err('not_ready', `The checkout is ${ck.status}; resolve its messages first.`, '$.status', ck.status === 'canceled' ? 'unrecoverable' : 'recoverable')] };
|
|
576
|
-
}
|
|
577
|
-
const instruments = body.payment?.instruments ?? [];
|
|
578
|
-
const reject = (content, path = '$.payment.instruments') => ({ ...ck, messages: [err('payment_failed', content, path)] });
|
|
579
|
-
if (instruments.length !== 1)
|
|
580
|
-
return reject('Exactly one payment instrument is needed.');
|
|
581
|
-
const [pi] = instruments;
|
|
582
|
-
if (pi.handler_id !== SANDBOX_HANDLER_ID)
|
|
583
|
-
return reject(`Unknown payment handler ${pi.handler_id}; this store accepts ${SANDBOX_HANDLER_ID}.`, '$.payment.instruments[0].handler_id');
|
|
584
|
-
if (pi.type !== 'card')
|
|
585
|
-
return reject(`Instrument type ${pi.type} is not accepted; use card.`, '$.payment.instruments[0].type');
|
|
586
|
-
if (pi.credential?.type !== SANDBOX_CREDENTIAL_TYPE)
|
|
587
|
-
return reject(`Credential type must be ${SANDBOX_CREDENTIAL_TYPE}.`, '$.payment.instruments[0].credential.type');
|
|
588
|
-
if (pi.credential.token === SANDBOX_TOKEN_DECLINED)
|
|
589
|
-
return reject('The card was declined.', '$.payment.instruments[0]');
|
|
590
|
-
if (pi.credential.token !== SANDBOX_TOKEN_SUCCESS)
|
|
591
|
-
return reject(`Unknown sandbox token; use ${SANDBOX_TOKEN_SUCCESS} or ${SANDBOX_TOKEN_DECLINED}.`, '$.payment.instruments[0].credential.token');
|
|
592
|
-
if (body.signals)
|
|
593
|
-
ck.signals = body.signals;
|
|
594
|
-
if (body.attribution)
|
|
595
|
-
ck.attribution = body.attribution;
|
|
596
|
-
const { token: _t, ...credential } = pi.credential;
|
|
597
|
-
ck.payment = { instruments: [{ ...pi, selected: true, credential }] };
|
|
598
|
-
const order = placeOrder(ctx, ck);
|
|
599
|
-
ck.status = 'completed';
|
|
600
|
-
ck.order = { id: order.id, label: order.label, permalink_url: order.permalink_url };
|
|
601
|
-
delete ck.continue_url;
|
|
602
|
-
delete ck.expires_at;
|
|
603
|
-
delete ck.messages;
|
|
604
|
-
return ck;
|
|
605
|
-
}
|
|
606
|
-
function placeOrder(ctx, ck) {
|
|
607
|
-
const orders = ctx.record.ucp.orders;
|
|
608
|
-
const orderId = id('ord');
|
|
609
|
-
const method = ck.fulfillment?.methods?.[0];
|
|
610
|
-
const destination = method?.destinations?.find((d) => d.id === method.selected_destination_id);
|
|
611
|
-
const option = method?.groups?.[0]?.options?.find((o) => o.id === method.groups[0].selected_option_id);
|
|
612
|
-
const { type: _type, id: _id, ...address } = destination ?? {};
|
|
613
|
-
const order = {
|
|
614
|
-
ucp: envelope(ctx, 'order'),
|
|
615
|
-
id: orderId,
|
|
616
|
-
label: `#${1001 + Object.keys(orders).length}`,
|
|
617
|
-
checkout_id: ck.id,
|
|
618
|
-
permalink_url: `${ctx.origin}/orders/${orderId}`,
|
|
619
|
-
currency: ck.currency,
|
|
620
|
-
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' })),
|
|
621
|
-
fulfillment: {
|
|
622
|
-
expectations: destination
|
|
623
|
-
? [{ 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' }]
|
|
624
|
-
: [],
|
|
625
|
-
events: [],
|
|
626
|
-
},
|
|
627
|
-
adjustments: [],
|
|
628
|
-
totals: ck.totals,
|
|
629
|
-
_platform: ck._platform,
|
|
630
|
-
};
|
|
631
|
-
if (ck.policies)
|
|
632
|
-
order.policies = ck.policies;
|
|
633
|
-
if (ck.attribution)
|
|
634
|
-
order.attribution = ck.attribution;
|
|
635
|
-
if (ck.buyer)
|
|
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) });
|
|
639
|
-
orders[orderId] = order;
|
|
640
|
-
const webhook = platformWebhook(ctx);
|
|
641
|
-
if (webhook)
|
|
642
|
-
ctx.record.ucp.webhooks.push({ id: crypto.randomUUID(), timestamp: Math.floor(Date.now() / 1000), url: webhook, order_id: orderId, status: 'pending' });
|
|
643
|
-
return order;
|
|
644
|
-
}
|
|
645
|
-
function platformWebhook(ctx) {
|
|
646
|
-
return ctx.webhookUrl ?? null;
|
|
647
|
-
}
|
|
648
|
-
export function getOrder(ctx, orderId) {
|
|
649
|
-
return ctx.record.ucp.orders[orderId] ?? null;
|
|
650
|
-
}
|
|
651
|
-
// ---------- ask
|
|
652
|
-
export function ask(ctx, body) {
|
|
653
|
-
const market = pickMarket(ctx, body.context);
|
|
654
|
-
const products = Object.values(applied(ctx).products);
|
|
655
|
-
const name = ctx.record.name ?? ctx.record.org;
|
|
656
|
-
const q = String(body.query ?? '').toLowerCase();
|
|
657
|
-
const price = (p) => (market ? `${p.prices[market.currency]} ${market.currency}` : '');
|
|
658
|
-
let plain;
|
|
659
|
-
if (products.length === 0)
|
|
660
|
-
plain = `${name} has no products yet.`;
|
|
661
|
-
else {
|
|
662
|
-
const hits = products.filter((p) => q && `${p.name} ${p.description ?? ''}`.toLowerCase().split(/\W+/).some((w) => w.length > 2 && q.includes(w)));
|
|
663
|
-
const list = (hits.length ? hits : products).map((p) => `${p.name} (${p.sku}, ${price(p)})`).join('; ');
|
|
664
|
-
plain = hits.length ? `${name} sells ${list}.` : `${name} sells ${products.length} products: ${list}. Ships to ${markets(ctx).flatMap((m) => m.countries).join(', ')}.`;
|
|
665
|
-
}
|
|
666
|
-
const out = { ucp: envelope(ctx, 'ask'), answer: { plain }, links: policyLinks(ctx).map((l) => ({ ...l, title: l.type.replace(/_/g, ' ') })) };
|
|
667
|
-
if (body.conversation?.id)
|
|
668
|
-
out.conversation = { id: body.conversation.id, expires_at: later(1) };
|
|
669
|
-
if (/\b(buy|order|cancel|refund|add to cart|checkout|pay)\b/.test(q)) {
|
|
670
|
-
out.messages = [{ type: 'info', code: 'operation_not_performed', content: 'Ask answers questions only; use the cart and checkout capabilities to buy.' }];
|
|
671
|
-
}
|
|
672
|
-
return out;
|
|
673
|
-
}
|