domma-cms 0.84.1 → 0.85.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +4 -4
  2. package/admin/dist/domma/domma-tools.css +3 -3
  3. package/admin/dist/domma/domma-tools.min.js +4 -4
  4. package/admin/js/lib/themes.js +1 -1
  5. package/package.json +2 -2
  6. package/plugins/free-tier.lock.json +49 -1
  7. package/plugins/shopping-cart/CLAUDE.md +93 -0
  8. package/plugins/shopping-cart/admin/css/index.css +224 -0
  9. package/plugins/shopping-cart/admin/lib/kit.js +190 -0
  10. package/plugins/shopping-cart/admin/templates/shop.html +1 -0
  11. package/plugins/shopping-cart/admin/views/orders.js +365 -0
  12. package/plugins/shopping-cart/admin/views/overview.js +214 -0
  13. package/plugins/shopping-cart/admin/views/products.js +547 -0
  14. package/plugins/shopping-cart/admin/views/settings.js +247 -0
  15. package/plugins/shopping-cart/admin/views/shop.js +130 -0
  16. package/plugins/shopping-cart/collections/shop-orders.json +20 -0
  17. package/plugins/shopping-cart/collections/shop-products.json +24 -0
  18. package/plugins/shopping-cart/config.js +52 -0
  19. package/plugins/shopping-cart/inject/body.html +2 -0
  20. package/plugins/shopping-cart/lib/extensions.js +96 -0
  21. package/plugins/shopping-cart/lib/payments.js +281 -0
  22. package/plugins/shopping-cart/lib/pricing.js +232 -0
  23. package/plugins/shopping-cart/lib/render.js +282 -0
  24. package/plugins/shopping-cart/lib/samples.js +34 -0
  25. package/plugins/shopping-cart/lib/shop.js +436 -0
  26. package/plugins/shopping-cart/lib/summary.js +127 -0
  27. package/plugins/shopping-cart/plugin.js +559 -0
  28. package/plugins/shopping-cart/plugin.json +52 -0
  29. package/plugins/shopping-cart/plugin.public.js +144 -0
  30. package/plugins/shopping-cart/public/cart.js +715 -0
  31. package/plugins/shopping-cart/public/lib/money.js +105 -0
  32. package/plugins/shopping-cart/public/lib/orders.js +122 -0
  33. package/plugins/shopping-cart/public/lib/products.js +235 -0
  34. package/plugins/shopping-cart/public/samples/candle-2.svg +16 -0
  35. package/plugins/shopping-cart/public/samples/candle.svg +16 -0
  36. package/plugins/shopping-cart/public/samples/mug-2.svg +14 -0
  37. package/plugins/shopping-cart/public/samples/mug.svg +14 -0
  38. package/plugins/shopping-cart/public/samples/notebook-2.svg +15 -0
  39. package/plugins/shopping-cart/public/samples/notebook.svg +15 -0
  40. package/plugins/shopping-cart/public/samples/print-2.svg +14 -0
  41. package/plugins/shopping-cart/public/samples/print.svg +14 -0
  42. package/plugins/shopping-cart/public/samples/tee-2.svg +12 -0
  43. package/plugins/shopping-cart/public/samples/tee.svg +12 -0
  44. package/plugins/shopping-cart/public/samples/tote-2.svg +13 -0
  45. package/plugins/shopping-cart/public/samples/tote.svg +13 -0
  46. package/plugins/shopping-cart/public/shop.css +415 -0
  47. package/plugins/shopping-cart/templates/shop.html +4 -0
  48. package/plugins/shopping-cart/tests/api.test.js +144 -0
  49. package/plugins/shopping-cart/tests/pure.test.js +210 -0
  50. package/scripts/setup.js +1 -0
