domma-cms 1.6.34 → 1.6.36

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 (106) hide show
  1. package/admin/js/api.js +1 -1
  2. package/admin/js/lib/effects-builder.js +1 -1
  3. package/admin/js/lib/pages-arrange.js +1 -1
  4. package/admin/js/lib/scribe-composer.js +1 -1
  5. package/admin/js/lib/scribe-model.js +1 -1
  6. package/admin/js/lib/shortcode-preview.js +1 -1
  7. package/admin/js/lib/shortcode-registry.js +2 -2
  8. package/admin/js/templates/docs/usage-site-settings.html +2 -1
  9. package/admin/js/templates/page-editor.html +2 -1
  10. package/admin/js/templates/pages.html +1 -1
  11. package/admin/js/templates/theme.html +1 -1
  12. package/admin/js/views/pages.js +3 -3
  13. package/admin/js/views/theme.js +5 -5
  14. package/package.json +1 -1
  15. package/plugins/_lib/call/ice.js +5 -2
  16. package/plugins/_lib/call/rooms.js +9 -3
  17. package/plugins/_lib/call/routes.js +7 -4
  18. package/plugins/_lib/mail/defaults.js +12 -0
  19. package/plugins/_lib/mail/hostGuard.js +86 -0
  20. package/plugins/_lib/mail/readRoutes.js +20 -1
  21. package/plugins/_lib/outreach/audience.js +76 -0
  22. package/plugins/_lib/outreach/collections/outreach-contacts.schema.json +12 -0
  23. package/plugins/_lib/outreach/collections/outreach-groups.schema.json +10 -0
  24. package/plugins/_lib/outreach/collections/outreach-unsubscribes.schema.json +11 -0
  25. package/plugins/_lib/outreach/expiry.js +46 -0
  26. package/plugins/_lib/outreach/index.js +15 -0
  27. package/plugins/_lib/outreach/ledger.js +127 -0
  28. package/plugins/_lib/outreach/sending.js +121 -0
  29. package/plugins/_lib/outreach/suppression.js +62 -0
  30. package/plugins/_lib/outreach/tokens.js +35 -0
  31. package/plugins/_lib/outreach/unsubscribe.js +46 -0
  32. package/plugins/_lib/public/call/call.js +3 -1
  33. package/plugins/blog/CLAUDE.md +11 -1
  34. package/plugins/blog/docs/guide.md +1 -1
  35. package/plugins/blog/lib/page.js +32 -0
  36. package/plugins/blog/plugin.js +58 -16
  37. package/plugins/blog/plugin.json +2 -2
  38. package/plugins/blog/plugin.public.js +5 -4
  39. package/plugins/blog/tests/public.test.js +30 -1
  40. package/plugins/booking/CLAUDE.md +4 -1
  41. package/plugins/booking/docs/guide.md +1 -1
  42. package/plugins/booking/plugin.js +16 -21
  43. package/plugins/booking/plugin.json +2 -2
  44. package/plugins/booking/tests/api.test.js +26 -1
  45. package/plugins/feedback/CLAUDE.md +7 -2
  46. package/plugins/feedback/admin/views/feedback.js +1 -1
  47. package/plugins/feedback/lib/receiver.js +1 -1
  48. package/plugins/feedback/plugin.json +3 -3
  49. package/plugins/free-tier.lock.json +63 -51
  50. package/plugins/mail-reader/CLAUDE.md +6 -0
  51. package/plugins/mail-reader/docs/guide.md +1 -0
  52. package/plugins/mail-reader/plugin.json +2 -2
  53. package/plugins/shopping-cart/CLAUDE.md +62 -1
  54. package/plugins/shopping-cart/admin/css/index.css +16 -0
  55. package/plugins/shopping-cart/admin/lib/chart.js +64 -0
  56. package/plugins/shopping-cart/admin/lib/kit.js +15 -0
  57. package/plugins/shopping-cart/admin/views/orders.js +1 -0
  58. package/plugins/shopping-cart/admin/views/overview.js +4 -53
  59. package/plugins/shopping-cart/admin/views/sales.js +133 -0
  60. package/plugins/shopping-cart/admin/views/settings.js +3 -1
  61. package/plugins/shopping-cart/admin/views/shop.js +2 -1
  62. package/plugins/shopping-cart/config.js +1 -0
  63. package/plugins/shopping-cart/docs/guide.md +26 -1
  64. package/plugins/shopping-cart/inject/body.html +2 -2
  65. package/plugins/shopping-cart/lib/accounts.js +185 -0
  66. package/plugins/shopping-cart/lib/commission.js +90 -0
  67. package/plugins/shopping-cart/lib/extensions.js +12 -4
  68. package/plugins/shopping-cart/lib/payments.js +7 -4
  69. package/plugins/shopping-cart/lib/render.js +13 -1
  70. package/plugins/shopping-cart/lib/sales.js +193 -0
  71. package/plugins/shopping-cart/lib/shop.js +21 -3
  72. package/plugins/shopping-cart/plugin.js +180 -35
  73. package/plugins/shopping-cart/plugin.json +3 -3
  74. package/plugins/shopping-cart/plugin.public.js +17 -1
  75. package/plugins/shopping-cart/public/account.js +277 -0
  76. package/plugins/shopping-cart/public/cart.js +16 -0
  77. package/plugins/shopping-cart/public/lib/session.js +82 -0
  78. package/plugins/shopping-cart/public/shop.css +27 -0
  79. package/plugins/shopping-cart/roles/shop-customer.json +8 -0
  80. package/plugins/shopping-cart/tests/accounts.test.js +136 -0
  81. package/plugins/shopping-cart/tests/api.test.js +34 -0
  82. package/plugins/shopping-cart/tests/commission.test.js +90 -0
  83. package/plugins/shopping-cart/tests/pure.test.js +16 -1
  84. package/plugins/shopping-cart/tests/sales.test.js +73 -0
  85. package/plugins/support/CLAUDE.md +6 -0
  86. package/plugins/support/admin/views/support.js +1 -1
  87. package/plugins/support/docs/guide.md +90 -0
  88. package/plugins/support/lib/receiver.js +1 -1
  89. package/plugins/support/plugin.json +3 -3
  90. package/public/css/site.css +1 -1
  91. package/public/js/effects.js +1 -1
  92. package/public/js/scribe-style.mjs +1 -1
  93. package/public/js/site.js +1 -1
  94. package/public/js/text-style.mjs +1 -1
  95. package/server/routes/api/analytics.js +32 -0
  96. package/server/routes/api/collections.js +148 -15
  97. package/server/routes/api/theme.js +12 -2
  98. package/server/routes/outreach-public.js +68 -0
  99. package/server/server.js +10 -1
  100. package/server/services/authChecks.js +8 -0
  101. package/server/services/collectionAccess.js +191 -0
  102. package/server/services/collections.js +8 -2
  103. package/server/services/markdown.js +7 -2
  104. package/server/services/outreachUnsubscribe.js +54 -0
  105. package/server/services/request-log.js +70 -0
  106. package/server/templates/page.html +3 -3
