domma-cms 1.6.33 → 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.
Files changed (75) hide show
  1. package/admin/js/api.js +1 -1
  2. package/admin/js/lib/effect-defs.js +1 -1
  3. package/admin/js/lib/effects-builder.js +1 -1
  4. package/admin/js/lib/pages-arrange.js +1 -1
  5. package/admin/js/lib/scribe-composer.js +1 -1
  6. package/admin/js/lib/scribe-model.js +1 -1
  7. package/admin/js/lib/shortcode-preview.js +1 -1
  8. package/admin/js/lib/shortcode-registry.js +3 -3
  9. package/admin/js/templates/docs/usage-site-settings.html +2 -1
  10. package/admin/js/templates/page-editor.html +2 -1
  11. package/admin/js/templates/pages.html +1 -1
  12. package/admin/js/templates/theme.html +1 -1
  13. package/admin/js/views/pages.js +3 -3
  14. package/admin/js/views/theme.js +5 -5
  15. package/package.json +1 -1
  16. package/plugins/_lib/outreach/audience.js +76 -0
  17. package/plugins/_lib/outreach/collections/outreach-contacts.schema.json +12 -0
  18. package/plugins/_lib/outreach/collections/outreach-groups.schema.json +10 -0
  19. package/plugins/_lib/outreach/collections/outreach-unsubscribes.schema.json +11 -0
  20. package/plugins/_lib/outreach/expiry.js +46 -0
  21. package/plugins/_lib/outreach/index.js +15 -0
  22. package/plugins/_lib/outreach/ledger.js +127 -0
  23. package/plugins/_lib/outreach/sending.js +121 -0
  24. package/plugins/_lib/outreach/suppression.js +62 -0
  25. package/plugins/_lib/outreach/tokens.js +35 -0
  26. package/plugins/_lib/outreach/unsubscribe.js +46 -0
  27. package/plugins/free-tier.lock.json +35 -24
  28. package/plugins/shopping-cart/CLAUDE.md +64 -0
  29. package/plugins/shopping-cart/admin/css/index.css +16 -0
  30. package/plugins/shopping-cart/admin/lib/chart.js +64 -0
  31. package/plugins/shopping-cart/admin/lib/kit.js +15 -0
  32. package/plugins/shopping-cart/admin/views/orders.js +3 -2
  33. package/plugins/shopping-cart/admin/views/overview.js +4 -53
  34. package/plugins/shopping-cart/admin/views/sales.js +133 -0
  35. package/plugins/shopping-cart/admin/views/settings.js +3 -1
  36. package/plugins/shopping-cart/admin/views/shop.js +2 -1
  37. package/plugins/shopping-cart/config.js +1 -0
  38. package/plugins/shopping-cart/docs/guide.md +25 -0
  39. package/plugins/shopping-cart/inject/body.html +2 -2
  40. package/plugins/shopping-cart/lib/accounts.js +180 -0
  41. package/plugins/shopping-cart/lib/commission.js +90 -0
  42. package/plugins/shopping-cart/lib/extensions.js +12 -4
  43. package/plugins/shopping-cart/lib/pricing.js +65 -13
  44. package/plugins/shopping-cart/lib/render.js +17 -4
  45. package/plugins/shopping-cart/lib/sales.js +193 -0
  46. package/plugins/shopping-cart/lib/shop.js +24 -3
  47. package/plugins/shopping-cart/lib/summary.js +4 -2
  48. package/plugins/shopping-cart/plugin.js +156 -22
  49. package/plugins/shopping-cart/plugin.json +2 -2
  50. package/plugins/shopping-cart/plugin.public.js +17 -1
  51. package/plugins/shopping-cart/public/account.js +277 -0
  52. package/plugins/shopping-cart/public/cart.js +16 -0
  53. package/plugins/shopping-cart/public/lib/session.js +82 -0
  54. package/plugins/shopping-cart/public/shop.css +27 -0
  55. package/plugins/shopping-cart/roles/shop-customer.json +8 -0
  56. package/plugins/shopping-cart/tests/accounts.test.js +127 -0
  57. package/plugins/shopping-cart/tests/api.test.js +63 -0
  58. package/plugins/shopping-cart/tests/commission.test.js +90 -0
  59. package/plugins/shopping-cart/tests/pure.test.js +30 -1
  60. package/plugins/shopping-cart/tests/sales.test.js +73 -0
  61. package/public/css/site.css +1 -1
  62. package/public/js/effects.js +1 -1
  63. package/public/js/scribe-style.mjs +1 -1
  64. package/public/js/shortcode-expiry.mjs +1 -0
  65. package/public/js/site.js +1 -1
  66. package/public/js/text-style.mjs +1 -1
  67. package/server/routes/api/theme.js +12 -2
  68. package/server/routes/outreach-public.js +68 -0
  69. package/server/routes/public.js +24 -1
  70. package/server/server.js +5 -0
  71. package/server/services/content.js +7 -7
  72. package/server/services/markdown.js +15 -5
  73. package/server/services/outreachUnsubscribe.js +54 -0
  74. package/server/services/shortcodeExpiry.js +146 -0
  75. package/server/templates/page.html +3 -3
@@ -42,6 +42,21 @@ export function loadKit() {
42
42
  const seg = (id) => encodeURIComponent(String(id));
43
43
  export const api = {
44
44
  summary: () => H.get(`${BASE}/summary`),
45
+ sales: (q) => H.get(`${BASE}/sales?${new URLSearchParams(Object.entries(q).filter(([, v]) => v && v !== 'custom'))}`),
46
+ /** The CSV: fetched with the session (authenticate is Bearer-only) and saved as a file. */
47
+ async salesCsv(q) {
48
+ let token = null;
49
+ try { token = S.get('auth_token'); } catch { /* none */ }
50
+ const res = await fetch(`${BASE}/sales.csv?${new URLSearchParams(Object.entries(q).filter(([, v]) => v && v !== 'custom'))}`, {headers: token ? {Authorization: `Bearer ${token}`} : {}});
51
+ if (!res.ok) throw new Error(`the server answered ${res.status}`);
52
+ const name = (res.headers.get('content-disposition') || '').match(/filename="([^"]+)"/)?.[1] || 'sales.csv';
53
+ const a = document.createElement('a');
54
+ a.href = URL.createObjectURL(await res.blob());
55
+ a.download = name;
56
+ document.body.appendChild(a);
57
+ a.click();
58
+ setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1000);
59
+ },
45
60
  extensions: () => H.get(`${BASE}/extensions`),
