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,210 @@
1
+ import {test} from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {createHmac} from 'node:crypto';
4
+ import {formatMoney, fromMinor, includedTax, percentOf, toMinor} from '../public/lib/money.js';
5
+ import {available, cleanProduct, priceRange, publicProduct, slugify, unitPrice} from '../public/lib/products.js';
6
+ import {canMove, cleanCheckout} from '../public/lib/orders.js';
7
+ import {cleanLines, priceBasket} from '../lib/pricing.js';
8
+ import {paypalPaid, stripeForm, stripeLineItems, stripeSessionPaid, stripeVerify} from '../lib/payments.js';
9
+ import {badge, summarise} from '../lib/summary.js';
10
+ import {cartRegistry} from '../lib/extensions.js';
11
+ import {matchShopPath} from '../plugin.public.js';
12
+ import {cleanPath, cleanSettings, MASK, maskSettings} from '../lib/shop.js';
13
+ import defaults from '../config.js';
14
+
15
+ test('money: minor units in, formatted out', () => {
16
+ assert.equal(toMinor('19.99'), 1999);
17
+ assert.equal(toMinor('£1,299.50'), 129950);
18
+ assert.equal(toMinor(12), 1200);
19
+ assert.equal(toMinor('1000', 'JPY'), 1000);
20
+ for (const bad of ['', 'abc', '-3', null, '1.2.3']) assert.equal(toMinor(bad), null, String(bad));
21
+ assert.equal(fromMinor(1999), '19.99');
22
+ assert.equal(formatMoney(1999, 'GBP'), '£19.99');
23
+ assert.equal(formatMoney(2000, 'GBP', {compact: true}), '£20');
24
+ assert.equal(percentOf(1000, 20), 200);
25
+ assert.equal(includedTax(12000, 20), 2000);
26
+ });
27
+
28
+ test('products: cleaned, slugged, variants and stock', () => {
29
+ assert.equal(slugify('Blue Mug (Large) & Saucer!'), 'blue-mug-large-and-saucer');
30
+ const {data, errors} = cleanProduct({name: 'Tee', price: 2000, trackStock: true,
31
+ variants: [{name: 'S', stock: 2}, {name: 'M', stock: 0, price: 2200}, {name: 's'}]});
32
+ assert.ok(errors.variant2, 'a duplicate option name is refused');
33
+ assert.equal(data.slug, 'tee');
34
+ assert.equal(data.variants.length, 2);
35
+ const p = {...data};
36
+ assert.equal(unitPrice(p, p.variants[1].id), 2200);
37
+ assert.equal(unitPrice(p, p.variants[0].id), 2000);
38
+ assert.equal(available(p, p.variants[0].id), 2);
39
+ assert.equal(available(p, p.variants[1].id), 0);
40
+ assert.deepEqual(priceRange(p), {min: 2000, max: 2200});
41
+ assert.equal(available({trackStock: false}), Infinity);
42
+ const pub = publicProduct({id: 'x', data: p});
43
+ assert.equal(pub.variants[0].available, 2);
44
+ assert.equal(cleanProduct({name: '', price: 'x'}, {minor: false}).errors.price, 'Not a price.');
45
+ assert.equal(cleanProduct({name: 'A', price: '12.50'}, {minor: false}).data.price, 1250);
46
+ assert.equal(cleanProduct({name: 'A', price: 1000, compareAt: 900}).data.compareAt, null, 'a "was" price below the price is dropped');
47
+ assert.deepEqual(cleanProduct({name: 'A', price: 1, images: ['javascript:alert(1)', '/media/a.jpg']}).data.images, ['/media/a.jpg']);
48
+ });
49
+
50
+ test('orders: moves, checkout cleaning', () => {
51
+ assert.ok(canMove('paid', 'fulfilled'));
52
+ assert.ok(!canMove('cancelled', 'paid'));
53
+ assert.ok(!canMove('pending', 'refunded'));
54
+ const bad = cleanCheckout({customer: {name: '', email: 'nope'}, address: {country: 'FR'}}, {countries: ['GB']});
55
+ assert.ok(bad.errors.name && bad.errors.email && bad.errors.line1 && bad.errors.country);
56
+ const ok = cleanCheckout({customer: {name: 'Ann', email: 'Ann@Example.com'}}, {needsAddress: false});
57
+ assert.deepEqual(ok.errors, {});
58
+ assert.equal(ok.customer.email, 'ann@example.com');
59
+ assert.equal(ok.address, null);
60
+ });
61
+
62
+ const catalogue = new Map([
63
+ ['p1', {id: 'p1', data: {name: 'Mug', status: 'active', price: 1000, trackStock: true, stock: 3, variants: [], shippable: true}}],
64
+ ['p2', {id: 'p2', data: {name: 'Ebook', status: 'active', price: 500, trackStock: false, variants: [], shippable: false}}],
65
+ ['p3', {id: 'p3', data: {name: 'Old', status: 'archived', price: 100, variants: []}}]
66
+ ]);
67
+ const S = {...defaults, currency: 'GBP'};
68
+
69
+ test('pricing: stock caps quantities, archived products drop out', async () => {
70
+ const lines = cleanLines([{productId: 'p1', qty: 2}, {productId: 'p1', qty: 3}, {productId: 'p3', qty: 1}, {productId: '../x', qty: 1}, {productId: 'p2', qty: 0}]);
71
+ assert.deepEqual(lines.map(l => [l.productId, l.qty]), [['p1', 5], ['p3', 1]]);
72
+ const q = await priceBasket({lines, products: catalogue, settings: S});
73
+ assert.equal(q.lines.length, 1);
74
+ assert.equal(q.lines[0].qty, 3, 'only three left');
75
+ assert.equal(q.subtotal, 3000);
76
+ assert.equal(q.problems.length, 2);
77
+ });
78
+
79
+ test('pricing: flat delivery, free over a threshold, collection, nothing to ship', async () => {
80
+ const s = {...S, shippingFlat: 395, freeShippingOver: 2500, allowCollection: true};
81
+ let q = await priceBasket({lines: [{productId: 'p1', variantId: '', qty: 1}], products: catalogue, settings: s});
82
+ assert.equal(q.shipping, 395);
83
+ assert.equal(q.total, 1395);
84
+ q = await priceBasket({lines: [{productId: 'p1', variantId: '', qty: 3}], products: catalogue, settings: s});
85
+ assert.equal(q.shipping, 0);
86
+ assert.equal(q.adjustments[0].label, 'Free delivery');
87
+ q = await priceBasket({lines: [{productId: 'p1', variantId: '', qty: 1}], products: catalogue, settings: s, input: {delivery: 'collect'}});
88
+ assert.equal(q.shipping, 0);
89
+ q = await priceBasket({lines: [{productId: 'p2', variantId: '', qty: 1}], products: catalogue, settings: s});
90
+ assert.equal(q.requiresShipping, false);
91
+ assert.equal(q.adjustments.length, 0);
92
+ });
93
+
94
+ test('pricing: tax inclusive shows, exclusive adds', async () => {
95
+ const lines = [{productId: 'p1', variantId: '', qty: 1}];
96
+ let q = await priceBasket({lines, products: catalogue, settings: {...S, taxRate: 20, pricesIncludeTax: true, shippingFlat: 200}});
97
+ assert.equal(q.total, 1200);
98
+ assert.equal(q.tax, 200, 'the VAT inside £12');
99
+ assert.ok(q.taxIncluded);
100
+ q = await priceBasket({lines, products: catalogue, settings: {...S, taxRate: 10, pricesIncludeTax: false, taxShipping: false, shippingFlat: 200}});
101
+ assert.equal(q.tax, 100);
102
+ assert.equal(q.total, 1300);
103
+ });
104
+
105
+ test('pricing: an extension replaces a stage; a discount never goes below nothing; a failure falls back', async () => {
106
+ const lines = [{productId: 'p1', variantId: '', qty: 1}];
107
+ let q = await priceBasket({lines, products: catalogue, settings: S, handlers: {discount: () => [{label: 'Huge', amount: -99999, code: 'X'}]}});
108
+ assert.equal(q.discount, -1000);
109
+ assert.equal(q.total, 0);
110
+ q = await priceBasket({lines, products: catalogue, settings: {...S, shippingFlat: 300}, handlers: {shipping: () => { throw new Error('boom'); }}});
111
+ assert.equal(q.shipping, 300, 'the built-in rate stands in');
112
+ assert.equal(q.internalNotes.length, 1);
113
+ assert.equal(q.notes.length, 0, 'the shopper is not told about our fault');
114
+ });
115
+
116
+ test('registry: stages, tabs, and emit that never throws', async () => {
117
+ const reg = cartRegistry();
118
+ reg.register('test-ext', {stages: {discount: () => []}, tabs: [{key: 'x', label: 'X', module: '/plugins/test-ext/admin/x.js'}, {key: 'bad', module: 'https://evil/x.js'}],
119
+ on: {paid: () => { throw new Error('nope'); }}, checkout: {codes: true}});
120
+ assert.equal(typeof reg.handlers().discount, 'function');
121
+ assert.deepEqual(reg.tabs().map(t => t.key), ['x']);
122
+ assert.deepEqual(reg.checkoutFeatures(), {codes: true});
123
+ reg.emit('paid', {});
124
+ await new Promise(r => setTimeout(r, 5));
125
+ reg.unregister('test-ext');
126
+ assert.equal(reg.list().length, 0);
127
+ });
128
+
129
+ test('stripe: form encoding, itemising, signature, paid check', () => {
130
+ assert.equal(stripeForm({a: {b: [{c: 1}]}, d: 'x', e: null}).toString(), 'a%5Bb%5D%5B0%5D%5Bc%5D=1&d=x');
131
+ const order = {id: 'o1', number: 1001, currency: 'GBP', total: 1395,
132
+ lines: [{name: 'Mug', variantName: '', unitPrice: 1000, qty: 1, image: ''}],
133
+ adjustments: [{stage: 'shipping', label: 'Delivery', amount: 395}, {stage: 'tax', label: 'Incl VAT', amount: 232, included: true}]};
134
+ assert.equal(stripeLineItems(order).length, 2, 'the mug and delivery; included tax is not a line');
135
+ assert.equal(stripeLineItems({...order, adjustments: [{stage: 'discount', label: 'X', amount: -100}]}).length, 1, 'discounted: one line');
136
+ const body = '{"x":1}';
137
+ const t = 1_700_000_000;
138
+ const sig = createHmac('sha256', 'whsec_test').update(`${t}.${body}`).digest('hex');
139
+ assert.ok(stripeVerify(Buffer.from(body), `t=${t},v1=${sig}`, 'whsec_test', 300, t + 10));
140
+ assert.ok(!stripeVerify(Buffer.from(body), `t=${t},v1=${sig}`, 'whsec_test', 300, t + 1000), 'too old');
141
+ assert.ok(!stripeVerify(Buffer.from('{"x":2}'), `t=${t},v1=${sig}`, 'whsec_test', 300, t));
142
+ const session = {payment_status: 'paid', client_reference_id: 'o1', currency: 'gbp', amount_total: 1395, id: 'cs_1', payment_intent: 'pi_1'};
143
+ assert.deepEqual(stripeSessionPaid(session, order), {ref: 'cs_1', payment: 'pi_1'});
144
+ assert.equal(stripeSessionPaid({...session, amount_total: 1}, order), null, 'the wrong amount is not a payment');
145
+ });
146
+
147
+ test('paypal: only a completed capture of the right amount counts', () => {
148
+ const order = {id: 'o1', currency: 'GBP', total: 1395};
149
+ const pp = {id: 'PP1', status: 'COMPLETED', purchase_units: [{reference_id: 'o1', payments: {captures: [{id: 'CAP1', status: 'COMPLETED', amount: {currency_code: 'GBP', value: '13.95'}}]}}]};
150
+ assert.deepEqual(paypalPaid(pp, order), {ref: 'PP1', payment: 'CAP1'});
151
+ assert.equal(paypalPaid({...pp, status: 'APPROVED'}, order), null);
152
+ assert.equal(paypalPaid(pp, {...order, total: 2000}), null);
153
+ });
154
+
155
+ test('storefront addresses', () => {
156
+ assert.deepEqual(matchShopPath('/shop', '/shop'), {name: 'index', params: {}});
157
+ assert.equal(matchShopPath('/shop/cart', '/shop').name, 'cart');
158
+ assert.deepEqual(matchShopPath('/shop/blue-mug', '/shop'), {name: 'product', params: {slug: 'blue-mug'}});
159
+ assert.deepEqual(matchShopPath('/shop/order/0f8c3a2e-1111', '/shop'), {name: 'order', params: {id: '0f8c3a2e-1111'}});
160
+ assert.equal(matchShopPath('/shopping', '/shop'), null);
161
+ assert.equal(matchShopPath('/shop/a/b/c', '/shop'), null);
162
+ assert.equal(cleanPath('Store/'), '/store');
163
+ assert.equal(cleanPath('/admin'), '/shop', 'never over the admin');
164
+ });
165
+
166
+ test('settings: secrets masked, a mask keeps the stored value, bad keys refused', () => {
167
+ const current = {...defaults, stripeSecretKey: 'sk_test_abcdefghijklmnop'};
168
+ assert.equal(maskSettings(current).stripeSecretKey, MASK);
169
+ assert.equal(maskSettings(current).paypalSecret, '');
170
+ let r = cleanSettings({stripeSecretKey: MASK, taxRate: '20'}, current);
171
+ assert.equal(r.settings.stripeSecretKey, current.stripeSecretKey);
172
+ assert.equal(r.settings.taxRate, 20);
173
+ r = cleanSettings({stripeSecretKey: 'pk_test_wrong', stripeEnabled: true}, current);
174
+ assert.ok(r.errors.stripeSecretKey);
175
+ r = cleanSettings({paypalEnabled: true}, current);
176
+ assert.ok(r.errors.paypalEnabled);
177
+ });
178
+
179
+ test('summary and badge', () => {
180
+ const now = Date.parse('2026-09-26T12:00:00Z');
181
+ const o = (status, total, daysAgo, extra = {}) => ({id: `${status}${total}`, meta: {createdAt: new Date(now - daysAgo * 864e5).toISOString()},
182
+ data: {status, total, number: 1000 + total, paidAt: ['paid', 'fulfilled'].includes(status) ? new Date(now - daysAgo * 864e5).toISOString() : undefined,
183
+ lines: [{productId: 'p1', name: 'Mug', qty: 1, lineTotal: total}], customer: {name: 'Ann'}, ...extra}});
184
+ const orders = [o('paid', 500, 0), o('fulfilled', 1500, 3), o('pending', 900, 1), o('refunded', 700, 2), o('paid', 100, 40)];
185
+ const s = summarise(orders, [{id: 'p1', data: {status: 'active', trackStock: true, stock: 1, name: 'Mug'}}], {...S, lowStockAt: 3}, now);
186
+ assert.equal(s.days30.revenue, 2000);
187
+ assert.equal(s.days30.orders, 2);
188
+ assert.equal(s.toFulfil, 2);
189
+ assert.equal(s.abandoned, 1);
190
+ assert.equal(s.lowStockCount, 1);
191
+ assert.equal(s.series.length, 30);
192
+ const b = badge(orders, S, now);
193
+ assert.equal(b.count, 2);
194
+ assert.equal(b.tone, 'danger', 'one has waited 40 days');
195
+ });
196
+
197
+ test('pricing: a shipping handler can offer services to choose between', async () => {
198
+ const lines = [{productId: 'p1', variantId: '', qty: 1}];
199
+ const shipping = ({input}) => {
200
+ const choices = [{id: 'std', label: 'Standard', amount: 300}, {id: 'exp', label: 'Express', amount: 900}];
201
+ const pick = choices.find(c => c.id === input.service) || choices[0];
202
+ return {adjustments: [{label: pick.label, amount: pick.amount, ref: pick.id}], choices};
203
+ };
204
+ let q = await priceBasket({lines, products: catalogue, settings: S, handlers: {shipping}});
205
+ assert.deepEqual(q.shippingChoices.map(c => c.id), ['std', 'exp']);
206
+ assert.equal(q.shippingChoice, 'std');
207
+ q = await priceBasket({lines, products: catalogue, settings: S, input: {service: 'exp'}, handlers: {shipping}});
208
+ assert.equal(q.shipping, 900);
209
+ assert.equal(q.shippingChoice, 'exp');
210
+ });
package/scripts/setup.js CHANGED
@@ -46,6 +46,7 @@ const THEMES = [
46
46
  'unicorn-dark', 'unicorn-light',
47
47
  'dreamy-dark', 'dreamy-light',
48
48
  'wedding-dark', 'wedding-light',
49
+ 'corporate-dark', 'corporate-light',
49
50
  ];
50
51
 
51
52
  const SERVER_CONFIG = path.join(ROOT, 'config', 'server.json');