@@ -291,3 +291,37 @@ test('an extension checks out lines it priced, with a delivery fee and address;
291
291
  reg.unregister('test-takeaway');
292
292
  }
293
293
  });
294
+
295
+ test('sales: the report and its CSV over the API', async () => {
296
+ const r = await call('GET', '/sales?range=year&by=month');
297
+ assert.equal(r.status, 200, JSON.stringify(r.body));
298
+ assert.ok(r.body.totals && Array.isArray(r.body.periods) && r.body.periods.length >= 1);
299
+ assert.ok(r.body.totals.orders >= 1, 'the earlier tests sold things');
300
+ const csv = await app.inject({method: 'GET', url: '/sales.csv?from=2020-01-01&to=2030-12-31'});
301
+ assert.equal(csv.statusCode, 200);
302
+ assert.match(csv.headers['content-disposition'], /attachment; filename="sales-2020-01-01-to-2030-12-31\.csv"/);
303
+ assert.ok(csv.body.startsWith('Order,Paid,Status'));
304
+ });
305
+
306
+ test('review: links for emails never come from a request\'s Host header; two refunds at once have one winner', async () => {
307
+ const reg = globalThis[Symbol.for('domma.shopping-cart')];
308
+ await call('PUT', '/settings', {offlineEnabled: true, countries: ['GB'], taxRate: 0, shippingFlat: 0});
309
+ const pen = (await call('POST', '/products', {name: 'Pen', price: 500, status: 'active', shippable: false})).body;
310
+ // A forged Host on a public request must not become the address in anyone's email.
311
+ const forged = await app.inject({method: 'POST', url: '/quote', headers: {host: 'evil.example'}, payload: {lines: [{productId: pen.id, qty: 1}]}});
312
+ assert.equal(forged.statusCode, 200);
313
+ assert.equal(String(reg.services.siteBase()).includes('evil.example'), false);
314
+ const placed = await call('POST', '/checkout', {lines: [{productId: pen.id, qty: 1}], provider: 'offline', customer: {name: 'Eve', email: 'eve@example.com'}});
315
+ assert.equal(placed.status, 200, JSON.stringify(placed.body));
316
+ const order = await reg.services.getOrder(placed.body.orderId);
317
+ const receipt = await reg.services.receiptUrl(order);
318
+ assert.equal(receipt.includes('evil.example'), false, `receipt link: ${receipt}`);
319
+ assert.equal((await reg.services.linkBase()).includes('evil.example'), false);
320
+ if (receipt) assert.match(receipt, /^https?:\/\/[^/]+\/shop\/order\//);
321
+
322
+ // Paid, then refunded twice in the same instant: one refund, one "only a paid order" refusal.
323
+ await call('PUT', `/orders/${order.id}/status`, {status: 'paid'});
324
+ const both = await Promise.all([call('POST', `/orders/${order.id}/refund`, {}), call('POST', `/orders/${order.id}/refund`, {})]);
325
+ assert.deepEqual(both.map(r => r.status).sort(), [200, 409], JSON.stringify(both.map(r => r.body)));
326
+ assert.equal((await reg.services.getOrder(order.id)).data.history.filter(h => h.status === 'refunded').length, 1);
327
+ });
@@ -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
+ });
@@ -5,7 +5,7 @@ import {formatMoney, fromMinor, includedTax, percentOf, toMinor} from '../public
5
5
  import {available, cleanProduct, priceRange, publicProduct, slugify, unitPrice} from '../public/lib/products.js';
