domma-cms 1.6.33 → 1.6.34
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/lib/effect-defs.js +1 -1
- package/admin/js/lib/scribe-model.js +1 -1
- package/admin/js/lib/shortcode-registry.js +2 -2
- package/package.json +1 -1
- package/plugins/free-tier.lock.json +13 -13
- package/plugins/shopping-cart/CLAUDE.md +19 -0
- package/plugins/shopping-cart/admin/views/orders.js +2 -2
- package/plugins/shopping-cart/admin/views/shop.js +1 -1
- package/plugins/shopping-cart/lib/pricing.js +65 -13
- package/plugins/shopping-cart/lib/render.js +4 -3
- package/plugins/shopping-cart/lib/shop.js +11 -3
- package/plugins/shopping-cart/lib/summary.js +4 -2
- package/plugins/shopping-cart/plugin.js +46 -21
- package/plugins/shopping-cart/plugin.json +2 -2
- package/plugins/shopping-cart/tests/api.test.js +52 -0
- package/plugins/shopping-cart/tests/pure.test.js +30 -1
- package/public/css/site.css +1 -1
- package/public/js/shortcode-expiry.mjs +1 -0
- package/server/routes/public.js +24 -1
- package/server/services/content.js +7 -7
- package/server/services/markdown.js +8 -3
- package/server/services/shortcodeExpiry.js +146 -0
- package/server/templates/page.html +1 -1
|
@@ -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);
|
|
@@ -28,7 +28,7 @@ import {formatMoney} from './public/lib/money.js';
|
|
|
28
28
|
import {publicProduct, byShelf} from './public/lib/products.js';
|
|
29
29
|
import {cartRegistry} from './lib/extensions.js';
|
|
30
30
|
import {SAMPLES} from './lib/samples.js';
|
|
31
|
-
import {cleanLines, priceBasket} from './lib/pricing.js';
|
|
31
|
+
import {cleanCharges, cleanCustomLines, cleanLines, priceBasket} from './lib/pricing.js';
|
|
32
32
|
import {badge, summarise} from './lib/summary.js';
|
|
33
33
|
import * as pay from './lib/payments.js';
|
|
34
34
|
import {
|
|
@@ -58,7 +58,7 @@ const receiptPath = (s, order) => `${s.shopPath}/order/${order.id}?t=${order.dat
|
|
|
58
58
|
const fail = (reply, status, error, extra = {}) => reply.code(status).send({error, ...extra});
|
|
59
59
|
|
|
60
60
|
/** The quote, from a request body, at today's prices. */
|
|
61
|
-
async function quoteFor(body, settings, {allowUnlisted = false} = {}) {
|
|
61
|
+
async function quoteFor(body, settings, {allowUnlisted = false, custom = [], charges = null} = {}) {
|
|
62
62
|
const lines = cleanLines(body?.lines);
|
|
63
63
|
const ids = new Set(lines.map(l => l.productId));
|
|
64
64
|
const products = new Map();
|
|
@@ -73,7 +73,8 @@ async function quoteFor(body, settings, {allowUnlisted = false} = {}) {
|
|
|
73
73
|
email: String(body?.customer?.email || '').slice(0, 200),
|
|
74
74
|
allowUnlisted
|
|
75
75
|
};
|
|
76
|
-
const quote = await priceBasket({lines, products, settings, input, handlers: cartRegistry().handlers()
|
|
76
|
+
const quote = await priceBasket({lines, products, settings, input, handlers: cartRegistry().handlers(),
|
|
77
|
+
custom: cleanCustomLines(custom), charges: cleanCharges(charges)});
|
|
77
78
|
return {quote, input};
|
|
78
79
|
}
|
|
79
80
|
|
|
@@ -326,32 +327,37 @@ export default async function shoppingCart(fastify, options) {
|
|
|
326
327
|
* through them first; only when they say yes is the order marked refunded.
|
|
327
328
|
* An offline order is marked only - the money goes back the way it came.
|
|
328
329
|
*/
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
330
|
+
/** Refund an order in full (see above). Throws {status, message}. */
|
|
331
|
+
async function refundOrder(id, {restock = false, note = '', viaProvider = true, by = 'admin'} = {}) {
|
|
332
|
+
const bad = (status, message) => Object.assign(new Error(message), {status});
|
|
333
|
+
const o = await getOrder(id);
|
|
334
|
+
if (!o) throw bad(404, 'No such order.');
|
|
332
335
|
const d = o.data;
|
|
333
|
-
if (!['paid', 'fulfilled'].includes(d.status))
|
|
336
|
+
if (!['paid', 'fulfilled'].includes(d.status)) throw bad(409, 'Only a paid order can be refunded.');
|
|
334
337
|
const s = await liveSettings();
|
|
335
|
-
const {restock = false, note = '', viaProvider = true} = request.body || {};
|
|
336
338
|
let how = 'marked refunded';
|
|
337
339
|
try {
|
|
338
340
|
if (viaProvider && d.provider === 'stripe' && d.paymentRef) {
|
|
339
|
-
if (!s.stripeSecretKey)
|
|
341
|
+
if (!s.stripeSecretKey) throw bad(409, 'Stripe is not set up any more - refund it in Stripe, then mark it refunded here.');
|
|
340
342
|
await pay.stripeRefund({secretKey: s.stripeSecretKey, paymentIntent: d.paymentRef});
|
|
341
343
|
how = 'refunded through Stripe';
|
|
342
344
|
} else if (viaProvider && d.provider === 'paypal' && d.paymentRef) {
|
|
343
|
-
if (!s.paypalClientId || !s.paypalSecret)
|
|
345
|
+
if (!s.paypalClientId || !s.paypalSecret) throw bad(409, 'PayPal is not set up any more - refund it in PayPal, then mark it refunded here.');
|
|
344
346
|
await pay.paypalRefund({creds: {clientId: s.paypalClientId, secret: s.paypalSecret, mode: s.paypalMode}, captureId: d.paymentRef});
|
|
345
347
|
how = 'refunded through PayPal';
|
|
346
348
|
}
|
|
347
349
|
} catch (err) {
|
|
348
|
-
|
|
350
|
+
throw bad(err.status || 502, err.status === 409 ? err.message : `Not refunded: ${err.message}`);
|
|
349
351
|
}
|
|
352
|
+
const {order, from} = await moveOrder(o.id, 'refunded', {by, note: [how, String(note || '')].filter(Boolean).join(' - '), restock: Boolean(restock)});
|
|
353
|
+
announceMove(order, from, 'refunded');
|
|
354
|
+
return {order, how};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
fastify.post('/orders/:id/refund', can('manage'), async (request, reply) => {
|
|
358
|
+
const {restock = false, note = '', viaProvider = true} = request.body || {};
|
|
350
359
|
try {
|
|
351
|
-
|
|
352
|
-
note: [how, String(note || '')].filter(Boolean).join(' - '), restock: Boolean(restock)});
|
|
353
|
-
announceMove(order, from, 'refunded');
|
|
354
|
-
return {order, how};
|
|
360
|
+
return await refundOrder(request.params.id, {restock, note, viaProvider, by: request.user?.name || request.user?.email || 'admin'});
|
|
355
361
|
} catch (err) {
|
|
356
362
|
return fail(reply, err.status || 500, err.message);
|
|
357
363
|
}
|
|
@@ -546,28 +552,39 @@ export default async function shoppingCart(fastify, options) {
|
|
|
546
552
|
* (it comes back in on.paid) and its own page to come back to. Throws {status, message}.
|
|
547
553
|
*
|
|
548
554
|
* @param {object} p
|
|
549
|
-
* @param {{productId: string, qty: number}[]} p.lines
|
|
550
|
-
* @param {
|
|
555
|
+
* @param {{productId: string, qty: number}[]} [p.lines]
|
|
556
|
+
* @param {object[]} [p.custom] - lines the extension priced (pricing.js cleanCustomLines): Takeaway's dishes
|
|
557
|
+
* @param {object[]} [p.charges] - [{label, amount}] standing in for delivery (Takeaway's delivery fee)
|
|
558
|
+
* @param {'none'|'collect'|'deliver'} [p.delivery] - 'deliver' needs `address`; neither is ever posted
|
|
559
|
+
* @param {object} [p.address]
|
|
560
|
+
* @param {string} [p.note]
|
|
561
|
+
* @param {string} [p.code] - a discount code (Shopping Cart Pro)
|
|
562
|
+
* @param {boolean} [p.ownEmails] - the extension writes to the shopper itself: other extensions' shopper emails stay quiet
|
|
563
|
+
* @param {string} [p.payNote] - offline only: what the receipt says about paying, instead of the shop's offline instructions
|
|
564
|
+
* @param {{name: string, email: string, phone?: string}} p.customer
|
|
551
565
|
* @param {string} [p.provider] - one of the Shop's methods; the first when left out
|
|
552
566
|
* @param {string} [p.ref] - the extension's reference, 64 characters at most
|
|
553
567
|
* @param {string} [p.returnTo] - a path on this site ('/admin/#/...'), where the shopper comes back to
|
|
554
568
|
* @param {string} [p.base] - this site's origin, from the extension's request
|
|
555
569
|
*/
|
|
556
|
-
async function startCheckout({lines,
|
|
570
|
+
async function startCheckout({lines = [], custom = [], charges = null, delivery = 'none', address = null, note = '', code = '',
|
|
571
|
+
customer, provider = '', ref = '', returnTo = '', base = '', ownEmails = false, payNote = ''} = {}) {
|
|
557
572
|
const bad = (status, message) => Object.assign(new Error(message), {status});
|
|
558
573
|
const s = await liveSettings();
|
|
559
|
-
const {quote} = await quoteFor({lines, customer}, s, {allowUnlisted: true});
|
|
574
|
+
const {quote} = await quoteFor({lines, customer, code, address}, s, {allowUnlisted: true, custom, charges});
|
|
560
575
|
if (!quote.lines.length) throw bad(400, 'Nothing to buy.');
|
|
561
576
|
if (quote.problems.length) throw bad(409, quote.problems.map(p => p.message).join(' '));
|
|
562
577
|
if (quote.requiresShipping) throw bad(400, 'This checkout is only for things that are not posted.');
|
|
563
|
-
const
|
|
578
|
+
const deliver = delivery === 'deliver';
|
|
579
|
+
const checkout = cleanCheckout({customer, address, note}, {needsAddress: deliver, countries: s.countries});
|
|
564
580
|
if (Object.keys(checkout.errors).length) throw bad(400, Object.values(checkout.errors)[0]);
|
|
565
581
|
const methods = methodsOf(s).map(m => m.id);
|
|
566
582
|
const via = quote.total === 0 ? 'free' : (provider || methods[0] || '');
|
|
567
583
|
if (via !== 'free' && !methods.includes(via)) throw bad(409, 'The Shop has no way to take payment set up yet.');
|
|
568
584
|
const origin = /^https?:\/\/[^/\s]+$/i.test(base) ? base.replace(/\/+$/, '') : siteBase(null);
|
|
569
585
|
const back = /^\/(?!\/)/.test(String(returnTo)) ? `${origin}${returnTo}` : '';
|
|
570
|
-
const order = await createOrder({quote, checkout, provider: via, delivery: '
|
|
586
|
+
const order = await createOrder({quote, checkout, provider: via, delivery: deliver ? 'ship' : delivery === 'collect' ? 'collect' : 'none',
|
|
587
|
+
code: String(code || '').slice(0, 60), ref, returnTo: back, ownEmails: ownEmails === true, payNote});
|
|
571
588
|
registry.emit('checkout', order);
|
|
572
589
|
const opened = await openPayment(order, s, via, origin);
|
|
573
590
|
if (!opened) throw bad(400, 'Choose how to pay.');
|
|
@@ -581,6 +598,14 @@ export default async function shoppingCart(fastify, options) {
|
|
|
581
598
|
}
|
|
582
599
|
|
|
583
600
|
registry.services.startCheckout = startCheckout;
|
|
601
|
+
/** What startCheckout would charge, without ordering: a basket page's totals. Same arguments. */
|
|
602
|
+
registry.services.quote = async ({lines = [], custom = [], charges = null, code = '', customer = {}, address = null} = {}) => {
|
|
603
|
+
const s = await liveSettings();
|
|
604
|
+
const {quote} = await quoteFor({lines, customer, code, address}, s, {allowUnlisted: true, custom, charges});
|
|
605
|
+
return shopperQuote(quote, s);
|
|
606
|
+
};
|
|
607
|
+
/** Refund an order in full, through its provider first (Takeaway's Reject). Throws {status, message}. */
|
|
608
|
+
registry.services.refund = (id, opts = {}) => refundOrder(id, {by: 'shop', ...opts});
|
|
584
609
|
registry.services.checkOrder = checkOrder;
|
|
585
610
|
registry.services.paymentMethods = async () => methodsOf(await liveSettings()).map(m => ({id: m.id, label: m.label}));
|
|
586
611
|
// An extension looks after its own (unlisted) products: made, changed and removed through these.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shopping-cart",
|
|
3
3
|
"displayName": "Shopping Cart",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.3.0",
|
|
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",
|
|
@@ -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.3.0",
|
|
49
49
|
"exportName": "shopView"
|
|
50
50
|
}
|
|
51
51
|
}
|
|
@@ -239,3 +239,55 @@ 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
|
+
});
|
|
@@ -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});
|