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
|
@@ -486,6 +486,20 @@ function checkout() {
|
|
|
486
486
|
};
|
|
487
487
|
form.addEventListener('input', keep);
|
|
488
488
|
|
|
489
|
+
// A signed-in customer: their details, and their first saved address, where nothing is typed yet.
|
|
490
|
+
import('./lib/session.js').then(async ({me}) => {
|
|
491
|
+
const mine = await me().catch(() => null);
|
|
492
|
+
if (!mine) return;
|
|
493
|
+
document.querySelector('[data-acc-link]')?.remove();
|
|
494
|
+
const a = mine.addresses[0] || {};
|
|
495
|
+
const fill = {name: mine.name, email: mine.email, phone: mine.phone, line1: a.line1, line2: a.line2, city: a.city, region: a.region, postcode: a.postcode, country: a.country};
|
|
496
|
+
for (const [k, v] of Object.entries(fill)) {
|
|
497
|
+
const el = form.elements[k];
|
|
498
|
+
if (el && v && !el.value) el.value = v;
|
|
499
|
+
}
|
|
500
|
+
keep();
|
|
501
|
+
}).catch(() => {});
|
|
502
|
+
|
|
489
503
|
// A reminder email links here with the order: fill the basket from it (another device, a cleared browser).
|
|
490
504
|
const params = new URLSearchParams(location.search);
|
|
491
505
|
if (params.get('restore') && params.get('t')) {
|
|
@@ -713,3 +727,5 @@ if (page === 'product') productPage();
|
|
|
713
727
|
if (page === 'index') storefront();
|
|
714
728
|
if (page === 'cart') checkout();
|
|
715
729
|
if (page === 'order') receipt();
|
|
730
|
+
// Customer accounts: their own pages, and a receipt's "save your details" box.
|
|
731
|
+
if (page === 'account' || document.querySelector('[data-shop-join]')) import('./account.js').catch(() => {});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A customer's sign-in, in the browser - shared by the Shop's pages and any extension's (Takeaway).
|
|
3
|
+
*
|
|
4
|
+
* Signing in is core's own `/api/auth/login`; the tokens it gives are kept in this browser (localStorage, one
|
|
5
|
+
* key for the whole site) and sent as a Bearer header to the Cart's account routes. A 401 refreshes once through
|
|
6
|
+
* `/api/auth/refresh`; a refresh that fails signs the customer out here.
|
|
7
|
+
*
|
|
8
|
+
* @module shopping-cart/public/lib/session
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const KEY = 'dm-shop:account';
|
|
12
|
+
const ACCOUNT_API = '/api/plugins/shopping-cart/account';
|
|
13
|
+
|
|
14
|
+
const read = () => { try { return JSON.parse(localStorage.getItem(KEY) || 'null'); } catch { return null; } };
|
|
15
|
+
const write = (v) => { try { if (v) localStorage.setItem(KEY, JSON.stringify(v)); else localStorage.removeItem(KEY); } catch { /* private mode */ } };
|
|
16
|
+
|
|
17
|
+
/** {name, email} when this browser holds a sign-in, else null. */
|
|
18
|
+
export function signedIn() {
|
|
19
|
+
const s = read();
|
|
20
|
+
return s?.token ? {name: s.name || '', email: s.email || ''} : null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function json(res) {
|
|
24
|
+
const body = await res.json().catch(() => ({}));
|
|
25
|
+
if (!res.ok) throw Object.assign(new Error(body.error || body.message || `The server answered ${res.status}`), {status: res.status, body});
|
|
26
|
+
return body;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Sign in with core's login. A staff account with a sign-in check (a code) is sent to /admin.
|
|
31
|
+
* @returns {Promise<{name: string, email: string}>}
|
|
32
|
+
*/
|
|
33
|
+
export async function signIn(email, password) {
|
|
34
|
+
const body = await json(await fetch('/api/auth/login', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({email, password})}));
|
|
35
|
+
if (!body.token) throw new Error('This account signs in at /admin.');
|
|
36
|
+
write({token: body.token, refreshToken: body.refreshToken, name: body.user?.name || '', email: body.user?.email || email});
|
|
37
|
+
return signedIn();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function signOut() {
|
|
41
|
+
const s = read();
|
|
42
|
+
write(null);
|
|
43
|
+
if (s?.refreshToken) await fetch('/api/auth/logout', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({refreshToken: s.refreshToken})}).catch(() => {});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function refresh() {
|
|
47
|
+
const s = read();
|
|
48
|
+
if (!s?.refreshToken) return false;
|
|
49
|
+
try {
|
|
50
|
+
const r = await fetch('/api/auth/refresh', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({refreshToken: s.refreshToken})});
|
|
51
|
+
if (!r.ok) { write(null); return false; }
|
|
52
|
+
const {token} = await r.json();
|
|
53
|
+
write({...s, token});
|
|
54
|
+
return true;
|
|
55
|
+
} catch { return false; }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A request as the signed-in customer; one refresh on a 401. */
|
|
59
|
+
export async function authed(url, {method = 'GET', body} = {}) {
|
|
60
|
+
const go = () => fetch(url, {method, headers: {...(body ? {'Content-Type': 'application/json'} : {}), Authorization: `Bearer ${read()?.token || ''}`},
|
|
61
|
+
body: body ? JSON.stringify(body) : undefined});
|
|
62
|
+
let res = await go();
|
|
63
|
+
if (res.status === 401 && await refresh()) res = await go();
|
|
64
|
+
if (res.status === 401) write(null);
|
|
65
|
+
return json(res);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The signed-in customer's account (details, addresses, orders), or null when nobody is signed in here. */
|
|
69
|
+
export async function me() {
|
|
70
|
+
if (!read()?.token) return null;
|
|
71
|
+
try { return await authed(ACCOUNT_API); } catch (err) { if (err.status === 401) return null; throw err; }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const account = {
|
|
75
|
+
register: (d) => fetch(`${ACCOUNT_API}/register`, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(d)}).then(json),
|
|
76
|
+
confirm: (u, t) => fetch(`${ACCOUNT_API}/confirm`, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({u, t})}).then(json),
|
|
77
|
+
forgot: (email) => fetch(`${ACCOUNT_API}/forgot`, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({email})}).then(json),
|
|
78
|
+
/** Core's own reset, with the token from the emailed link. */
|
|
79
|
+
reset: (token, password) => fetch('/api/auth/reset-password', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({token, password})}).then(json),
|
|
80
|
+
save: (d) => authed(ACCOUNT_API, {method: 'PUT', body: d}),
|
|
81
|
+
remove: (password) => authed(ACCOUNT_API, {method: 'DELETE', body: {password}})
|
|
82
|
+
};
|
|
@@ -425,3 +425,30 @@ html.shop-locked { overflow: hidden; }
|
|
|
425
425
|
.shop-line-controls { flex-wrap: wrap; }
|
|
426
426
|
}
|
|
427
427
|
.shop-co-title { font-size: clamp(1.8rem, 3vw, 2.4rem); letter-spacing: -.02em; margin: 0 0 1.25rem; }
|
|
428
|
+
/* Customer accounts */
|
|
429
|
+
.shop-account { max-width: 44rem; margin: 2rem auto; }
|
|
430
|
+
.shop-account h1 { margin: 0 0 1rem; }
|
|
431
|
+
.shop-acc { display: flex; flex-direction: column; gap: 1rem; }
|
|
432
|
+
.shop-acc-form { display: flex; flex-direction: column; gap: .75rem; max-width: 26rem; }
|
|
433
|
+
.shop-acc-note { padding: .75rem 1rem; border-radius: var(--shop-radius-sm); background: var(--shop-softer); border: 1px solid var(--shop-line); margin: 0; }
|
|
434
|
+
.shop-acc-note.is-good, .shop-co-error.is-good { color: var(--shop-good); }
|
|
435
|
+
.shop-seg { display: inline-flex; gap: .25rem; padding: .25rem; border-radius: 999px; background: var(--shop-soft); align-self: flex-start; }
|
|
436
|
+
.shop-seg button { border: 0; background: none; color: var(--shop-text); font: inherit; font-weight: 600; padding: .45rem 1rem; border-radius: 999px; cursor: pointer; }
|
|
437
|
+
.shop-seg button.is-on { background: var(--shop-bg); box-shadow: 0 1px 3px rgba(0, 0, 0, .15); }
|
|
438
|
+
.shop-acc-card { border: 1px solid var(--shop-line); border-radius: var(--shop-radius); padding: 1rem 1.2rem; }
|
|
439
|
+
.shop-acc-card h2 { font-size: 1.1rem; margin: 0 0 .75rem; }
|
|
440
|
+
.shop-acc-card form { display: flex; flex-direction: column; gap: .6rem; max-width: 26rem; }
|
|
441
|
+
.shop-acc-orders, .shop-acc-addrs { list-style: none; padding: 0; margin: 0 0 .5rem; display: flex; flex-direction: column; gap: .6rem; }
|
|
442
|
+
.shop-acc-orders li { border-bottom: 1px solid var(--shop-line); padding-bottom: .6rem; }
|
|
443
|
+
.shop-acc-order { display: grid; grid-template-columns: auto 1fr auto auto; gap: .75rem; color: var(--shop-text); text-decoration: none !important; }
|
|
444
|
+
.shop-acc-orders small { color: var(--shop-muted); }
|
|
445
|
+
.shop-acc-ext { margin-top: .35rem; }
|
|
446
|
+
.shop-acc-addrs li { display: flex; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
|
|
447
|
+
.shop-acc-addr { margin-top: .75rem; }
|
|
448
|
+
.shop-acc-danger summary { cursor: pointer; color: var(--shop-muted); }
|
|
449
|
+
.shop-linkbtn.is-danger { color: var(--shop-sale); }
|
|
450
|
+
.shop-acc-link { font-size: .92rem; }
|
|
451
|
+
.shop-crumbs .shop-acc-link { float: right; }
|
|
452
|
+
.shop-r-join { margin-top: 1.25rem; padding-top: 1rem; border-top: 1px solid var(--shop-line); }
|
|
453
|
+
.shop-r-join h2 { font-size: 1rem; }
|
|
454
|
+
.shop-r-join form { display: flex; flex-direction: column; gap: .6rem; }
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Customer accounts against the real user and collection services in the domma-cms checkout `make test` runs in.
|
|
3
|
+
* The test's authenticate reads `x-user` (a real user id) the way core's reads a token; mail is caught with
|
|
4
|
+
* setAccountMailer. Every user it makes, and its collections, are removed; config/plugins.json is restored.
|
|
5
|
+
*/
|
|
6
|
+
import {test, before, after} from 'node:test';
|
|
7
|
+
import assert from 'node:assert/strict';
|
|
8
|
+
import fs from 'node:fs/promises';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import Fastify from 'fastify';
|
|
11
|
+
import shoppingCart from '../plugin.js';
|
|
12
|
+
import {setAccountMailer, cleanAddress, ordersFor} from '../lib/accounts.js';
|
|
13
|
+
import {deleteUser, getUserByEmail, getUserById} from '../../../server/services/users.js';
|
|
14
|
+
import {checkResetToken} from '../../../server/services/passwordReset.js';
|
|
15
|
+
|
|
16
|
+
const PLUGINS = path.resolve('config/plugins.json');
|
|
17
|
+
const DIRS = ['shop-products', 'shop-orders'].map(c => path.resolve('content/collections', c));
|
|
18
|
+
const EMAILS = ['acc-ann@example.test', 'acc-bo@example.test'];
|
|
19
|
+
let saved;
|
|
20
|
+
let app;
|
|
21
|
+
const mail = [];
|
|
22
|
+
|
|
23
|
+
async function cleanUsers() {
|
|
24
|
+
for (const e of EMAILS) { const u = await getUserByEmail(e); if (u) await deleteUser(u.id); }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
before(async () => {
|
|
28
|
+
saved = await fs.readFile(PLUGINS, 'utf8').catch(() => null);
|
|
29
|
+
for (const d of DIRS) await fs.rm(d, {recursive: true, force: true});
|
|
30
|
+
await cleanUsers();
|
|
31
|
+
setAccountMailer((m) => { mail.push(m); });
|
|
32
|
+
delete globalThis[Symbol.for('domma.shopping-cart')];
|
|
33
|
+
app = Fastify();
|
|
34
|
+
// x-user: that real user; x-anon: nobody (401); neither: the shop's admin, for the admin routes.
|
|
35
|
+
const authenticate = async (req, reply) => {
|
|
36
|
+
if (req.headers['x-anon']) return reply.code(401).send({error: 'Unauthorised'});
|
|
37
|
+
if (!req.headers['x-user']) { req.user = {id: 'admin', name: 'Sam', role: 'admin'}; return undefined; }
|
|
38
|
+
const u = await getUserById(String(req.headers['x-user']));
|
|
39
|
+
if (!u) return reply.code(401).send({error: 'Unauthorised'});
|
|
40
|
+
req.user = {id: u.id, name: u.name, email: u.email, role: u.role};
|
|
41
|
+
return undefined;
|
|
42
|
+
};
|
|
43
|
+
await app.register(shoppingCart, {auth: {authenticate, requirePermission: () => async () => {}}, settings: {}, hooks: {registerShortcode() {}}});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
after(async () => {
|
|
47
|
+
setAccountMailer(null);
|
|
48
|
+
await app?.close();
|
|
49
|
+
await cleanUsers();
|
|
50
|
+
for (const d of DIRS) await fs.rm(d, {recursive: true, force: true});
|
|
51
|
+
if (saved === null) await fs.rm(PLUGINS, {force: true});
|
|
52
|
+
else await fs.writeFile(PLUGINS, saved);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const call = async (method, url, body, user = null) => {
|
|
56
|
+
const res = await app.inject({method, url, payload: body, headers: user === 'anon' ? {'x-anon': '1'} : user ? {'x-user': user} : {}});
|
|
57
|
+
let json = null;
|
|
58
|
+
try { json = res.json(); } catch { /* not JSON */ }
|
|
59
|
+
return {status: res.statusCode, body: json};
|
|
60
|
+
};
|
|
61
|
+
const linkIn = (m) => /https?:\/\/\S+/.exec(m.text)?.[0] || '';
|
|
62
|
+
|
|
63
|
+
test('accounts: off by default; create, confirm by email, details, addresses, orders by email, reset, delete', async () => {
|
|
64
|
+
assert.equal((await call('POST', '/account/register', {name: 'Ann', email: EMAILS[0], password: 'a-long-password'})).status, 404, 'off until switched on');
|
|
65
|
+
const put = await call('PUT', '/settings', {accounts: true, offlineEnabled: true, countries: ['GB']});
|
|
66
|
+
assert.equal(put.status, 200, JSON.stringify(put.body));
|
|
67
|
+
assert.equal(put.body.settings?.accounts ?? put.body.accounts, true, JSON.stringify(put.body).slice(0, 300));
|
|
68
|
+
|
|
69
|
+
assert.equal((await call('POST', '/account/register', {name: 'Ann', email: EMAILS[0], password: 'short'})).status, 400);
|
|
70
|
+
assert.equal((await call('POST', '/account/register', {name: '', email: EMAILS[0], password: 'a-long-password'})).status, 400);
|
|
71
|
+
let r = await call('POST', '/account/register', {name: 'Ann Example', email: EMAILS[0], password: 'a-long-password'});
|
|
72
|
+
assert.equal(r.status, 200, JSON.stringify(r.body));
|
|
73
|
+
let user = await getUserByEmail(EMAILS[0]);
|
|
74
|
+
assert.deepEqual([user.isActive, user.role], [false, 'shop-customer'], 'inactive until confirmed');
|
|
75
|
+
const confirmLink = new URL(linkIn(mail.at(-1)));
|
|
76
|
+
assert.match(confirmLink.pathname, /\/shop\/account\/confirm$/);
|
|
77
|
+
|
|
78
|
+
// A second try with the same email says the same, and tells the holder - nothing new is made.
|
|
79
|
+
r = await call('POST', '/account/register', {name: 'Someone', email: EMAILS[0], password: 'another-password'});
|
|
80
|
+
assert.equal(r.status, 200);
|
|
81
|
+
assert.equal(mail.length, 1, 'still pending: no second email, no second account');
|
|
82
|
+
|
|
83
|
+
assert.equal((await call('POST', '/account/confirm', {u: user.id, t: '0'.repeat(48)})).status, 404);
|
|
84
|
+
r = await call('POST', '/account/confirm', {u: confirmLink.searchParams.get('u'), t: confirmLink.searchParams.get('t')});
|
|
85
|
+
assert.deepEqual(r.body, {confirmed: true, email: EMAILS[0]});
|
|
86
|
+
user = await getUserById(user.id);
|
|
87
|
+
assert.equal(user.isActive, true);
|
|
88
|
+
assert.ok(!user.meta.shop.pending);
|
|
89
|
+
r = await call('POST', '/account/register', {name: 'Ann', email: EMAILS[0], password: 'a-long-password'});
|
|
90
|
+
assert.match(mail.at(-1).subject, /already have an account/);
|
|
91
|
+
|
|
92
|
+
// Signed in: details, addresses (junk dropped), and the orders placed with this email - from before it existed too.
|
|
93
|
+
assert.equal((await call('GET', '/account', null, 'anon')).status, 401);
|
|
94
|
+
const product = (await call('POST', '/products', {name: 'Mug', price: 1000, status: 'active', shippable: false})).body;
|
|
95
|
+
const placed = await call('POST', '/checkout', {lines: [{productId: product.id, qty: 1}], provider: 'offline', customer: {name: 'Ann', email: EMAILS[0].toUpperCase()}});
|
|
96
|
+
assert.equal(placed.status, 200, JSON.stringify(placed.body));
|
|
97
|
+
await call('POST', '/checkout', {lines: [{productId: product.id, qty: 1}], provider: 'offline', customer: {name: 'Bo', email: EMAILS[1]}});
|
|
98
|
+
r = await call('GET', '/account', null, user.id);
|
|
99
|
+
assert.equal(r.status, 200, JSON.stringify(r.body));
|
|
100
|
+
assert.deepEqual([r.body.name, r.body.email, r.body.orders.length], ['Ann Example', EMAILS[0], 1], 'only her own orders');
|
|
101
|
+
assert.match(r.body.orders[0].receipt, /^\/shop\/order\/.+\?t=/);
|
|
102
|
+
r = await call('PUT', '/account', {phone: '07700 900000', addresses: [{label: 'Home', line1: '1 High St', city: 'York', postcode: 'yo1 7hh'}, {line1: '', city: 'x'}]}, user.id);
|
|
103
|
+
assert.deepEqual([r.body.phone, r.body.addresses.length, r.body.addresses[0].postcode], ['07700 900000', 1, 'YO1 7HH']);
|
|
104
|
+
|
|
105
|
+
// Forgotten password: core's reset token, the link to the shop's page; nobody else is told anything.
|
|
106
|
+
const sent = mail.length;
|
|
107
|
+
assert.deepEqual((await call('POST', '/account/forgot', {email: 'nobody@example.test'})).body, {ok: true});
|
|
108
|
+
assert.equal(mail.length, sent, 'no account, no email - and the same answer');
|
|
109
|
+
await call('POST', '/account/forgot', {email: EMAILS[0]});
|
|
110
|
+
const reset = new URL(linkIn(mail.at(-1)));
|
|
111
|
+
assert.match(reset.pathname, /\/shop\/account\/reset$/);
|
|
112
|
+
assert.equal((await checkResetToken(reset.searchParams.get('t')))?.id, user.id, 'core accepts the token');
|
|
113
|
+
|
|
114
|
+
// Delete: only with the password; the orders stay.
|
|
115
|
+
assert.equal((await call('DELETE', '/account', {password: 'wrong'}, user.id)).status, 403);
|
|
116
|
+
assert.equal((await call('DELETE', '/account', {password: 'a-long-password'}, user.id)).status, 200);
|
|
117
|
+
assert.equal(await getUserByEmail(EMAILS[0]), null);
|
|
118
|
+
assert.equal((await call('GET', '/orders')).body.filter(o => o.data.email === EMAILS[0]).length, 1);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('accounts: pure helpers', () => {
|
|
122
|
+
assert.equal(cleanAddress({line1: '1 High St'}), null, 'a town too');
|
|
123
|
+
assert.equal(cleanAddress({line1: '1 High St', city: 'York', country: 'ZZ'}).country, 'GB');
|
|
124
|
+
const o = (email, status = 'paid', at = '2026-10-01') => ({data: {email, status}, meta: {createdAt: at}});
|
|
125
|
+
assert.deepEqual(ordersFor([o('a@x.test'), o('A@X.test', 'paid', '2026-10-05'), o('a@x.test', 'pending'), o('b@x.test')], 'a@x.test').map(x => x.meta.createdAt),
|
|
126
|
+
['2026-10-05', '2026-10-01'], 'theirs, newest first, no unfinished checkouts');
|
|
127
|
+
});
|
|
@@ -239,3 +239,66 @@ test('an extension keeps its own part of a product, and moves its orders on', as
|
|
|
239
239
|
reg.unregister('test-dl');
|
|
240
240
|
}
|
|
241
241
|
});
|
|
242
|
+
|
|
243
|
+
test('an extension checks out lines it priced, with a delivery fee and address; refunds through the Cart', async () => {
|
|
244
|
+
const {cartRegistry} = await import('../lib/extensions.js');
|
|
245
|
+
const reg = cartRegistry();
|
|
246
|
+
const paid = [];
|
|
247
|
+
reg.register('test-takeaway', {on: {paid: (o) => paid.push(o)}});
|
|
248
|
+
try {
|
|
249
|
+
await call('PUT', '/settings', {offlineEnabled: true, countries: ['GB'], taxRate: 0, shippingFlat: 395});
|
|
250
|
+
const dishes = [{name: 'Vindaloo', optionName: 'Protein', variantName: 'Chicken', unitPrice: 950, qty: 2, detail: ['+ Extra chilli £0.50'], ref: 'dish:vin'},
|
|
251
|
+
{name: 'Pilau rice', unitPrice: 300, qty: 1, ref: 'dish:rice'}];
|
|
252
|
+
const charges = [{label: 'Delivery (YO10)', amount: 250}];
|
|
253
|
+
const q = await reg.services.quote({custom: dishes, charges});
|
|
254
|
+
assert.equal(q.total, 1900 + 300 + 250);
|
|
255
|
+
assert.equal(q.requiresShipping, false);
|
|
256
|
+
|
|
257
|
+
// Delivering needs an address; collecting does not.
|
|
258
|
+
await assert.rejects(reg.services.startCheckout({custom: dishes, charges, delivery: 'deliver', customer: {name: 'Ann', email: 'ann@x.test'}, provider: 'offline'}), /address/i);
|
|
259
|
+
const out = await reg.services.startCheckout({custom: dishes, charges, delivery: 'deliver', note: 'Ring the bell',
|
|
260
|
+
address: {line1: '1 High St', city: 'York', postcode: 'yo10 5dd', country: 'GB'},
|
|
261
|
+
customer: {name: 'Ann', email: 'ann@x.test', phone: '07700 900000'}, provider: 'offline', ref: 'takeaway:t1', base: 'https://site.test'});
|
|
262
|
+
assert.equal(out.total, 2450);
|
|
263
|
+
let order = await reg.services.checkOrder(out.orderId);
|
|
264
|
+
assert.equal(order.data.delivery, 'ship');
|
|
265
|
+
assert.equal(order.data.address.postcode, 'YO10 5DD');
|
|
266
|
+
assert.equal(order.data.note, 'Ring the bell');
|
|
267
|
+
assert.equal(order.data.customer.phone, '07700 900000');
|
|
268
|
+
const vin = order.data.lines.find(l => l.ref === 'dish:vin');
|
|
269
|
+
assert.deepEqual([vin.custom, vin.variantName, vin.detail], [true, 'Chicken', ['+ Extra chilli £0.50']]);
|
|
270
|
+
assert.deepEqual((await call('GET', `/order/${order.id}?t=${order.data.token}`)).body.lines[0].detail, ['+ Extra chilli £0.50']);
|
|
271
|
+
|
|
272
|
+
// Paid: no stock to take, the extension hears; then refunded through the Cart.
|
|
273
|
+
const r = await call('PUT', `/orders/${order.id}/status`, {status: 'paid'});
|
|
274
|
+
assert.equal(r.status, 200, JSON.stringify(r.body));
|
|
275
|
+
await new Promise(res => setTimeout(res, 20));
|
|
276
|
+
assert.equal(paid.at(-1)?.data?.ref, 'takeaway:t1');
|
|
277
|
+
const refunded = await reg.services.refund(order.id, {note: 'Kitchen closed early'});
|
|
278
|
+
assert.equal(refunded.order.data.status, 'refunded');
|
|
279
|
+
assert.match(refunded.order.data.history.at(-1).note, /Kitchen closed early/);
|
|
280
|
+
await assert.rejects(reg.services.refund(order.id), /Only a paid order/);
|
|
281
|
+
|
|
282
|
+
// Collecting: no address asked, no delivery.
|
|
283
|
+
const c = await reg.services.startCheckout({custom: dishes, delivery: 'collect', customer: {name: 'Bo', email: 'bo@x.test'}, provider: 'offline'});
|
|
284
|
+
order = await reg.services.checkOrder(c.orderId);
|
|
285
|
+
assert.equal(order.data.delivery, 'collect');
|
|
286
|
+
assert.equal(order.data.total, 2200);
|
|
287
|
+
// The storefront's own checkout never takes priced lines.
|
|
288
|
+
const s = await call('POST', '/checkout', {custom: dishes, provider: 'offline', customer: {name: 'Cy', email: 'cy@x.test'}});
|
|
289
|
+
assert.equal(s.status, 400);
|
|
290
|
+
} finally {
|
|
291
|
+
reg.unregister('test-takeaway');
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test('sales: the report and its CSV over the API', async () => {
|
|
296
|
+
const r = await call('GET', '/sales?range=year&by=month');
|
|
297
|
+
assert.equal(r.status, 200, JSON.stringify(r.body));
|
|
298
|
+
assert.ok(r.body.totals && Array.isArray(r.body.periods) && r.body.periods.length >= 1);
|
|
299
|
+
assert.ok(r.body.totals.orders >= 1, 'the earlier tests sold things');
|
|
300
|
+
const csv = await app.inject({method: 'GET', url: '/sales.csv?from=2020-01-01&to=2030-12-31'});
|
|
301
|
+
assert.equal(csv.statusCode, 200);
|
|
302
|
+
assert.match(csv.headers['content-disposition'], /attachment; filename="sales-2020-01-01-to-2030-12-31\.csv"/);
|
|
303
|
+
assert.ok(csv.body.startsWith('Order,Paid,Status'));
|
|
304
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform commission: the pure rules, then a real sale with config/commission.json written as the manager would
|
|
3
|
+
* (in the domma-cms checkout `make test` runs in - removed again after).
|
|
4
|
+
*/
|
|
5
|
+
import {test, before, after} from 'node:test';
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import fs from 'node:fs/promises';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import Fastify from 'fastify';
|
|
10
|
+
import shoppingCart from '../plugin.js';
|
|
11
|
+
import {cleanRates, commissionFor, orderType, rateFor, rateText} from '../lib/commission.js';
|
|
12
|
+
|
|
13
|
+
const FILE = path.resolve('config/commission.json');
|
|
14
|
+
const PLUGINS = path.resolve('config/plugins.json');
|
|
15
|
+
const DIRS = ['shop-products', 'shop-orders'].map(c => path.resolve('content/collections', c));
|
|
16
|
+
let savedFile;
|
|
17
|
+
let savedPlugins;
|
|
18
|
+
let app;
|
|
19
|
+
|
|
20
|
+
before(async () => {
|
|
21
|
+
savedFile = await fs.readFile(FILE, 'utf8').catch(() => null);
|
|
22
|
+
savedPlugins = await fs.readFile(PLUGINS, 'utf8').catch(() => null);
|
|
23
|
+
for (const d of DIRS) await fs.rm(d, {recursive: true, force: true});
|
|
24
|
+
delete globalThis[Symbol.for('domma.shopping-cart')];
|
|
25
|
+
app = Fastify();
|
|
26
|
+
await app.register(shoppingCart, {auth: {authenticate: async (req) => { req.user = {id: 'a', role: 'admin'}; }, requirePermission: () => async () => {}}, settings: {}, hooks: {registerShortcode() {}}});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
after(async () => {
|
|
30
|
+
await app?.close();
|
|
31
|
+
for (const d of DIRS) await fs.rm(d, {recursive: true, force: true});
|
|
32
|
+
if (savedFile === null) await fs.rm(FILE, {force: true}); else await fs.writeFile(FILE, savedFile);
|
|
33
|
+
if (savedPlugins === null) await fs.rm(PLUGINS, {force: true}); else await fs.writeFile(PLUGINS, savedPlugins);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const call = async (method, url, body) => {
|
|
37
|
+
const res = await app.inject({method, url, payload: body});
|
|
38
|
+
let json = null;
|
|
39
|
+
try { json = res.json(); } catch { /* not JSON */ }
|
|
40
|
+
return {status: res.statusCode, body: json, text: res.body};
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
test('commission: rates cleaned, the kind of sale, per-sale amounts', () => {
|
|
44
|
+
const {rates} = cleanRates({rates: {default: {percent: 2, fixed: 20}, takeaway: {percent: '3.5'}, shop: {percent: -1}, junk: {percent: 9}}});
|
|
45
|
+
assert.deepEqual(rates, {default: {percent: 2, fixed: 20}, takeaway: {percent: 3.5, fixed: 0}});
|
|
46
|
+
assert.equal(orderType({data: {ref: 'takeaway:x'}}), 'takeaway');
|
|
47
|
+
assert.equal(orderType({data: {ref: 'purchase-1'}}), 'addons');
|
|
48
|
+
assert.equal(orderType({data: {}}), 'shop');
|
|
49
|
+
assert.deepEqual(rateFor('shop', rates), {percent: 2, fixed: 20}, 'no rate of its own: the default');
|
|
50
|
+
assert.deepEqual(commissionFor({data: {total: 2150}}, rates, 'T'), {type: 'shop', base: 2150, percent: 2, fixed: 20, amount: 43 + 20, at: 'T'});
|
|
51
|
+
assert.equal(commissionFor({data: {total: 2150, ref: 'takeaway:x'}}, rates).amount, 75, '3.5% of £21.50, rounded');
|
|
52
|
+
assert.equal(commissionFor({data: {total: 0}}, rates), null, 'a free order carries none');
|
|
53
|
+
assert.equal(commissionFor({data: {total: 10}}, rates).amount, 10, 'never more than the sale');
|
|
54
|
+
assert.equal(commissionFor({data: {total: 1000}}, {}), null, 'no rates: none');
|
|
55
|
+
assert.equal(rateText({percent: 2, fixed: 20}), '2% + £0.20 per sale');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('commission: stored on the sale when paid, at that day\'s rate; returned with a refund; in Sales and the CSV', async () => {
|
|
59
|
+
await call('PUT', '/settings', {offlineEnabled: true, countries: ['GB']});
|
|
60
|
+
const mug = (await call('POST', '/products', {name: 'Mug', price: 1000, status: 'active', shippable: false})).body;
|
|
61
|
+
const buy = async () => (await call('POST', '/checkout', {lines: [{productId: mug.id, qty: 2}], provider: 'offline', customer: {name: 'Ann', email: 'ann@example.test'}})).body.orderId;
|
|
62
|
+
|
|
63
|
+
// No file yet: a sale carries no commission.
|
|
64
|
+
let id = await buy();
|
|
65
|
+
let o = (await call('PUT', `/orders/${id}/status`, {status: 'paid'})).body;
|
|
66
|
+
assert.equal(o.data.commission, undefined);
|
|
67
|
+
|
|
68
|
+
// The manager writes the rate; the next sale has it (no restart).
|
|
69
|
+
await fs.writeFile(FILE, JSON.stringify({rates: {default: {percent: 5, fixed: 10}}, updatedAt: '2026-10-12T00:00:00Z', by: 'super'}));
|
|
70
|
+
id = await buy();
|
|
71
|
+
o = (await call('PUT', `/orders/${id}/status`, {status: 'paid'})).body;
|
|
72
|
+
assert.deepEqual([o.data.commission.amount, o.data.commission.percent, o.data.commission.type], [110, 5, 'shop']);
|
|
73
|
+
// A later rate leaves it as it was.
|
|
74
|
+
await new Promise(r => setTimeout(r, 15));
|
|
75
|
+
await fs.writeFile(FILE, JSON.stringify({rates: {default: {percent: 50}}}));
|
|
76
|
+
o = (await call('PUT', `/orders/${id}/status`, {status: 'fulfilled'})).body;
|
|
77
|
+
assert.equal(o.data.commission.amount, 110);
|
|
78
|
+
const s = (await call('GET', '/settings')).body.commission;
|
|
79
|
+
assert.deepEqual([s.set, s.rates[0].text], [true, '50% per sale']);
|
|
80
|
+
// Refunded: returned.
|
|
81
|
+
o = (await call('POST', `/orders/${id}/refund`, {})).body.order;
|
|
82
|
+
assert.deepEqual([o.data.commission.reversed, o.data.commission.amount], [true, 110]);
|
|
83
|
+
const r = (await call('GET', '/sales?range=year')).body;
|
|
84
|
+
assert.deepEqual([r.totals.commission, r.totals.commissionBack, r.totals.commissionNet], [110, 110, 0]);
|
|
85
|
+
const csv = (await call('GET', '/sales.csv?range=year')).text;
|
|
86
|
+
assert.ok(csv.split('\r\n')[0].endsWith('Commission,Commission rate'));
|
|
87
|
+
// The customer never sees it.
|
|
88
|
+
const receipt = (await call('GET', `/order/${id}?t=${o.data.token}`)).body;
|
|
89
|
+
assert.ok(!('commission' in receipt));
|
|
90
|
+
});
|
|
@@ -4,7 +4,7 @@ import {createHmac} from 'node:crypto';
|
|
|
4
4
|
import {formatMoney, fromMinor, includedTax, percentOf, toMinor} from '../public/lib/money.js';
|
|
5
5
|
import {available, cleanProduct, priceRange, publicProduct, slugify, unitPrice} from '../public/lib/products.js';
|
|
6
6
|
import {canMove, cleanCheckout} from '../public/lib/orders.js';
|
|
7
|
-
import {cleanLines, priceBasket} from '../lib/pricing.js';
|
|
7
|
+
import {cleanCharges, cleanCustomLines, cleanLines, priceBasket} from '../lib/pricing.js';
|
|
8
8
|
import {paypalPaid, stripeForm, stripeLineItems, stripeSessionPaid, stripeVerify} from '../lib/payments.js';
|
|
9
9
|
import {badge, summarise} from '../lib/summary.js';
|
|
10
10
|
import {cartRegistry} from '../lib/extensions.js';
|
|
@@ -77,6 +77,35 @@ test('pricing: stock caps quantities, archived products drop out', async () => {
|
|
|
77
77
|
assert.equal(q.problems.length, 2);
|
|
78
78
|
});
|
|
79
79
|
|
|
80
|
+
test('pricing: lines an extension priced, and its charges standing in for delivery', async () => {
|
|
81
|
+
const custom = cleanCustomLines([
|
|
82
|
+
{name: 'Vindaloo', optionName: 'Protein', variantName: 'Chicken', unitPrice: 950, qty: 2, taxClass: 'standard',
|
|
83
|
+
detail: ['+ Extra chilli £0.50', ' No onions ', ''], category: 'Mains', ref: 'dish:vin'},
|
|
84
|
+
{name: 'Junk', unitPrice: -5, qty: 1}, {name: '', unitPrice: 100, qty: 1}, {name: 'Naan', unitPrice: 300, qty: 0}, {name: 'Rice', unitPrice: 1.5, qty: 1}
|
|
85
|
+
]);
|
|
86
|
+
assert.equal(custom.length, 1, 'no name, a negative or fractional price, or no quantity: dropped');
|
|
87
|
+
assert.deepEqual(custom[0].detail, ['+ Extra chilli £0.50', 'No onions']);
|
|
88
|
+
assert.equal(cleanCharges(undefined), null, 'no charges: the shipping stage runs as usual');
|
|
89
|
+
assert.deepEqual(cleanCharges([{label: 'Delivery (YO10)', amount: 250}, {amount: 'x'}]), [{label: 'Delivery (YO10)', amount: 250}]);
|
|
90
|
+
|
|
91
|
+
const settings = {...S, shippingFlat: 395, taxRate: 20, pricesIncludeTax: true, taxShipping: true};
|
|
92
|
+
let q = await priceBasket({lines: cleanLines([{productId: 'p1', qty: 1}]), products: catalogue, settings, custom, charges: cleanCharges([{label: 'Delivery', amount: 250}])});
|
|
93
|
+
assert.equal(q.lines.length, 2);
|
|
94
|
+
const dish = q.lines.find(l => l.custom);
|
|
95
|
+
assert.equal(dish.lineTotal, 1900);
|
|
96
|
+
assert.equal(dish.productId, '');
|
|
97
|
+
assert.equal(dish.shippable, false);
|
|
98
|
+
assert.equal(q.requiresShipping, true, 'the product still needs posting');
|
|
99
|
+
assert.deepEqual(q.adjustments.filter(a => a.stage === 'shipping').map(a => a.amount), [250], 'the charge, not the flat rate');
|
|
100
|
+
assert.equal(q.total, 1000 + 1900 + 250);
|
|
101
|
+
assert.equal(q.adjustments.find(a => a.stage === 'tax').amount, Math.round((1000 + 1900 + 250) / 6), 'taxed as delivery is');
|
|
102
|
+
// Only the extension's lines: nothing to post, and no charges means no delivery at all.
|
|
103
|
+
q = await priceBasket({lines: [], products: catalogue, settings, custom});
|
|
104
|
+
assert.equal(q.requiresShipping, false);
|
|
105
|
+
assert.equal(q.adjustments.filter(a => a.stage === 'shipping').length, 0);
|
|
106
|
+
assert.equal(q.subtotal, 1900);
|
|
107
|
+
});
|
|
108
|
+
|
|
80
109
|
test('pricing: flat delivery, free over a threshold, collection, nothing to ship', async () => {
|
|
81
110
|
const s = {...S, shippingFlat: 395, freeShippingOver: 2500, allowCollection: true};
|
|
82
111
|
let q = await priceBasket({lines: [{productId: 'p1', variantId: '', qty: 1}], products: catalogue, settings: s});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import {test} from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {dateIn, periodOf, periodsBetween, rangeFor, salesCsv, salesReport, sourceOf} from '../lib/sales.js';
|
|
4
|
+
|
|
5
|
+
const order = (n, {status = 'paid', paidAt, refundedAt, ref = '', provider = 'stripe', lines, adjustments = [], delivery = 'none', ext} = {}) => {
|
|
6
|
+
const subtotal = lines.reduce((s, l) => s + l.lineTotal, 0);
|
|
7
|
+
const total = subtotal + adjustments.filter(a => !a.included).reduce((s, a) => s + a.amount, 0);
|
|
8
|
+
return {id: `o${n}`, meta: {createdAt: paidAt}, data: {number: 1000 + n, status, paidAt, refundedAt, ref, provider, lines, adjustments, subtotal, total, delivery, ext,
|
|
9
|
+
customer: {name: `Customer ${n}`}}};
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const ORDERS = [
|
|
13
|
+
// A Shop order with delivery and a discount, paid late on 1 Oct UK time (still 1 Oct in London, 23:30 BST = 22:30Z).
|
|
14
|
+
order(1, {paidAt: '2026-10-01T22:30:00Z', lines: [{productId: 'mug', qty: 2, lineTotal: 2000}],
|
|
15
|
+
adjustments: [{stage: 'discount', amount: -200}, {stage: 'shipping', amount: 395}, {stage: 'tax', amount: 366, included: true}]}),
|
|
16
|
+
// Just after midnight UK time on 2 Oct (23:10Z on 1 Oct): a London date of 2 Oct.
|
|
17
|
+
order(2, {paidAt: '2026-10-01T23:10:00Z', provider: 'offline', lines: [{productId: 'ebook', qty: 1, lineTotal: 900}],
|
|
18
|
+
ext: {'shopping-cart-pro': {downloads: [{productId: 'ebook'}]}}}),
|
|
19
|
+
// Takeaway, delivered.
|
|
20
|
+
order(3, {paidAt: '2026-10-03T18:00:00Z', ref: 'takeaway:abc', delivery: 'ship', lines: [{productId: '', custom: true, qty: 1, lineTotal: 1900}],
|
|
21
|
+
adjustments: [{stage: 'shipping', amount: 250}]}),
|
|
22
|
+
// Paid 4 Oct, refunded 9 Oct.
|
|
23
|
+
order(4, {status: 'refunded', paidAt: '2026-10-04T12:00:00Z', refundedAt: '2026-10-09T09:00:00Z', lines: [{productId: 'mug', qty: 1, lineTotal: 1000}]}),
|
|
24
|
+
// Never paid: not a sale.
|
|
25
|
+
order(5, {status: 'pending', paidAt: '2026-10-05T12:00:00Z', lines: [{productId: 'mug', qty: 1, lineTotal: 1000}]}),
|
|
26
|
+
// Another add-on's sale (Cards Pro credits).
|
|
27
|
+
order(6, {paidAt: '2026-10-06T12:00:00Z', ref: 'purchase-9', provider: 'free', lines: [{productId: 'credits', qty: 1, lineTotal: 0}]})
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
test('sales: dates in the shop\'s zone, periods, ranges', () => {
|
|
31
|
+
assert.equal(dateIn('2026-10-01T23:10:00Z', 'Europe/London'), '2026-10-02');
|
|
32
|
+
assert.equal(dateIn('2026-10-01T23:10:00Z', 'UTC'), '2026-10-01');
|
|
33
|
+
assert.equal(periodOf('2026-10-08', 'week'), '2026-10-05', 'weeks start on Monday');
|
|
34
|
+
assert.equal(periodOf('2026-10-08', 'month'), '2026-10-01');
|
|
35
|
+
assert.deepEqual(periodsBetween('2026-09-28', '2026-10-12', 'week'), ['2026-09-28', '2026-10-05', '2026-10-12']);
|
|
36
|
+
assert.deepEqual(periodsBetween('2026-11-15', '2027-01-02', 'month'), ['2026-11-01', '2026-12-01', '2027-01-01']);
|
|
37
|
+
assert.deepEqual(rangeFor('lastmonth', '2026-10-12'), {from: '2026-09-01', to: '2026-09-30'});
|
|
38
|
+
assert.deepEqual(rangeFor('week', '2026-10-12'), {from: '2026-10-12', to: '2026-10-12'});
|
|
39
|
+
assert.deepEqual(rangeFor('last7', '2026-10-12'), {from: '2026-10-06', to: '2026-10-12'});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('sales: sources, figures, refunds on their own day, payment methods', () => {
|
|
43
|
+
assert.deepEqual(ORDERS.slice(0, 3).map(o => sourceOf(o, o.data.lines[0])), ['shop', 'downloads', 'takeaway']);
|
|
44
|
+
assert.equal(sourceOf(ORDERS[5], ORDERS[5].data.lines[0]), 'addons');
|
|
45
|
+
const r = salesReport(ORDERS, {from: '2026-10-01', to: '2026-10-12', by: 'day'});
|
|
46
|
+
assert.equal(r.totals.orders, 5, 'the unpaid one is not a sale');
|
|
47
|
+
assert.equal(r.totals.takings, 2195 + 900 + 2150 + 1000 + 0);
|
|
48
|
+
assert.deepEqual([r.totals.discount, r.totals.delivery, r.totals.taxIncluded, r.totals.refunds, r.totals.refundCount], [200, 645, 366, 1000, 1]);
|
|
49
|
+
assert.equal(r.totals.net, r.totals.takings - 1000);
|
|
50
|
+
const src = Object.fromEntries(r.sources.map(s => [s.key, s.amount]));
|
|
51
|
+
assert.deepEqual(src, {shop: 3000, downloads: 900, takeaway: 1900, addons: 0});
|
|
52
|
+
assert.deepEqual(r.takeaway, {collect: 0, deliver: 1});
|
|
53
|
+
assert.equal(r.methods[0].key, 'stripe');
|
|
54
|
+
const day = (d) => r.periods.find(p => p.period === d);
|
|
55
|
+
assert.equal(day('2026-10-01').takings, 2195);
|
|
56
|
+
assert.equal(day('2026-10-02').takings, 900, 'paid after midnight in London');
|
|
57
|
+
assert.equal(day('2026-10-09').refunds, 1000);
|
|
58
|
+
assert.equal(r.periods.length, 12, 'empty days are there too');
|
|
59
|
+
// A range that holds only the refund: no sale, the refund still counts.
|
|
60
|
+
const later = salesReport(ORDERS, {from: '2026-10-08', to: '2026-10-10'});
|
|
61
|
+
assert.deepEqual([later.totals.orders, later.totals.refunds, later.totals.net], [0, 1000, -1000]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('sales: the CSV, one row per order, quoted where it must be', () => {
|
|
65
|
+
const csv = salesCsv([...ORDERS, order(7, {paidAt: '2026-10-07T12:00:00Z', lines: [{productId: 'x', qty: 1, lineTotal: 500}]})].map(o => (o.id === 'o7' ? {...o, data: {...o.data, customer: {name: 'Smith, "Jo"'}}} : o)),
|
|
66
|
+
{from: '2026-10-01', to: '2026-10-12'});
|
|
67
|
+
const rows = csv.trim().split('\r\n');
|
|
68
|
+
assert.equal(rows[0].split(',')[0], 'Order');
|
|
69
|
+
assert.equal(rows.length, 1 + 6);
|
|
70
|
+
assert.ok(rows.some(r => r.startsWith('#1001,2026-10-01,paid,,Shop,Card (Stripe),Customer 1,20.00,2.00,3.95,0.00,3.66,21.95')));
|
|
71
|
+
assert.ok(rows.some(r => r.includes('"Smith, ""Jo"""')));
|
|
72
|
+
assert.ok(rows.some(r => r.startsWith('#1004,2026-10-04,refunded,2026-10-09')));
|
|
73
|
+
});
|