domma-cms 0.84.0 → 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.
- package/README.md +4 -4
- package/admin/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +4 -4
- package/admin/js/lib/themes.js +1 -1
- package/package.json +2 -2
- package/plugins/free-tier.lock.json +53 -5
- package/plugins/security/plugin.js +10 -0
- package/plugins/security/plugin.json +1 -1
- package/plugins/security/server/facts.js +3 -1
- package/plugins/shopping-cart/CLAUDE.md +93 -0
- package/plugins/shopping-cart/admin/css/index.css +224 -0
- package/plugins/shopping-cart/admin/lib/kit.js +190 -0
- package/plugins/shopping-cart/admin/templates/shop.html +1 -0
- package/plugins/shopping-cart/admin/views/orders.js +365 -0
- package/plugins/shopping-cart/admin/views/overview.js +214 -0
- package/plugins/shopping-cart/admin/views/products.js +547 -0
- package/plugins/shopping-cart/admin/views/settings.js +247 -0
- package/plugins/shopping-cart/admin/views/shop.js +130 -0
- package/plugins/shopping-cart/collections/shop-orders.json +20 -0
- package/plugins/shopping-cart/collections/shop-products.json +24 -0
- package/plugins/shopping-cart/config.js +52 -0
- package/plugins/shopping-cart/inject/body.html +2 -0
- package/plugins/shopping-cart/lib/extensions.js +96 -0
- package/plugins/shopping-cart/lib/payments.js +281 -0
- package/plugins/shopping-cart/lib/pricing.js +232 -0
- package/plugins/shopping-cart/lib/render.js +282 -0
- package/plugins/shopping-cart/lib/samples.js +34 -0
- package/plugins/shopping-cart/lib/shop.js +436 -0
- package/plugins/shopping-cart/lib/summary.js +127 -0
- package/plugins/shopping-cart/plugin.js +559 -0
- package/plugins/shopping-cart/plugin.json +52 -0
- package/plugins/shopping-cart/plugin.public.js +144 -0
- package/plugins/shopping-cart/public/cart.js +715 -0
- package/plugins/shopping-cart/public/lib/money.js +105 -0
- package/plugins/shopping-cart/public/lib/orders.js +122 -0
- package/plugins/shopping-cart/public/lib/products.js +235 -0
- package/plugins/shopping-cart/public/samples/candle-2.svg +16 -0
- package/plugins/shopping-cart/public/samples/candle.svg +16 -0
- package/plugins/shopping-cart/public/samples/mug-2.svg +14 -0
- package/plugins/shopping-cart/public/samples/mug.svg +14 -0
- package/plugins/shopping-cart/public/samples/notebook-2.svg +15 -0
- package/plugins/shopping-cart/public/samples/notebook.svg +15 -0
- package/plugins/shopping-cart/public/samples/print-2.svg +14 -0
- package/plugins/shopping-cart/public/samples/print.svg +14 -0
- package/plugins/shopping-cart/public/samples/tee-2.svg +12 -0
- package/plugins/shopping-cart/public/samples/tee.svg +12 -0
- package/plugins/shopping-cart/public/samples/tote-2.svg +13 -0
- package/plugins/shopping-cart/public/samples/tote.svg +13 -0
- package/plugins/shopping-cart/public/shop.css +415 -0
- package/plugins/shopping-cart/templates/shop.html +4 -0
- package/plugins/shopping-cart/tests/api.test.js +144 -0
- package/plugins/shopping-cart/tests/pure.test.js +210 -0
- 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