@@ -0,0 +1,436 @@
1
+ /**
2
+ * The Shop's data: products and orders in their collections, the live
3
+ * settings, order numbers, and the moves an order makes - paid, fulfilled,
4
+ * cancelled, refunded - with the stock that goes with them.
5
+ *
6
+ * Every write to an order goes through `withLock`, one at a time. A Stripe
7
+ * webhook and the shopper's own return can arrive in the same instant, and
8
+ * both say "paid": the second must find the first's work done, or the stock
9
+ * is taken twice.
10
+ *
11
+ * @module shopping-cart/lib/shop
12
+ */
13
+
14
+ import {randomBytes} from 'crypto';
15
+ import {createCollection, createEntry, deleteEntry, getCollection, getEntry, listEntries, updateEntry} from '../../../server/services/collections.js';
16
+ import {getPluginSettings, getPluginStates, savePluginState} from '../../../server/services/plugins.js';
17
+ import {cleanProduct} from '../public/lib/products.js';
18
+ import {NEXT, SOLD, canMove} from '../public/lib/orders.js';
19
+ import {currencyOf} from '../public/lib/money.js';
20
+ import defaults from '../config.js';
21
+
22
+ export const NAME = 'shopping-cart';
23
+ export const PRODUCTS = 'shop-products';
24
+ export const ORDERS = 'shop-orders';
25
+
26
+ /** Keys the settings slideover may write, and which of them are secret. */
27
+ export const SETTING_KEYS = Object.keys(defaults);
28
+ export const SECRET_KEYS = ['stripeSecretKey', 'stripeWebhookSecret', 'paypalSecret'];
29
+ export const MASK = '••••••••';
30
+
31
+ // ---- Setup ---------------------------------------------------------------------
32
+
33
+ /** Make the collections that are missing. Every boot: a plugin switched on by copy never has onEnable run. */
34
+ export async function ensureCollections() {
35
+ const dir = new URL('../collections/', import.meta.url);
36
+ const fs = await import('fs/promises');
37
+ for (const slug of [PRODUCTS, ORDERS]) {
38
+ if (await getCollection(slug)) continue;
39
+ const schema = JSON.parse(await fs.readFile(new URL(`${slug}.json`, dir), 'utf8'));
40
+ await createCollection(schema);
41
+ console.log(`[shopping-cart] Created collection: ${slug}`);
42
+ }
43
+ }
44
+
45
+ // ---- Settings ------------------------------------------------------------------
46
+
47
+ /** The settings as they are NOW - read per request, so a change needs no restart. */
48
+ export async function liveSettings() {
49
+ const s = await getPluginSettings(NAME);
50
+ return {...defaults, ...s, currency: currencyOf(s.currency), shopPath: cleanPath(s.shopPath)};
51
+ }
52
+
53
+ /** The same, synchronously - for shortcodes, which cannot wait. */
54
+ export function settingsNow() {
55
+ const s = getPluginStates()[NAME]?.settings || {};
56
+ return {...defaults, ...s, currency: currencyOf(s.currency), shopPath: cleanPath(s.shopPath)};
57
+ }
58
+
59
+ /** "/shop" from "shop/", "/Shop//", "" - one leading slash, no trailing one. */
60
+ export function cleanPath(p) {
61
+ const t = '/' + String(p || '/shop').toLowerCase().replace(/[^a-z0-9/_-]/g, '').replace(/\/+/g, '/').replace(/^\/|\/$/g, '');
62
+ return t === '/' || /^\/(admin|api|plugins|dist|media|assets|login)(\/|$)/.test(t) ? '/shop' : t;
63
+ }
64
+
65
+ /** For the browser: every setting, secrets masked (or '' when unset). */
66
+ export function maskSettings(s) {
67
+ const out = {...s};
68
+ for (const k of SECRET_KEYS) out[k] = s[k] ? MASK : '';
69
+ return out;
70
+ }
71
+
72
+ /**
73
+ * Clean a settings save. A masked secret keeps the stored value; an empty one clears it.
74
+ *
75
+ * @returns {{settings: object, errors: Object<string, string>}}
76
+ */
77
+ export function cleanSettings(input, current) {
78
+ const errors = {};
79
+ const out = {};
80
+ const str = (k, max = 300) => String(input[k] ?? current[k] ?? '').trim().slice(0, max);
81
+ const bool = (k) => (input[k] === undefined ? Boolean(current[k]) : Boolean(input[k]));
82
+ const minor = (k) => {
83
+ const v = input[k] ?? current[k];
84
+ const n = Number(v);
85
+ if (!Number.isInteger(n) || n < 0 || n > 1e9) { errors[k] = 'An amount, 0 or more.'; return current[k] ?? 0; }
86
+ return n;
87
+ };
88
+ out.shopTitle = str('shopTitle', 80) || 'Shop';
89
+ out.shopPath = cleanPath(input.shopPath ?? current.shopPath);
90
+ out.shopIntro = str('shopIntro', 1000);
91
+ out.currency = currencyOf(input.currency ?? current.currency);
92
+ out.columns = [2, 3, 4].includes(Number(input.columns)) ? Number(input.columns) : (current.columns || 3);
93
+ out.showSearch = bool('showSearch');
94
+ out.showCategories = bool('showCategories');
95
+ out.floatingCart = bool('floatingCart');
96
+ out.cartPosition = ['right', 'left'].includes(input.cartPosition) ? input.cartPosition : (current.cartPosition || 'right');
97
+ out.lowStockAt = Math.max(0, Math.min(1000, Math.round(Number(input.lowStockAt ?? current.lowStockAt) || 0)));
98
+ out.showStockLeft = bool('showStockLeft');
99
+ out.termsUrl = str('termsUrl', 300);
100
+ if (out.termsUrl && !/^(\/(?!\/)|https?:\/\/)/.test(out.termsUrl)) errors.termsUrl = 'A page on this site (/terms) or a full https:// address.';
101
+
102
+ const countries = Array.isArray(input.countries) ? input.countries : (current.countries || ['GB']);
103
+ out.countries = [...new Set(countries.map(c => String(c).toUpperCase()).filter(c => /^[A-Z]{2}$/.test(c)))].slice(0, 250);
104
+ if (!out.countries.length) errors.countries = 'Pick at least one country you deliver to.';
105
+ out.shippingFlat = minor('shippingFlat');
106
+ out.shippingLabel = str('shippingLabel', 60) || 'Delivery';
107
+ out.freeShippingOver = minor('freeShippingOver');
108
+ out.allowCollection = bool('allowCollection');
109
+ out.collectionLabel = str('collectionLabel', 60) || 'Collect in person';
110
+
111
+ const rate = Number(input.taxRate ?? current.taxRate);
112
+ if (!Number.isFinite(rate) || rate < 0 || rate > 100) errors.taxRate = 'A percentage from 0 to 100.';
113
+ out.taxRate = Number.isFinite(rate) && rate >= 0 && rate <= 100 ? Math.round(rate * 1000) / 1000 : (current.taxRate || 0);
114
+ out.taxLabel = str('taxLabel', 30) || 'Tax';
115
+ out.pricesIncludeTax = bool('pricesIncludeTax');
116
+ out.taxShipping = bool('taxShipping');
117
+
118
+ out.stripeEnabled = bool('stripeEnabled');
119
+ out.paypalEnabled = bool('paypalEnabled');
120
+ out.paypalMode = input.paypalMode === 'live' ? 'live' : input.paypalMode === 'sandbox' ? 'sandbox' : (current.paypalMode || 'sandbox');
121
+ out.paypalClientId = str('paypalClientId', 200);
122
+ out.offlineEnabled = bool('offlineEnabled');
123
+ out.offlineLabel = str('offlineLabel', 60) || 'Pay offline';
124
+ out.offlineInstructions = str('offlineInstructions', 2000);
125
+ for (const k of SECRET_KEYS) {
126
+ const v = input[k];
127
+ out[k] = v === undefined || v === MASK ? (current[k] || '') : String(v).trim().slice(0, 300);
128
+ }
129
+ if (out.stripeSecretKey && !/^(sk|rk)_(test|live)_[A-Za-z0-9]{10,}$/.test(out.stripeSecretKey)) errors.stripeSecretKey = 'A Stripe secret key starts sk_test_ or sk_live_.';
130
+ if (out.stripeWebhookSecret && !/^whsec_[A-Za-z0-9]{10,}$/.test(out.stripeWebhookSecret)) errors.stripeWebhookSecret = 'A webhook signing secret starts whsec_.';
131
+ if (out.stripeEnabled && !out.stripeSecretKey) errors.stripeEnabled = 'Stripe needs its secret key.';
132
+ if (out.paypalEnabled && (!out.paypalClientId || !out.paypalSecret)) errors.paypalEnabled = 'PayPal needs its client id and secret.';
133
+ return {settings: out, errors};
134
+ }
135
+
136
+ export async function saveSettings(next) {
137
+ const state = getPluginStates()[NAME] || {};
138
+ savePluginState(NAME, {settings: {...(state.settings || {}), ...next}});
139
+ }
140
+
141
+ /** The payment methods a shopper can choose now, in order. */
142
+ export function methodsOf(s) {
143
+ const out = [];
144
+ if (s.stripeEnabled && s.stripeSecretKey) out.push({id: 'stripe', label: 'Card, Apple Pay or Google Pay'});
145
+ if (s.paypalEnabled && s.paypalClientId && s.paypalSecret) out.push({id: 'paypal', label: 'PayPal'});
146
+ if (s.offlineEnabled) out.push({id: 'offline', label: s.offlineLabel || 'Pay offline'});
147
+ return out;
148
+ }
149
+
150
+ // ---- Products ------------------------------------------------------------------
151
+
152
+ export async function allProducts() {
153
+ const {entries = []} = await listEntries(PRODUCTS, {limit: 0});
154
+ shelf = entries;
155
+ return entries;
156
+ }
157
+
158
+ /**
159
+ * The products as last read, for the shortcodes: the CMS runs shortcode
160
+ * handlers synchronously, so they cannot wait on the collection. Refreshed at
161
+ * boot and after every product or stock write made here.
162
+ */
163
+ let shelf = [];
164
+ export const shelfNow = () => shelf;
165
+ export const refreshShelf = () => allProducts().catch(() => shelf);
166
+
167
+ export const getProduct = (id) => getEntry(PRODUCTS, id);
168
+
169
+ /** A slug no other product has: "mug", "mug-2", … */
170
+ async function uniqueSlug(slug, exceptId = null) {
171
+ const taken = new Set((await allProducts()).filter(e => e.id !== exceptId).map(e => e.data?.slug));
172
+ if (!taken.has(slug)) return slug;
173
+ for (let i = 2; i < 1000; i++) if (!taken.has(`${slug}-${i}`)) return `${slug}-${i}`;
174
+ return `${slug}-${Date.now()}`;
175
+ }
176
+
177
+ export async function saveProduct(input, {id = null, currency = 'GBP', by = null} = {}) {
178
+ const {data, errors} = cleanProduct(input, {currency, minor: true});
179
+ if (Object.keys(errors).length) return {errors};
180
+ data.slug = await uniqueSlug(data.slug, id);
181
+ if (id) {
182
+ const had = await getProduct(id);
183
+ if (!had) return {notFound: true};
184
+ const entry = await updateEntry(PRODUCTS, id, data);
185
+ await refreshShelf();
186
+ return {entry};
187
+ }
188
+ const entry = await createEntry(PRODUCTS, data, {createdBy: by, source: 'plugin'});
189
+ await refreshShelf();
190
+ return {entry};
191
+ }
192
+
193
+ export async function removeProduct(id) {
194
+ await deleteEntry(PRODUCTS, id);
195
+ await refreshShelf();
196
+ }
197
+
198
+ // ---- Orders --------------------------------------------------------------------
199
+
200
+ let chain = Promise.resolve();
201
+ /** Run `fn` after every write already queued - order writes never interleave. */
202
+ export function withLock(fn) {
203
+ const run = chain.then(fn, fn);
204
+ chain = run.catch(() => {});
205
+ return run;
206
+ }
207
+
208
+ export async function allOrders() {
209
+ const {entries = []} = await listEntries(ORDERS, {limit: 0, sort: 'createdAt', order: 'desc'});
210
+ return entries;
211
+ }
212
+
213
+ export const getOrder = (id) => getEntry(ORDERS, id);
214
+
215
+ /** The next order number: stored in plugin state, never below any order that exists. */
216
+ async function nextNumber() {
217
+ const state = getPluginStates()[NAME] || {};
218
+ let n = Number(state.orderSeq) || 0;
219
+ if (!n) {
220
+ const orders = await allOrders();
221
+ n = Math.max(1000, ...orders.map(o => Number(o.data?.number) || 0));
222
+ }
223
+ n += 1;
224
+ savePluginState(NAME, {orderSeq: n});
225
+ return n;
226
+ }
227
+
228
+ /**
229
+ * A new order from a quote and what the shopper typed.
230
+ *
231
+ * @returns {Promise<{id, data}>}
232
+ */
233
+ export function createOrder({quote, checkout, provider, delivery, code = ''}) {
234
+ return withLock(async () => {
235
+ const now = new Date().toISOString();
236
+ const data = {
237
+ number: await nextNumber(),
238
+ status: 'pending',
239
+ token: randomBytes(18).toString('hex'),
240
+ currency: quote.currency,
241
+ lines: quote.lines.map(l => ({
242
+ productId: l.productId, variantId: l.variantId, name: l.name, variantName: l.variantName,
243
+ optionName: l.optionName, slug: l.slug, image: l.image, sku: l.sku, unitPrice: l.unitPrice,
244
+ qty: l.qty, lineTotal: l.lineTotal, shippable: l.shippable, weight: l.weight, taxClass: l.taxClass, category: l.category || ''
245
+ })),
246
+ subtotal: quote.subtotal,
247
+ adjustments: quote.adjustments,
248
+ discount: quote.discount,
249
+ shipping: quote.shipping,
250
+ tax: quote.tax,
251
+ taxIncluded: quote.taxIncluded,
252
+ total: quote.total,
253
+ requiresShipping: quote.requiresShipping,
254
+ delivery,
255
+ customer: checkout.customer,
256
+ email: checkout.customer.email,
257
+ address: checkout.address,
258
+ note: checkout.note,
259
+ code,
260
+ provider,
261
+ providerRef: '',
262
+ paymentRef: '',
263
+ stockTaken: false,
264
+ history: [{at: now, status: 'pending', by: 'shopper', note: `Checkout with ${provider}`}]
265
+ };
266
+ return createEntry(ORDERS, data, {source: 'plugin'});
267
+ });
268
+ }
269
+
270
+ /** Merge `patch` into an order's data. Inside the lock. */
271
+ async function patchOrder(id, patch) {
272
+ const had = await getOrder(id);
273
+ if (!had) throw Object.assign(new Error('No such order.'), {status: 404});
274
+ return updateEntry(ORDERS, id, {...had.data, ...patch});
275
+ }
276
+
277
+ /** Record the provider's reference for a pending order (session / PayPal order id). */
278
+ export const setProviderRef = (id, providerRef) => withLock(() => patchOrder(id, {providerRef}));
279
+
280
+ /** Stock out (-1) or back in (+1) for an order's lines. */
281
+ async function moveStock(order, sign) {
282
+ const byProduct = new Map();
283
+ for (const l of order.lines || []) {
284
+ if (!byProduct.has(l.productId)) byProduct.set(l.productId, []);
285
+ byProduct.get(l.productId).push(l);
286
+ }
287
+ for (const [productId, lines] of byProduct) {
288
+ const entry = await getProduct(productId);
289
+ const p = entry?.data;
290
+ if (!p || !p.trackStock) continue;
291
+ const next = {...p, variants: (p.variants || []).map(v => ({...v}))};
292
+ for (const l of lines) {
293
+ if (next.variants.length) {
294
+ const v = next.variants.find(x => x.id === l.variantId);
295
+ if (v) v.stock = Math.max(0, (Number(v.stock) || 0) + sign * l.qty);
296
+ } else {
297
+ next.stock = Math.max(0, (Number(next.stock) || 0) + sign * l.qty);
298
+ }
299
+ }
300
+ await updateEntry(PRODUCTS, productId, next);
301
+ }
302
+ await refreshShelf();
303
+ }
304
+
305
+ /**
306
+ * Money received. Idempotent: an order already paid (or further on) is left
307
+ * as it is and `changed` is false, so the webhook and the return can race.
308
+ *
309
+ * @param {string} id
310
+ * @param {{provider?: string, ref?: string, payment?: string, by?: string, note?: string}} info
311
+ * @returns {Promise<{order: object, changed: boolean, from?: string}>}
312
+ */
313
+ export function markPaid(id, info = {}) {
314
+ return withLock(async () => {
315
+ const had = await getOrder(id);
316
+ if (!had) throw Object.assign(new Error('No such order.'), {status: 404});
317
+ const d = had.data;
318
+ if (!['pending', 'awaiting'].includes(d.status)) return {order: had, changed: false};
319
+ if (!d.stockTaken) await moveStock(d, -1);
320
+ const now = new Date().toISOString();
321
+ const order = await updateEntry(ORDERS, id, {
322
+ ...d,
323
+ status: 'paid',
324
+ paidAt: now,
325
+ stockTaken: true,
326
+ ...(info.ref ? {providerRef: info.ref} : {}),
327
+ ...(info.payment ? {paymentRef: info.payment} : {}),
328
+ history: [...(d.history || []), {at: now, status: 'paid', by: info.by || d.provider, note: info.note || ''}]
329
+ });
330
+ return {order, changed: true, from: d.status};
331
+ });
332
+ }
333
+
334
+ /**
335
+ * Move an order by hand (the admin). Only the moves in NEXT; stock follows:
336
+ * taken when it becomes paid, given back on a refund when `restock` is set.
337
+ *
338
+ * @returns {Promise<{order: object, from: string}>}
339
+ */
340
+ export function moveOrder(id, to, {by = 'admin', note = '', restock = false, tracking = null} = {}) {
341
+ return withLock(async () => {
342
+ const had = await getOrder(id);
343
+ if (!had) throw Object.assign(new Error('No such order.'), {status: 404});
344
+ const d = had.data;
345
+ if (!canMove(d.status, to)) {
346
+ throw Object.assign(new Error(`An order that is ${d.status} cannot become ${to}. It can become: ${(NEXT[d.status] || []).join(', ') || 'nothing'}.`), {status: 409});
347
+ }
348
+ const now = new Date().toISOString();
349
+ const patch = {status: to, history: [...(d.history || []), {at: now, status: to, by, note: String(note || '').slice(0, 500)}]};
350
+ if (to === 'paid' && !d.stockTaken) { await moveStock(d, -1); patch.stockTaken = true; }
351
+ if (to === 'paid' && !d.paidAt) patch.paidAt = now;
352
+ if (to === 'fulfilled') {
353
+ patch.fulfilledAt = now;
354
+ if (tracking) patch.tracking = tracking;
355
+ }
356
+ if (to === 'cancelled') patch.cancelledAt = now;
357
+ if (to === 'refunded') {
358
+ patch.refundedAt = now;
359
+ if (restock && d.stockTaken) { await moveStock(d, +1); patch.stockTaken = false; patch.restocked = true; }
360
+ }
361
+ const order = await updateEntry(ORDERS, id, {...d, ...patch});
362
+ return {order, from: d.status};
363
+ });
364
+ }
365
+
366
+ /** The admin's own note and the tracking details - no status change. */
367
+ export const annotateOrder = (id, {staffNote, tracking}) => withLock(() => patchOrder(id, {
368
+ ...(staffNote !== undefined ? {staffNote: String(staffNote).slice(0, 4000)} : {}),
369
+ ...(tracking !== undefined ? {tracking} : {})
370
+ }));
371
+
372
+ /** Extensions keep their own facts on an order (emails sent, a code used) under `ext.<owner>`. */
373
+ export const extendOrder = (id, owner, facts) => withLock(async () => {
374
+ const had = await getOrder(id);
375
+ if (!had) return null;
376
+ const ext = {...(had.data.ext || {}), [owner]: {...(had.data.ext?.[owner] || {}), ...facts}};
377
+ return updateEntry(ORDERS, id, {...had.data, ext});
378
+ });
379
+
380
+ /** Only an order that never took money can be deleted. */
381
+ export const removeOrder = (id) => withLock(async () => {
382
+ const had = await getOrder(id);
383
+ if (!had) throw Object.assign(new Error('No such order.'), {status: 404});
384
+ if (SOLD.has(had.data.status) || had.data.status === 'refunded') {
385
+ throw Object.assign(new Error('A paid order is a record of a sale - refund or keep it, but it cannot be deleted.'), {status: 409});
386
+ }
387
+ await deleteEntry(ORDERS, id);
388
+ });
389
+
390
+ /** Tracking details, cleaned: carrier, number, and an https link. */
391
+ export function cleanTracking(t) {
392
+ if (!t || typeof t !== 'object') return null;
393
+ const url = String(t.url || '').trim();
394
+ const out = {
395
+ carrier: String(t.carrier || '').trim().slice(0, 60),
396
+ number: String(t.number || '').trim().slice(0, 80),
397
+ url: /^https:\/\/[^\s"'<>]+$/.test(url) ? url.slice(0, 400) : ''
398
+ };
399
+ return out.carrier || out.number || out.url ? out : null;
400
+ }
401
+
402
+ // ---- What a shopper may see of their order ------------------------------------
403
+
404
+ export function publicOrder(entry) {
405
+ const d = entry.data;
406
+ return {
407
+ id: entry.id,
408
+ number: d.number,
409
+ status: d.status,
410
+ currency: d.currency,
411
+ lines: d.lines.map(l => ({productId: l.productId, variantId: l.variantId || '', name: l.name, variantName: l.variantName, optionName: l.optionName, qty: l.qty,
412
+ unitPrice: l.unitPrice, lineTotal: l.lineTotal, image: l.image, slug: l.slug})),
413
+ subtotal: d.subtotal,
414
+ adjustments: (d.adjustments || []).map(a => ({stage: a.stage, label: a.label, amount: a.amount, included: Boolean(a.included)})),
415
+ total: d.total,
416
+ customer: {name: d.customer?.name || ''},
417
+ email: d.email,
418
+ address: d.address,
419
+ delivery: d.delivery,
420
+ provider: d.provider,
421
+ tracking: d.tracking || null,
422
+ placedAt: entry.meta?.createdAt,
423
+ paidAt: d.paidAt || null,
424
+ fulfilledAt: d.fulfilledAt || null
425
+ };
426
+ }
427
+
428
+ /** Does this token open this order's receipt? Constant-time. */
429
+ export function tokenOk(order, token) {
430
+ const a = Buffer.from(String(order?.data?.token || ''));
431
+ const b = Buffer.from(String(token || ''));
432
+ if (!a.length || a.length !== b.length) return false;
433
+ let diff = 0;
434
+ for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
435
+ return diff === 0;
436
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * The Shop's Overview figures and the sidebar badge. PURE: orders and
3
+ * products in, plain numbers and text out.
4
+ *
5
+ * Sales are orders that took money and kept it (paid or fulfilled), dated by
6
+ * when they were PAID - an order placed on Monday and paid on Tuesday is
7
+ * Tuesday's sale.
8
+ *
9
+ * @module shopping-cart/lib/summary
10
+ */
11
+
12
+ import {SOLD, orderLabel} from '../public/lib/orders.js';
13
+ import {formatMoney} from '../public/lib/money.js';
14
+ import {totalStock} from '../public/lib/products.js';
15
+
16
+ const DAY = 86400000;
17
+
18
+ /** "2026-09-26" in the server's time zone. */
19
+ export const dayKey = (t) => {
20
+ const d = new Date(t);
21
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
22
+ };
23
+
24
+ const soldAt = (o) => new Date(o.data.paidAt || o.meta?.createdAt).getTime();
25
+
26
+ /**
27
+ * @param {{id, data, meta}[]} orders
28
+ * @param {{id, data}[]} products
29
+ * @param {object} settings
30
+ * @param {number} [now]
31
+ */
32
+ export function summarise(orders, products, settings, now = Date.now()) {
33
+ const cur = settings.currency;
34
+ const sold = orders.filter(o => SOLD.has(o.data?.status));
35
+ const within = (days) => sold.filter(o => soldAt(o) >= now - days * DAY);
36
+ const sum = (list) => list.reduce((s, o) => s + (o.data.total || 0), 0);
37
+ const startToday = new Date(now); startToday.setHours(0, 0, 0, 0);
38
+
39
+ const today = sold.filter(o => soldAt(o) >= startToday.getTime());
40
+ const d30 = within(30);
41
+ const prev30 = sold.filter(o => soldAt(o) < now - 30 * DAY && soldAt(o) >= now - 60 * DAY);
42
+
43
+ // Thirty days of sales, oldest first, including the empty days.
44
+ const series = [];
45
+ const byDay = new Map();
46
+ for (const o of d30) {
47
+ const k = dayKey(soldAt(o));
48
+ const had = byDay.get(k) || {revenue: 0, orders: 0};
49
+ byDay.set(k, {revenue: had.revenue + (o.data.total || 0), orders: had.orders + 1});
50
+ }
51
+ for (let i = 29; i >= 0; i--) {
52
+ const k = dayKey(now - i * DAY);
53
+ series.push({day: k, ...(byDay.get(k) || {revenue: 0, orders: 0})});
54
+ }
55
+
56
+ const units = new Map();
57
+ for (const o of d30) {
58
+ for (const l of o.data.lines || []) {
59
+ const had = units.get(l.productId) || {productId: l.productId, name: l.name, qty: 0, revenue: 0};
60
+ had.qty += l.qty;
61
+ had.revenue += l.lineTotal;
62
+ units.set(l.productId, had);
63
+ }
64
+ }
65
+ const topProducts = [...units.values()].sort((a, b) => b.revenue - a.revenue).slice(0, 5);
66
+
67
+ const lowAt = Number(settings.lowStockAt) || 0;
68
+ const lowStock = products
69
+ .filter(p => p.data?.status === 'active' && p.data?.trackStock)
70
+ .map(p => ({id: p.id, name: p.data.name, stock: totalStock(p.data),
71
+ variants: (p.data.variants || []).filter(v => (Number(v.stock) || 0) <= lowAt).map(v => `${v.name}: ${v.stock ?? 0}`)}))
72
+ .filter(p => p.stock <= lowAt || p.variants.length)
73
+ .sort((a, b) => a.stock - b.stock);
74
+
75
+ const toFulfil = orders.filter(o => o.data?.status === 'paid');
76
+ const awaiting = orders.filter(o => o.data?.status === 'awaiting');
77
+ const abandoned = orders.filter(o => o.data?.status === 'pending' && new Date(o.meta?.createdAt).getTime() < now - 3600000
78
+ && new Date(o.meta?.createdAt).getTime() >= now - 7 * DAY);
79
+
80
+ const revenue30 = sum(d30);
81
+ const revenuePrev = sum(prev30);
82
+ return {
83
+ currency: cur,
84
+ today: {revenue: sum(today), orders: today.length},
85
+ days30: {revenue: revenue30, orders: d30.length, average: d30.length ? Math.round(revenue30 / d30.length) : 0,
86
+ change: revenuePrev ? Math.round((revenue30 - revenuePrev) / revenuePrev * 100) : null},
87
+ allTime: {revenue: sum(sold), orders: sold.length},
88
+ series,
89
+ topProducts,
90
+ lowStock: lowStock.slice(0, 10),
91
+ lowStockCount: lowStock.length,
92
+ toFulfil: toFulfil.length,
93
+ awaiting: awaiting.length,
94
+ abandoned: abandoned.length,
95
+ products: {total: products.length, active: products.filter(p => p.data?.status === 'active').length},
96
+ recent: orders.slice(0, 8).map(o => ({id: o.id, number: o.data.number, status: o.data.status, total: o.data.total,
97
+ name: o.data.customer?.name || o.data.email, at: o.meta?.createdAt, items: (o.data.lines || []).reduce((s, l) => s + l.qty, 0)}))
98
+ };
99
+ }
100
+
101
+ /** The sidebar badge: paid orders waiting to be sent. Details in the hover. */
102
+ export function badge(orders, settings, now = Date.now()) {
103
+ const cur = settings.currency;
104
+ const toFulfil = orders.filter(o => o.data?.status === 'paid')
105
+ .sort((a, b) => new Date(a.data.paidAt || a.meta?.createdAt) - new Date(b.data.paidAt || b.meta?.createdAt));
106
+ const awaiting = orders.filter(o => o.data?.status === 'awaiting').length;
107
+ const n = toFulfil.length;
108
+ const oldest = n ? now - new Date(toFulfil[0].data.paidAt || toFulfil[0].meta?.createdAt).getTime() : 0;
109
+ return {
110
+ count: n,
111
+ label: n ? `${n} order${n === 1 ? '' : 's'} to fulfil` : 'No orders waiting',
112
+ // Red only when something has waited more than two days.
113
+ ...(oldest > 2 * DAY ? {tone: 'danger'} : {}),
114
+ details: {
115
+ title: 'Shop',
116
+ rows: [
117
+ {label: 'To fulfil', value: String(n)},
118
+ ...(awaiting ? [{label: 'Awaiting payment', value: String(awaiting)}] : [])
119
+ ],
120
+ items: toFulfil.slice(0, 6).map(o => ({
121
+ text: `${orderLabel(o.data)} ${o.data.customer?.name || o.data.email || ''}`.trim(),
122
+ meta: formatMoney(o.data.total, cur)
123
+ })),
124
+ empty: 'Nothing to send.'
125
+ }
126
+ };
127
+ }