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.
@@ -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
- const had = units.get(l.productId) || {productId: l.productId, name: l.name, qty: 0, revenue: 0};
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(l.productId, had);
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
- fastify.post('/orders/:id/refund', can('manage'), async (request, reply) => {
330
- const o = await getOrder(request.params.id);
331
- if (!o) return fail(reply, 404, 'No such order.');
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)) return fail(reply, 409, 'Only a paid order can be refunded.');
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) return fail(reply, 409, 'Stripe is not set up any more - refund it in Stripe, then mark it refunded here.');
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) return fail(reply, 409, 'PayPal is not set up any more - refund it in PayPal, then mark it refunded here.');
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
- return fail(reply, err.status || 502, `Not refunded: ${err.message}`);
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
- const {order, from} = await moveOrder(o.id, 'refunded', {by: request.user?.name || request.user?.email || 'admin',
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 {{name: string, email: string}} p.customer
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, customer, provider = '', ref = '', returnTo = '', base = ''} = {}) {
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 checkout = cleanCheckout({customer}, {needsAddress: false});
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: 'none', ref, returnTo: back});
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.2.0",
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.2.0",
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});