domma-cms 1.6.34 → 1.6.35
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/admin/js/api.js +1 -1
- package/admin/js/lib/effects-builder.js +1 -1
- package/admin/js/lib/pages-arrange.js +1 -1
- package/admin/js/lib/scribe-composer.js +1 -1
- package/admin/js/lib/scribe-model.js +1 -1
- package/admin/js/lib/shortcode-preview.js +1 -1
- package/admin/js/lib/shortcode-registry.js +2 -2
- package/admin/js/templates/docs/usage-site-settings.html +2 -1
- package/admin/js/templates/page-editor.html +2 -1
- package/admin/js/templates/pages.html +1 -1
- package/admin/js/templates/theme.html +1 -1
- package/admin/js/views/pages.js +3 -3
- package/admin/js/views/theme.js +5 -5
- package/package.json +1 -1
- package/plugins/_lib/outreach/audience.js +76 -0
- package/plugins/_lib/outreach/collections/outreach-contacts.schema.json +12 -0
- package/plugins/_lib/outreach/collections/outreach-groups.schema.json +10 -0
- package/plugins/_lib/outreach/collections/outreach-unsubscribes.schema.json +11 -0
- package/plugins/_lib/outreach/expiry.js +46 -0
- package/plugins/_lib/outreach/index.js +15 -0
- package/plugins/_lib/outreach/ledger.js +127 -0
- package/plugins/_lib/outreach/sending.js +121 -0
- package/plugins/_lib/outreach/suppression.js +62 -0
- package/plugins/_lib/outreach/tokens.js +35 -0
- package/plugins/_lib/outreach/unsubscribe.js +46 -0
- package/plugins/free-tier.lock.json +33 -22
- package/plugins/shopping-cart/CLAUDE.md +45 -0
- package/plugins/shopping-cart/admin/css/index.css +16 -0
- package/plugins/shopping-cart/admin/lib/chart.js +64 -0
- package/plugins/shopping-cart/admin/lib/kit.js +15 -0
- package/plugins/shopping-cart/admin/views/orders.js +1 -0
- package/plugins/shopping-cart/admin/views/overview.js +4 -53
- package/plugins/shopping-cart/admin/views/sales.js +133 -0
- package/plugins/shopping-cart/admin/views/settings.js +3 -1
- package/plugins/shopping-cart/admin/views/shop.js +2 -1
- package/plugins/shopping-cart/config.js +1 -0
- package/plugins/shopping-cart/docs/guide.md +25 -0
- package/plugins/shopping-cart/inject/body.html +2 -2
- package/plugins/shopping-cart/lib/accounts.js +180 -0
- package/plugins/shopping-cart/lib/commission.js +90 -0
- package/plugins/shopping-cart/lib/extensions.js +12 -4
- package/plugins/shopping-cart/lib/render.js +13 -1
- package/plugins/shopping-cart/lib/sales.js +193 -0
- package/plugins/shopping-cart/lib/shop.js +13 -0
- package/plugins/shopping-cart/plugin.js +110 -1
- package/plugins/shopping-cart/plugin.json +2 -2
- package/plugins/shopping-cart/plugin.public.js +17 -1
- package/plugins/shopping-cart/public/account.js +277 -0
- package/plugins/shopping-cart/public/cart.js +16 -0
- package/plugins/shopping-cart/public/lib/session.js +82 -0
- package/plugins/shopping-cart/public/shop.css +27 -0
- package/plugins/shopping-cart/roles/shop-customer.json +8 -0
- package/plugins/shopping-cart/tests/accounts.test.js +127 -0
- package/plugins/shopping-cart/tests/api.test.js +11 -0
- package/plugins/shopping-cart/tests/commission.test.js +90 -0
- package/plugins/shopping-cart/tests/sales.test.js +73 -0
- package/public/css/site.css +1 -1
- package/public/js/effects.js +1 -1
- package/public/js/scribe-style.mjs +1 -1
- package/public/js/site.js +1 -1
- package/public/js/text-style.mjs +1 -1
- package/server/routes/api/theme.js +12 -2
- package/server/routes/outreach-public.js +68 -0
- package/server/server.js +5 -0
- package/server/services/markdown.js +7 -2
- package/server/services/outreachUnsubscribe.js +54 -0
- package/server/templates/page.html +3 -3
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform commission: the pure rules, then a real sale with config/commission.json written as the manager would
|
|
3
|
+
* (in the domma-cms checkout `make test` runs in - removed again after).
|
|
4
|
+
*/
|
|
5
|
+
import {test, before, after} from 'node:test';
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import fs from 'node:fs/promises';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import Fastify from 'fastify';
|
|
10
|
+
import shoppingCart from '../plugin.js';
|
|
11
|
+
import {cleanRates, commissionFor, orderType, rateFor, rateText} from '../lib/commission.js';
|
|
12
|
+
|
|
13
|
+
const FILE = path.resolve('config/commission.json');
|
|
14
|
+
const PLUGINS = path.resolve('config/plugins.json');
|
|
15
|
+
const DIRS = ['shop-products', 'shop-orders'].map(c => path.resolve('content/collections', c));
|
|
16
|
+
let savedFile;
|
|
17
|
+
let savedPlugins;
|
|
18
|
+
let app;
|
|
19
|
+
|
|
20
|
+
before(async () => {
|
|
21
|
+
savedFile = await fs.readFile(FILE, 'utf8').catch(() => null);
|
|
22
|
+
savedPlugins = await fs.readFile(PLUGINS, 'utf8').catch(() => null);
|
|
23
|
+
for (const d of DIRS) await fs.rm(d, {recursive: true, force: true});
|
|
24
|
+
delete globalThis[Symbol.for('domma.shopping-cart')];
|
|
25
|
+
app = Fastify();
|
|
26
|
+
await app.register(shoppingCart, {auth: {authenticate: async (req) => { req.user = {id: 'a', role: 'admin'}; }, requirePermission: () => async () => {}}, settings: {}, hooks: {registerShortcode() {}}});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
after(async () => {
|
|
30
|
+
await app?.close();
|
|
31
|
+
for (const d of DIRS) await fs.rm(d, {recursive: true, force: true});
|
|
32
|
+
if (savedFile === null) await fs.rm(FILE, {force: true}); else await fs.writeFile(FILE, savedFile);
|
|
33
|
+
if (savedPlugins === null) await fs.rm(PLUGINS, {force: true}); else await fs.writeFile(PLUGINS, savedPlugins);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const call = async (method, url, body) => {
|
|
37
|
+
const res = await app.inject({method, url, payload: body});
|
|
38
|
+
let json = null;
|
|
39
|
+
try { json = res.json(); } catch { /* not JSON */ }
|
|
40
|
+
return {status: res.statusCode, body: json, text: res.body};
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
test('commission: rates cleaned, the kind of sale, per-sale amounts', () => {
|
|
44
|
+
const {rates} = cleanRates({rates: {default: {percent: 2, fixed: 20}, takeaway: {percent: '3.5'}, shop: {percent: -1}, junk: {percent: 9}}});
|
|
45
|
+
assert.deepEqual(rates, {default: {percent: 2, fixed: 20}, takeaway: {percent: 3.5, fixed: 0}});
|
|
46
|
+
assert.equal(orderType({data: {ref: 'takeaway:x'}}), 'takeaway');
|
|
47
|
+
assert.equal(orderType({data: {ref: 'purchase-1'}}), 'addons');
|
|
48
|
+
assert.equal(orderType({data: {}}), 'shop');
|
|
49
|
+
assert.deepEqual(rateFor('shop', rates), {percent: 2, fixed: 20}, 'no rate of its own: the default');
|
|
50
|
+
assert.deepEqual(commissionFor({data: {total: 2150}}, rates, 'T'), {type: 'shop', base: 2150, percent: 2, fixed: 20, amount: 43 + 20, at: 'T'});
|
|
51
|
+
assert.equal(commissionFor({data: {total: 2150, ref: 'takeaway:x'}}, rates).amount, 75, '3.5% of £21.50, rounded');
|
|
52
|
+
assert.equal(commissionFor({data: {total: 0}}, rates), null, 'a free order carries none');
|
|
53
|
+
assert.equal(commissionFor({data: {total: 10}}, rates).amount, 10, 'never more than the sale');
|
|
54
|
+
assert.equal(commissionFor({data: {total: 1000}}, {}), null, 'no rates: none');
|
|
55
|
+
assert.equal(rateText({percent: 2, fixed: 20}), '2% + £0.20 per sale');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('commission: stored on the sale when paid, at that day\'s rate; returned with a refund; in Sales and the CSV', async () => {
|
|
59
|
+
await call('PUT', '/settings', {offlineEnabled: true, countries: ['GB']});
|
|
60
|
+
const mug = (await call('POST', '/products', {name: 'Mug', price: 1000, status: 'active', shippable: false})).body;
|
|
61
|
+
const buy = async () => (await call('POST', '/checkout', {lines: [{productId: mug.id, qty: 2}], provider: 'offline', customer: {name: 'Ann', email: 'ann@example.test'}})).body.orderId;
|
|
62
|
+
|
|
63
|
+
// No file yet: a sale carries no commission.
|
|
64
|
+
let id = await buy();
|
|
65
|
+
let o = (await call('PUT', `/orders/${id}/status`, {status: 'paid'})).body;
|
|
66
|
+
assert.equal(o.data.commission, undefined);
|
|
67
|
+
|
|
68
|
+
// The manager writes the rate; the next sale has it (no restart).
|
|
69
|
+
await fs.writeFile(FILE, JSON.stringify({rates: {default: {percent: 5, fixed: 10}}, updatedAt: '2026-10-12T00:00:00Z', by: 'super'}));
|
|
70
|
+
id = await buy();
|
|
71
|
+
o = (await call('PUT', `/orders/${id}/status`, {status: 'paid'})).body;
|
|
72
|
+
assert.deepEqual([o.data.commission.amount, o.data.commission.percent, o.data.commission.type], [110, 5, 'shop']);
|
|
73
|
+
// A later rate leaves it as it was.
|
|
74
|
+
await new Promise(r => setTimeout(r, 15));
|
|
75
|
+
await fs.writeFile(FILE, JSON.stringify({rates: {default: {percent: 50}}}));
|
|
76
|
+
o = (await call('PUT', `/orders/${id}/status`, {status: 'fulfilled'})).body;
|
|
77
|
+
assert.equal(o.data.commission.amount, 110);
|
|
78
|
+
const s = (await call('GET', '/settings')).body.commission;
|
|
79
|
+
assert.deepEqual([s.set, s.rates[0].text], [true, '50% per sale']);
|
|
80
|
+
// Refunded: returned.
|
|
81
|
+
o = (await call('POST', `/orders/${id}/refund`, {})).body.order;
|
|
82
|
+
assert.deepEqual([o.data.commission.reversed, o.data.commission.amount], [true, 110]);
|
|
83
|
+
const r = (await call('GET', '/sales?range=year')).body;
|
|
84
|
+
assert.deepEqual([r.totals.commission, r.totals.commissionBack, r.totals.commissionNet], [110, 110, 0]);
|
|
85
|
+
const csv = (await call('GET', '/sales.csv?range=year')).text;
|
|
86
|
+
assert.ok(csv.split('\r\n')[0].endsWith('Commission,Commission rate'));
|
|
87
|
+
// The customer never sees it.
|
|
88
|
+
const receipt = (await call('GET', `/order/${id}?t=${o.data.token}`)).body;
|
|
89
|
+
assert.ok(!('commission' in receipt));
|
|
90
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import {test} from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {dateIn, periodOf, periodsBetween, rangeFor, salesCsv, salesReport, sourceOf} from '../lib/sales.js';
|
|
4
|
+
|
|
5
|
+
const order = (n, {status = 'paid', paidAt, refundedAt, ref = '', provider = 'stripe', lines, adjustments = [], delivery = 'none', ext} = {}) => {
|
|
6
|
+
const subtotal = lines.reduce((s, l) => s + l.lineTotal, 0);
|
|
7
|
+
const total = subtotal + adjustments.filter(a => !a.included).reduce((s, a) => s + a.amount, 0);
|
|
8
|
+
return {id: `o${n}`, meta: {createdAt: paidAt}, data: {number: 1000 + n, status, paidAt, refundedAt, ref, provider, lines, adjustments, subtotal, total, delivery, ext,
|
|
9
|
+
customer: {name: `Customer ${n}`}}};
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const ORDERS = [
|
|
13
|
+
// A Shop order with delivery and a discount, paid late on 1 Oct UK time (still 1 Oct in London, 23:30 BST = 22:30Z).
|
|
14
|
+
order(1, {paidAt: '2026-10-01T22:30:00Z', lines: [{productId: 'mug', qty: 2, lineTotal: 2000}],
|
|
15
|
+
adjustments: [{stage: 'discount', amount: -200}, {stage: 'shipping', amount: 395}, {stage: 'tax', amount: 366, included: true}]}),
|
|
16
|
+
// Just after midnight UK time on 2 Oct (23:10Z on 1 Oct): a London date of 2 Oct.
|
|
17
|
+
order(2, {paidAt: '2026-10-01T23:10:00Z', provider: 'offline', lines: [{productId: 'ebook', qty: 1, lineTotal: 900}],
|
|
18
|
+
ext: {'shopping-cart-pro': {downloads: [{productId: 'ebook'}]}}}),
|
|
19
|
+
// Takeaway, delivered.
|
|
20
|
+
order(3, {paidAt: '2026-10-03T18:00:00Z', ref: 'takeaway:abc', delivery: 'ship', lines: [{productId: '', custom: true, qty: 1, lineTotal: 1900}],
|
|
21
|
+
adjustments: [{stage: 'shipping', amount: 250}]}),
|
|
22
|
+
// Paid 4 Oct, refunded 9 Oct.
|
|
23
|
+
order(4, {status: 'refunded', paidAt: '2026-10-04T12:00:00Z', refundedAt: '2026-10-09T09:00:00Z', lines: [{productId: 'mug', qty: 1, lineTotal: 1000}]}),
|
|
24
|
+
// Never paid: not a sale.
|
|
25
|
+
order(5, {status: 'pending', paidAt: '2026-10-05T12:00:00Z', lines: [{productId: 'mug', qty: 1, lineTotal: 1000}]}),
|
|
26
|
+
// Another add-on's sale (Cards Pro credits).
|
|
27
|
+
order(6, {paidAt: '2026-10-06T12:00:00Z', ref: 'purchase-9', provider: 'free', lines: [{productId: 'credits', qty: 1, lineTotal: 0}]})
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
test('sales: dates in the shop\'s zone, periods, ranges', () => {
|
|
31
|
+
assert.equal(dateIn('2026-10-01T23:10:00Z', 'Europe/London'), '2026-10-02');
|
|
32
|
+
assert.equal(dateIn('2026-10-01T23:10:00Z', 'UTC'), '2026-10-01');
|
|
33
|
+
assert.equal(periodOf('2026-10-08', 'week'), '2026-10-05', 'weeks start on Monday');
|
|
34
|
+
assert.equal(periodOf('2026-10-08', 'month'), '2026-10-01');
|
|
35
|
+
assert.deepEqual(periodsBetween('2026-09-28', '2026-10-12', 'week'), ['2026-09-28', '2026-10-05', '2026-10-12']);
|
|
36
|
+
assert.deepEqual(periodsBetween('2026-11-15', '2027-01-02', 'month'), ['2026-11-01', '2026-12-01', '2027-01-01']);
|
|
37
|
+
assert.deepEqual(rangeFor('lastmonth', '2026-10-12'), {from: '2026-09-01', to: '2026-09-30'});
|
|
38
|
+
assert.deepEqual(rangeFor('week', '2026-10-12'), {from: '2026-10-12', to: '2026-10-12'});
|
|
39
|
+
assert.deepEqual(rangeFor('last7', '2026-10-12'), {from: '2026-10-06', to: '2026-10-12'});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('sales: sources, figures, refunds on their own day, payment methods', () => {
|
|
43
|
+
assert.deepEqual(ORDERS.slice(0, 3).map(o => sourceOf(o, o.data.lines[0])), ['shop', 'downloads', 'takeaway']);
|
|
44
|
+
assert.equal(sourceOf(ORDERS[5], ORDERS[5].data.lines[0]), 'addons');
|
|
45
|
+
const r = salesReport(ORDERS, {from: '2026-10-01', to: '2026-10-12', by: 'day'});
|
|
46
|
+
assert.equal(r.totals.orders, 5, 'the unpaid one is not a sale');
|
|
47
|
+
assert.equal(r.totals.takings, 2195 + 900 + 2150 + 1000 + 0);
|
|
48
|
+
assert.deepEqual([r.totals.discount, r.totals.delivery, r.totals.taxIncluded, r.totals.refunds, r.totals.refundCount], [200, 645, 366, 1000, 1]);
|
|
49
|
+
assert.equal(r.totals.net, r.totals.takings - 1000);
|
|
50
|
+
const src = Object.fromEntries(r.sources.map(s => [s.key, s.amount]));
|
|
51
|
+
assert.deepEqual(src, {shop: 3000, downloads: 900, takeaway: 1900, addons: 0});
|
|
52
|
+
assert.deepEqual(r.takeaway, {collect: 0, deliver: 1});
|
|
53
|
+
assert.equal(r.methods[0].key, 'stripe');
|
|
54
|
+
const day = (d) => r.periods.find(p => p.period === d);
|
|
55
|
+
assert.equal(day('2026-10-01').takings, 2195);
|
|
56
|
+
assert.equal(day('2026-10-02').takings, 900, 'paid after midnight in London');
|
|
57
|
+
assert.equal(day('2026-10-09').refunds, 1000);
|
|
58
|
+
assert.equal(r.periods.length, 12, 'empty days are there too');
|
|
59
|
+
// A range that holds only the refund: no sale, the refund still counts.
|
|
60
|
+
const later = salesReport(ORDERS, {from: '2026-10-08', to: '2026-10-10'});
|
|
61
|
+
assert.deepEqual([later.totals.orders, later.totals.refunds, later.totals.net], [0, 1000, -1000]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('sales: the CSV, one row per order, quoted where it must be', () => {
|
|
65
|
+
const csv = salesCsv([...ORDERS, order(7, {paidAt: '2026-10-07T12:00:00Z', lines: [{productId: 'x', qty: 1, lineTotal: 500}]})].map(o => (o.id === 'o7' ? {...o, data: {...o.data, customer: {name: 'Smith, "Jo"'}}} : o)),
|
|
66
|
+
{from: '2026-10-01', to: '2026-10-12'});
|
|
67
|
+
const rows = csv.trim().split('\r\n');
|
|
68
|
+
assert.equal(rows[0].split(',')[0], 'Order');
|
|
69
|
+
assert.equal(rows.length, 1 + 6);
|
|
70
|
+
assert.ok(rows.some(r => r.startsWith('#1001,2026-10-01,paid,,Shop,Card (Stripe),Customer 1,20.00,2.00,3.95,0.00,3.66,21.95')));
|
|
71
|
+
assert.ok(rows.some(r => r.includes('"Smith, ""Jo"""')));
|
|
72
|
+
assert.ok(rows.some(r => r.startsWith('#1004,2026-10-04,refunded,2026-10-09')));
|
|
73
|
+
});
|