46
61
  products: {
47
62
  list: () => H.get(`${BASE}/products`),
@@ -220,7 +220,7 @@ th,td{text-align:left;padding:.5rem;border-bottom:1px solid #ddd}td.q{width:4rem
220
220
  <div class="box"><div><h1>${esc(settings.shopTitle || 'Shop')}</h1><p class="muted">Packing slip · ${esc(orderLabel(d))} · ${esc(fmtDate(o.meta?.createdAt))}</p></div>
221
221
  <div><strong>${esc(d.customer?.name || '')}</strong><br>${d.delivery === 'collect' ? 'Collecting in person' : addr}</div></div>
222
222
  <table><thead><tr><th>Qty</th><th>Item</th><th>SKU</th></tr></thead><tbody>
223
- ${(d.lines || []).map(l => `<tr><td class="q">${l.qty}</td><td>${esc(l.name)}${l.variantName ? ` - ${esc(l.variantName)}` : ''}</td><td class="muted">${esc(l.sku || '')}</td></tr>`).join('')}
223
+ ${(d.lines || []).map(l => `<tr><td class="q">${l.qty}</td><td>${esc(l.name)}${l.variantName ? ` - ${esc(l.variantName)}` : ''}${(l.detail || []).map(x => `<br><small>${esc(x)}</small>`).join('')}</td><td class="muted">${esc(l.sku || '')}</td></tr>`).join('')}
224
224
  </tbody></table>${d.note ? `<div class="note"><strong>Note from the shopper:</strong><br>${esc(d.note)}</div>` : ''}
225
225
  <p class="muted">Thank you for your order.</p><script>onload=()=>print()<\/script></body></html>`);
226
226
  w.document.close();
@@ -283,7 +283,7 @@ ${(d.lines || []).map(l => `<tr><td class="q">${l.qty}</td><td>${esc(l.name)}${l
283
283
  </div>
284
284
  <table class="shp-lines">
285
285
  ${(d.lines || []).map(l => `<tr><td class="shp-lines-img">${l.image ? `<img src="${esc(l.image)}" alt="">` : ''}</td>
286
- <td><strong>${esc(l.name)}</strong>${l.variantName ? `<small>${esc(l.optionName || 'Option')}: ${esc(l.variantName)}</small>` : ''}${l.sku ? `<small>SKU ${esc(l.sku)}</small>` : ''}</td>
286
+ <td><strong>${esc(l.name)}</strong>${l.variantName ? `<small>${esc(l.optionName || 'Option')}: ${esc(l.variantName)}</small>` : ''}${(l.detail || []).map(x => `<small>${esc(x)}</small>`).join('')}${l.sku ? `<small>SKU ${esc(l.sku)}</small>` : ''}</td>
287
287
  <td class="shp-num">${l.qty} × ${esc(money(l.unitPrice))}</td><td class="shp-amt">${esc(money(l.lineTotal))}</td></tr>`).join('')}
288
288
  </table>
289
289
  <dl class="shp-totals">
@@ -301,6 +301,7 @@ ${(d.lines || []).map(l => `<tr><td class="q">${l.qty}</td><td>${esc(l.name)}${l
301
301
  <label class="form-label">Staff note <span data-help="Only you see this. Saved when you click away."></span></label>
302
302
  <textarea class="form-input" rows="2" data-model="staffNote.value" data-part="staff" placeholder="Wrapped as a gift, sent second class…"></textarea>
303
303
  ${d.paymentRef || d.providerRef ? `<p class="shp-muted shp-ref">Payment reference: <code>${esc(d.paymentRef || d.providerRef)}</code></p>` : ''}
304
+ ${d.commission ? `<p class="shp-muted shp-ref">Platform commission: ${esc(money(d.commission.amount))} (${esc(`${d.commission.percent}%${d.commission.fixed ? ` + ${money(d.commission.fixed)}` : ''}`)})${d.commission.reversed ? ' - returned with the refund' : ''}</p>` : ''}
304
305
  ${ext.length ? `<div class="shp-ext">${ext.map(x => `<span class="shp-chip is-muted">${esc(x.k)}: ${esc(String(x.v))}</span>`).join('')}</div>` : ''}
305
306
  <div data-part="ext-panels" hidden></div>
306
307
  <h4>History</h4>
@@ -7,6 +7,7 @@
7
7
  * steps to its first sale instead.
8
8
  */
9
9
 
10
+ import {barChart} from '../lib/chart.js';
10
11
  import {api, esc, fmtDate, money, when} from '../lib/kit.js';
11
12
  import {STATUS_LABEL, STATUS_TONE} from '/plugins/shopping-cart/public/lib/orders.js';
12
13
 
@@ -157,58 +158,8 @@ export async function mount(root, ctx) {
157
158
  I.scan(view);
158
159
  }
159
160
 
160
- /** Thirty bars, 2px apart, rounded tops on the baseline; hover (or focus) says the day and the takings. */
161
+ /** Thirty days of takings, one bar a day. */
161
162
  function drawChart(el, series, scope) {
162
- const W = Math.max(360, Math.round(el.clientWidth) || 600), H = 170, padL = 44, padB = 22, padT = 8;
163
- const max = Math.max(...series.map(d => d.revenue));
164
- const top = niceMax(max);
165
- const n = series.length;
166
- const slot = (W - padL) / n;
167
- const bw = Math.max(2, slot - 2);
168
- const y = (v) => padT + (H - padT - padB) * (1 - v / top);
169
- const ticks = [0, top / 2, top];
170
- const day = (k) => fmtDate(k);
171
- const bars = series.map((d, i) => {
172
- const x = padL + i * slot + 1;
173
- const h = Math.max(0, H - padB - y(d.revenue));
174
- const r = Math.min(4, bw / 2, h);
175
- const path = h > 0
176
- ? `M${x},${H - padB} v${-(h - r)} q0,${-r} ${r},${-r} h${bw - 2 * r} q${r},0 ${r},${r} v${h - r} z`
177
- : '';
178
- return `<g class="shp-bar" data-i="${i}" tabindex="0" aria-label="${esc(day(d.day))}: ${esc(money(d.revenue))}, ${d.orders} orders">
179
- <rect x="${padL + i * slot}" y="${padT}" width="${slot}" height="${H - padT - padB}" fill="transparent"/>
180
- ${path ? `<path d="${path}"/>` : `<rect class="shp-bar-zero" x="${x}" y="${H - padB - 1}" width="${bw}" height="1"/>`}
181
- </g>`;
182
- }).join('');
183
- const labels = [0, Math.floor(n / 2), n - 1].map(i => `<text x="${padL + i * slot + slot / 2}" y="${H - 6}" text-anchor="middle">${esc(day(series[i].day))}</text>`).join('');
184
- el.innerHTML = `<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="Takings for each of the last 30 days">
185
- ${ticks.map(t => `<line class="shp-gridline" x1="${padL}" x2="${W}" y1="${y(t)}" y2="${y(t)}"/><text class="shp-axis" x="${padL - 6}" y="${y(t) + 4}" text-anchor="end">${esc(money(t, {compact: true}))}</text>`).join('')}
186
- ${bars}
187
- <g class="shp-axis">${labels}</g>
188
- </svg><div class="shp-tip" hidden></div>`;
189
- const tip = el.querySelector('.shp-tip');
190
- const show = (g) => {
191
- const d = series[Number(g.dataset.i)];
192
- tip.innerHTML = `<strong>${esc(money(d.revenue))}</strong><span>${esc(day(d.day))} · ${d.orders} order${d.orders === 1 ? '' : 's'}</span>`;
193
- tip.hidden = false;
194
- const r = g.getBoundingClientRect(), box = el.getBoundingClientRect();
195
- tip.style.left = `${Math.min(box.width - 120, Math.max(0, r.left - box.left + r.width / 2 - 60))}px`;
196
- el.querySelectorAll('.shp-bar.is-on').forEach(b => b.classList.remove('is-on'));
197
- g.classList.add('is-on');
198
- };
199
- const hide = () => { tip.hidden = true; el.querySelectorAll('.shp-bar.is-on').forEach(b => b.classList.remove('is-on')); };
200
- const over = (e) => { const g = e.target.closest('.shp-bar'); if (g) show(g); };
201
- el.addEventListener('mouseover', over);
202
- el.addEventListener('focusin', over);
203
- el.addEventListener('mouseleave', hide);
204
- el.addEventListener('focusout', hide);
205
- scope(() => { el.removeEventListener('mouseover', over); el.removeEventListener('focusin', over); });
206
- }
207
-
208
- /** A round top for the axis: 1, 2 or 5 × a power of ten, in minor units. */
209
- function niceMax(v) {
210
- if (v <= 0) return 1000;
211
- const p = 10 ** Math.floor(Math.log10(v));
212
- for (const m of [1, 2, 5, 10]) if (m * p >= v) return m * p;
213
- return 10 * p;
163
+ barChart(el, series.map(d => ({value: d.revenue, label: fmtDate(d.day), tip: `${d.orders} order${d.orders === 1 ? '' : 's'}`})),
164
+ {money, ariaLabel: 'Takings for each of the last 30 days', scope});
214
165
  }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Shop › Sales - what the shop took, for a range of dates: the headline figures, takings over time (by day, week or
3
+ * month), where the money came from (Shop, Downloads & keys, Takeaway, other add-ons), how it was paid, and a CSV
4
+ * for the accountant. Refunds count on the day they were made. Every figure is lib/sales.js on the server.
5
+ */
6
+ import {api, esc, fmtDate, money} from '../lib/kit.js';
7
+ import {barChart} from '../lib/chart.js';
8
+
9
+ const RANGES = [['today', 'Today'], ['week', 'This week'], ['last7', 'Last 7 days'], ['month', 'This month'], ['lastmonth', 'Last month'], ['last30', 'Last 30 days'], ['year', 'This year']];
10
+ const BY = [['day', 'Day'], ['week', 'Week'], ['month', 'Month']];
11
+
12
+ export async function mount(root, ctx) {
13
+ const {scope, here, kit} = ctx;
14
+ const saved = (() => { try { return JSON.parse(sessionStorage.getItem('shop-sales') || '{}'); } catch { return {}; } })();
15
+ const q = {range: saved.range || 'last30', by: saved.by || 'day', from: '', to: ''};
16
+
17
+ root.innerHTML = `<div class="shp-sales">
18
+ <div class="shp-sales-bar">
19
+ <span class="shp-seg" data-part="ranges">${RANGES.map(([k, l]) => `<button type="button" data-range="${k}">${l}</button>`).join('')}<button type="button" data-range="custom">Dates…</button></span>
20
+ <span class="shp-sales-custom" data-part="custom" hidden><input type="date" class="form-input" data-part="from"> to <input type="date" class="form-input" data-part="to">
21
+ <button type="button" class="btn btn-sm btn-primary" data-act="apply">Show</button></span>
22
+ <span class="shp-spacer"></span>
23
+ <span class="shp-seg" data-part="by">${BY.map(([k, l]) => `<button type="button" data-by="${k}">${l}</button>`).join('')}</span>
24
+ <button type="button" class="btn btn-sm btn-outline" data-act="csv"><span data-icon="download" data-icon-size="13"></span> CSV</button>
25
+ </div>
26
+ <p class="shp-muted" data-part="span"></p>
27
+ <div class="shp-tiles" data-part="tiles"></div>
28
+ <section class="shp-card shp-chart-card"><header><h3>Takings</h3><span class="shp-muted" data-part="chart-total"></span></header><div class="shp-chart" data-part="chart"></div></section>
29
+ <div class="shp-sales-cols">
30
+ <section class="shp-card"><header><h3>Where it came from <span data-help="Line amounts before discounts. Takeaway is Takeaway's food orders; Downloads & keys are products Shopping Cart Pro delivered as files or licence keys."></span></h3></header><div data-part="sources"></div></section>
31
+ <section class="shp-card"><header><h3>How it was paid</h3></header><div data-part="methods"></div></section>
32
+ </div>
33
+ <section class="shp-card"><header><h3>The figures</h3><span class="shp-muted" data-part="rate"></span></header><table class="shp-sales-table" data-part="figures"></table></section>
34
+ <details class="shp-card shp-sales-periods"><summary>Each period, as a table</summary><table class="shp-sales-table" data-part="periods"></table></details>
35
+ </div>`;
36
+ I.scan(root);
37
+ scope(kit.helpPopovers(root, {position: 'bottom'}));
38
+ const part = (n) => root.querySelector(`[data-part="${n}"]`);
39
+ let report = null;
40
+
41
+ const label = (p, by) => {
42
+ if (by === 'month') return new Intl.DateTimeFormat('en-GB', {month: 'short', year: 'numeric', timeZone: 'UTC'}).format(new Date(`${p}T12:00:00Z`));
43
+ if (by === 'week') return `w/c ${fmtDate(p)}`;
44
+ return fmtDate(p);
45
+ };
46
+
47
+ function render() {
48
+ const r = report;
49
+ const t = r.totals;
50
+ for (const b of part('ranges').querySelectorAll('[data-range]')) b.classList.toggle('is-on', b.dataset.range === (q.from ? 'custom' : q.range));
51
+ for (const b of part('by').querySelectorAll('[data-by]')) b.classList.toggle('is-on', b.dataset.by === q.by);
52
+ part('custom').hidden = !q.from && q.range !== 'custom';
53
+ part('from').value = r.from;
54
+ part('to').value = r.to;
55
+ part('span').textContent = r.from === r.to ? fmtDate(r.from) : `${fmtDate(r.from)} to ${fmtDate(r.to)}`;
56
+ const tile = (value, name, note = '') => `<div class="shp-tile"><span class="shp-tile-label">${esc(name)}</span><span class="shp-tile-value">${esc(value)}</span>${note ? `<span class="shp-tile-sub">${esc(note)}</span>` : ''}</div>`;
57
+ part('tiles').innerHTML = [
58
+ tile(money(t.net), 'Net takings', t.refunds ? `${money(t.takings)} taken, ${money(t.refunds)} refunded` : ''),
59
+ tile(String(t.orders), 'Orders', t.items ? `${t.items} item${t.items === 1 ? '' : 's'}` : ''),
60
+ tile(money(t.average), 'Average order'),
61
+ tile(money(t.taxAdded + t.taxIncluded), 'Tax collected', t.taxIncluded ? 'included in prices' : '')
62
+ ].join('');
63
+ part('chart-total').textContent = `${money(t.takings)} · ${t.orders} order${t.orders === 1 ? '' : 's'}`;
64
+ barChart(part('chart'), r.periods.map(p => ({value: p.takings, label: label(p.period, r.by),
65
+ tip: `${p.orders} order${p.orders === 1 ? '' : 's'}${p.refunds ? `, ${money(p.refunds)} refunded` : ''}`})),
66
+ {money, ariaLabel: `Takings by ${r.by}`, scope});
67
+
68
+ const share = (rows, valueKey, countKey, noun) => {
69
+ const max = Math.max(1, ...rows.map(x => x[valueKey]));
70
+ return rows.length ? rows.map(x => `<div class="shp-top"><span class="shp-top-name">${esc(x.label)}<small>${x[countKey]} ${noun}${x[countKey] === 1 ? '' : 's'}</small></span>
71
+ <span class="shp-top-bar"><span style="width:${Math.round(x[valueKey] / max * 100)}%"></span></span><span class="shp-mini-amt">${esc(money(x[valueKey]))}</span></div>`).join('')
72
+ : '<p class="shp-muted">Nothing sold in these dates.</p>';
73
+ };
74
+ const sources = r.sources.filter(s => s.orders);
75
+ part('sources').innerHTML = share(sources, 'amount', 'orders', 'order')
76
+ + (r.takeaway.collect + r.takeaway.deliver ? `<p class="shp-muted shp-sales-note">Takeaway: ${r.takeaway.collect} collected, ${r.takeaway.deliver} delivered.</p>` : '');
77
+ part('methods').innerHTML = share(r.methods, 'takings', 'orders', 'order');
78
+
79
+ const row = (k, v, cls = '') => `<tr class="${cls}"><th>${esc(k)}</th><td>${esc(money(v))}</td></tr>`;
80
+ part('figures').innerHTML = [
81
+ row('Items, before discounts', t.subtotal),
82
+ t.discount ? row('Discounts', -t.discount) : '',
83
+ row('Delivery charged', t.delivery),
84
+ t.taxAdded ? row('Tax added', t.taxAdded) : '',
85
+ row('Taken', t.takings, 'is-sum'),
86
+ row(`Refunded (${t.refundCount})`, -t.refunds),
87
+ row('Net takings', t.net, 'is-total'),
88
+ t.taxIncluded ? row('Of which tax (included in prices)', t.taxIncluded, 'is-muted') : '',
89
+ ...(t.commission || t.commissionBack || r.commission?.set ? [
90
+ row('Platform commission', -t.commission),
91
+ t.commissionBack ? row('Commission returned on refunds', t.commissionBack) : '',
92
+ row('After commission', t.afterCommission, 'is-sum')] : [])
93
+ ].join('');
94
+ part('rate').textContent = r.commission?.set ? `Platform commission: ${r.commission.rates.map(x => `${x.label} ${x.text}`).join(' · ')}` : '';
95
+ part('periods').innerHTML = `<thead><tr><th>${esc(BY.find(x => x[0] === r.by)[1])}</th><th>Orders</th><th>Taken</th><th>Refunded</th><th>Net</th></tr></thead><tbody>${r.periods.map(p => `<tr>
96
+ <td>${esc(label(p.period, r.by))}</td><td>${p.orders}</td><td>${esc(money(p.takings))}</td><td>${p.refunds ? esc(money(-p.refunds)) : ''}</td><td>${esc(money(p.net))}</td></tr>`).join('')}</tbody>`;
97
+ I.scan(root);
98
+ }
99
+
100
+ async function load() {
101
+ try {
102
+ report = await api.sales(q);
103
+ if (!here()) return;
104
+ try { sessionStorage.setItem('shop-sales', JSON.stringify({range: q.range, by: q.by})); } catch { /* private mode */ }
105
+ render();
106
+ } catch (err) {
107
+ part('tiles').textContent = `Sales could not load: ${err.message || err}`;
108
+ }
109
+ }
110
+
111
+ root.addEventListener('click', async (e) => {
112
+ const r = e.target.closest('[data-range]');
113
+ if (r) {
114
+ if (r.dataset.range === 'custom') { q.range = 'custom'; part('custom').hidden = false; part('ranges').querySelectorAll('[data-range]').forEach(b => b.classList.toggle('is-on', b === r)); return; }
115
+ q.range = r.dataset.range; q.from = ''; q.to = '';
116
+ return load();
117
+ }
118
+ const b = e.target.closest('[data-by]');
119
+ if (b) { q.by = b.dataset.by; return load(); }
120
+ const a = e.target.closest('[data-act]')?.dataset.act;
121
+ if (a === 'apply') {
122
+ if (!part('from').value || !part('to').value) { E.toast('Choose both dates.', {type: 'warning'}); return undefined; }
123
+ q.from = part('from').value; q.to = part('to').value;
124
+ return load();
125
+ }
126
+ if (a === 'csv') {
127
+ try { await api.salesCsv({...q, from: q.from || report?.from, to: q.to || report?.to}); } catch (err) { E.toast(`No CSV: ${err.message}`, {type: 'error'}); }
128
+ }
129
+ return undefined;
130
+ });
131
+
132
+ await load();
133
+ }
@@ -16,7 +16,7 @@ const SECTIONS = [
16
16
  {key: 'payment', label: 'Payment', icon: 'credit-card'}
17
17
  ];
18
18
  const MONEY_KEYS = ['shippingFlat', 'freeShippingOver'];
19
- const BOOL_KEYS = ['showSearch', 'showCategories', 'floatingCart', 'showStockLeft', 'allowCollection', 'pricesIncludeTax', 'taxShipping',
19
+ const BOOL_KEYS = ['showSearch', 'showCategories', 'floatingCart', 'showStockLeft', 'accounts', 'allowCollection', 'pricesIncludeTax', 'taxShipping',
20
20
  'stripeEnabled', 'paypalEnabled', 'offlineEnabled'];
21
21
 
22
22
  export async function openSettings(ctx, section = 'store') {
@@ -79,6 +79,7 @@ export async function openSettings(ctx, section = 'store') {
79
79
  <select class="form-select" data-model="cartPosition.value"><option value="right">Right</option><option value="left">Left</option></select></div>
80
80
  <div><label class="form-label">Low stock at <span data-help="At or below this, a product is flagged in the Overview and the Low stock filter."></span></label>
81
81
  <input class="form-input" type="number" min="0" data-model="lowStockAt.value"></div>
82
+ <div class="shp-wide">${check('accounts', 'Customer accounts', 'Customers can create an account (optional - guests still check out): their details and addresses are filled in, and their orders are listed at the shop address followed by /account. Needs the site\'s email set up.')}</div>
82
83
  <div class="shp-wide"><label class="form-label">Terms of sale page <span data-help="If set, shoppers tick 'I agree to the terms of sale' (linked here) before paying. /terms or a full https:// address."></span></label>
83
84
  <input class="form-input" data-model="termsUrl.value" placeholder="/terms"></div>
84
85
  </section>
@@ -109,6 +110,7 @@ export async function openSettings(ctx, section = 'store') {
109
110
  </section>
110
111
 
111
112
  <section data-bind-hidden="!isPayment.value" class="shp-pay">
113
+ ${info.commission?.set ? `<p class="shp-note">Platform commission, set by your provider: ${info.commission.rates.map(x => `${esc(x.label)} ${esc(x.text)}`).join(' · ')}. It is worked out on each sale when it is paid and shown in Sales.</p>` : ''}
112
114
  <div class="shp-provider">
113
115
  <div class="shp-provider-head">${check('stripeEnabled', '<strong>Stripe</strong> - cards, Apple Pay, Google Pay', 'Shoppers pay on Stripe’s own secure page; card details never touch this site. Needs a Stripe account.')}
114
116
  <span class="shp-chip is-muted" data-bind-hidden="!stripeMode" data-bind-text="stripeMode"></span></div>
@@ -15,10 +15,11 @@
15
15
  import {api, loadKit, setCurrency, store} from '../lib/kit.js';
16
16
 
17
17
  const ROUTE = /^#\/plugins\/shopping-cart(\/|$)/;
18
- const V = '1.2.0';
18
+ const V = '1.6.0';
19
19
  const TABS = [
20
20
  {key: 'overview', label: 'Overview', icon: 'bar-chart', module: `/plugins/shopping-cart/admin/views/overview.js?v=${V}`},
21
21
  {key: 'orders', label: 'Orders', icon: 'receipt', module: `/plugins/shopping-cart/admin/views/orders.js?v=${V}`},
22
+ {key: 'sales', label: 'Sales', icon: 'trending-up', module: `/plugins/shopping-cart/admin/views/sales.js?v=${V}`},
22
23
  {key: 'products', label: 'Products', icon: 'package', module: `/plugins/shopping-cart/admin/views/products.js?v=${V}`}
23
24
  ];
24
25
 
@@ -23,6 +23,7 @@ export default {
23
23
  lowStockAt: 3,
24
24
  showStockLeft: true,
25
25
  termsUrl: '',
26
+ accounts: false, // customers can create an account (lib/accounts.js) - needs the site's email set up
26
27
 
27
28
  // Delivery
28
29
  countries: ['GB'],
@@ -74,6 +74,21 @@ Refunds:
74
74
  - An offline order is only marked refunded. Give the money back yourself, the way it came.
75
75
  - Tick **Put the items back in stock** to return the stock.
76
76
 
77
+ ### Sales
78
+
79
+ What the shop took, for a range of dates. Choose **Today**, **This week**, **Last 7 days**, **This month**, **Last month**, **Last 30 days**, **This year** or **Dates…**, and show it by **Day**, **Week** or **Month**.
80
+
81
+ - **Net takings** (what was taken, less refunds), **Orders**, **Average order** and **Tax collected**.
82
+ - **Takings** - a chart; point at a bar for its figures.
83
+ - **Where it came from** - your Shop's products, downloads and licence keys (Shopping Cart Pro), Takeaway orders, and other add-ons.
84
+ - **How it was paid** - card, PayPal, offline.
85
+ - **The figures** - items, discounts, delivery, tax, refunds and net; **Each period, as a table** below.
86
+ - **CSV** - one row per order, for your accountant.
87
+
88
+ A sale counts on the day it was paid, and a refund on the day it was made.
89
+
90
+ If your provider charges a commission on sales, **The figures** also show the commission, any returned on refunds, and what is left after commission; the rate is shown beside them and in Shop settings > Payment. Each sale's commission is worked out when it is paid, at the rate of that day, and shown on the order. You cannot change the rate yourself - ask your provider.
91
+
77
92
  ### Products
78
93
 
79
94
  1. Pick a filter: **All**, **On sale**, **Drafts**, **Low stock** or **Archived**.
@@ -191,3 +206,13 @@ The admin role has both by default.
191
206
  - Refunds are full refunds only.
192
207
  - One flat delivery charge and one tax rate. Shopping Cart Pro adds shipping zones, rates by weight or order value, tax by country and tax class, and discount codes.
193
208
  - Behind a proxy, set Site URL in Site Settings or Stripe and PayPal may send shoppers back to the wrong address.
209
+
210
+ ## Customer accounts
211
+
212
+ Switch on **Customer accounts** in Shop settings (the cog) > Store to let customers create an account. It is optional - customers can still check out as guests. Your site's email must be set up (Site Settings > Email), because new accounts are confirmed by an emailed link.
213
+
214
+ - Customers sign in, create an account or reset a forgotten password on the account page: your shop's address followed by `/account` (for example `/shop/account`). The shop and the basket link to it.
215
+ - Signed in, their name, email, phone and first saved address are filled in at checkout.
216
+ - The account page lists every order placed with their email address, before or after they signed up, with a link to each receipt. They can change their details, keep up to 8 addresses, sign out, and delete their account (their orders stay with you as a record of the sale).
217
+ - After a guest checkout, the receipt offers to create an account with the order's email.
218
+ - Customers get the **Shop Customer** role, which has no access to the admin.
@@ -1,2 +1,2 @@
1
- <link rel="stylesheet" href="/plugins/shopping-cart/public/shop.css?v=1.2.0">
2
- <script type="module" src="/plugins/shopping-cart/public/cart.js?v=1.0.0"></script>
1
+ <link rel="stylesheet" href="/plugins/shopping-cart/public/shop.css?v=1.4.0">
2
+ <script type="module" src="/plugins/shopping-cart/public/cart.js?v=1.4.0"></script>
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Customer accounts - optional (Shop settings `accounts`), one for the Shop and every extension that sells
3
+ * through it (Takeaway).
4
+ *
5
+ * A customer is a core user with the plugin role `shop-customer` (roles/shop-customer.json: no permissions, the
6
+ * admin confined to My Profile). The Cart makes it INACTIVE and emails a link; clicking it activates the account
7
+ * (Job Board's pattern). Sign in, refresh and the new password itself are core's own routes; this module makes,
8
+ * confirms, describes and deletes accounts, and keeps what the shop needs on the user: `meta.shop`
9
+ * {phone, addresses: [{id, label, line1, line2, city, region, postcode, country}], pending: {hash, until}}.
10
+ *
11
+ * An account's orders are the orders placed with its email - before or after it signed up. The email was proved by
12
+ * the confirmation link, so nobody can claim orders that are not theirs, and nothing has to be linked.
13
+ *
14
+ * Needs SMTP: without a mail server nothing can be confirmed, so accounts cannot be made (`mailReady`).
15
+ *
16
+ * @module shopping-cart/lib/accounts
17
+ */
18
+ import {createHash, randomBytes, timingSafeEqual} from 'crypto';
19
+ import {createUser, deleteUser, getUserByEmail, getUserById, updateUser, validatePassword} from '../../../server/services/users.js';
20
+ import {issueResetToken} from '../../../server/services/passwordReset.js';
21
+ import {checkPassword} from '../../../server/services/authChecks.js';
22
+ import {createTransport, isSmtpConfigured, sendEmail} from '../../../server/services/email.js';
23
+ import {getConfig} from '../../../server/config.js';
24
+ import {COUNTRIES, isEmail} from '../public/lib/orders.js';
25
+
26
+ export const ROLE = 'shop-customer';
27
+ const PENDING_FOR = 48 * 3600000;
28
+ const MAX_ADDRESSES = 8;
29
+
30
+ const text = (v, max) => String(v ?? '').replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);
31
+ const sha = (s) => createHash('sha256').update(String(s)).digest('hex');
32
+ const bad = (status, message, extra = {}) => Object.assign(new Error(message), {status, ...extra});
33
+ const site = () => { try { return getConfig('site') || {}; } catch { return {}; } };
34
+
35
+ // ---- Mail --------------------------------------------------------------------------------
36
+
37
+ let mailer = null;
38
+ /** Tests only: `fn(msg)` receives every message. Pass null to restore. */
39
+ export function setAccountMailer(fn) { mailer = fn; }
40
+ export const mailReady = () => Boolean(mailer) || isSmtpConfigured(site().smtp || {});
41
+
42
+ const escHtml = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
43
+
44
+ async function mail({to, subject, lines, link, button}) {
45
+ const html = `${lines.map(l => `<p>${escHtml(l)}</p>`).join('')}${link ? `<p><a href="${escHtml(link)}" style="display:inline-block;padding:10px 18px;background:#2563eb;color:#fff;border-radius:6px;text-decoration:none">${escHtml(button)}</a></p>
46
+ <p style="color:#666;font-size:13px">Or paste this into your browser: ${escHtml(link)}</p>` : ''}`;
47
+ const text = [...lines, link ? `${button}: ${link}` : ''].filter(Boolean).join('\n\n');
48
+ const msg = {to, subject, html, text};
49
+ if (mailer) { await mailer(msg); return true; }
50
+ const s = site();
51
+ if (!isSmtpConfigured(s.smtp || {})) return false;
52
+ await sendEmail(await createTransport(s.smtp), {from: s.smtp.fromAddress || s.contactEmail || 'noreply@example.com', fromName: s.smtp.fromName || s.title || 'Shop', ...msg});
53
+ return true;
54
+ }
55
+
56
+ // ---- Addresses ------------------------------------------------------------------------------
57
+
58
+ /** A saved address, checked; null when it has no first line or town. */
59
+ export function cleanAddress(a = {}) {
60
+ const out = {
61
+ id: /^[a-z0-9]{4,16}$/.test(String(a.id || '')) ? String(a.id) : Math.random().toString(36).slice(2, 10),
62
+ label: text(a.label, 40),
63
+ line1: text(a.line1, 160), line2: text(a.line2, 160), city: text(a.city, 100), region: text(a.region, 100),
64
+ postcode: text(a.postcode, 20).toUpperCase(),
65
+ country: COUNTRIES[String(a.country || '').toUpperCase()] ? String(a.country).toUpperCase() : 'GB'
66
+ };
67
+ return out.line1 && out.city ? out : null;
68
+ }
69
+
70
+ // ---- Making and confirming ---------------------------------------------------------------------
71
+
72
+ /**
73
+ * A new account, inactive until its email link is clicked. Says the same thing whether or not the email is
74
+ * already in use (an account holder gets a "you already have an account" email instead), so the form cannot be
75
+ * used to find out who shops here.
76
+ *
77
+ * @returns {Promise<{sent: boolean}>}
78
+ */
79
+ export async function register({name, email, password, phone = ''}, {link, shopTitle = 'the shop', log = console}) {
80
+ const addr = String(email || '').trim().toLowerCase();
81
+ const who = text(name, 120);
82
+ if (!who) throw bad(400, 'Your name, please.', {errors: {name: 'Your name.'}});
83
+ if (!isEmail(addr)) throw bad(400, 'An email address we can write to.', {errors: {email: 'An email address.'}});
84
+ if (typeof password !== 'string' || password.length < 8) throw bad(400, 'A password of 8 characters or more.', {errors: {password: '8 characters or more.'}});
85
+ const problems = await checkPassword(password, {email: addr, name: who}, {log});
86
+ if (problems.length) throw bad(400, problems.join(' '), {errors: {password: problems[0]}});
87
+ if (!mailReady()) throw bad(409, 'Accounts cannot be made just now - please check out as a guest.');
88
+
89
+ const had = await getUserByEmail(addr);
90
+ if (had) {
91
+ const pending = had.meta?.shop?.pending;
92
+ // An unconfirmed account that ran out: start again.
93
+ if (!had.isActive && pending && Date.parse(pending.until) < Date.now() && had.role === ROLE) {
94
+ await deleteUser(had.id);
95
+ } else {
96
+ if (had.isActive) {
97
+ await mail({to: addr, subject: `You already have an account with ${shopTitle}`,
98
+ lines: [`Someone - probably you - tried to create an account with this email at ${shopTitle}. You already have one.`,
99
+ 'Sign in with your password, or choose "Forgotten your password?" to set a new one.'],
100
+ link: link('account'), button: 'Sign in'}).catch(err => log.warn?.(`[shopping-cart] account email: ${err.message}`));
101
+ }
102
+ return {sent: true};
103
+ }
104
+ }
105
+ const token = randomBytes(24).toString('hex');
106
+ const user = await createUser({name: who, email: addr, password, role: ROLE, isActive: false,
107
+ meta: {shop: {phone: text(phone, 40), addresses: [], pending: {hash: sha(token), until: new Date(Date.now() + PENDING_FOR).toISOString()}}}});
108
+ try {
109
+ await mail({to: addr, subject: `Confirm your account with ${shopTitle}`,
110
+ lines: [`Hello ${who.split(' ')[0]} - thanks for creating an account with ${shopTitle}.`, 'Click below to confirm your email address. The link works for 48 hours.'],
111
+ link: link(`account/confirm?u=${user.id}&t=${token}`), button: 'Confirm my account'});
112
+ } catch (err) {
113
+ // An account nobody can confirm is no use: take it away again and say so.
114
+ await deleteUser(user.id).catch(() => {});
115
+ log.warn?.(`[shopping-cart] confirmation email to ${addr}: ${err.message}`);
116
+ throw bad(502, 'We could not send the confirmation email just now - please try again later, or check out as a guest.');
117
+ }
118
+ return {sent: true};
119
+ }
120
+
121
+ /** Click the link: the account is active. Returns the user, or throws. */
122
+ export async function confirm(userId, token) {
123
+ const user = /^[0-9a-f-]{36}$/i.test(String(userId || '')) ? await getUserById(String(userId)) : null;
124
+ const pending = user?.meta?.shop?.pending;
125
+ if (user?.isActive && !pending) return user; // already confirmed - a second click is fine
126
+ if (!user || !pending) throw bad(404, 'That link is not one we know - it may have been used already.');
127
+ const a = Buffer.from(sha(token));
128
+ const b = Buffer.from(String(pending.hash || ''));
129
+ if (a.length !== b.length || !timingSafeEqual(a, b)) throw bad(404, 'That link is not one we know - it may have been used already.');
130
+ if (Date.parse(pending.until) < Date.now()) throw bad(410, 'That link has run out - create your account again and we will send a new one.');
131
+ const {pending: _done, ...shop} = user.meta.shop;
132
+ return updateUser(user.id, {isActive: true, meta: {...user.meta, shop}});
133
+ }
134
+
135
+ /** "Forgotten your password?": core's reset token, with a link to the shop's own page. Always quiet about who exists. */
136
+ export async function forgot(email, {link, shopTitle = 'the shop', log = console}) {
137
+ const user = isEmail(email) ? await getUserByEmail(String(email).trim().toLowerCase()) : null;
138
+ if (!user?.isActive || !mailReady()) return {sent: false};
139
+ const {token} = await issueResetToken(user.id);
140
+ await mail({to: user.email, subject: `Set a new password for ${shopTitle}`,
141
+ lines: [`Hello ${String(user.name || '').split(' ')[0] || 'there'} - here is the link to set a new password.`, 'If you did not ask for this, ignore this email; your password stays as it is.'],
142
+ link: link(`account/reset?t=${token}`), button: 'Set a new password'}).catch(err => log.warn?.(`[shopping-cart] reset email: ${err.message}`));
143
+ return {sent: true};
144
+ }
145
+
146
+ // ---- The signed-in customer ---------------------------------------------------------------------
147
+
148
+ /** What the account page and checkouts show of a user. */
149
+ export function describe(user) {
150
+ const shop = user?.meta?.shop || {};
151
+ return {name: user.name, email: user.email, phone: shop.phone || '', addresses: shop.addresses || [], customer: user.role === ROLE};
152
+ }
153
+
154
+ export async function updateDetails(userId, {name, phone, addresses}) {
155
+ const user = await getUserById(userId);
156
+ if (!user) throw bad(404, 'Your account is gone.');
157
+ const shop = {...(user.meta?.shop || {})};
158
+ const patch = {};
159
+ if (name !== undefined) { const n = text(name, 120); if (!n) throw bad(400, 'Your name, please.'); patch.name = n; }
160
+ if (phone !== undefined) shop.phone = text(phone, 40);
161
+ if (addresses !== undefined) shop.addresses = (Array.isArray(addresses) ? addresses : []).map(cleanAddress).filter(Boolean).slice(0, MAX_ADDRESSES);
162
+ return describe(await updateUser(user.id, {...patch, meta: {...(user.meta || {}), shop}}));
163
+ }
164
+
165
+ /**
166
+ * Delete my account. Only a customer's own (staff are removed by an admin), and only with the password. The
167
+ * orders stay - a record of the sale - with nothing linking them to a login.
168
+ */
169
+ export async function remove(userId, password) {
170
+ const user = await getUserById(userId);
171
+ if (!user) throw bad(404, 'Your account is gone.');
172
+ if (user.role !== ROLE) throw bad(409, 'This is a staff account - an administrator removes it.');
173
+ const full = await getUserByEmail(user.email);
174
+ if (!full?.password || !(await validatePassword(String(password || ''), full.password))) throw bad(403, 'That password is not right.');
175
+ await deleteUser(user.id);
176
+ }
177
+
178
+ /** Orders placed with this email, newest first. */
179
+ export const ordersFor = (orders, email) => orders.filter(o => String(o.data?.email || '').toLowerCase() === String(email || '').toLowerCase()
180
+ && o.data.status !== 'pending').sort((a, b) => String(b.meta?.createdAt).localeCompare(String(a.meta?.createdAt)));
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Platform commission - a value on each sale, set per tenant (site) by the platform's super admin on the manager.
3
+ *
4
+ * The manager writes the site's rates to `config/commission.json` (the site's admins have no way to change it; the
5
+ * Cart only ever reads it). When an order is PAID the Cart works out that sale's commission and stores it on the
6
+ * order (`order.data.commission`), so every sale carries its own figure at the rate of its day - a later change of
7
+ * rate never rewrites the past. A refund marks it reversed. The customer never sees it.
8
+ *
9
+ * config/commission.json {rates: {default: {percent, fixed}, shop?: {…}, takeaway?: {…}, addons?: {…}}, updatedAt, by}
10
+ * order.data.commission {type, base, percent, fixed, amount, at, reversed?, reversedAt?}
11
+ *
12
+ * `percent` is of what the customer paid for the sale (the order total); `fixed` is minor units per sale. A free
13
+ * order (total 0) carries none. No file, or no rate for the type and no default: no commission.
14
+ *
15
+ * @module shopping-cart/lib/commission
16
+ */
17
+ import fs from 'fs';
18
+ import path from 'path';
19
+
20
+ export const TYPES = {shop: 'Shop', takeaway: 'Takeaway', addons: 'Other add-ons'};
21
+ const FILE = () => path.resolve('config', 'commission.json');
22
+
23
+ /** A rate, checked: percent 0-100 (to 3 places), fixed whole minor units. Null when it says nothing. */
24
+ export function cleanRate(r) {
25
+ if (!r || typeof r !== 'object') return null;
26
+ const percent = Number(r.percent);
27
+ const fixed = Number(r.fixed);
28
+ const out = {
29
+ percent: Number.isFinite(percent) && percent >= 0 && percent <= 100 ? Math.round(percent * 1000) / 1000 : 0,
30
+ fixed: Number.isInteger(fixed) && fixed >= 0 && fixed <= 1e6 ? fixed : 0
31
+ };
32
+ return out.percent || out.fixed ? out : null;
33
+ }
34
+
35
+ /** The rates as the manager wrote them, checked. */
36
+ export function cleanRates(input) {
37
+ const rates = {};
38
+ for (const k of ['default', ...Object.keys(TYPES)]) {
39
+ const r = cleanRate(input?.rates?.[k]);
40
+ if (r) rates[k] = r;
41
+ }
42
+ return {rates, updatedAt: String(input?.updatedAt || ''), by: String(input?.by || '')};
43
+ }
44
+
45
+ /** Which kind of sale an order is: Takeaway's, another add-on's, or the Shop's own (downloads included). */
46
+ export function orderType(order) {
47
+ const ref = String(order?.data?.ref || '');
48
+ if (ref.startsWith('takeaway:')) return 'takeaway';
49
+ if (ref) return 'addons';
50
+ return 'shop';
51
+ }
52
+
53
+ /** The rate for a kind of sale: its own, else the default, else none. */
54
+ export const rateFor = (type, rates) => rates?.[type] || rates?.default || null;
55
+
56
+ /**
57
+ * One sale's commission, at these rates. Null when there is none to charge.
58
+ * @param {{data: object}} order
59
+ * @param {object} rates - cleanRates().rates
60
+ * @param {string} [at] - when (ISO), for the record
61
+ */
62
+ export function commissionFor(order, rates, at = new Date().toISOString()) {
63
+ const type = orderType(order);
64
+ const r = rateFor(type, rates);
65
+ const base = Number(order?.data?.total) || 0;
66
+ if (!r || base <= 0) return null;
67
+ const amount = Math.min(base, Math.round(base * r.percent / 100) + r.fixed);
68
+ return {type, base, percent: r.percent, fixed: r.fixed, amount, at};
69
+ }
70
+
71
+ /** "2% + 20p per sale" for people. */
72
+ export function rateText(r, money = (n) => `£${(n / 100).toFixed(2)}`) {
73
+ if (!r) return 'None';
74
+ return [r.percent ? `${r.percent}%` : '', r.fixed ? money(r.fixed) : ''].filter(Boolean).join(' + ') + ' per sale';
75
+ }
76
+
77
+ // ---- Reading the file ---------------------------------------------------------------------------
78
+
79
+ let cached = {mtime: -1, value: {rates: {}, updatedAt: '', by: ''}};
80
+
81
+ /** The site's commission (read when the file changes - the manager may write it at any time, with no restart). */
82
+ export function currentCommission() {
83
+ try {
84
+ const st = fs.statSync(FILE());
85
+ if (st.mtimeMs !== cached.mtime) cached = {mtime: st.mtimeMs, value: cleanRates(JSON.parse(fs.readFileSync(FILE(), 'utf8')))};
86
+ } catch {
87
+ cached = {mtime: -1, value: {rates: {}, updatedAt: '', by: ''}};
88
+ }
89
+ return cached.value;
90
+ }