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.
- 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/call/ice.js +5 -2
- package/plugins/_lib/call/rooms.js +9 -3
- package/plugins/_lib/call/routes.js +7 -4
- package/plugins/_lib/mail/defaults.js +12 -0
- package/plugins/_lib/mail/hostGuard.js +86 -0
- package/plugins/_lib/mail/readRoutes.js +20 -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/_lib/public/call/call.js +3 -1
- package/plugins/blog/CLAUDE.md +11 -1
- package/plugins/blog/docs/guide.md +1 -1
- package/plugins/blog/lib/page.js +32 -0
- package/plugins/blog/plugin.js +58 -16
- package/plugins/blog/plugin.json +2 -2
- package/plugins/blog/plugin.public.js +5 -4
- package/plugins/blog/tests/public.test.js +30 -1
- package/plugins/booking/CLAUDE.md +4 -1
- package/plugins/booking/docs/guide.md +1 -1
- package/plugins/booking/plugin.js +16 -21
- package/plugins/booking/plugin.json +2 -2
- package/plugins/booking/tests/api.test.js +26 -1
- package/plugins/feedback/CLAUDE.md +7 -2
- package/plugins/feedback/admin/views/feedback.js +1 -1
- package/plugins/feedback/lib/receiver.js +1 -1
- package/plugins/feedback/plugin.json +3 -3
- package/plugins/free-tier.lock.json +63 -51
- package/plugins/mail-reader/CLAUDE.md +6 -0
- package/plugins/mail-reader/docs/guide.md +1 -0
- package/plugins/mail-reader/plugin.json +2 -2
- package/plugins/shopping-cart/CLAUDE.md +62 -1
- 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 +26 -1
- package/plugins/shopping-cart/inject/body.html +2 -2
- package/plugins/shopping-cart/lib/accounts.js +185 -0
- package/plugins/shopping-cart/lib/commission.js +90 -0
- package/plugins/shopping-cart/lib/extensions.js +12 -4
- package/plugins/shopping-cart/lib/payments.js +7 -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 +21 -3
- package/plugins/shopping-cart/plugin.js +180 -35
- package/plugins/shopping-cart/plugin.json +3 -3
- 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 +136 -0
- package/plugins/shopping-cart/tests/api.test.js +34 -0
- package/plugins/shopping-cart/tests/commission.test.js +90 -0
- package/plugins/shopping-cart/tests/pure.test.js +16 -1
- package/plugins/shopping-cart/tests/sales.test.js +73 -0
- package/plugins/support/CLAUDE.md +6 -0
- package/plugins/support/admin/views/support.js +1 -1
- package/plugins/support/docs/guide.md +90 -0
- package/plugins/support/lib/receiver.js +1 -1
- package/plugins/support/plugin.json +3 -3
- 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/analytics.js +32 -0
- package/server/routes/api/collections.js +148 -15
- package/server/routes/api/theme.js +12 -2
- package/server/routes/outreach-public.js +68 -0
- package/server/server.js +10 -1
- package/server/services/authChecks.js +8 -0
- package/server/services/collectionAccess.js +191 -0
- package/server/services/collections.js +8 -2
- package/server/services/markdown.js +7 -2
- package/server/services/outreachUnsubscribe.js +54 -0
- package/server/services/request-log.js +70 -0
- package/server/templates/page.html +3 -3
|
@@ -110,6 +110,7 @@ export function storefrontHtml({entries, settings, base, category = '', query =
|
|
|
110
110
|
const intro = settings.shopIntro ? `<p class="shop-intro">${esc(settings.shopIntro)}</p>` : '';
|
|
111
111
|
return `<header class="shop-hero">
|
|
112
112
|
<h1>${esc(settings.shopTitle || 'Shop')}</h1>${intro}
|
|
113
|
+
${settings.accounts ? `<a class="shop-linkbtn shop-acc-link" href="${esc(base)}/account">Your account</a>` : ''}
|
|
113
114
|
</header>
|
|
114
115
|
${chips || search || sort ? `<div class="shop-toolbar">${chips}<div class="shop-tools">${search}${sort}</div></div>` : ''}
|
|
115
116
|
${entries.length
|
|
@@ -181,7 +182,7 @@ ${related.length ? `<section class="shop-related"><h2>You may also like</h2>${gr
|
|
|
181
182
|
/** The basket and checkout, one page. The script fills it from the basket in the browser. */
|
|
182
183
|
export function cartHtml({settings, base}) {
|
|
183
184
|
const countries = (settings.countries || ['GB']).map(c => `<option value="${esc(c)}">${esc(COUNTRIES[c] || c)}</option>`).join('');
|
|
184
|
-
return `<nav class="shop-crumbs" aria-label="Breadcrumb"><a href="${esc(base)}">${ICON.back}Keep shopping</a
|
|
185
|
+
return `<nav class="shop-crumbs" aria-label="Breadcrumb"><a href="${esc(base)}">${ICON.back}Keep shopping</a>${settings.accounts ? ` <a class="shop-acc-link" href="${esc(base)}/account" data-acc-link>Sign in for faster checkout</a>` : ''}</nav>
|
|
185
186
|
<h1 class="shop-co-title">Your basket</h1>
|
|
186
187
|
<div class="shop-checkout" data-shop-checkout>
|
|
187
188
|
<div class="shop-co-main">
|
|
@@ -233,6 +234,16 @@ export function cartHtml({settings, base}) {
|
|
|
233
234
|
</div>`;
|
|
234
235
|
}
|
|
235
236
|
|
|
237
|
+
/** The account page's frame; public/account.js fills it (signed in or not is the browser's to know). */
|
|
238
|
+
export function accountHtml({settings, view = 'account'}) {
|
|
239
|
+
const title = view === 'reset' ? 'Set a new password' : view === 'confirm' ? 'Confirming your account…' : 'Your account';
|
|
240
|
+
return `<div class="shop-account" data-shop-account="${esc(view)}">
|
|
241
|
+
<h1>${esc(title)}</h1>
|
|
242
|
+
<div data-acc-body><noscript>Your account needs JavaScript switched on.</noscript></div>
|
|
243
|
+
<p class="shop-muted"><a class="shop-linkbtn" href="${esc(settings.shopPath)}">Back to the shop</a></p>
|
|
244
|
+
</div>`;
|
|
245
|
+
}
|
|
246
|
+
|
|
236
247
|
/**
|
|
237
248
|
* A shopper's receipt. `parts` are extensions' sections ([{owner, html}],
|
|
238
249
|
* their own HTML, escaped by them) - Shopping Cart Pro's downloads.
|
|
@@ -282,6 +293,7 @@ export function receiptHtml({order, settings, base, problem = '', parts = []}) {
|
|
|
282
293
|
${addr.length ? `<h2>${order.delivery === 'collect' ? 'Collection' : 'Delivering to'}</h2><address>${addr.map(esc).join('<br>')}</address>` : order.delivery === 'collect' ? `<h2>Collection</h2><p>${esc(settings.collectionLabel || 'Collect in person')}</p>` : ''}
|
|
283
294
|
<h2>Placed</h2><p>${esc(fmtDateTime(order.placedAt))}</p>
|
|
284
295
|
<p><a class="shop-linkbtn" href="${esc(base)}">Continue shopping</a> · <button type="button" class="shop-linkbtn" data-shop-print>Print</button></p>
|
|
296
|
+
${settings.accounts && !waiting && !closed ? `<div class="shop-r-join" data-shop-join data-email="${esc(order.email || '')}" data-name="${esc(order.customer?.name || '')}" hidden></div>` : ''}
|
|
285
297
|
</section>
|
|
286
298
|
</div>
|
|
287
299
|
</div>`;
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Sales report. PURE: orders in, figures out - for the Sales tab and its CSV.
|
|
3
|
+
*
|
|
4
|
+
* A sale is an order that took money (paid, fulfilled - or refunded later), dated by when it was PAID, in the
|
|
5
|
+
* shop's time zone. A refund is dated by when it was refunded, so a period's net is what really stayed.
|
|
6
|
+
*
|
|
7
|
+
* Every line is put to a SOURCE, from what the order already says about itself:
|
|
8
|
+
* takeaway - an order Takeaway placed (`ref: 'takeaway:…'`)
|
|
9
|
+
* downloads - a line for a product Shopping Cart Pro gave a download link or licence key for
|
|
10
|
+
* addons - an order another extension placed (any other `ref` - Cards Pro's credits, …)
|
|
11
|
+
* shop - everything else: the Shop's own products
|
|
12
|
+
*
|
|
13
|
+
* Platform commission (lib/commission.js) is the figure each order was given when it was paid; it counts on the day it
|
|
14
|
+
* was paid and comes back on the day of a refund.
|
|
15
|
+
*
|
|
16
|
+
* Amounts are minor units. Line amounts are before discounts; the order-level figures (discount, delivery, tax)
|
|
17
|
+
* are reported beside them, not shared out across sources.
|
|
18
|
+
*
|
|
19
|
+
* @module shopping-cart/lib/sales
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export const SOURCES = {shop: 'Shop', downloads: 'Downloads & keys', takeaway: 'Takeaway', addons: 'Other add-ons'};
|
|
23
|
+
const PROVIDERS = {stripe: 'Card (Stripe)', paypal: 'PayPal', offline: 'Paid offline', free: 'Free'};
|
|
24
|
+
const TOOK = new Set(['paid', 'fulfilled', 'refunded']);
|
|
25
|
+
const DAY = 86400000;
|
|
26
|
+
|
|
27
|
+
const parts = new Map();
|
|
28
|
+
/** The shop's calendar date of an instant: 'YYYY-MM-DD'. */
|
|
29
|
+
export function dateIn(t, tz = 'Europe/London') {
|
|
30
|
+
if (!parts.has(tz)) {
|
|
31
|
+
try { parts.set(tz, new Intl.DateTimeFormat('en-CA', {timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit'})); }
|
|
32
|
+
catch { parts.set(tz, new Intl.DateTimeFormat('en-CA', {timeZone: 'Europe/London', year: 'numeric', month: '2-digit', day: '2-digit'})); }
|
|
33
|
+
}
|
|
34
|
+
return parts.get(tz).format(new Date(t));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const addDays = (date, n) => { const [y, m, d] = date.split('-').map(Number); return new Date(Date.UTC(y, m - 1, d + n)).toISOString().slice(0, 10); };
|
|
38
|
+
const weekday = (date) => { const [y, m, d] = date.split('-').map(Number); return new Date(Date.UTC(y, m - 1, d)).getUTCDay(); };
|
|
39
|
+
|
|
40
|
+
/** The period a date falls in: the day, the Monday of its week, or the 1st of its month. */
|
|
41
|
+
export function periodOf(date, by) {
|
|
42
|
+
if (by === 'month') return `${date.slice(0, 7)}-01`;
|
|
43
|
+
if (by === 'week') return addDays(date, -((weekday(date) + 6) % 7));
|
|
44
|
+
return date;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Every period from `from` to `to`, in order, so empty ones show as empty. */
|
|
48
|
+
export function periodsBetween(from, to, by) {
|
|
49
|
+
const out = [];
|
|
50
|
+
let p = periodOf(from, by);
|
|
51
|
+
const last = periodOf(to, by);
|
|
52
|
+
for (let i = 0; p <= last && i < 800; i++) {
|
|
53
|
+
out.push(p);
|
|
54
|
+
if (by === 'month') { const [y, m] = p.split('-').map(Number); p = new Date(Date.UTC(y, m, 1)).toISOString().slice(0, 10); }
|
|
55
|
+
else p = addDays(p, by === 'week' ? 7 : 1);
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Which source an order line belongs to. */
|
|
61
|
+
export function sourceOf(order, line) {
|
|
62
|
+
const ref = String(order.data?.ref || '');
|
|
63
|
+
if (ref.startsWith('takeaway:')) return 'takeaway';
|
|
64
|
+
if (ref) return 'addons';
|
|
65
|
+
const pro = order.data?.ext?.['shopping-cart-pro'];
|
|
66
|
+
const digital = new Set([...(pro?.downloads || []), ...(pro?.keys || [])].map(x => x.productId));
|
|
67
|
+
if (line.productId && digital.has(line.productId)) return 'downloads';
|
|
68
|
+
return 'shop';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** One order's money, split the way the report shows it. */
|
|
72
|
+
export function orderFigures(o) {
|
|
73
|
+
const d = o.data;
|
|
74
|
+
const adj = d.adjustments || [];
|
|
75
|
+
const sum = (f) => adj.filter(f).reduce((n, a) => n + a.amount, 0);
|
|
76
|
+
return {
|
|
77
|
+
subtotal: d.subtotal || 0,
|
|
78
|
+
discount: -sum(a => a.stage === 'discount'),
|
|
79
|
+
delivery: sum(a => a.stage === 'shipping'),
|
|
80
|
+
taxAdded: sum(a => a.stage === 'tax' && !a.included),
|
|
81
|
+
taxIncluded: sum(a => a.stage === 'tax' && a.included),
|
|
82
|
+
total: d.total || 0
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The report for `from`..`to` (dates, both included), in periods of `by`.
|
|
88
|
+
*
|
|
89
|
+
* @param {{id, data, meta}[]} orders
|
|
90
|
+
* @param {{from: string, to: string, by?: 'day'|'week'|'month', timeZone?: string}} opts
|
|
91
|
+
*/
|
|
92
|
+
export function salesReport(orders, {from, to, by = 'day', timeZone = 'Europe/London'}) {
|
|
93
|
+
const inRange = (date) => date >= from && date <= to;
|
|
94
|
+
const periods = new Map(periodsBetween(from, to, by).map(p => [p, {period: p, orders: 0, takings: 0, refunds: 0, commission: 0}]));
|
|
95
|
+
const totals = {orders: 0, items: 0, subtotal: 0, discount: 0, delivery: 0, taxAdded: 0, taxIncluded: 0, takings: 0, refunds: 0, refundCount: 0,
|
|
96
|
+
commission: 0, commissionBack: 0};
|
|
97
|
+
const sources = Object.fromEntries(Object.keys(SOURCES).map(k => [k, {key: k, label: SOURCES[k], orders: 0, amount: 0}]));
|
|
98
|
+
const methods = new Map();
|
|
99
|
+
const takeaway = {collect: 0, deliver: 0};
|
|
100
|
+
|
|
101
|
+
for (const o of orders) {
|
|
102
|
+
const d = o.data || {};
|
|
103
|
+
if (!TOOK.has(d.status)) continue;
|
|
104
|
+
const paidDate = dateIn(d.paidAt || o.meta?.createdAt, timeZone);
|
|
105
|
+
if (inRange(paidDate)) {
|
|
106
|
+
const f = orderFigures(o);
|
|
107
|
+
totals.orders++;
|
|
108
|
+
for (const k of ['subtotal', 'discount', 'delivery', 'taxAdded', 'taxIncluded']) totals[k] += f[k];
|
|
109
|
+
totals.takings += f.total;
|
|
110
|
+
const seen = new Set();
|
|
111
|
+
for (const l of d.lines || []) {
|
|
112
|
+
const s = sourceOf(o, l);
|
|
113
|
+
sources[s].amount += l.lineTotal || 0;
|
|
114
|
+
totals.items += l.qty || 0;
|
|
115
|
+
if (!seen.has(s)) { sources[s].orders++; seen.add(s); }
|
|
116
|
+
}
|
|
117
|
+
if (String(d.ref || '').startsWith('takeaway:')) takeaway[d.delivery === 'ship' ? 'deliver' : 'collect']++;
|
|
118
|
+
const m = methods.get(d.provider) || {key: d.provider, label: PROVIDERS[d.provider] || d.provider || 'Other', orders: 0, takings: 0};
|
|
119
|
+
m.orders++; m.takings += f.total;
|
|
120
|
+
methods.set(d.provider, m);
|
|
121
|
+
const c = d.commission?.amount || 0;
|
|
122
|
+
totals.commission += c;
|
|
123
|
+
const p = periods.get(periodOf(paidDate, by));
|
|
124
|
+
if (p) { p.orders++; p.takings += f.total; p.commission += c; }
|
|
125
|
+
}
|
|
126
|
+
if (d.status === 'refunded') {
|
|
127
|
+
const refDate = dateIn(d.refundedAt || d.paidAt || o.meta?.createdAt, timeZone);
|
|
128
|
+
if (inRange(refDate)) {
|
|
129
|
+
totals.refunds += d.total || 0;
|
|
130
|
+
totals.refundCount++;
|
|
131
|
+
const p = periods.get(periodOf(refDate, by));
|
|
132
|
+
if (p) p.refunds += d.total || 0;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (d.commission?.reversed) {
|
|
136
|
+
const backDate = dateIn(d.commission.reversedAt || d.refundedAt || o.meta?.createdAt, timeZone);
|
|
137
|
+
if (inRange(backDate)) {
|
|
138
|
+
totals.commissionBack += d.commission.amount || 0;
|
|
139
|
+
const p = periods.get(periodOf(backDate, by));
|
|
140
|
+
if (p) p.commission -= d.commission.amount || 0;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
from, to, by,
|
|
146
|
+
totals: {...totals, net: totals.takings - totals.refunds, average: totals.orders ? Math.round(totals.takings / totals.orders) : 0,
|
|
147
|
+
commissionNet: totals.commission - totals.commissionBack, afterCommission: totals.takings - totals.refunds - (totals.commission - totals.commissionBack)},
|
|
148
|
+
periods: [...periods.values()].map(p => ({...p, net: p.takings - p.refunds})),
|
|
149
|
+
sources: Object.values(sources).filter(s => s.orders || s.key === 'shop'),
|
|
150
|
+
methods: [...methods.values()].sort((a, b) => b.takings - a.takings),
|
|
151
|
+
takeaway
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The named ranges the Sales tab offers, as {from, to} dates for `today`. */
|
|
156
|
+
export function rangeFor(name, today) {
|
|
157
|
+
const [y, m] = today.split('-').map(Number);
|
|
158
|
+
const monthStart = (yy, mm) => new Date(Date.UTC(yy, mm - 1, 1)).toISOString().slice(0, 10);
|
|
159
|
+
switch (name) {
|
|
160
|
+
case 'today': return {from: today, to: today};
|
|
161
|
+
case 'week': return {from: periodOf(today, 'week'), to: today};
|
|
162
|
+
case 'last7': return {from: addDays(today, -6), to: today};
|
|
163
|
+
case 'month': return {from: monthStart(y, m), to: today};
|
|
164
|
+
case 'lastmonth': return {from: monthStart(y, m - 1), to: addDays(monthStart(y, m), -1)};
|
|
165
|
+
case 'last30': return {from: addDays(today, -29), to: today};
|
|
166
|
+
case 'year': return {from: `${y}-01-01`, to: today};
|
|
167
|
+
default: return {from: addDays(today, -29), to: today};
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const csvCell = (v) => { const s = String(v ?? ''); return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; };
|
|
172
|
+
const pounds = (n) => ((n || 0) / 100).toFixed(2);
|
|
173
|
+
|
|
174
|
+
/** One row per order sold or refunded in the range - for the accountant. */
|
|
175
|
+
export function salesCsv(orders, {from, to, timeZone = 'Europe/London'}) {
|
|
176
|
+
const rows = [['Order', 'Paid', 'Status', 'Refunded', 'Sources', 'Payment', 'Customer', 'Subtotal', 'Discount', 'Delivery', 'Tax added', 'Tax included', 'Total', 'Commission', 'Commission rate']];
|
|
177
|
+
const list = orders.filter(o => TOOK.has(o.data?.status)).filter(o => {
|
|
178
|
+
const paid = dateIn(o.data.paidAt || o.meta?.createdAt, timeZone);
|
|
179
|
+
const refunded = o.data.refundedAt ? dateIn(o.data.refundedAt, timeZone) : '';
|
|
180
|
+
return (paid >= from && paid <= to) || (refunded && refunded >= from && refunded <= to);
|
|
181
|
+
}).sort((a, b) => String(a.data.paidAt || a.meta?.createdAt).localeCompare(String(b.data.paidAt || b.meta?.createdAt)));
|
|
182
|
+
for (const o of list) {
|
|
183
|
+
const d = o.data;
|
|
184
|
+
const f = orderFigures(o);
|
|
185
|
+
rows.push([`#${d.number}`, dateIn(d.paidAt || o.meta?.createdAt, timeZone), d.status, d.refundedAt ? dateIn(d.refundedAt, timeZone) : '',
|
|
186
|
+
[...new Set((d.lines || []).map(l => SOURCES[sourceOf(o, l)]))].join('; '), PROVIDERS[d.provider] || d.provider, d.customer?.name || d.email || '',
|
|
187
|
+
pounds(f.subtotal), pounds(f.discount), pounds(f.delivery), pounds(f.taxAdded), pounds(f.taxIncluded), pounds(f.total),
|
|
188
|
+
d.commission ? pounds(d.commission.reversed ? 0 : d.commission.amount) : '', d.commission ? `${d.commission.percent}% + ${pounds(d.commission.fixed)}` : '']);
|
|
189
|
+
}
|
|
190
|
+
return rows.map(r => r.map(csvCell).join(',')).join('\r\n') + '\r\n';
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export {DAY};
|
|
@@ -18,6 +18,14 @@ import {cleanProduct} from '../public/lib/products.js';
|
|
|
18
18
|
import {NEXT, SOLD, canMove} from '../public/lib/orders.js';
|
|
19
19
|
import {currencyOf} from '../public/lib/money.js';
|
|
20
20
|
import {cartRegistry} from './extensions.js';
|
|
21
|
+
import {commissionFor, currentCommission} from './commission.js';
|
|
22
|
+
|
|
23
|
+
/** The platform's commission on a sale being paid now - kept if it already has one. */
|
|
24
|
+
function commissionOn(d, at) {
|
|
25
|
+
if (d.commission) return {};
|
|
26
|
+
const c = commissionFor({data: d}, currentCommission().rates, at);
|
|
27
|
+
return c ? {commission: c} : {};
|
|
28
|
+
}
|
|
21
29
|
import defaults from '../config.js';
|
|
22
30
|
|
|
23
31
|
export const NAME = 'shopping-cart';
|
|
@@ -98,6 +106,7 @@ export function cleanSettings(input, current) {
|
|
|
98
106
|
out.lowStockAt = Math.max(0, Math.min(1000, Math.round(Number(input.lowStockAt ?? current.lowStockAt) || 0)));
|
|
99
107
|
out.showStockLeft = bool('showStockLeft');
|
|
100
108
|
out.termsUrl = str('termsUrl', 300);
|
|
109
|
+
out.accounts = bool('accounts');
|
|
101
110
|
if (out.termsUrl && !/^(\/(?!\/)|https?:\/\/)/.test(out.termsUrl)) errors.termsUrl = 'A page on this site (/terms) or a full https:// address.';
|
|
102
111
|
|
|
103
112
|
const countries = Array.isArray(input.countries) ? input.countries : (current.countries || ['GB']);
|
|
@@ -360,6 +369,7 @@ export function markPaid(id, info = {}) {
|
|
|
360
369
|
stockTaken: true,
|
|
361
370
|
...(info.ref ? {providerRef: info.ref} : {}),
|
|
362
371
|
...(info.payment ? {paymentRef: info.payment} : {}),
|
|
372
|
+
...commissionOn(d, now),
|
|
363
373
|
history: [...(d.history || []), {at: now, status: 'paid', by: info.by || d.provider, note: info.note || ''}]
|
|
364
374
|
});
|
|
365
375
|
return {order, changed: true, from: d.status};
|
|
@@ -372,8 +382,13 @@ export function markPaid(id, info = {}) {
|
|
|
372
382
|
*
|
|
373
383
|
* @returns {Promise<{order: object, from: string}>}
|
|
374
384
|
*/
|
|
375
|
-
export function moveOrder(id, to,
|
|
376
|
-
return withLock(
|
|
385
|
+
export function moveOrder(id, to, opts = {}) {
|
|
386
|
+
return withLock(() => moveOrderNow(id, to, opts));
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** moveOrder's body - for a caller that already holds the lock (a refund wraps the provider call and the move in one). */
|
|
390
|
+
export async function moveOrderNow(id, to, {by = 'admin', note = '', restock = false, tracking = null} = {}) {
|
|
391
|
+
{
|
|
377
392
|
const had = await getOrder(id);
|
|
378
393
|
if (!had) throw Object.assign(new Error('No such order.'), {status: 404});
|
|
379
394
|
const d = had.data;
|
|
@@ -384,6 +399,9 @@ export function moveOrder(id, to, {by = 'admin', note = '', restock = false, tra
|
|
|
384
399
|
const patch = {status: to, history: [...(d.history || []), {at: now, status: to, by, note: String(note || '').slice(0, 500)}]};
|
|
385
400
|
if (to === 'paid' && !d.stockTaken) { await moveStock(d, -1); patch.stockTaken = true; }
|
|
386
401
|
if (to === 'paid' && !d.paidAt) patch.paidAt = now;
|
|
402
|
+
if (to === 'paid') Object.assign(patch, commissionOn(d, now));
|
|
403
|
+
// A refund gives the platform's commission back too (statements net it off in the month of the refund).
|
|
404
|
+
if (to === 'refunded' && d.commission && !d.commission.reversed) patch.commission = {...d.commission, reversed: true, reversedAt: now};
|
|
387
405
|
if (to === 'fulfilled') {
|
|
388
406
|
patch.fulfilledAt = now;
|
|
389
407
|
if (tracking) patch.tracking = tracking;
|
|
@@ -395,7 +413,7 @@ export function moveOrder(id, to, {by = 'admin', note = '', restock = false, tra
|
|
|
395
413
|
}
|
|
396
414
|
const order = await updateEntry(ORDERS, id, {...d, ...patch});
|
|
397
415
|
return {order, from: d.status};
|
|
398
|
-
}
|
|
416
|
+
}
|
|
399
417
|
}
|
|
400
418
|
|
|
401
419
|
/** The admin's own note and the tracking details - no status change. */
|
|
@@ -23,35 +23,61 @@
|
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
25
|
import {getConfig} from '../../server/config.js';
|
|
26
|
+
import {originOf, trustedOrigin} from '../../server/services/passwordReset.js';
|
|
26
27
|
import {cleanCheckout, orderLabel} from './public/lib/orders.js';
|
|
27
28
|
import {formatMoney} from './public/lib/money.js';
|
|
28
29
|
import {publicProduct, byShelf} from './public/lib/products.js';
|
|
29
30
|
import {cartRegistry} from './lib/extensions.js';
|
|
30
31
|
import {SAMPLES} from './lib/samples.js';
|
|
32
|
+
import * as accounts from './lib/accounts.js';
|
|
33
|
+
import {dateIn, rangeFor, salesCsv, salesReport} from './lib/sales.js';
|
|
34
|
+
import {TYPES as COMMISSION_TYPES, currentCommission, rateFor, rateText} from './lib/commission.js';
|
|
35
|
+
import {getUserById} from '../../server/services/users.js';
|
|
31
36
|
import {cleanCharges, cleanCustomLines, cleanLines, priceBasket} from './lib/pricing.js';
|
|
32
37
|
import {badge, summarise} from './lib/summary.js';
|
|
33
38
|
import * as pay from './lib/payments.js';
|
|
34
39
|
import {
|
|
35
40
|
NAME, allOrders, allProducts, annotateOrder, cleanSettings, cleanTracking, createOrder, ensureCollections, extendOrder,
|
|
36
|
-
getOrder, getProduct, liveSettings, markPaid, maskSettings, methodsOf, moveOrder, publicOrder, removeOrder,
|
|
37
|
-
refreshShelf, removeProduct, saveProduct, saveSettings, setProviderRef, settingsNow, shelfNow, tokenOk, updateProductExt
|
|
41
|
+
getOrder, getProduct, liveSettings, markPaid, maskSettings, methodsOf, moveOrder, moveOrderNow, publicOrder, removeOrder,
|
|
42
|
+
refreshShelf, removeProduct, saveProduct, saveSettings, setProviderRef, settingsNow, shelfNow, tokenOk, updateProductExt, withLock
|
|
38
43
|
} from './lib/shop.js';
|
|
39
44
|
import {cardHtml, esc, gridHtml} from './lib/render.js';
|
|
40
45
|
|
|
41
|
-
/** The
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
/** Where this site lives, for the addresses a payment provider sends shoppers back to. */
|
|
45
|
-
function siteBase(request) {
|
|
46
|
+
/** The Site URL setting as an origin, or ''. */
|
|
47
|
+
function configuredBase() {
|
|
46
48
|
let base = '';
|
|
47
49
|
try { base = String(getConfig('site')?.baseUrl || ''); } catch { /* no site config */ }
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
return /^https?:\/\/[^/\s]+/i.test(base) ? base.replace(/\/+$/, '') : '';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Where this site lives, for the addresses a payment provider sends shoppers back to: the Site URL setting,
|
|
55
|
+
* else the origin of the shopper's own request (they land back where they came from). Never remembered for
|
|
56
|
+
* later: any request can claim any Host, and a kept one would put a stranger's address - and the receipt
|
|
57
|
+
* tokens - in every customer email until the next request.
|
|
58
|
+
*/
|
|
59
|
+
function siteBase(request) {
|
|
60
|
+
return configuredBase() || (request ? `${request.protocol}://${request.host || request.headers.host || request.hostname}` : '');
|
|
53
61
|
}
|
|
54
62
|
|
|
63
|
+
let warnedNoBase = false;
|
|
64
|
+
let warn = (msg) => console.warn(msg);
|
|
65
|
+
/**
|
|
66
|
+
* The address for links with no shopper's request behind them (emails, links handed to other Tools): core's
|
|
67
|
+
* rule as for password resets - the Site URL, else an address someone has really signed in on. '' when the
|
|
68
|
+
* site knows none; the email then goes without its link, and the log says why once.
|
|
69
|
+
*/
|
|
70
|
+
async function quietBase() {
|
|
71
|
+
const base = configuredBase() || await trustedOrigin('').catch(() => '');
|
|
72
|
+
if (!base && !warnedNoBase) {
|
|
73
|
+
warnedNoBase = true;
|
|
74
|
+
warn('[shopping-cart] Site URL is not set (Site Settings): emails go out without links to the order until it is.');
|
|
75
|
+
}
|
|
76
|
+
return base;
|
|
77
|
+
}
|
|
78
|
+
/** The same rule for an email answering a request (account confirm, forgotten password). */
|
|
79
|
+
const linkBase = (request) => configuredBase() || trustedOrigin(originOf(request)).catch(() => '');
|
|
80
|
+
|
|
55
81
|
const receiptPath = (s, order) => `${s.shopPath}/order/${order.id}?t=${order.data.token}`;
|
|
56
82
|
|
|
57
83
|
/** A problem for the shopper, sent as JSON with a status. */
|
|
@@ -134,6 +160,7 @@ function registerShortcodes(hooks) {
|
|
|
134
160
|
}
|
|
135
161
|
|
|
136
162
|
export default async function shoppingCart(fastify, options) {
|
|
163
|
+
warn = (msg) => fastify.log.warn(msg);
|
|
137
164
|
const {authenticate, requirePermission} = options.auth;
|
|
138
165
|
const hooks = options.hooks || {};
|
|
139
166
|
const can = (action) => ({preHandler: [authenticate, requirePermission('shop', action)]});
|
|
@@ -177,7 +204,10 @@ export default async function shoppingCart(fastify, options) {
|
|
|
177
204
|
name: NAME,
|
|
178
205
|
liveSettings, allOrders, getOrder, allProducts, getProduct, extendOrder, publicOrder,
|
|
179
206
|
formatMoney, orderLabel,
|
|
207
|
+
/** The Site URL as an origin, or '' - sync, for the shopper-facing paths. Email links: `linkBase`. */
|
|
180
208
|
siteBase: () => siteBase(null),
|
|
209
|
+
/** The address for links in emails (async): the Site URL, else an address someone has really signed in on, else ''. */
|
|
210
|
+
linkBase: quietBase,
|
|
181
211
|
/** Change only an extension's part of a product, under the order lock (lib/shop.js updateProductExt). */
|
|
182
212
|
updateProductExt,
|
|
183
213
|
/** Move an order on (Shopping Cart Pro fulfils an order with nothing to post), telling every extension. */
|
|
@@ -186,7 +216,7 @@ export default async function shoppingCart(fastify, options) {
|
|
|
186
216
|
announceMove(order, from, order.data.status);
|
|
187
217
|
return order;
|
|
188
218
|
},
|
|
189
|
-
receiptUrl: (order) => liveSettings()
|
|
219
|
+
receiptUrl: async (order) => { const [s, base] = await Promise.all([liveSettings(), quietBase()]); return base ? `${base}${receiptPath(s, order)}` : ''; }
|
|
190
220
|
};
|
|
191
221
|
|
|
192
222
|
// ---- Admin: overview, badge, extensions ----------------------------------------
|
|
@@ -201,6 +231,38 @@ export default async function shoppingCart(fastify, options) {
|
|
|
201
231
|
};
|
|
202
232
|
});
|
|
203
233
|
|
|
234
|
+
/**
|
|
235
|
+
* The Sales report: ?range=today|week|last7|month|lastmonth|last30|year, or ?from=&to= (dates), and ?by=day|week|month.
|
|
236
|
+
* Dates are the shop's (settings.timeZone, Europe/London unless set).
|
|
237
|
+
*/
|
|
238
|
+
const salesRange = (q, s) => {
|
|
239
|
+
const today = dateIn(Date.now(), s.timeZone || 'Europe/London');
|
|
240
|
+
const isDate = (d) => /^\d{4}-\d{2}-\d{2}$/.test(String(d || ''));
|
|
241
|
+
let {from, to} = isDate(q.from) && isDate(q.to) ? {from: q.from, to: q.to} : rangeFor(String(q.range || 'last30'), today);
|
|
242
|
+
if (from > to) [from, to] = [to, from];
|
|
243
|
+
return {from, to, today};
|
|
244
|
+
};
|
|
245
|
+
/** The platform's commission for this site, for people: each kind of sale and its rate. Read-only here. */
|
|
246
|
+
const commissionInfo = (s) => {
|
|
247
|
+
const {rates, updatedAt} = currentCommission();
|
|
248
|
+
const money = (n) => formatMoney(n, s.currency);
|
|
249
|
+
return {set: Object.keys(rates).length > 0, updatedAt,
|
|
250
|
+
rates: Object.entries(COMMISSION_TYPES).map(([k, label]) => ({key: k, label, text: rateText(rateFor(k, rates), money)}))};
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
fastify.get('/sales', can('read'), async (request) => {
|
|
254
|
+
const [orders, s] = await Promise.all([allOrders(), liveSettings()]);
|
|
255
|
+
const {from, to, today} = salesRange(request.query || {}, s);
|
|
256
|
+
const by = ['day', 'week', 'month'].includes(request.query?.by) ? request.query.by : 'day';
|
|
257
|
+
return {...salesReport(orders, {from, to, by, timeZone: s.timeZone || 'Europe/London'}), today, currency: s.currency, commission: commissionInfo(s)};
|
|
258
|
+
});
|
|
259
|
+
fastify.get('/sales.csv', can('read'), async (request, reply) => {
|
|
260
|
+
const [orders, s] = await Promise.all([allOrders(), liveSettings()]);
|
|
261
|
+
const {from, to} = salesRange(request.query || {}, s);
|
|
262
|
+
return reply.type('text/csv; charset=utf-8').header('Content-Disposition', `attachment; filename="sales-${from}-to-${to}.csv"`)
|
|
263
|
+
.header('Cache-Control', 'no-store').send(salesCsv(orders, {from, to, timeZone: s.timeZone || 'Europe/London'}));
|
|
264
|
+
});
|
|
265
|
+
|
|
204
266
|
fastify.get('/count', can('read'), async () => {
|
|
205
267
|
const [orders, settings, products] = await Promise.all([allOrders(), liveSettings(), allProducts()]);
|
|
206
268
|
return badge(orders, settings, Date.now(), products);
|
|
@@ -330,26 +392,32 @@ export default async function shoppingCart(fastify, options) {
|
|
|
330
392
|
/** Refund an order in full (see above). Throws {status, message}. */
|
|
331
393
|
async function refundOrder(id, {restock = false, note = '', viaProvider = true, by = 'admin'} = {}) {
|
|
332
394
|
const bad = (status, message) => Object.assign(new Error(message), {status});
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
if (
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
395
|
+
// The whole refund runs under the order lock: two refunds at once would both reach the provider, and a
|
|
396
|
+
// write landing between the provider's yes and ours would be lost. The provider call carries an
|
|
397
|
+
// Idempotency-Key from the order id, so a retry after a timeout returns the first refund, not a second.
|
|
398
|
+
const {order, from, how} = await withLock(async () => {
|
|
399
|
+
const o = await getOrder(id);
|
|
400
|
+
if (!o) throw bad(404, 'No such order.');
|
|
401
|
+
const d = o.data;
|
|
402
|
+
if (!['paid', 'fulfilled'].includes(d.status)) throw bad(409, 'Only a paid order can be refunded.');
|
|
403
|
+
const s = await liveSettings();
|
|
404
|
+
let via = 'marked refunded';
|
|
405
|
+
try {
|
|
406
|
+
if (viaProvider && d.provider === 'stripe' && d.paymentRef) {
|
|
407
|
+
if (!s.stripeSecretKey) throw bad(409, 'Stripe is not set up any more - refund it in Stripe, then mark it refunded here.');
|
|
408
|
+
await pay.stripeRefund({secretKey: s.stripeSecretKey, paymentIntent: d.paymentRef, idempotencyKey: `${NAME}-refund-${o.id}`});
|
|
409
|
+
via = 'refunded through Stripe';
|
|
410
|
+
} else if (viaProvider && d.provider === 'paypal' && d.paymentRef) {
|
|
411
|
+
if (!s.paypalClientId || !s.paypalSecret) throw bad(409, 'PayPal is not set up any more - refund it in PayPal, then mark it refunded here.');
|
|
412
|
+
await pay.paypalRefund({creds: {clientId: s.paypalClientId, secret: s.paypalSecret, mode: s.paypalMode}, captureId: d.paymentRef});
|
|
413
|
+
via = 'refunded through PayPal';
|
|
414
|
+
}
|
|
415
|
+
} catch (err) {
|
|
416
|
+
throw bad(err.status || 502, err.status === 409 ? err.message : `Not refunded: ${err.message}`);
|
|
348
417
|
}
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
}
|
|
352
|
-
const {order, from} = await moveOrder(o.id, 'refunded', {by, note: [how, String(note || '')].filter(Boolean).join(' - '), restock: Boolean(restock)});
|
|
418
|
+
const moved = await moveOrderNow(o.id, 'refunded', {by, note: [via, String(note || '')].filter(Boolean).join(' - '), restock: Boolean(restock)});
|
|
419
|
+
return {...moved, how: via};
|
|
420
|
+
});
|
|
353
421
|
announceMove(order, from, 'refunded');
|
|
354
422
|
return {order, how};
|
|
355
423
|
}
|
|
@@ -379,7 +447,8 @@ export default async function shoppingCart(fastify, options) {
|
|
|
379
447
|
return {
|
|
380
448
|
settings: maskSettings(s),
|
|
381
449
|
webhookUrl: `${siteBase(request)}/api/plugins/shopping-cart/stripe/webhook`,
|
|
382
|
-
stripeMode: pay.stripeMode(s.stripeSecretKey)
|
|
450
|
+
stripeMode: pay.stripeMode(s.stripeSecretKey),
|
|
451
|
+
commission: commissionInfo(s)
|
|
383
452
|
};
|
|
384
453
|
});
|
|
385
454
|
|
|
@@ -413,6 +482,83 @@ export default async function shoppingCart(fastify, options) {
|
|
|
413
482
|
}
|
|
414
483
|
});
|
|
415
484
|
|
|
485
|
+
// ---- Customer accounts (lib/accounts.js) --------------------------------------------
|
|
486
|
+
//
|
|
487
|
+
// Optional (settings.accounts). Made, confirmed and described here; signed in, refreshed and given a new
|
|
488
|
+
// password by core's own /api/auth routes. A signed-in customer sends core's Bearer token.
|
|
489
|
+
|
|
490
|
+
// A boolean, the reply sent on the side: awaiting a Fastify reply resolves (empty) once it is sent, so
|
|
491
|
+
// `if (await fail(...))` would carry on and run the route anyway.
|
|
492
|
+
const accountsOff = async (reply) => {
|
|
493
|
+
if ((await liveSettings()).accounts) return false;
|
|
494
|
+
fail(reply, 404, 'Customer accounts are not switched on.');
|
|
495
|
+
return true;
|
|
496
|
+
};
|
|
497
|
+
/** `rest` -> an absolute address under the shop, for emails. */
|
|
498
|
+
const shopLink = async (request) => {
|
|
499
|
+
const s = await liveSettings();
|
|
500
|
+
const base = await linkBase(request);
|
|
501
|
+
return {s, base, link: (rest) => `${base}${s.shopPath}/${rest}`};
|
|
502
|
+
};
|
|
503
|
+
const accountFail = (reply, err) => fail(reply, err.status || 500, err.message, err.errors ? {errors: err.errors} : {});
|
|
504
|
+
|
|
505
|
+
fastify.post('/account/register', {config: {rateLimit: {max: 5, timeWindow: '10 minutes'}}}, async (request, reply) => {
|
|
506
|
+
if (await accountsOff(reply)) return undefined;
|
|
507
|
+
const {s, base, link} = await shopLink(request);
|
|
508
|
+
// A confirm link needs an address the site trusts; the Host header is not one.
|
|
509
|
+
if (!base) return fail(reply, 409, 'Accounts cannot be made just now - please check out as a guest.');
|
|
510
|
+
try {
|
|
511
|
+
return await accounts.register(request.body || {}, {link, shopTitle: s.shopTitle || 'the shop', log: request.log});
|
|
512
|
+
} catch (err) { return accountFail(reply, err); }
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
fastify.post('/account/confirm', {config: {rateLimit: {max: 20, timeWindow: '10 minutes'}}}, async (request, reply) => {
|
|
516
|
+
if (await accountsOff(reply)) return undefined;
|
|
517
|
+
try {
|
|
518
|
+
// Nothing about the account comes back - not even its email: `u` is a user id, and those are public.
|
|
519
|
+
await accounts.confirm(request.body?.u, request.body?.t);
|
|
520
|
+
return {confirmed: true};
|
|
521
|
+
} catch (err) { return accountFail(reply, err); }
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
fastify.post('/account/forgot', {config: {rateLimit: {max: 5, timeWindow: '10 minutes'}}}, async (request, reply) => {
|
|
525
|
+
if (await accountsOff(reply)) return undefined;
|
|
526
|
+
const {s, base, link} = await shopLink(request);
|
|
527
|
+
if (base) await accounts.forgot(request.body?.email, {link, shopTitle: s.shopTitle || 'the shop', log: request.log}).catch(() => null);
|
|
528
|
+
else request.log.warn('[shopping-cart] password reset not sent: the site has no trusted address (set the Site URL)');
|
|
529
|
+
// The same answer whoever asked: the form must not tell anyone who has an account.
|
|
530
|
+
return {ok: true};
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
/** The signed-in customer: details, saved addresses and the orders placed with their email. */
|
|
534
|
+
fastify.get('/account', {preHandler: [authenticate]}, async (request, reply) => {
|
|
535
|
+
const user = await getUserById(request.user.id);
|
|
536
|
+
if (!user || !user.isActive) return fail(reply, 401, 'Please sign in again.');
|
|
537
|
+
const s = await liveSettings();
|
|
538
|
+
const mine = accounts.ordersFor(await allOrders(), user.email).slice(0, 50);
|
|
539
|
+
const orders = [];
|
|
540
|
+
for (const o of mine) {
|
|
541
|
+
orders.push({id: o.id, number: o.data.number, status: o.data.status, total: o.data.total, currency: o.data.currency,
|
|
542
|
+
placedAt: o.meta?.createdAt, receipt: receiptPath(s, o), lines: (o.data.lines || []).map(l => `${l.qty} × ${l.name}${l.variantName ? ` (${l.variantName})` : ''}`),
|
|
543
|
+
parts: await registry.accountParts(o, {settings: s})});
|
|
544
|
+
}
|
|
545
|
+
return {...accounts.describe(user), orders, accounts: Boolean(s.accounts)};
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
fastify.put('/account', {preHandler: [authenticate]}, async (request, reply) => {
|
|
549
|
+
try {
|
|
550
|
+
const b = request.body || {};
|
|
551
|
+
return await accounts.updateDetails(request.user.id, {name: b.name, phone: b.phone, addresses: b.addresses});
|
|
552
|
+
} catch (err) { return accountFail(reply, err); }
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
fastify.delete('/account', {preHandler: [authenticate], config: {rateLimit: {max: 5, timeWindow: '10 minutes'}}}, async (request, reply) => {
|
|
556
|
+
try {
|
|
557
|
+
await accounts.remove(request.user.id, request.body?.password);
|
|
558
|
+
return {deleted: true};
|
|
559
|
+
} catch (err) { return accountFail(reply, err); }
|
|
560
|
+
});
|
|
561
|
+
|
|
416
562
|
// ---- Shoppers --------------------------------------------------------------------
|
|
417
563
|
|
|
418
564
|
fastify.get('/catalogue', async (request) => {
|
|
@@ -422,7 +568,6 @@ export default async function shoppingCart(fastify, options) {
|
|
|
422
568
|
});
|
|
423
569
|
|
|
424
570
|
fastify.post('/quote', async (request) => {
|
|
425
|
-
siteBase(request);
|
|
426
571
|
const s = await liveSettings();
|
|
427
572
|
const {quote} = await quoteFor(request.body, s);
|
|
428
573
|
return shopperQuote(quote, s);
|
|
@@ -581,7 +726,7 @@ export default async function shoppingCart(fastify, options) {
|
|
|
581
726
|
const methods = methodsOf(s).map(m => m.id);
|
|
582
727
|
const via = quote.total === 0 ? 'free' : (provider || methods[0] || '');
|
|
583
728
|
if (via !== 'free' && !methods.includes(via)) throw bad(409, 'The Shop has no way to take payment set up yet.');
|
|
584
|
-
const origin = /^https?:\/\/[^/\s]+$/i.test(base) ? base.replace(/\/+$/, '') :
|
|
729
|
+
const origin = configuredBase() || (/^https?:\/\/[^/\s]+$/i.test(base) ? base.replace(/\/+$/, '') : await quietBase());
|
|
585
730
|
const back = /^\/(?!\/)/.test(String(returnTo)) ? `${origin}${returnTo}` : '';
|
|
586
731
|
const order = await createOrder({quote, checkout, provider: via, delivery: deliver ? 'ship' : delivery === 'collect' ? 'collect' : 'none',
|
|
587
732
|
code: String(code || '').slice(0, 60), ref, returnTo: back, ownEmails: ownEmails === true, payNote});
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shopping-cart",
|
|
3
3
|
"displayName": "Shopping Cart",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.6.1",
|
|
5
5
|
"tier": "free",
|
|
6
6
|
"description": "Sell from your site: products with options, stock and pictures, a storefront and a cart that follows shoppers from page to page, and checkout with Stripe, PayPal or payment offline. Orders to fulfil in the sidebar, refunds from the order.",
|
|
7
7
|
"author": "Domma CMS",
|
|
8
|
-
"date": "2026-10-
|
|
8
|
+
"date": "2026-10-13",
|
|
9
9
|
"icon": "shopping-bag",
|
|
10
10
|
"permissions": [
|
|
11
11
|
{
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
],
|
|
46
46
|
"views": {
|
|
47
47
|
"plugin-shopping-cart": {
|
|
48
|
-
"entry": "shopping-cart/admin/views/shop.js?v=1.
|
|
48
|
+
"entry": "shopping-cart/admin/views/shop.js?v=1.6.0",
|
|
49
49
|
"exportName": "shopView"
|
|
50
50
|
}
|
|
51
51
|
}
|