domma-cms 0.84.1 → 0.86.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 (70) 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/sidebar-renderer.js +3 -3
  5. package/admin/js/lib/themes.js +1 -1
  6. package/config/menus/admin-sidebar.json +21 -8
  7. package/config/plugins.json +0 -18
  8. package/package.json +2 -2
  9. package/plugins/_lib/mail/diagnostics.js +3 -2
  10. package/plugins/blog/CLAUDE.md +33 -0
  11. package/plugins/blog/lib/access.js +64 -0
  12. package/plugins/blog/lib/notices.js +38 -0
  13. package/plugins/blog/plugin.js +84 -26
  14. package/plugins/blog/plugin.json +173 -20
  15. package/plugins/blog/roles/blog-author.json +15 -2
  16. package/plugins/blog/tests/access.test.js +32 -0
  17. package/plugins/blog/tests/comment-notices.test.js +140 -0
  18. package/plugins/blog/tests/notices.test.js +20 -0
  19. package/plugins/blog/tests/public.test.js +3 -1
  20. package/plugins/free-tier.lock.json +63 -10
  21. package/plugins/mail-reader/plugin.js +12 -1
  22. package/plugins/mail-reader/plugin.json +23 -4
  23. package/plugins/shopping-cart/CLAUDE.md +93 -0
  24. package/plugins/shopping-cart/admin/css/index.css +224 -0
  25. package/plugins/shopping-cart/admin/lib/kit.js +190 -0
  26. package/plugins/shopping-cart/admin/templates/shop.html +1 -0
  27. package/plugins/shopping-cart/admin/views/orders.js +365 -0
  28. package/plugins/shopping-cart/admin/views/overview.js +214 -0
  29. package/plugins/shopping-cart/admin/views/products.js +547 -0
  30. package/plugins/shopping-cart/admin/views/settings.js +247 -0
  31. package/plugins/shopping-cart/admin/views/shop.js +130 -0
  32. package/plugins/shopping-cart/collections/shop-orders.json +20 -0
  33. package/plugins/shopping-cart/collections/shop-products.json +24 -0
  34. package/plugins/shopping-cart/config.js +52 -0
  35. package/plugins/shopping-cart/inject/body.html +2 -0
  36. package/plugins/shopping-cart/lib/extensions.js +96 -0
  37. package/plugins/shopping-cart/lib/payments.js +281 -0
  38. package/plugins/shopping-cart/lib/pricing.js +232 -0
  39. package/plugins/shopping-cart/lib/render.js +282 -0
  40. package/plugins/shopping-cart/lib/samples.js +34 -0
  41. package/plugins/shopping-cart/lib/shop.js +436 -0
  42. package/plugins/shopping-cart/lib/summary.js +127 -0
  43. package/plugins/shopping-cart/plugin.js +559 -0
  44. package/plugins/shopping-cart/plugin.json +52 -0
  45. package/plugins/shopping-cart/plugin.public.js +144 -0
  46. package/plugins/shopping-cart/public/cart.js +715 -0
  47. package/plugins/shopping-cart/public/lib/money.js +105 -0
  48. package/plugins/shopping-cart/public/lib/orders.js +122 -0
  49. package/plugins/shopping-cart/public/lib/products.js +235 -0
  50. package/plugins/shopping-cart/public/samples/candle-2.svg +16 -0
  51. package/plugins/shopping-cart/public/samples/candle.svg +16 -0
  52. package/plugins/shopping-cart/public/samples/mug-2.svg +14 -0
  53. package/plugins/shopping-cart/public/samples/mug.svg +14 -0
  54. package/plugins/shopping-cart/public/samples/notebook-2.svg +15 -0
  55. package/plugins/shopping-cart/public/samples/notebook.svg +15 -0
  56. package/plugins/shopping-cart/public/samples/print-2.svg +14 -0
  57. package/plugins/shopping-cart/public/samples/print.svg +14 -0
  58. package/plugins/shopping-cart/public/samples/tee-2.svg +12 -0
  59. package/plugins/shopping-cart/public/samples/tee.svg +12 -0
  60. package/plugins/shopping-cart/public/samples/tote-2.svg +13 -0
  61. package/plugins/shopping-cart/public/samples/tote.svg +13 -0
  62. package/plugins/shopping-cart/public/shop.css +415 -0
  63. package/plugins/shopping-cart/templates/shop.html +4 -0
  64. package/plugins/shopping-cart/tests/api.test.js +144 -0
  65. package/plugins/shopping-cart/tests/pure.test.js +210 -0
  66. package/scripts/setup.js +1 -0
  67. package/server/routes/api/notifications.js +4 -3
  68. package/server/services/notify.js +4 -2
  69. package/server/services/pluginScope.js +85 -0
  70. package/server/services/plugins.js +7 -2