6
6
  import {canMove, cleanCheckout} from '../public/lib/orders.js';
7
7
  import {cleanCharges, cleanCustomLines, cleanLines, priceBasket} from '../lib/pricing.js';
8
- import {paypalPaid, stripeForm, stripeLineItems, stripeSessionPaid, stripeVerify} from '../lib/payments.js';
8
+ import {paypalPaid, stripeCreateSession, stripeForm, stripeLineItems, stripeRefund, stripeSessionPaid, stripeVerify} from '../lib/payments.js';
9
9
  import {badge, summarise} from '../lib/summary.js';
10
10
  import {cartRegistry} from '../lib/extensions.js';
11
11
  import {matchShopPath} from '../plugin.public.js';
@@ -298,3 +298,18 @@ test('pricing: a shipping handler can offer services to choose between', async (
298
298
  assert.equal(q.shipping, 900);
299
299
  assert.equal(q.shippingChoice, 'exp');
300
300
  });
301
+
302
+ test('payments: Stripe refunds and sessions carry an Idempotency-Key, so a retry after a timeout is not a second one', async () => {
303
+ const seen = [];
304
+ const realFetch = globalThis.fetch;
305
+ globalThis.fetch = async (url, init) => { seen.push({url, init}); return {ok: true, json: async () => ({id: 'cs_test_1', url: 'https://pay.test/x'})}; };
306
+ try {
307
+ await stripeRefund({secretKey: 'sk_test_x', paymentIntent: 'pi_1', idempotencyKey: 'shopping-cart-refund-o1'});
308
+ assert.equal(seen[0].init.headers['Idempotency-Key'], 'shopping-cart-refund-o1');
309
+ await stripeRefund({secretKey: 'sk_test_x', paymentIntent: 'pi_1'});
310
+ assert.equal('Idempotency-Key' in seen[1].init.headers, false, 'none asked for, none sent');
311
+ await stripeCreateSession({secretKey: 'sk_test_x', order: {id: 'o1', number: 1001, currency: 'GBP', total: 100, lines: [{name: 'Mug', qty: 1, unitPrice: 100, lineTotal: 100}], adjustments: []},
312
+ successUrl: 'https://s.test/ok', cancelUrl: 'https://s.test/no'});
313
+ assert.equal(seen[2].init.headers['Idempotency-Key'], 'shop-session-o1');
314
+ } finally { globalThis.fetch = realFetch; }
315
+ });
@@ -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
+ });
@@ -76,3 +76,9 @@ manager also "Quote in reply").
76
76
 
