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.
- package/admin/js/api.js +1 -1
- package/admin/js/lib/effect-defs.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 +3 -3
- package/admin/js/templates/docs/usage-site-settings.html +2 -1
- package/admin/js/templates/page-editor.html +2 -1
- package/admin/js/templates/pages.html +1 -1
- package/admin/js/templates/theme.html +1 -1
- package/admin/js/views/pages.js +3 -3
- package/admin/js/views/theme.js +5 -5
- package/package.json +1 -1
- package/plugins/_lib/outreach/audience.js +76 -0
- package/plugins/_lib/outreach/collections/outreach-contacts.schema.json +12 -0
- package/plugins/_lib/outreach/collections/outreach-groups.schema.json +10 -0
- package/plugins/_lib/outreach/collections/outreach-unsubscribes.schema.json +11 -0
- package/plugins/_lib/outreach/expiry.js +46 -0
- package/plugins/_lib/outreach/index.js +15 -0
- package/plugins/_lib/outreach/ledger.js +127 -0
- package/plugins/_lib/outreach/sending.js +121 -0
- package/plugins/_lib/outreach/suppression.js +62 -0
- package/plugins/_lib/outreach/tokens.js +35 -0
- package/plugins/_lib/outreach/unsubscribe.js +46 -0
- package/plugins/free-tier.lock.json +35 -24
- package/plugins/shopping-cart/CLAUDE.md +64 -0
- package/plugins/shopping-cart/admin/css/index.css +16 -0
- package/plugins/shopping-cart/admin/lib/chart.js +64 -0
- package/plugins/shopping-cart/admin/lib/kit.js +15 -0
- package/plugins/shopping-cart/admin/views/orders.js +3 -2
- package/plugins/shopping-cart/admin/views/overview.js +4 -53
- package/plugins/shopping-cart/admin/views/sales.js +133 -0
- package/plugins/shopping-cart/admin/views/settings.js +3 -1
- package/plugins/shopping-cart/admin/views/shop.js +2 -1
- package/plugins/shopping-cart/config.js +1 -0
- package/plugins/shopping-cart/docs/guide.md +25 -0
- package/plugins/shopping-cart/inject/body.html +2 -2
- package/plugins/shopping-cart/lib/accounts.js +180 -0
- package/plugins/shopping-cart/lib/commission.js +90 -0
- package/plugins/shopping-cart/lib/extensions.js +12 -4
- package/plugins/shopping-cart/lib/pricing.js +65 -13
- package/plugins/shopping-cart/lib/render.js +17 -4
- package/plugins/shopping-cart/lib/sales.js +193 -0
- package/plugins/shopping-cart/lib/shop.js +24 -3
- package/plugins/shopping-cart/lib/summary.js +4 -2
- package/plugins/shopping-cart/plugin.js +156 -22
- package/plugins/shopping-cart/plugin.json +2 -2
- package/plugins/shopping-cart/plugin.public.js +17 -1
- package/plugins/shopping-cart/public/account.js +277 -0
- package/plugins/shopping-cart/public/cart.js +16 -0
- package/plugins/shopping-cart/public/lib/session.js +82 -0
- package/plugins/shopping-cart/public/shop.css +27 -0
- package/plugins/shopping-cart/roles/shop-customer.json +8 -0
- package/plugins/shopping-cart/tests/accounts.test.js +127 -0
- package/plugins/shopping-cart/tests/api.test.js +63 -0
- package/plugins/shopping-cart/tests/commission.test.js +90 -0
- package/plugins/shopping-cart/tests/pure.test.js +30 -1
- package/plugins/shopping-cart/tests/sales.test.js +73 -0
- package/public/css/site.css +1 -1
- package/public/js/effects.js +1 -1
- package/public/js/scribe-style.mjs +1 -1
- package/public/js/shortcode-expiry.mjs +1 -0
- package/public/js/site.js +1 -1
- package/public/js/text-style.mjs +1 -1
- package/server/routes/api/theme.js +12 -2
- package/server/routes/outreach-public.js +68 -0
- package/server/routes/public.js +24 -1
- package/server/server.js +5 -0
- package/server/services/content.js +7 -7
- package/server/services/markdown.js +15 -5
- package/server/services/outreachUnsubscribe.js +54 -0
- package/server/services/shortcodeExpiry.js +146 -0
- package/server/templates/page.html +3 -3
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* panels: [{key, label, icon, module, help, on}], // a section in the product editor or an order (on: 'product' | 'order')
|
|
18
18
|
* product: {clean(input, {had, copy, product, variantMap})}, // checks what a product panel saves (product.ext[owner])
|
|
19
19
|
* receipt(order, {settings}), // HTML for the shopper's receipt page (escape it yourself)
|
|
20
|
+
* account(order, {settings}), // HTML beside an order on a customer's account page (Reorder)
|
|
20
21
|
* checkout: {codes: true} // storefront features to show
|
|
21
22
|
* });
|
|
22
23
|
*
|
|
@@ -55,6 +56,7 @@ export function cartRegistry() {
|
|
|
55
56
|
panels: Array.isArray(ext.panels) ? ext.panels : [],
|
|
56
57
|
product: ext.product || {},
|
|
57
58
|
receipt: typeof ext.receipt === 'function' ? ext.receipt : null,
|
|
59
|
+
account: typeof ext.account === 'function' ? ext.account : null,
|
|
58
60
|
checkout: ext.checkout || {},
|
|
59
61
|
order: Number(ext.order) || 0
|
|
60
62
|
});
|
|
@@ -128,17 +130,23 @@ export function cartRegistry() {
|
|
|
128
130
|
* What extensions add to a shopper's receipt: [{owner, html}]. Each has
|
|
129
131
|
* a few seconds; one that fails or dawdles is left out, never the page.
|
|
130
132
|
*/
|
|
131
|
-
|
|
132
|
-
|
|
133
|
+
receiptParts(order, ctx = {}) { return reg.parts('receipt', order, ctx); },
|
|
134
|
+
|
|
135
|
+
/** What extensions put beside an order on a customer's account page: [{owner, html}]. */
|
|
136
|
+
accountParts(order, ctx = {}) { return reg.parts('account', order, ctx); },
|
|
137
|
+
|
|
138
|
+
/** One HTML part per extension that has a `kind` handler; each has a few seconds. */
|
|
139
|
+
async parts(kind, order, ctx = {}) {
|
|
140
|
+
const parts = await Promise.all(reg.list().filter(e => e[kind]).map(async (e) => {
|
|
133
141
|
let timer;
|
|
134
142
|
try {
|
|
135
143
|
const html = await Promise.race([
|
|
136
|
-
Promise.resolve().then(() => e
|
|
144
|
+
Promise.resolve().then(() => e[kind](order, ctx)),
|
|
137
145
|
new Promise((_, no) => { timer = setTimeout(() => no(new Error('took too long')), RECEIPT_WAIT); })
|
|
138
146
|
]);
|
|
139
147
|
return typeof html === 'string' && html.trim() ? {owner: e.owner, html} : null;
|
|
140
148
|
} catch (err) {
|
|
141
|
-
console.warn(`[shopping-cart] ${e.owner} failed on the
|
|
149
|
+
console.warn(`[shopping-cart] ${e.owner} failed on the ${kind}: ${err?.message || err}`);
|
|
142
150
|
return null;
|
|
143
151
|
} finally {
|
|
144
152
|
clearTimeout(timer);
|
|
@@ -14,11 +14,16 @@
|
|
|
14
14
|
* `{label, amount}` in minor units, negative for a discount; `included: true`
|
|
15
15
|
* for tax already inside the prices (shown, not added).
|
|
16
16
|
*
|
|
17
|
+
* An extension's own checkout (services.startCheckout - Takeaway's dishes) may
|
|
18
|
+
* add lines it priced itself (`custom`, never from a browser) and fixed
|
|
19
|
+
* `charges` (its delivery fee), which stand in for the shipping stage - so tax
|
|
20
|
+
* treats them as it treats delivery.
|
|
21
|
+
*
|
|
17
22
|
* @module shopping-cart/lib/pricing
|
|
18
23
|
*/
|
|
19
24
|
|
|
20
25
|
import {includedTax, percentOf} from '../public/lib/money.js';
|
|
21
|
-
import {available, unitPrice, variantOf} from '../public/lib/products.js';
|
|
26
|
+
import {TAX_CLASSES, available, safeImage, unitPrice, variantOf} from '../public/lib/products.js';
|
|
22
27
|
|
|
23
28
|
export const STAGES = ['discount', 'shipping', 'tax'];
|
|
24
29
|
export const MAX_LINES = 100;
|
|
@@ -46,6 +51,38 @@ export function cleanLines(raw) {
|
|
|
46
51
|
return [...out.values()];
|
|
47
52
|
}
|
|
48
53
|
|
|
54
|
+
const text = (v, max) => String(v ?? '').replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);
|
|
55
|
+
const minor = (v) => { const n = Number(v); return Number.isInteger(n) && n >= 0 && n <= 1e9 ? n : null; };
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Lines an extension priced itself (server code only - never a browser's):
|
|
59
|
+
* `[{name, optionName, variantName, unitPrice, qty, taxClass, detail[], image, category, ref}]`.
|
|
60
|
+
* Junk dropped; `detail` is what was chosen with it ("+ Extra cheese £1.00").
|
|
61
|
+
*/
|
|
62
|
+
export function cleanCustomLines(raw) {
|
|
63
|
+
const out = [];
|
|
64
|
+
for (const l of Array.isArray(raw) ? raw : []) {
|
|
65
|
+
const name = text(l?.name, 140);
|
|
66
|
+
const unit = minor(l?.unitPrice);
|
|
67
|
+
const qty = Math.floor(Number(l?.qty));
|
|
68
|
+
if (!name || unit === null || !(qty > 0)) continue;
|
|
69
|
+
out.push({
|
|
70
|
+
name, optionName: text(l.optionName, 40), variantName: text(l.variantName, 80), unitPrice: unit, qty: Math.min(MAX_QTY, qty),
|
|
71
|
+
taxClass: TAX_CLASSES.includes(l.taxClass) ? l.taxClass : 'standard',
|
|
72
|
+
detail: (Array.isArray(l.detail) ? l.detail : []).map(d => text(d, 120)).filter(Boolean).slice(0, 20),
|
|
73
|
+
image: safeImage(l.image) || '', category: text(l.category, 60), ref: text(l.ref, 64)
|
|
74
|
+
});
|
|
75
|
+
if (out.length >= MAX_LINES) break;
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Fixed charges (a delivery fee): `[{label, amount}]`, or null when there are none to stand in for shipping. */
|
|
81
|
+
export function cleanCharges(raw) {
|
|
82
|
+
if (!Array.isArray(raw)) return null;
|
|
83
|
+
return raw.map(c => ({label: text(c?.label, 120) || 'Delivery', amount: minor(c?.amount)})).filter(c => c.amount !== null).slice(0, 5);
|
|
84
|
+
}
|
|
85
|
+
|
|
49
86
|
// ---- The built-in handlers -------------------------------------------------
|
|
50
87
|
|
|
51
88
|
/** No discounts in the free Cart: Pro's codes replace this. */
|
|
@@ -111,9 +148,11 @@ function cleanAdjustments(result, stage) {
|
|
|
111
148
|
* @param {object} args.settings - the Cart's live settings
|
|
112
149
|
* @param {object} [args.input] - {code, country, region, postcode, delivery}
|
|
113
150
|
* @param {Object<string, Function>} [args.handlers] - stage -> handler; missing = built in
|
|
151
|
+
* @param {object[]} [args.custom] - cleanCustomLines(): lines an extension priced
|
|
152
|
+
* @param {object[]|null} [args.charges] - cleanCharges(): stand in for the shipping stage
|
|
114
153
|
* @returns {Promise<object>} the quote
|
|
115
154
|
*/
|
|
116
|
-
export async function priceBasket({lines, products, settings, input = {}, handlers = {}}) {
|
|
155
|
+
export async function priceBasket({lines, products, settings, input = {}, handlers = {}, custom = [], charges = null}) {
|
|
117
156
|
const currency = settings.currency || 'GBP';
|
|
118
157
|
const out = [];
|
|
119
158
|
const problems = [];
|
|
@@ -162,6 +201,14 @@ export async function priceBasket({lines, products, settings, input = {}, handle
|
|
|
162
201
|
});
|
|
163
202
|
}
|
|
164
203
|
|
|
204
|
+
for (const c of custom) {
|
|
205
|
+
out.push({
|
|
206
|
+
productId: '', variantId: '', name: c.name, variantName: c.variantName, optionName: c.optionName, slug: '', image: c.image,
|
|
207
|
+
sku: '', unitPrice: c.unitPrice, qty: c.qty, lineTotal: c.unitPrice * c.qty, available: null, shippable: false, weight: null,
|
|
208
|
+
taxClass: c.taxClass, category: c.category, custom: true, detail: c.detail, ref: c.ref
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
165
212
|
const subtotal = out.reduce((s, l) => s + l.lineTotal, 0);
|
|
166
213
|
const requiresShipping = out.some(l => l.shippable);
|
|
167
214
|
const weight = out.reduce((s, l) => s + (l.weight || 0) * l.qty, 0);
|
|
@@ -173,17 +220,22 @@ export async function priceBasket({lines, products, settings, input = {}, handle
|
|
|
173
220
|
for (const stage of STAGES) {
|
|
174
221
|
const handler = typeof handlers[stage] === 'function' ? handlers[stage] : BUILT_IN[stage];
|
|
175
222
|
let result;
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
223
|
+
// An extension's fixed charges are the delivery: no shipping rules asked.
|
|
224
|
+
if (stage === 'shipping' && charges) {
|
|
225
|
+
result = charges;
|
|
226
|
+
} else {
|
|
227
|
+
try {
|
|
228
|
+
result = await handler({
|
|
229
|
+
stage, lines: out, subtotal, runningTotal, currency, settings, input,
|
|
230
|
+
adjustments: [...adjustments], requiresShipping, weight
|
|
231
|
+
});
|
|
232
|
+
} catch (err) {
|
|
233
|
+
// An extension that throws must not take checkout down with it:
|
|
234
|
+
// fall back to the built-in rule for this stage and say so.
|
|
235
|
+
result = await BUILT_IN[stage]({stage, lines: out, subtotal, runningTotal, currency, settings, input,
|
|
236
|
+
adjustments: [...adjustments], requiresShipping, weight});
|
|
237
|
+
notes.push({stage, message: `The ${stage} rules failed (${err?.message || err}); the standard ${stage} was used.`, internal: true});
|
|
238
|
+
}
|
|
187
239
|
}
|
|
188
240
|
let adds = cleanAdjustments(result, stage);
|
|
189
241
|
// A discount can take the basket to nothing, never below it.
|
|
@@ -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.
|
|
@@ -249,7 +260,7 @@ export function receiptHtml({order, settings, base, problem = '', parts = []}) {
|
|
|
249
260
|
const closed = status === 'cancelled' || status === 'refunded';
|
|
250
261
|
const lines = order.lines.map(l => `<li class="shop-r-line">
|
|
251
262
|
${l.image ? `<img src="${esc(safeImage(l.image))}" alt="" loading="lazy">` : `<span class="shop-r-noimg">${ICON.bag}</span>`}
|
|
252
|
-
<span class="shop-r-name">${esc(l.name)}${l.variantName ? `<small>${esc(l.optionName || 'Option')}: ${esc(l.variantName)}</small>` : ''}</span>
|
|
263
|
+
<span class="shop-r-name">${esc(l.name)}${l.variantName ? `<small>${esc(l.optionName || 'Option')}: ${esc(l.variantName)}</small>` : ''}${(l.detail || []).map(d => `<small>${esc(d)}</small>`).join('')}</span>
|
|
253
264
|
<span class="shop-r-qty">× ${l.qty}</span>
|
|
254
265
|
<span class="shop-r-amt">${esc(formatMoney(l.lineTotal, cur))}</span></li>`).join('');
|
|
255
266
|
const totals = `<dl class="shop-totals">
|
|
@@ -266,12 +277,13 @@ export function receiptHtml({order, settings, base, problem = '', parts = []}) {
|
|
|
266
277
|
<p class="shop-muted">Order #${esc(order.number)}</p>
|
|
267
278
|
<h1>${closed ? esc(STATUS_PUBLIC[status]) : waiting ? 'Confirming your payment…' : `Thank you${order.customer?.name ? `, ${esc(order.customer.name.split(' ')[0])}` : ''}!`}</h1>
|
|
268
279
|
<p>${waiting ? 'This takes a moment. You can keep this page open.'
|
|
269
|
-
: status === 'awaiting' ? 'Your order is in. It is sent as soon as your payment arrives.'
|
|
280
|
+
: status === 'awaiting' ? (order.payNote ? 'Your order is in.' : 'Your order is in. It is sent as soon as your payment arrives.')
|
|
270
281
|
: closed ? '' : `A record of your order is below${order.email ? ` - we will write to <strong>${esc(order.email)}</strong> if anything changes` : ''}.`}</p>
|
|
271
282
|
</div>
|
|
272
283
|
</div>
|
|
273
284
|
${problem === 'paypal' && waiting ? '<p class="shop-co-error">PayPal did not confirm the payment. Nothing has been taken - <a href="' + esc(base) + '/cart">try again from your basket</a>.</p>' : ''}
|
|
274
|
-
${status === 'awaiting' &&
|
|
285
|
+
${status === 'awaiting' && order.payNote ? `<div class="shop-r-note"><strong>Payment</strong><p>${esc(order.payNote)}</p><p>${esc(formatMoney(order.total, cur))} to pay.</p></div>` : ''}
|
|
286
|
+
${status === 'awaiting' && !order.payNote && settings.offlineInstructions ? `<div class="shop-r-note"><strong>${esc(settings.offlineLabel || 'How to pay')}</strong><p>${esc(settings.offlineInstructions)}</p><p>Please quote <strong>#${esc(order.number)}</strong> with your payment - ${esc(formatMoney(order.total, cur))}.</p></div>` : ''}
|
|
275
287
|
${closed ? '' : `<ol class="shop-r-steps">${steps.map(s => `<li class="${s.done ? 'is-done' : ''}"><span></span>${esc(s.label)}</li>`).join('')}</ol>`}
|
|
276
288
|
${t ? `<div class="shop-r-note">${ICON.truck} <strong>${esc(t.carrier || 'Tracking')}</strong> ${esc(t.number || '')} ${t.url ? `<a href="${esc(t.url)}" target="_blank" rel="noopener">Track it</a>` : ''}</div>` : ''}
|
|
277
289
|
${parts.map(p => `<div class="shop-r-ext" data-ext="${esc(p.owner)}">${p.html}</div>`).join('')}
|
|
@@ -281,6 +293,7 @@ export function receiptHtml({order, settings, base, problem = '', parts = []}) {
|
|
|
281
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>` : ''}
|
|
282
294
|
<h2>Placed</h2><p>${esc(fmtDateTime(order.placedAt))}</p>
|
|
283
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>` : ''}
|
|
284
297
|
</section>
|
|
285
298
|
</div>
|
|
286
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']);
|
|
@@ -254,7 +263,7 @@ async function nextNumber() {
|
|
|
254
263
|
*
|
|
255
264
|
* @returns {Promise<{id, data}>}
|
|
256
265
|
*/
|
|
257
|
-
export function createOrder({quote, checkout, provider, delivery, code = '', ref = '', returnTo = ''}) {
|
|
266
|
+
export function createOrder({quote, checkout, provider, delivery, code = '', ref = '', returnTo = '', ownEmails = false, payNote = ''}) {
|
|
258
267
|
return withLock(async () => {
|
|
259
268
|
const now = new Date().toISOString();
|
|
260
269
|
const data = {
|
|
@@ -265,7 +274,9 @@ export function createOrder({quote, checkout, provider, delivery, code = '', ref
|
|
|
265
274
|
lines: quote.lines.map(l => ({
|
|
266
275
|
productId: l.productId, variantId: l.variantId, name: l.name, variantName: l.variantName,
|
|
267
276
|
optionName: l.optionName, slug: l.slug, image: l.image, sku: l.sku, unitPrice: l.unitPrice,
|
|
268
|
-
qty: l.qty, lineTotal: l.lineTotal, shippable: l.shippable, weight: l.weight, taxClass: l.taxClass, category: l.category || ''
|
|
277
|
+
qty: l.qty, lineTotal: l.lineTotal, shippable: l.shippable, weight: l.weight, taxClass: l.taxClass, category: l.category || '',
|
|
278
|
+
// A line an extension priced (Takeaway's dish): no product behind it, and what was chosen with it.
|
|
279
|
+
...(l.custom ? {custom: true, detail: l.detail || [], ref: l.ref || ''} : {})
|
|
269
280
|
})),
|
|
270
281
|
subtotal: quote.subtotal,
|
|
271
282
|
adjustments: quote.adjustments,
|
|
@@ -285,6 +296,10 @@ export function createOrder({quote, checkout, provider, delivery, code = '', ref
|
|
|
285
296
|
ref: String(ref || '').slice(0, 64),
|
|
286
297
|
// Where an extension's shopper goes back to after paying, instead of the Shop's receipt.
|
|
287
298
|
returnTo: String(returnTo || '').slice(0, 500),
|
|
299
|
+
// The extension behind it writes to the shopper itself (Takeaway): other shopper emails stay quiet.
|
|
300
|
+
...(ownEmails ? {ownEmails: true} : {}),
|
|
301
|
+
// An extension's own word on paying offline ("Pay when you collect"), shown instead of the shop's instructions.
|
|
302
|
+
...(payNote ? {payNote: String(payNote).replace(/[\u0000-\u001f]/g, ' ').trim().slice(0, 300)} : {}),
|
|
288
303
|
provider,
|
|
289
304
|
providerRef: '',
|
|
290
305
|
paymentRef: '',
|
|
@@ -309,6 +324,7 @@ export const setProviderRef = (id, providerRef) => withLock(() => patchOrder(id,
|
|
|
309
324
|
async function moveStock(order, sign) {
|
|
310
325
|
const byProduct = new Map();
|
|
311
326
|
for (const l of order.lines || []) {
|
|
327
|
+
if (!l.productId) continue; // an extension's own line has no stock here
|
|
312
328
|
if (!byProduct.has(l.productId)) byProduct.set(l.productId, []);
|
|
313
329
|
byProduct.get(l.productId).push(l);
|
|
314
330
|
}
|
|
@@ -353,6 +369,7 @@ export function markPaid(id, info = {}) {
|
|
|
353
369
|
stockTaken: true,
|
|
354
370
|
...(info.ref ? {providerRef: info.ref} : {}),
|
|
355
371
|
...(info.payment ? {paymentRef: info.payment} : {}),
|
|
372
|
+
...commissionOn(d, now),
|
|
356
373
|
history: [...(d.history || []), {at: now, status: 'paid', by: info.by || d.provider, note: info.note || ''}]
|
|
357
374
|
});
|
|
358
375
|
return {order, changed: true, from: d.status};
|
|
@@ -377,6 +394,9 @@ export function moveOrder(id, to, {by = 'admin', note = '', restock = false, tra
|
|
|
377
394
|
const patch = {status: to, history: [...(d.history || []), {at: now, status: to, by, note: String(note || '').slice(0, 500)}]};
|
|
378
395
|
if (to === 'paid' && !d.stockTaken) { await moveStock(d, -1); patch.stockTaken = true; }
|
|
379
396
|
if (to === 'paid' && !d.paidAt) patch.paidAt = now;
|
|
397
|
+
if (to === 'paid') Object.assign(patch, commissionOn(d, now));
|
|
398
|
+
// A refund gives the platform's commission back too (statements net it off in the month of the refund).
|
|
399
|
+
if (to === 'refunded' && d.commission && !d.commission.reversed) patch.commission = {...d.commission, reversed: true, reversedAt: now};
|
|
380
400
|
if (to === 'fulfilled') {
|
|
381
401
|
patch.fulfilledAt = now;
|
|
382
402
|
if (tracking) patch.tracking = tracking;
|
|
@@ -437,7 +457,7 @@ export function publicOrder(entry) {
|
|
|
437
457
|
status: d.status,
|
|
438
458
|
currency: d.currency,
|
|
439
459
|
lines: d.lines.map(l => ({productId: l.productId, variantId: l.variantId || '', name: l.name, variantName: l.variantName, optionName: l.optionName, qty: l.qty,
|
|
440
|
-
unitPrice: l.unitPrice, lineTotal: l.lineTotal, image: l.image, slug: l.slug})),
|
|
460
|
+
unitPrice: l.unitPrice, lineTotal: l.lineTotal, image: l.image, slug: l.slug, detail: l.detail || []})),
|
|
441
461
|
subtotal: d.subtotal,
|
|
442
462
|
adjustments: (d.adjustments || []).map(a => ({stage: a.stage, label: a.label, amount: a.amount, included: Boolean(a.included)})),
|
|
443
463
|
total: d.total,
|
|
@@ -447,6 +467,7 @@ export function publicOrder(entry) {
|
|
|
447
467
|
delivery: d.delivery,
|
|
448
468
|
provider: d.provider,
|
|
449
469
|
tracking: d.tracking || null,
|
|
470
|
+
payNote: d.payNote || '',
|
|
450
471
|
placedAt: entry.meta?.createdAt,
|
|
451
472
|
paidAt: d.paidAt || null,
|
|
452
473
|
fulfilledAt: d.fulfilledAt || null
|
|
@@ -56,10 +56,12 @@ export function summarise(orders, products, settings, now = Date.now()) {
|
|
|
56
56
|
const units = new Map();
|
|
57
57
|
for (const o of d30) {
|
|
58
58
|
for (const l of o.data.lines || []) {
|
|
59
|
-
|
|
59
|
+
// An extension's own line (a Takeaway dish) has no product: counted by its own ref, else its name.
|
|
60
|
+
const key = l.productId || `ref:${l.ref || l.name}`;
|
|
61
|
+
const had = units.get(key) || {productId: l.productId, name: l.name, qty: 0, revenue: 0};
|
|
60
62
|
had.qty += l.qty;
|
|
61
63
|
had.revenue += l.lineTotal;
|
|
62
|
-
units.set(
|
|
64
|
+
units.set(key, had);
|
|
63
65
|
}
|
|
64
66
|
}
|
|
65
67
|
const topProducts = [...units.values()].sort((a, b) => b.revenue - a.revenue).slice(0, 5);
|