@@ -0,0 +1,559 @@
1
+ /**
2
+ * Shopping Cart - the API (mounted at /api/plugins/shopping-cart).
3
+ *
4
+ * Admin (the `shop` permission; read to look, manage to change):
5
+ * GET /summary Overview figures
6
+ * GET /count sidebar badge: paid orders to fulfil
7
+ * GET /extensions admin tabs extensions (Pro) add
8
+ * GET /products · POST /products · PUT /products/:id · DELETE /products/:id
9
+ * POST /products/:id/duplicate · PUT /products/:id/stock
10
+ * GET /orders · GET /orders/:id · PUT /orders/:id/status · PUT /orders/:id/note
11
+ * POST /orders/:id/refund · DELETE /orders/:id
12
+ * GET /settings · PUT /settings · POST /settings/test/:provider
13
+ *
14
+ * Shoppers (no sign-in):
15
+ * GET /catalogue products on sale
16
+ * POST /quote the basket priced (the ONE pricing: lib/pricing.js)
17
+ * POST /checkout an order, and where to pay for it
18
+ * GET /order/:id?t= an order's receipt (token-gated), checking Stripe on the way
19
+ * GET /paypal/return PayPal sends the shopper back here; we capture
20
+ * POST /stripe/webhook Stripe's word that a session was paid
21
+ *
22
+ * Storefront pages are plugin.public.js.
23
+ */
24
+
25
+ import {getConfig} from '../../server/config.js';
26
+ import {cleanCheckout, orderLabel} from './public/lib/orders.js';
27
+ import {formatMoney} from './public/lib/money.js';
28
+ import {publicProduct, byShelf} from './public/lib/products.js';
29
+ import {cartRegistry} from './lib/extensions.js';
30
+ import {SAMPLES} from './lib/samples.js';
31
+ import {cleanLines, priceBasket} from './lib/pricing.js';
32
+ import {badge, summarise} from './lib/summary.js';
33
+ import * as pay from './lib/payments.js';
34
+ import {
35
+ NAME, allOrders, allProducts, annotateOrder, cleanSettings, cleanTracking, createOrder, ensureCollections, extendOrder,
36
+ getOrder, getProduct, liveSettings, markPaid, maskSettings, methodsOf, moveOrder, publicOrder, removeOrder,
37
+ refreshShelf, removeProduct, saveProduct, saveSettings, setProviderRef, settingsNow, shelfNow, tokenOk
38
+ } from './lib/shop.js';
39
+ import {cardHtml, esc, gridHtml} from './lib/render.js';
40
+
41
+ /** The origin shoppers last reached us on - for emails, which have no request. */
42
+ let lastOrigin = '';
43
+
44
+ /** Where this site lives, for the addresses a payment provider sends shoppers back to. */
45
+ function siteBase(request) {
46
+ let base = '';
47
+ try { base = String(getConfig('site')?.baseUrl || ''); } catch { /* no site config */ }
48
+ if (/^https?:\/\/[^/\s]+/i.test(base)) return base.replace(/\/+$/, '');
49
+ if (!request) return lastOrigin;
50
+ const origin = `${request.protocol}://${request.host || request.headers.host || request.hostname}`;
51
+ lastOrigin = origin;
52
+ return origin;
53
+ }
54
+
55
+ const receiptPath = (s, order) => `${s.shopPath}/order/${order.id}?t=${order.data.token}`;
56
+
57
+ /** A problem for the shopper, sent as JSON with a status. */
58
+ const fail = (reply, status, error, extra = {}) => reply.code(status).send({error, ...extra});
59
+
60
+ /** The quote, from a request body, at today's prices. */
61
+ async function quoteFor(body, settings) {
62
+ const lines = cleanLines(body?.lines);
63
+ const ids = new Set(lines.map(l => l.productId));
64
+ const products = new Map();
65
+ for (const e of await allProducts()) if (ids.has(e.id)) products.set(e.id, e);
66
+ const input = {
67
+ code: String(body?.code || '').trim().slice(0, 60),
68
+ country: String(body?.address?.country || body?.country || settings.countries?.[0] || '').toUpperCase().slice(0, 2),
69
+ region: String(body?.address?.region || '').slice(0, 100),
70
+ postcode: String(body?.address?.postcode || '').slice(0, 20),
71
+ delivery: body?.delivery === 'collect' ? 'collect' : 'ship',
72
+ service: String(body?.service || '').slice(0, 40),
73
+ email: String(body?.customer?.email || '').slice(0, 200)
74
+ };
75
+ const quote = await priceBasket({lines, products, settings, input, handlers: cartRegistry().handlers()});
76
+ return {quote, input};
77
+ }
78
+
79
+ /** What the storefront shows of a quote. */
80
+ function shopperQuote(q, settings) {
81
+ return {
82
+ currency: q.currency, lines: q.lines, problems: q.problems, notes: q.notes, subtotal: q.subtotal,
83
+ adjustments: q.adjustments, total: q.total, taxIncluded: q.taxIncluded, requiresShipping: q.requiresShipping,
84
+ itemCount: q.itemCount, shippingChoices: q.shippingChoices, shippingChoice: q.shippingChoice, methods: methodsOf(settings).map(m => ({id: m.id, label: m.label})),
85
+ features: {collection: Boolean(settings.allowCollection), collectionLabel: settings.collectionLabel, ...cartRegistry().checkoutFeatures()}
86
+ };
87
+ }
88
+
89
+ /**
90
+ * Sell from any page:
91
+ * [shop-products category="Mugs" limit="8" columns="4" featured="true" /]
92
+ * [shop-product slug="blue-mug" /]
93
+ * [buy-button slug="blue-mug" label="Buy now" /]
94
+ * Rendered from the products as last read (shortcodes run synchronously).
95
+ */
96
+ function registerShortcodes(hooks) {
97
+ if (typeof hooks.registerShortcode !== 'function') return;
98
+ const onSale = () => shelfNow().filter(e => e.data?.status === 'active').sort(byShelf);
99
+
100
+ hooks.registerShortcode('shop-products', (attrStr, _body, ctx) => {
101
+ const a = ctx.parseShortcodeAttrs(attrStr || '');
102
+ const s = settingsNow();
103
+ let list = onSale();
104
+ if (a.category) list = list.filter(e => String(e.data.category).toLowerCase() === String(a.category).toLowerCase());
105
+ if (a.tag) list = list.filter(e => (e.data.tags || []).includes(String(a.tag).toLowerCase()));
106
+ if (a.featured === 'true' || a.featured === true) list = list.filter(e => e.data.featured);
107
+ const limit = Math.max(1, Math.min(48, Number(a.limit) || 8));
108
+ list = list.slice(0, limit);
109
+ if (!list.length) return '<p class="shop-muted">Nothing on sale here yet.</p>';
110
+ return `<div class="shop-embed">${gridHtml(list, s, s.shopPath, {columns: a.columns})}</div>`;
111
+ });
112
+
113
+ hooks.registerShortcode('shop-product', (attrStr, _body, ctx) => {
114
+ const a = ctx.parseShortcodeAttrs(attrStr || '');
115
+ const s = settingsNow();
116
+ const e = onSale().find(x => x.data.slug === String(a.slug || ''));
117
+ if (!e) return '';
118
+ return `<div class="shop-embed is-single">${cardHtml(e, s, s.shopPath)}</div>`;
119
+ });
120
+
121
+ hooks.registerShortcode('buy-button', (attrStr, _body, ctx) => {
122
+ const a = ctx.parseShortcodeAttrs(attrStr || '');
123
+ const s = settingsNow();
124
+ const e = onSale().find(x => x.data.slug === String(a.slug || ''));
125
+ if (!e) return '';
126
+ const label = String(a.label || 'Add to basket').slice(0, 60);
127
+ if (e.data.variants?.length) {
128
+ return `<a class="shop-buy-button" href="${esc(`${s.shopPath}/${e.data.slug}`)}">${esc(label)}</a>`;
129
+ }
130
+ return `<button type="button" class="shop-buy-button" data-shop-add data-id="${esc(e.id)}" data-name="${esc(e.data.name)}" data-image="${esc(e.data.images?.[0] || '')}">${esc(label)} · ${esc(formatMoney(e.data.price, s.currency, {compact: true}))}</button>`;
131
+ });
132
+ }
133
+
134
+ export default async function shoppingCart(fastify, options) {
135
+ const {authenticate, requirePermission} = options.auth;
136
+ const hooks = options.hooks || {};
137
+ const can = (action) => ({preHandler: [authenticate, requirePermission('shop', action)]});
138
+ const registry = cartRegistry();
139
+
140
+ await ensureCollections();
141
+ await refreshShelf();
142
+ registerShortcodes(hooks);
143
+
144
+ // ---- Telling people ------------------------------------------------------------
145
+
146
+ function announcePaid(order) {
147
+ const d = order.data;
148
+ try {
149
+ hooks.notify?.({
150
+ title: `New order ${orderLabel(d)} - ${formatMoney(d.total, d.currency)}`,
151
+ body: `${d.customer?.name || d.email}: ${(d.lines || []).map(l => `${l.qty} × ${l.name}`).join(', ')}`.slice(0, 300),
152
+ severity: 'success',
153
+ link: `#/plugins/shopping-cart/orders/${order.id}`,
154
+ source: 'orders',
155
+ dedupeKey: `order-paid:${order.id}`
156
+ });
157
+ } catch { /* a notice never breaks a sale */ }
158
+ registry.emit('paid', order);
159
+ }
160
+
161
+ function announceMove(order, from, to) {
162
+ if (to === 'paid' && from !== 'fulfilled') announcePaid(order);
163
+ registry.emit('status', order, from, to);
164
+ }
165
+
166
+ if (typeof hooks.registerNotificationSource === 'function') {
167
+ try {
168
+ hooks.registerNotificationSource({id: 'orders', label: 'Shop orders', severity: 'success', defaultOn: true,
169
+ description: 'A new paid order in the Shop.'});
170
+ } catch { /* older engine */ }
171
+ }
172
+
173
+ // What extensions may use: the Cart's own data, read the Cart's way.
174
+ registry.services = {
175
+ name: NAME,
176
+ liveSettings, allOrders, getOrder, allProducts, getProduct, extendOrder, publicOrder,
177
+ formatMoney, orderLabel,
178
+ siteBase: () => siteBase(null),
179
+ receiptUrl: (order) => liveSettings().then(s => `${siteBase(null)}${receiptPath(s, order)}`)
180
+ };
181
+
182
+ // ---- Admin: overview, badge, extensions ----------------------------------------
183
+
184
+ fastify.get('/summary', can('read'), async () => {
185
+ const [orders, products, settings] = await Promise.all([allOrders(), allProducts(), liveSettings()]);
186
+ return {
187
+ ...summarise(orders, products, settings),
188
+ methods: methodsOf(settings).map(m => m.id),
189
+ shopPath: settings.shopPath,
190
+ shopTitle: settings.shopTitle
191
+ };
192
+ });
193
+
194
+ fastify.get('/count', can('read'), async () => {
195
+ const [orders, settings] = await Promise.all([allOrders(), liveSettings()]);
196
+ return badge(orders, settings);
197
+ });
198
+
199
+ fastify.get('/extensions', can('read'), async () => ({
200
+ tabs: registry.tabs(),
201
+ extensions: registry.list().map(e => ({owner: e.owner, label: e.label}))
202
+ }));
203
+
204
+ // ---- Admin: products -------------------------------------------------------------
205
+
206
+ fastify.get('/products', can('read'), async () => {
207
+ const products = await allProducts();
208
+ return products.sort(byShelf);
209
+ });
210
+
211
+ fastify.post('/products', can('manage'), async (request, reply) => {
212
+ const s = await liveSettings();
213
+ const r = await saveProduct(request.body || {}, {currency: s.currency, by: request.user?.id});
214
+ if (r.errors) return fail(reply, 400, Object.values(r.errors)[0], {errors: r.errors});
215
+ return reply.code(201).send(r.entry);
216
+ });
217
+
218
+ fastify.put('/products/:id', can('manage'), async (request, reply) => {
219
+ const s = await liveSettings();
220
+ const r = await saveProduct(request.body || {}, {id: request.params.id, currency: s.currency});
221
+ if (r.notFound) return fail(reply, 404, 'That product is gone.');
222
+ if (r.errors) return fail(reply, 400, Object.values(r.errors)[0], {errors: r.errors});
223
+ return r.entry;
224
+ });
225
+
226
+ /** Quick edits from the list: stock, status, featured - merged over the product. */
227
+ fastify.patch('/products/:id', can('manage'), async (request, reply) => {
228
+ const had = await getProduct(request.params.id);
229
+ if (!had) return fail(reply, 404, 'That product is gone.');
230
+ const b = request.body || {};
231
+ const patch = {};
232
+ for (const k of ['status', 'featured', 'stock', 'trackStock', 'position', 'variants']) if (b[k] !== undefined) patch[k] = b[k];
233
+ const s = await liveSettings();
234
+ const r = await saveProduct({...had.data, ...patch}, {id: had.id, currency: s.currency});
235
+ if (r.errors) return fail(reply, 400, Object.values(r.errors)[0], {errors: r.errors});
236
+ return r.entry;
237
+ });
238
+
239
+ fastify.post('/products/:id/duplicate', can('manage'), async (request, reply) => {
240
+ const had = await getProduct(request.params.id);
241
+ if (!had) return fail(reply, 404, 'That product is gone.');
242
+ const s = await liveSettings();
243
+ const r = await saveProduct({...had.data, name: `${had.data.name} (copy)`, slug: '', status: 'draft',
244
+ variants: (had.data.variants || []).map(v => ({...v, id: ''}))}, {currency: s.currency, by: request.user?.id});
245
+ if (r.errors) return fail(reply, 400, Object.values(r.errors)[0]);
246
+ return reply.code(201).send(r.entry);
247
+ });
248
+
249
+ /** The sample products, added as drafts or on sale (`live`). */
250
+ fastify.post('/samples', can('manage'), async (request, reply) => {
251
+ const s = await liveSettings();
252
+ const live = request.body?.live !== false;
253
+ const made = [];
254
+ for (const p of SAMPLES) {
255
+ const r = await saveProduct({...p, status: live ? 'active' : 'draft'}, {currency: s.currency, by: request.user?.id});
256
+ if (r.entry) made.push(r.entry);
257
+ }
258
+ return reply.code(201).send({count: made.length});
259
+ });
260
+
261
+ fastify.delete('/products/:id', can('manage'), async (request, reply) => {
262
+ const had = await getProduct(request.params.id);
263
+ if (!had) return fail(reply, 404, 'That product is gone.');
264
+ await removeProduct(had.id);
265
+ return {ok: true};
266
+ });
267
+
268
+ // ---- Admin: orders ---------------------------------------------------------------
269
+
270
+ fastify.get('/orders', can('read'), async () => allOrders());
271
+
272
+ fastify.get('/orders/:id', can('read'), async (request, reply) => {
273
+ const o = await getOrder(request.params.id);
274
+ return o || fail(reply, 404, 'No such order.');
275
+ });
276
+
277
+ fastify.put('/orders/:id/status', can('manage'), async (request, reply) => {
278
+ const {status, note = '', restock = false, tracking} = request.body || {};
279
+ try {
280
+ const {order, from} = await moveOrder(request.params.id, String(status), {
281
+ by: request.user?.name || request.user?.email || 'admin', note, restock: Boolean(restock),
282
+ tracking: cleanTracking(tracking)
283
+ });
284
+ announceMove(order, from, order.data.status);
285
+ return order;
286
+ } catch (err) {
287
+ return fail(reply, err.status || 500, err.message);
288
+ }
289
+ });
290
+
291
+ fastify.put('/orders/:id/note', can('manage'), async (request, reply) => {
292
+ const b = request.body || {};
293
+ try {
294
+ return await annotateOrder(request.params.id, {
295
+ ...(b.staffNote !== undefined ? {staffNote: b.staffNote} : {}),
296
+ ...(b.tracking !== undefined ? {tracking: cleanTracking(b.tracking)} : {})
297
+ });
298
+ } catch (err) {
299
+ return fail(reply, err.status || 500, err.message);
300
+ }
301
+ });
302
+
303
+ /**
304
+ * Refund an order in full. With Stripe or PayPal the money goes back
305
+ * through them first; only when they say yes is the order marked refunded.
306
+ * An offline order is marked only - the money goes back the way it came.
307
+ */
308
+ fastify.post('/orders/:id/refund', can('manage'), async (request, reply) => {
309
+ const o = await getOrder(request.params.id);
310
+ if (!o) return fail(reply, 404, 'No such order.');
311
+ const d = o.data;
312
+ if (!['paid', 'fulfilled'].includes(d.status)) return fail(reply, 409, 'Only a paid order can be refunded.');
313
+ const s = await liveSettings();
314
+ const {restock = false, note = '', viaProvider = true} = request.body || {};
315
+ let how = 'marked refunded';
316
+ try {
317
+ if (viaProvider && d.provider === 'stripe' && d.paymentRef) {
318
+ if (!s.stripeSecretKey) return fail(reply, 409, 'Stripe is not set up any more - refund it in Stripe, then mark it refunded here.');
319
+ await pay.stripeRefund({secretKey: s.stripeSecretKey, paymentIntent: d.paymentRef});
320
+ how = 'refunded through Stripe';
321
+ } else if (viaProvider && d.provider === 'paypal' && d.paymentRef) {
322
+ if (!s.paypalClientId || !s.paypalSecret) return fail(reply, 409, 'PayPal is not set up any more - refund it in PayPal, then mark it refunded here.');
323
+ await pay.paypalRefund({creds: {clientId: s.paypalClientId, secret: s.paypalSecret, mode: s.paypalMode}, captureId: d.paymentRef});
324
+ how = 'refunded through PayPal';
325
+ }
326
+ } catch (err) {
327
+ return fail(reply, err.status || 502, `Not refunded: ${err.message}`);
328
+ }
329
+ try {
330
+ const {order, from} = await moveOrder(o.id, 'refunded', {by: request.user?.name || request.user?.email || 'admin',
331
+ note: [how, String(note || '')].filter(Boolean).join(' - '), restock: Boolean(restock)});
332
+ announceMove(order, from, 'refunded');
333
+ return {order, how};
334
+ } catch (err) {
335
+ return fail(reply, err.status || 500, err.message);
336
+ }
337
+ });
338
+
339
+ fastify.delete('/orders/:id', can('manage'), async (request, reply) => {
340
+ try {
341
+ await removeOrder(request.params.id);
342
+ return {ok: true};
343
+ } catch (err) {
344
+ return fail(reply, err.status || 500, err.message);
345
+ }
346
+ });
347
+
348
+ // ---- Admin: settings ---------------------------------------------------------------
349
+
350
+ fastify.get('/settings', can('read'), async (request) => {
351
+ const s = await liveSettings();
352
+ return {
353
+ settings: maskSettings(s),
354
+ webhookUrl: `${siteBase(request)}/api/plugins/shopping-cart/stripe/webhook`,
355
+ stripeMode: pay.stripeMode(s.stripeSecretKey)
356
+ };
357
+ });
358
+
359
+ fastify.put('/settings', can('manage'), async (request, reply) => {
360
+ const current = await liveSettings();
361
+ const {settings, errors} = cleanSettings(request.body || {}, current);
362
+ if (Object.keys(errors).length) return fail(reply, 400, Object.values(errors)[0], {errors});
363
+ await saveSettings(settings);
364
+ return {settings: maskSettings(await liveSettings())};
365
+ });
366
+
367
+ /** Try the stored keys (or the ones typed, before saving). */
368
+ fastify.post('/settings/test/:provider', can('manage'), async (request, reply) => {
369
+ const s = await liveSettings();
370
+ const b = request.body || {};
371
+ const typed = (k) => (b[k] && b[k] !== '••••••••' ? String(b[k]).trim() : s[k]);
372
+ try {
373
+ if (request.params.provider === 'stripe') {
374
+ const key = typed('stripeSecretKey');
375
+ if (!key) return fail(reply, 400, 'Enter the secret key first.');
376
+ return await pay.stripeTest({secretKey: key});
377
+ }
378
+ if (request.params.provider === 'paypal') {
379
+ const creds = {clientId: typed('paypalClientId'), secret: typed('paypalSecret'), mode: b.paypalMode || s.paypalMode};
380
+ if (!creds.clientId || !creds.secret) return fail(reply, 400, 'Enter the client id and secret first.');
381
+ return await pay.paypalTest({creds});
382
+ }
383
+ return fail(reply, 404, 'No such provider.');
384
+ } catch (err) {
385
+ return fail(reply, 400, err.message);
386
+ }
387
+ });
388
+
389
+ // ---- Shoppers --------------------------------------------------------------------
390
+
391
+ fastify.get('/catalogue', async (request) => {
392
+ const s = await liveSettings();
393
+ const products = (await allProducts()).filter(e => e.data?.status === 'active').sort(byShelf).map(publicProduct);
394
+ return {currency: s.currency, shopPath: s.shopPath, products};
395
+ });
396
+
397
+ fastify.post('/quote', async (request) => {
398
+ siteBase(request);
399
+ const s = await liveSettings();
400
+ const {quote} = await quoteFor(request.body, s);
401
+ return shopperQuote(quote, s);
402
+ });
403
+
404
+ fastify.post('/checkout', async (request, reply) => {
405
+ const s = await liveSettings();
406
+ const body = request.body || {};
407
+ const {quote, input} = await quoteFor(body, s);
408
+ if (!quote.lines.length) return fail(reply, 400, 'Your basket is empty.', {quote: shopperQuote(quote, s)});
409
+ // Anything that changed the basket (sold out, fewer left, a code refused)
410
+ // goes back to the shopper to see BEFORE they pay.
411
+ if (quote.problems.length && !body.accept) {
412
+ return fail(reply, 409, quote.problems.map(p => p.message).join(' '), {quote: shopperQuote(quote, s)});
413
+ }
414
+ if (Number.isInteger(body.expectTotal) && body.expectTotal !== quote.total) {
415
+ return fail(reply, 409, `The total is now ${formatMoney(quote.total, quote.currency)}. Please check it and pay again.`, {quote: shopperQuote(quote, s)});
416
+ }
417
+ const collecting = input.delivery === 'collect' && s.allowCollection;
418
+ const checkout = cleanCheckout(body, {needsAddress: quote.requiresShipping && !collecting, countries: s.countries});
419
+ if (Object.keys(checkout.errors).length) return fail(reply, 400, Object.values(checkout.errors)[0], {errors: checkout.errors});
420
+
421
+ const methods = methodsOf(s).map(m => m.id);
422
+ const provider = quote.total === 0 ? 'free' : String(body.provider || '');
423
+ if (provider !== 'free' && !methods.includes(provider)) return fail(reply, 400, 'Choose how to pay.');
424
+
425
+ const order = await createOrder({quote, checkout, provider, delivery: collecting ? 'collect' : (quote.requiresShipping ? 'ship' : 'none'), code: input.code});
426
+ registry.emit('checkout', order);
427
+ const base = siteBase(request);
428
+ const receipt = `${base}${receiptPath(s, order)}`;
429
+
430
+ try {
431
+ if (provider === 'free') {
432
+ await markPaid(order.id, {by: 'free', note: 'Nothing to pay'}).then(r => r.changed && announcePaid(r.order));
433
+ return {redirect: receipt, orderId: order.id};
434
+ }
435
+ if (provider === 'offline') {
436
+ const {order: moved} = await moveOrder(order.id, 'awaiting', {by: 'shopper', note: s.offlineLabel});
437
+ registry.emit('status', moved, 'pending', 'awaiting');
438
+ try {
439
+ hooks.notify?.({title: `Order ${orderLabel(moved.data)} awaiting payment - ${formatMoney(moved.data.total, moved.data.currency)}`,
440
+ body: `${moved.data.customer?.name}: ${s.offlineLabel}`, severity: 'info', source: 'orders',
441
+ link: `#/plugins/shopping-cart/orders/${order.id}`, dedupeKey: `order-awaiting:${order.id}`});
442
+ } catch { /* never mind */ }
443
+ return {redirect: receipt, orderId: order.id};
444
+ }
445
+ if (provider === 'stripe') {
446
+ const session = await pay.stripeCreateSession({
447
+ secretKey: s.stripeSecretKey,
448
+ order: {id: order.id, ...order.data},
449
+ successUrl: `${receipt}&session_id={CHECKOUT_SESSION_ID}`,
450
+ cancelUrl: `${base}${s.shopPath}/cart?cancelled=${order.id}`,
451
+ shopName: s.shopTitle
452
+ });
453
+ await setProviderRef(order.id, session.id);
454
+ return {redirect: session.url, orderId: order.id};
455
+ }
456
+ if (provider === 'paypal') {
457
+ const pp = await pay.paypalCreateOrder({
458
+ creds: {clientId: s.paypalClientId, secret: s.paypalSecret, mode: s.paypalMode},
459
+ order: {id: order.id, ...order.data},
460
+ returnUrl: `${base}/api/plugins/shopping-cart/paypal/return?order=${order.id}&t=${order.data.token}`,
461
+ cancelUrl: `${base}${s.shopPath}/cart?cancelled=${order.id}`,
462
+ shopName: s.shopTitle
463
+ });
464
+ await setProviderRef(order.id, pp.id);
465
+ return {redirect: pp.url, orderId: order.id};
466
+ }
467
+ } catch (err) {
468
+ request.log.warn(`[shopping-cart] checkout ${order.data.number}: ${err.message}`);
469
+ return fail(reply, 502, `The payment page could not be opened: ${err.message.replace(/^(Stripe|PayPal): /, '')}. Nothing has been charged.`);
470
+ }
471
+ return fail(reply, 400, 'Choose how to pay.');
472
+ });
473
+
474
+ /**
475
+ * An order's receipt, for the page and for its polling. A pending Stripe
476
+ * order is checked with Stripe on the way: the shopper's return is the
477
+ * usual way we learn it was paid.
478
+ */
479
+ fastify.get('/order/:id', async (request, reply) => {
480
+ let o = await getOrder(request.params.id);
481
+ if (!o || !tokenOk(o, request.query?.t)) return fail(reply, 404, 'No such order.');
482
+ o = await confirmStripe(o, request.query?.session_id, request.log);
483
+ return publicOrder(o);
484
+ });
485
+
486
+ /** Ask Stripe about a pending order's session; mark it paid when Stripe says so. */
487
+ async function confirmStripe(o, sessionId, log) {
488
+ if (o.data.status !== 'pending' || o.data.provider !== 'stripe') return o;
489
+ const s = await liveSettings();
490
+ const id = sessionId && sessionId === o.data.providerRef ? sessionId : o.data.providerRef;
491
+ if (!id || !s.stripeSecretKey) return o;
492
+ try {
493
+ const session = await pay.stripeGetSession({secretKey: s.stripeSecretKey, sessionId: id});
494
+ const paid = pay.stripeSessionPaid(session, {id: o.id, ...o.data});
495
+ if (!paid) return o;
496
+ const r = await markPaid(o.id, {provider: 'stripe', ...paid, by: 'stripe'});
497
+ if (r.changed) announcePaid(r.order);
498
+ return r.order;
499
+ } catch (err) {
500
+ log?.warn?.(`[shopping-cart] Stripe check for ${o.data.number}: ${err.message}`);
501
+ return o;
502
+ }
503
+ }
504
+ registry.services.confirmStripe = confirmStripe;
505
+
506
+ fastify.get('/paypal/return', async (request, reply) => {
507
+ const {order: id, t, token} = request.query || {};
508
+ const o = await getOrder(String(id || ''));
509
+ const s = await liveSettings();
510
+ if (!o || !tokenOk(o, t)) return reply.redirect(`${s.shopPath}/cart`);
511
+ const receipt = receiptPath(s, o);
512
+ if (o.data.status !== 'pending' || o.data.provider !== 'paypal') return reply.redirect(receipt);
513
+ if (token !== o.data.providerRef) return reply.redirect(`${receipt}&problem=paypal`);
514
+ try {
515
+ const pp = await pay.paypalCapture({creds: {clientId: s.paypalClientId, secret: s.paypalSecret, mode: s.paypalMode}, paypalOrderId: token});
516
+ const paid = pay.paypalPaid(pp, {id: o.id, ...o.data});
517
+ if (!paid) return reply.redirect(`${receipt}&problem=paypal`);
518
+ const r = await markPaid(o.id, {provider: 'paypal', ...paid, by: 'paypal'});
519
+ if (r.changed) announcePaid(r.order);
520
+ return reply.redirect(receipt);
521
+ } catch (err) {
522
+ request.log.warn(`[shopping-cart] PayPal capture for ${o.data.number}: ${err.message}`);
523
+ return reply.redirect(`${receipt}&problem=paypal`);
524
+ }
525
+ });
526
+
527
+ // Stripe signs the RAW body, so this one route keeps it raw.
528
+ fastify.register(async function stripeWebhook(scope) {
529
+ scope.addContentTypeParser('application/json', {parseAs: 'buffer', bodyLimit: 1048576}, (_req, body, done) => done(null, body));
530
+ scope.post('/stripe/webhook', async (request, reply) => {
531
+ const s = await liveSettings();
532
+ if (!s.stripeWebhookSecret) return fail(reply, 503, 'Webhook signing secret not set.');
533
+ if (!pay.stripeVerify(request.body, request.headers['stripe-signature'], s.stripeWebhookSecret)) {
534
+ return fail(reply, 400, 'Bad signature.');
535
+ }
536
+ let event;
537
+ try { event = JSON.parse(request.body.toString('utf8')); } catch { return fail(reply, 400, 'Not JSON.'); }
538
+ if (!['checkout.session.completed', 'checkout.session.async_payment_succeeded'].includes(event?.type)) return {received: true};
539
+ const session = event.data?.object || {};
540
+ const id = session.client_reference_id || session.metadata?.orderId;
541
+ const o = id ? await getOrder(String(id)) : null;
542
+ if (!o) return {received: true};
543
+ const paid = pay.stripeSessionPaid(session, {id: o.id, ...o.data});
544
+ if (paid) {
545
+ const r = await markPaid(o.id, {provider: 'stripe', ...paid, by: 'stripe'});
546
+ if (r.changed) announcePaid(r.order);
547
+ }
548
+ return {received: true};
549
+ });
550
+ });
551
+ }
552
+
553
+ export async function onEnable() {
554
+ await ensureCollections();
555
+ }
556
+
557
+ export async function onDisable() {
558
+ console.log('[shopping-cart] Disabled. Products and orders are kept.');
559
+ }
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "shopping-cart",
3
+ "displayName": "Shopping Cart",
4
+ "version": "1.0.0",
5
+ "tier": "free",
6
+ "description": "Sell from your site: products with options, stock and pictures, a storefront and a cart that follows shoppers from page to page, and checkout with Stripe, PayPal or payment offline. Orders to fulfil in the sidebar, refunds from the order.",
7
+ "author": "Domma CMS",
8
+ "date": "2026-09-26",
9
+ "icon": "shopping-bag",
10
+ "permissions": [
11
+ {
12
+ "key": "shop",
13
+ "label": "Shop",
14
+ "group": "Plugins",
15
+ "icon": "shopping-bag",
16
+ "description": "See the Shop's orders and products (read) or run it: products, orders, refunds and settings (manage).",
17
+ "actions": [
18
+ {"key": "read", "label": "See", "description": "Open the Shop and read its orders and products"},
19
+ {"key": "manage", "label": "Manage", "description": "Edit products, move and refund orders, change the Shop's settings"}
20
+ ],
21
+ "grant": ["admin"]
22
+ }
23
+ ],
24
+ "inject": {
25
+ "bodyEnd": "inject/body.html"
26
+ },
27
+ "admin": {
28
+ "css": ["admin/css/index.css"],
29
+ "sidebar": [
30
+ {
31
+ "id": "shopping-cart",
32
+ "text": "Shop",
33
+ "icon": "shopping-bag",
34
+ "url": "#/plugins/shopping-cart",
35
+ "section": "#/plugins/shopping-cart",
36
+ "permission": "shop",
37
+ "countUrl": "/api/plugins/shopping-cart/count"
38
+ }
39
+ ],
40
+ "routes": [
41
+ {"path": "/plugins/shopping-cart", "view": "plugin-shopping-cart", "title": "Shop - Domma CMS"},
42
+ {"path": "/plugins/shopping-cart/:tab", "view": "plugin-shopping-cart", "title": "Shop - Domma CMS"},
43
+ {"path": "/plugins/shopping-cart/:tab/:id", "view": "plugin-shopping-cart", "title": "Shop - Domma CMS"}
44
+ ],
45
+ "views": {
46
+ "plugin-shopping-cart": {
47
+ "entry": "shopping-cart/admin/views/shop.js?v=1.0.0",
48
+ "exportName": "shopView"
49
+ }
50
+ }
51
+ }
52
+ }