77
77
  Site: new replies (`info`), else open requests as an inventory. Manager: requests waiting on Domma,
78
78
  `danger` while any is urgent, `attention` otherwise; nothing waiting = every request, inventory.
79
+
80
+ ## 1.1.2 (2026-10-13 Tool sweep hygiene)
81
+
82
+ - `docs/guide.md` added (the Documentation > Plugins page Feedback already had). The manifest entry carries
83
+ `?v=1.1.2` and `V = '1.1.2'` loads the sub-views - bump both with the version. lib/receiver.js header corrected
84
+ (fleet tokens are on disk since 2026-10-04).
@@ -14,7 +14,7 @@
14
14
  import {api, loadKit} from '../lib/kit.js';
15
15
 
16
16
  const ROUTE = /^#\/plugins\/support(\/|$)/;
17
- const V = '1.1.0';
17
+ const V = '1.1.2';
18
18
  let lifetime = null;
19
19
 
20
20
  export const supportView = {
@@ -0,0 +1,90 @@
1
+ ---
2
+ title: Guide
3
+ order: 1
4
+ ---
5
+
6
+ Support gets you help from Domma without leaving your admin: our email, phone and hours are always to hand, and a request is a conversation with the Domma team. It is free, on every site.
7
+
8
+ ## What it does
9
+
10
+ - Shows the **contact card**: Domma's email, phone, hours and how quickly to expect an answer.
11
+ - Sends a request to the Domma team with your name, your site and its CMS version attached, and shows Domma's replies underneath it, newest at the bottom.
12
+ - Tells you when Domma replies, and lets you write back in the same request.
13
+ - Keeps a request safe if Domma cannot be reached, and sends it as soon as it can.
14
+
15
+ ## Getting started
16
+
17
+ 1. Open [Support](#/plugins/support) from the admin sidebar.
18
+ 2. Click **Ask for help**.
19
+ 3. Pick **What is it about?**: I need help with something, Something is not working, Billing or my account, or Something else.
20
+ 4. Type a **Subject** (one line) and your **Message**.
21
+ 5. Choose **Normal** (we answer in order, usually within a working day) or **Urgent** (your site is down, or you cannot take orders, bookings or payments).
22
+ 6. Optional: fill in **Where** - the screen or page it is about, for example "Pages" or `/contact`.
23
+ 7. Click **Send**.
24
+
25
+ For something that is not working, say what you did, what you expected and what happened instead.
26
+
27
+ ## Screens
28
+
29
+ ### Requests
30
+
31
+ Each row shows the subject, when it was last written to, and a status:
32
+
33
+ | Status | Meaning |
34
+ |---|---|
35
+ | Waiting on Domma | Domma has it and has not replied yet |
36
+ | Domma replied | There is a reply - it is your turn |
37
+ | Resolved | Finished. Writing again reopens it |
38
+ | Waiting to send | Domma could not be reached yet; it goes automatically |
39
+
40
+ A dot on a row means a new reply. Click a row to read the conversation and **Reply**. Right-click a row (or use the row's menu) to **Archive**, **Resolve** or **Remove** it:
41
+
42
+ - **Archive** tidies a request away under **Archived**. It comes back by itself if Domma writes to it.
43
+ - **Resolve** closes it. You can still write to it, which reopens it.
44
+ - **Remove** takes it off your screen. Domma keeps its copy. Removing a request that has not reached Domma yet cancels it.
45
+
46
+ ### Not connected
47
+
48
+ If the screen offers the contact card only and says to email us, your site was not started by a Domma server, so there is nowhere to send a request to. Email or phone us instead.
49
+
50
+ ## Settings
51
+
52
+ There is no settings screen. Whoever runs the site can change one option in the plugin's configuration file:
53
+
54
+ | Option | Default | Meaning |
55
+ |---|---|---|
56
+ | `syncMinutes` | 5 | How often the site sends waiting requests and checks for replies |
57
+
58
+ Opening the Support screen also sends anything waiting and checks for replies straight away. The contact card is set by Domma and arrives with the first sync.
59
+
60
+ ## Notifications
61
+
62
+ When Domma replies, you get an admin notification with the request's subject. The sidebar badge counts replies you have not opened; opening the request clears it. With nothing new, the badge shows how many requests are waiting on Domma.
63
+
64
+ ## Permissions and roles
65
+
66
+ In System > Roles, Support appears in the **Plugins** group:
67
+
68
+ | Action | Label | Allows |
69
+ |---|---|---|
70
+ | `support.send` | Send | Ask Domma for help and see this site's requests |
71
+ | `support.manage` | Manage | Only used on the Domma server (see below) |
72
+
73
+ Only the **admin** role has them by default. Everyone with **Send** sees every request the site has sent, not just their own.
74
+
75
+ ## On the Domma server
76
+
77
+ On the Domma server itself (the Manager), the same screen is the **Inbox** of every site's requests, for holders of **Support > Manage**:
78
+
79
+ - Filter by **Open**, **Answered**, **Resolved**, **Archived** or **All**, urgent first; search subject, text, person and site.
80
+ - Open a request to read the conversation, **Reply** (the site sees it and is notified, and the person is emailed), **Resolve**, change its priority, and keep **Internal notes** (never sent to the site).
81
+ - Right-click a row to archive, resolve or **Delete** it. Archiving is Domma's own filing: the site does not see it, and a request comes out of the archive when the customer writes again.
82
+ - A new request or a customer's reply is emailed to the Domma support address, with the customer as Reply-To and `[URGENT]` in the subject when it is urgent.
83
+
84
+ ## Limitations
85
+
86
+ - Only sites started by a Domma server can send. A standalone install gets the contact card only.
87
+ - No attachments or screenshots. Describe the problem in words, and use **Where** for the page.
88
+ - Subjects are limited to 140 characters and messages to 8,000.
89
+ - The Domma server accepts up to 20 requests and replies from one site in 10 minutes. Any more show as **Waiting to send** and go later.
90
+ - You cannot edit or withdraw a message once sent. Write a follow-up instead.
@@ -6,7 +6,7 @@
6
6
  * names, emails and words.
7
7
  *
8
8
  * A site is who its fleet token says it is (site-manager/services/fleetTokens.js,
9
- * memory only, minted when the manager forked it). That module only exists where
9
+ * minted when the manager forked it and kept in site-manager/data/fleet-tokens.json). That module only exists where
10
10
  * Site Manager does, which is also how this plugin knows it is on the manager.
11
11
  *
12
12
  * @module support/lib/receiver
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "support",
3
3
  "displayName": "Support",
4
- "version": "1.1.1",
4
+ "version": "1.1.2",
5
5
  "tier": "free",
6
6
  "description": "Get help from Domma straight from your admin: our email, phone and hours always to hand, and a conversation with the Domma team for anything you need - urgent requests first. Right-click a request to archive, resolve or remove it.",
7
7
  "author": "Domma CMS",
8
- "date": "2026-10-04",
8
+ "date": "2026-10-13",
9
9
  "icon": "headphones",
10
10
  "permissions": [
11
11
  {
@@ -61,7 +61,7 @@
61
61
  ],
62
62
  "views": {
63
63
  "plugin-support": {
64
- "entry": "support/admin/views/support.js",
64
+ "entry": "support/admin/views/support.js?v=1.1.2",
65
65
  "exportName": "supportView"
66
66
  }
67
67
  }