domma-cms 1.6.37 → 1.6.39

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.
Files changed (82) hide show
  1. package/admin/dist/domma/domma-tools.css +3 -3
  2. package/admin/dist/domma/domma-tools.min.js +3 -3
  3. package/package.json +2 -2
  4. package/plugins/_lib/admin/mail/mail.css +1 -1
  5. package/plugins/_lib/admin/mail/oauth-panel.js +303 -0
  6. package/plugins/_lib/admin/mail/reader-view.js +126 -13
  7. package/plugins/_lib/mail/accounts.js +548 -225
  8. package/plugins/_lib/mail/imapPool.js +63 -13
  9. package/plugins/_lib/mail/oauth.js +453 -0
  10. package/plugins/_lib/mail/oauthRoutes.js +294 -0
  11. package/plugins/_lib/mail/readRoutes.js +23 -2
  12. package/plugins/_lib/mail/scheduler.js +30 -6
  13. package/plugins/blog/CLAUDE.md +11 -1
  14. package/plugins/blog/lib/cache.js +43 -0
  15. package/plugins/blog/lib/page.js +28 -9
  16. package/plugins/blog/plugin.js +14 -13
  17. package/plugins/blog/plugin.json +1 -1
  18. package/plugins/blog/plugin.public.js +1 -1
  19. package/plugins/blog/tests/cache.test.js +65 -0
  20. package/plugins/booking/CLAUDE.md +14 -2
  21. package/plugins/booking/admin/lib/diary-layout.js +18 -0
  22. package/plugins/booking/admin/views/diary.js +153 -152
  23. package/plugins/booking/admin/views/people.js +64 -66
  24. package/plugins/booking/admin/views/settings.js +31 -32
  25. package/plugins/booking/plugin.json +1 -1
  26. package/plugins/booking/tests/admin-diary.test.js +9 -1
  27. package/plugins/feedback/CLAUDE.md +62 -0
  28. package/plugins/feedback/admin/css/index.css +16 -1
  29. package/plugins/feedback/admin/lib/kit.js +106 -0
  30. package/plugins/feedback/admin/lib/settings.js +107 -0
  31. package/plugins/feedback/admin/views/feedback.js +4 -1
  32. package/plugins/feedback/admin/views/inbox.js +11 -2
  33. package/plugins/feedback/admin/views/send.js +74 -7
  34. package/plugins/feedback/docs/guide.md +9 -9
  35. package/plugins/feedback/lib/attachments.js +133 -0
  36. package/plugins/feedback/lib/cache.js +43 -0
  37. package/plugins/feedback/lib/receiver.js +36 -6
  38. package/plugins/feedback/lib/sender.js +89 -6
  39. package/plugins/feedback/lib/shape.js +111 -6
  40. package/plugins/feedback/plugin.js +173 -17
  41. package/plugins/feedback/plugin.json +2 -2
  42. package/plugins/feedback/tests/api.test.js +125 -3
  43. package/plugins/feedback/tests/settings.test.js +59 -0
  44. package/plugins/feedback/tests/shape.test.js +87 -1
  45. package/plugins/free-tier.lock.json +74 -62
  46. package/plugins/mail-reader/CLAUDE.md +15 -4
  47. package/plugins/mail-reader/plugin.json +1 -1
  48. package/plugins/shopping-cart/CLAUDE.md +35 -2
  49. package/plugins/shopping-cart/admin/css/index.css +3 -0
  50. package/plugins/shopping-cart/admin/views/orders.js +75 -23
  51. package/plugins/shopping-cart/admin/views/shop.js +1 -1
  52. package/plugins/shopping-cart/docs/guide.md +7 -3
  53. package/plugins/shopping-cart/lib/payments.js +15 -2
  54. package/plugins/shopping-cart/lib/refunds.js +51 -0
  55. package/plugins/shopping-cart/lib/sales.js +15 -8
  56. package/plugins/shopping-cart/lib/shop.js +38 -3
  57. package/plugins/shopping-cart/plugin.js +47 -19
  58. package/plugins/shopping-cart/plugin.json +2 -2
  59. package/plugins/shopping-cart/tests/api.test.js +52 -0
  60. package/plugins/shopping-cart/tests/commission.test.js +1 -1
  61. package/plugins/shopping-cart/tests/pure.test.js +16 -0
  62. package/plugins/shopping-cart/tests/refunds.test.js +42 -0
  63. package/plugins/support/CLAUDE.md +58 -0
  64. package/plugins/support/admin/css/index.css +16 -1
  65. package/plugins/support/admin/lib/kit.js +192 -8
  66. package/plugins/support/admin/lib/settings.js +123 -0
  67. package/plugins/support/admin/views/desk.js +55 -12
  68. package/plugins/support/admin/views/inbox.js +17 -4
  69. package/plugins/support/admin/views/support.js +4 -1
  70. package/plugins/support/docs/guide.md +7 -9
  71. package/plugins/support/lib/attachments.js +133 -0
  72. package/plugins/support/lib/cache.js +43 -0
  73. package/plugins/support/lib/receiver.js +45 -10
  74. package/plugins/support/lib/sender.js +96 -10
  75. package/plugins/support/lib/shape.js +153 -12
  76. package/plugins/support/plugin.js +159 -29
  77. package/plugins/support/plugin.json +2 -2
  78. package/plugins/support/tests/api.test.js +125 -3
  79. package/plugins/support/tests/settings.test.js +68 -0
  80. package/plugins/support/tests/shape.test.js +76 -2
  81. package/server/routes/outreach-public.js +2 -2
  82. package/server/services/request-log.js +3 -1
@@ -63,10 +63,13 @@ open on something rather than asking which before it has shown anything.
63
63
  is what makes it safe.)
64
64
  - **Credentials are not a collection.** `schema.plugin` is a badge, not a visibility gate - a
65
65
  plugin-owned collection still appears in admin Collections with a working Entries link, is
66
- reachable via `/api/v1`, and can be pointed at a shared Mongo. Hence `data/accounts.json`, 0600,
67
- gitignored, and excluded from the npm `files` list.
66
+ reachable via `/api/v1`, and can be pointed at a shared Mongo. Hence a file: the shared mail core
67
+ (`plugins/_lib/mail/accounts.js`) keeps them in `plugins/_lib/data/mail-accounts.json` (0600, written
68
+ atomically; `DOMMA_MAIL_DATA_DIR` moves it), gitignored and out of the npm `files` list. An older
69
+ site's `mail-reader/data/accounts.json` is adopted from there on first read.
68
70
  - **Key resolution is a chain**, not a single source: `MAIL_SECRET` → `INSTANCE_SECRET` →
69
- generated `data/.mailkey`. `INSTANCE_SECRET` only exists on sites that ran setup or registered
71
+ generated `plugins/_lib/data/.mailkey` (0600; `_lib/mail/secretbox.js`, which adopts a legacy
72
+ `mail-reader/data/.mailkey`). `INSTANCE_SECRET` only exists on sites that ran setup or registered
70
73
  with the manager, so relying on it alone breaks every older site. The sealing source is recorded
71
74
  in each token so a key change fails with a sentence, not a bare auth error.
72
75
  - **Never derive from `JWT_SECRET`.** Rotating it logs everyone out, which is recoverable; silently
@@ -88,7 +91,10 @@ open on something rather than asking which before it has shown anything.
88
91
 
89
92
  ## Testing
90
93
 
91
- `services/secretbox.js` and `services/render.js` are pure and unit-tested. Connection behaviour
94
+ The mail code is the engine's shared core, `plugins/_lib/mail/` (this plugin is little more than its
95
+ manifest, guide and plugin.js wiring); `secretbox.js` and `render.js` there are pure and unit-tested in
96
+ domma-cms (`tests/services/mail-secretbox.test.js`, `mail-render.test.js`, and the other `mail-*.test.js`
97
+ files). Connection behaviour
92
98
  should be checked against a **real IMAP server** - `mail.dommajs.org` answers on 993 (TLS) and 143
93
99
  (STARTTLS). A wrong-credentials attempt is enough to verify TLS, auth-failure mapping and that the
94
100
  pool leaves nothing open; it does not verify folder listing or rendering, which need a real mailbox.
@@ -114,3 +120,8 @@ this version only documents it in docs/guide.md. 0.116.8 is already released, so
114
120
  Saving or testing a mailbox on a private, loopback or link-local address is refused by the shared read routes
115
121
  unless it is the site's own outgoing server or `allowPrivateMailHosts` is set; `/accounts/test` is limited to
116
122
  10 a minute. Engine code - this version only documents the option in docs/guide.md. Ship with the matching core.
123
+
124
+ ## Notes
125
+
126
+ - 2026-10-13 (1.2.8): this file brought up to date - credentials, the key and the tested modules live in the
127
+ shared `plugins/_lib/mail` core and `plugins/_lib/data/`, not in this plugin's `services/` and `data/`. No code change.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mail-reader",
3
3
  "displayName": "Mail Reader",
4
- "version": "1.2.7",
4
+ "version": "1.2.8",
5
5
  "tier": "free",
6
6
  "description": "Read-only IMAP mailbox reader. Connects to a standard IMAP server and reads mail without ever writing to it.",
7
7
  "author": "Darryl Waterhouse",
@@ -70,7 +70,7 @@ A paid or refunded order cannot be deleted. Each order has a random `token`: the
70
70
  and checks `paypalPaid`. Sandbox/live by setting.
71
71
  - Offline: the order is `awaiting`; the receipt shows `offlineInstructions`. Mark paid from the admin.
72
72
  - Refunds: `POST /orders/:id/refund` refunds through Stripe (payment_intent) or PayPal (capture id) FIRST,
73
- and only then marks the order refunded. Full refunds only.
73
+ and only then records it. Part refunds since 1.6.2 (see the end of this file).
74
74
  - Secrets never leave the server: GET /settings masks them (`••••••••`); saving the mask keeps the value.
75
75
 
76
76
  ## Extensions (Shopping Cart Pro)
@@ -216,4 +216,37 @@ Cart only READS it (`lib/commission.js currentCommission`, re-read when the file
216
216
  is off when accounts are: user ids are public (`createdBy`), so "already confirmed -> here is the email" leaked emails.
217
217
  - `refundOrder` runs under `withLock` (provider call and `moveOrderNow` in one), and Stripe refunds carry
218
218
  `Idempotency-Key: shopping-cart-refund-<orderId>` (sessions: `shop-session-<orderId>`) so a retry after a timeout
219
- returns the first result.
219
+ returns the first result.
220
+
221
+ ## 1.6.2 (2026-10-13): part refunds
222
+
223
+ - `POST /orders/:id/refund {amount?, note, restock, viaProvider}` - `amount` in minor units refunds part of what is left;
224
+ left out, the rest goes back. `lib/refunds.js` (PURE, `tests/refunds.test.js`): `refundsOf` (an order refunded before
225
+ 1.6.2 reads as ONE refund of its total, dated `refundedAt`), `refundedSoFar`, `refundable`, `refundPlan` (400 for a bad
226
+ or too-large amount, 409 when nothing is left).
227
+ - The order keeps `refunds: [{at, amount, by, note, via, ref}]` and `refunded` (their sum). A part refund is recorded by
228
+ `recordRefundNow` (history line, status unchanged, no stock, no `status` event); the refund that leaves nothing moves the
229
+ order to `refunded` through `moveOrderNow` as before (restock, commission reversed, `announceMove`). Commission is
230
+ returned only on the full refund - a part refund leaves it whole (decision pending: pro-rata?).
231
+ - Everything still runs under `withLock`. Provider keys are per refund: Stripe `Idempotency-Key` and PayPal
232
+ `PayPal-Request-Id` = `shopping-cart-refund-<orderId>-<n>` (n = this refund's ordinal), so a retry after a timeout
233
+ returns the first result and the next part refund is a new request. PayPal sends `amount {value, currency_code}` only for
234
+ a part refund. Stripe/PayPal refund ids land in `refunds[].ref`.
235
+ - Sales: `refunds` and `refundCount` count each refund on its own day; the CSV's "Refunded" date is the last refund's and
236
+ a new last column "Refunded amount" holds the sum. `services.refund(id, {amount})` takes the same amount.
237
+ - Admin: the Refund dialog has an amount (prefilled with what is left) and a reason; restock is offered only for a full
238
+ refund; the order panel lists the refunds; the menu says "Refund more…" once part has gone back.
239
+
240
+
241
+ ## 1.6.2 (2026-10-13): badge cache
242
+ `lib/shop.js` keeps a write generation: `withLock` moves it as every write starts and ends, and `removeProduct` and
243
+ `saveSettings` move it too. `GET /count` (every admin tab, every minute, all orders + products) keeps one answer for
244
+ 60 s unless the generation moved; an answer worked out across a write is not kept. `services.writeGeneration` lets
245
+ extensions (Takeaway 0.4.2) cache their own order reads the same way - feature-detect it, older Carts lack it.
246
+
247
+ ## 1.6.3 (2026-10-13): the Refund form was empty
248
+ `E.confirm` sanitises its message (preset basic), which strips `<input>` - so 1.6.2's Refund dialog showed only the words,
249
+ no amount, reason or restock box, and OK refunded the WHOLE order. `refund()` in `admin/views/orders.js` is now a small
250
+ slideover (`openPanel`, like Fulfil): amount (prefilled with what is left), reason, restock box (greyed out unless the
251
+ amount is all that is left), a Refund button. Never put form fields in `E.confirm` / `E.alert`.
252
+ `tests/pure.test.js` "admin: no form fields inside E.confirm" guards it across the admin views.
@@ -206,6 +206,9 @@ a.shp-tile:hover { border-color: var(--dm-primary); }
206
206
  .shp-order a { color: var(--dm-primary); }
207
207
  .shp-callout { border-left: 3px solid var(--dm-warning); background: color-mix(in srgb, var(--dm-warning) 9%, transparent); padding: .6rem .8rem; border-radius: var(--dm-radius-sm); }
208
208
  .shp-callout p { white-space: pre-wrap; margin-top: .25rem; }
209
+ .shp-refunds { border-left-color: var(--dm-danger); background: color-mix(in srgb, var(--dm-danger) 8%, transparent); }
210
+ .shp-refunds ul { margin: .35rem 0 0; padding-left: 1.1rem; font-size: var(--dm-text-sm); color: var(--dm-text-muted); }
211
+ .shp-refunds code { font-size: var(--dm-text-xs); }
209
212
  .shp-ref { font-size: var(--dm-text-xs); }
210
213
  .shp-ext { display: flex; flex-wrap: wrap; gap: .3rem; }
211
214
  .shp-history { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: .4rem; border-left: 2px solid var(--dm-border); padding-left: .8rem; }
@@ -9,7 +9,7 @@
9
9
  * slip. A notification's link (#/plugins/shopping-cart/orders/<id>) opens one.
10
10
  */
11
11
 
12
- import {api, copy, entriesOf, esc, fmtDate, fmtDateTime, money, mountPanels, openPanel, refreshBadge, store, when, wireRows} from '../lib/kit.js';
12
+ import {api, copy, entriesOf, esc, fmtDate, fmtDateTime, money, moneyIn, moneyOut, mountPanels, openPanel, refreshBadge, store, when, wireRows} from '../lib/kit.js';
13
13
  import {NEXT, STATUS_LABEL, STATUS_TONE, addressLines, orderLabel} from '/plugins/shopping-cart/public/lib/orders.js';
14
14
 
15
15
  const FILTERS = [
@@ -169,27 +169,77 @@ export async function mount(root, ctx) {
169
169
  });
170
170
  }
171
171
 
172
- async function refund(o) {
173
- const via = {stripe: 'through Stripe', paypal: 'through PayPal'}[o.data.provider];
174
- const html = `<p>Refund ${esc(orderLabel(o.data))}, <strong>${esc(money(o.data.total))}</strong>, in full?</p>
175
- <p>${via && o.data.paymentRef ? `The money goes back ${via} straight away.` : 'This only marks it refunded - give the money back the way it came.'}</p>
176
- <label class="shp-check"><input type="checkbox" id="shp-restock" ${o.data.stockTaken ? 'checked' : 'disabled'}> Put the items back in stock</label>`;
177
- let restock = Boolean(o.data.stockTaken);
178
- const onChange = (e) => { if (e.target.id === 'shp-restock') restock = e.target.checked; };
179
- document.addEventListener('change', onChange);
180
- const ok = await E.confirm(html);
181
- document.removeEventListener('change', onChange);
182
- if (!ok) return null;
183
- try {
184
- const r = await api.orders.refund(o.id, {restock});
185
- replace(r.order);
186
- refreshBadge();
187
- E.toast(`${orderLabel(o.data)} ${r.how}.`, {type: 'success'});
188
- return r.order;
189
- } catch (err) {
190
- E.toast(err.message || String(err), {type: 'error'});
191
- return null;
192
- }
172
+ /** Money already given back on an order (legacy whole-order refunds included). */
173
+ const refundedOf = (d) => Array.isArray(d.refunds) ? d.refunds.reduce((n, r) => n + (Number(r.amount) || 0), 0) : d.status === 'refunded' ? d.total || 0 : 0;
174
+
175
+ /**
176
+ * Refund - all that is left, or part of it - in a small form of its own. (Not E.confirm: its message is
177
+ * sanitised, which strips inputs, so the amount, reason and restock box never showed and OK refunded it all.)
178
+ */
179
+ function refund(o) {
180
+ return new Promise((resolve) => {
181
+ const d = o.data;
182
+ const via = {stripe: 'through Stripe', paypal: 'through PayPal'}[d.provider];
183
+ const before = refundedOf(d);
184
+ const left = Math.max(0, (d.total || 0) - before);
185
+ const f = {amount: M.observable(moneyIn(left)), note: M.observable(''), saving: M.observable(false)};
186
+ let restock = Boolean(d.stockTaken);
187
+ const full = () => moneyOut(f.amount.peek()) === left;
188
+ let panel = null;
189
+ let box = null;
190
+ // Stock only comes back when the whole order is refunded, so the box greys out on a part refund.
191
+ const syncBox = () => { if (box && d.stockTaken) { box.disabled = !full(); box.checked = full() && restock; } };
192
+ const stop = M.effect(() => { f.amount.value; syncBox(); });
193
+ openPanel({
194
+ title: `${before ? 'Refund more of' : 'Refund'} ${orderLabel(d)}`,
195
+ size: 'sm',
196
+ model: f,
197
+ html: `<div class="shp-form">
198
+ <p class="shp-note">${before ? `<strong>${esc(money(left))}</strong> of ${esc(money(d.total))} is left to refund (${esc(money(before))} already refunded).` : `The order came to <strong>${esc(money(d.total))}</strong>.`}</p>
199
+ <label class="form-label" for="shp-refund-amount">Amount <span data-help="Leave the whole amount for a full refund. A smaller amount is a part refund: the order stays as it is, with what was given back on it, and you can refund the rest later."></span></label>
200
+ <input class="form-input" id="shp-refund-amount" inputmode="decimal" data-model="amount.value" aria-describedby="shp-refund-hint">
201
+ <p class="shp-muted" id="shp-refund-hint">${via && d.paymentRef ? `The money goes back ${via} straight away.` : 'This only records the refund - give the money back the way it came.'}</p>
202
+ <label class="form-label" for="shp-refund-note">Reason <span class="shp-muted">(optional, kept on the order)</span></label>
203
+ <input class="form-input" id="shp-refund-note" maxlength="200" data-model="note.value" placeholder="Damaged in the post, one item missing…">
204
+ <label class="shp-check"><input type="checkbox" id="shp-restock"> Put the items back in stock <span class="shp-muted">(full refund only)</span></label>
205
+ <div class="shp-actions"><span class="shp-spacer"></span>
206
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
207
+ <button type="button" class="btn btn-sm btn-danger" data-on-click="save" data-bind-disabled="saving.value"><span data-icon="credit-card" data-icon-size="13"></span> Refund</button></div>
208
+ </div>`,
209
+ before: (el) => {
210
+ box = el.querySelector('#shp-restock');
211
+ if (!d.stockTaken) box.disabled = true;
212
+ box.addEventListener('change', () => { restock = box.checked; });
213
+ syncBox();
214
+ },
215
+ cleanup: () => { stop?.(); resolve(null); },
216
+ methods: {
217
+ cancel: () => panel?.close(true),
218
+ async save() {
219
+ if (f.saving.peek()) return;
220
+ const amount = moneyOut(f.amount.peek());
221
+ if (!Number.isInteger(amount) || amount <= 0 || amount > left) {
222
+ E.toast(`Enter an amount between ${money(1)} and ${money(left)}.`, {type: 'error'});
223
+ return;
224
+ }
225
+ const all = amount === left;
226
+ f.saving.value = true;
227
+ try {
228
+ const r = await api.orders.refund(o.id, {amount: all ? undefined : amount, restock: all && restock, note: f.note.peek()});
229
+ replace(r.order);
230
+ refreshBadge();
231
+ E.toast(`${orderLabel(d)} ${r.how}.`, {type: 'success'});
232
+ resolve(r.order);
233
+ panel?.close(true);
234
+ } catch (err) {
235
+ E.toast(err.message || String(err), {type: 'error'});
236
+ } finally {
237
+ f.saving.value = false;
238
+ }
239
+ }
240
+ }
241
+ }).then(p => { panel = p; p.panel.querySelector('#shp-refund-amount')?.focus(); });
242
+ });
193
243
  }
194
244
 
195
245
  async function remove(o) {
@@ -235,7 +285,7 @@ ${(d.lines || []).map(l => `<tr><td class="q">${l.qty}</td><td>${esc(l.name)}${l
235
285
  ...(can('paid') && s !== 'fulfilled' ? [{label: 'Mark paid…', icon: 'check-circle', run: () => markPaid(o)}] : []),
236
286
  ...(can('fulfilled') ? [{label: o.data.delivery === 'collect' ? 'Mark collected…' : 'Mark fulfilled…', icon: 'truck', run: () => fulfil(o)}] : []),
237
287
  ...(s === 'fulfilled' ? [{label: 'Back to "to fulfil"', icon: 'rotate-ccw', run: () => move(o, 'paid', {note: 'Reopened'})}] : []),
238
- ...(can('refunded') ? [{label: 'Refund…', icon: 'credit-card', danger: true, run: () => refund(o)}] : []),
288
+ ...(can('refunded') ? [{label: refundedOf(o.data) ? 'Refund more…' : 'Refund…', icon: 'credit-card', danger: true, run: () => refund(o)}] : []),
239
289
  ...(can('cancelled') ? [{label: 'Cancel order…', icon: 'x-circle', danger: true, run: () => cancel(o)}] : [])
240
290
  ];
241
291
  }
@@ -301,6 +351,8 @@ ${(d.lines || []).map(l => `<tr><td class="q">${l.qty}</td><td>${esc(l.name)}${l
301
351
  <label class="form-label">Staff note <span data-help="Only you see this. Saved when you click away."></span></label>
302
352
  <textarea class="form-input" rows="2" data-model="staffNote.value" data-part="staff" placeholder="Wrapped as a gift, sent second class…"></textarea>
303
353
  ${d.paymentRef || d.providerRef ? `<p class="shp-muted shp-ref">Payment reference: <code>${esc(d.paymentRef || d.providerRef)}</code></p>` : ''}
354
+ ${Array.isArray(d.refunds) && d.refunds.length ? `<div class="shp-callout shp-refunds"><strong>Refunded ${esc(money(refundedOf(d)))}${refundedOf(d) < (d.total || 0) ? ` - ${esc(money((d.total || 0) - refundedOf(d)))} left to refund` : ' - in full'}</strong>
355
+ <ul>${d.refunds.map(r => `<li>${esc(money(r.amount))} · ${esc(fmtDateTime(r.at))} · ${esc(r.via || '')}${r.by ? ` by ${esc(r.by)}` : ''}${r.note ? ` - ${esc(r.note)}` : ''}${r.ref ? ` <code>${esc(r.ref)}</code>` : ''}</li>`).join('')}</ul></div>` : ''}
304
356
  ${d.commission ? `<p class="shp-muted shp-ref">Platform commission: ${esc(money(d.commission.amount))} (${esc(`${d.commission.percent}%${d.commission.fixed ? ` + ${money(d.commission.fixed)}` : ''}`)})${d.commission.reversed ? ' - returned with the refund' : ''}</p>` : ''}
305
357
  ${ext.length ? `<div class="shp-ext">${ext.map(x => `<span class="shp-chip is-muted">${esc(x.k)}: ${esc(String(x.v))}</span>`).join('')}</div>` : ''}
306
358
  <div data-part="ext-panels" hidden></div>
@@ -15,7 +15,7 @@
15
15
  import {api, loadKit, setCurrency, store} from '../lib/kit.js';
16
16
 
17
17
  const ROUTE = /^#\/plugins\/shopping-cart(\/|$)/;
18
- const V = '1.6.0';
18
+ const V = '1.6.2';
19
19
  const TABS = [
20
20
  {key: 'overview', label: 'Overview', icon: 'bar-chart', module: `/plugins/shopping-cart/admin/views/overview.js?v=${V}`},
21
21
  {key: 'orders', label: 'Orders', icon: 'receipt', module: `/plugins/shopping-cart/admin/views/orders.js?v=${V}`},
@@ -69,10 +69,14 @@ Actions:
69
69
 
70
70
  Refunds:
71
71
 
72
- - Refunds are full refunds only. You cannot refund part of an order.
73
- - A Stripe or PayPal order is refunded through Stripe or PayPal first. The order is only marked refunded once they accept it.
72
+ - **Refund…** asks how much. It starts at the whole amount still to refund; leave it for a full refund, or type less for a part refund.
73
+ - Add a **Reason** if you like. It is kept on the order.
74
+ - A part refund leaves the order as it is (Paid or Fulfilled). The order shows what was refunded, when and why, and how much is left. **Refund more…** gives back more, up to what is left.
75
+ - When everything has been given back, the order becomes Refunded.
76
+ - A Stripe or PayPal order is refunded through Stripe or PayPal first. The refund is only recorded once they accept it.
74
77
  - An offline order is only marked refunded. Give the money back yourself, the way it came.
75
- - Tick **Put the items back in stock** to return the stock.
78
+ - Tick **Put the items back in stock** to return the stock. This is only offered for a full refund (or the last part of one).
79
+ - Sales counts each refund on the day it was made. The CSV has a **Refunded amount** column.
76
80
 
77
81
  ### Sales
78
82
 
@@ -273,8 +273,21 @@ export function paypalPaid(pp, order) {
273
273
  return {ref: pp.id, payment: capture.id};
274
274
  }
275
275
 
276
- export async function paypalRefund({creds, captureId}) {
277
- return paypalCall(creds, 'POST', `/v2/payments/captures/${encodeURIComponent(captureId)}/refund`, {});
276
+ /**
277
+ * Refund a capture - in full with no `amount`, or part of it (`amount` in
278
+ * minor units of `currency`, which PayPal wants as a decimal string).
279
+ * `requestId` becomes PayPal-Request-Id, so a retry after a timeout returns
280
+ * the first refund instead of making a second.
281
+ */
282
+ export async function paypalRefund({creds, captureId, amount = 0, currency = '', requestId = ''}) {
283
+ const token = await paypalToken(creds);
284
+ const payload = amount && currency ? {amount: {value: fromMinor(amount, currency), currency_code: currency}} : {};
285
+ return call(`${paypalBase(creds.mode)}/v2/payments/captures/${encodeURIComponent(captureId)}/refund`, {
286
+ method: 'POST',
287
+ headers: {Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', Prefer: 'return=representation',
288
+ ...(requestId ? {'PayPal-Request-Id': String(requestId).slice(0, 108)} : {})},
289
+ body: JSON.stringify(payload)
290
+ }, 'PayPal');
278
291
  }
279
292
 
280
293
  export async function paypalTest({creds}) {
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Refund maths. PURE - how much of an order has gone back, how much may still
3
+ * go back, and what one more refund does to it.
4
+ *
5
+ * An order carries `refunds: [{at, amount, by, note, via, ref}]` (minor units)
6
+ * and `refunded` (their sum). Orders refunded before 1.6.2 have neither - just
7
+ * `status: 'refunded'` and `refundedAt` - so a missing list on a refunded order
8
+ * reads as one refund of the total, dated `refundedAt`.
9
+ *
10
+ * @module shopping-cart/lib/refunds
11
+ */
12
+
13
+ /** Every refund of an order, legacy whole-order refunds included. */
14
+ export function refundsOf(d) {
15
+ if (Array.isArray(d?.refunds)) return d.refunds;
16
+ if (d?.status === 'refunded') return [{at: d.refundedAt || d.paidAt || '', amount: d.total || 0, via: 'refunded', legacy: true}];
17
+ return [];
18
+ }
19
+
20
+ /** Minor units already given back. */
21
+ export const refundedSoFar = (d) => refundsOf(d).reduce((n, r) => n + (Number(r.amount) || 0), 0);
22
+
23
+ /** Minor units that may still go back. */
24
+ export const refundable = (d) => Math.max(0, (Number(d?.total) || 0) - refundedSoFar(d));
25
+
26
+ /**
27
+ * What one more refund of `requested` minor units does. `requested` empty
28
+ * (null, undefined, '') means "the rest" - a full refund of what is left.
29
+ *
30
+ * @param {object} d - order data
31
+ * @param {number|string|null} [requested]
32
+ * @returns {{amount: number, before: number, after: number, remaining: number, full: boolean}}
33
+ * @throws {Error & {status: number}} 400 for a bad amount, 409 when nothing is left to refund
34
+ */
35
+ export function refundPlan(d, requested = null) {
36
+ const bad = (status, message) => Object.assign(new Error(message), {status});
37
+ const total = Number(d?.total) || 0;
38
+ const before = refundedSoFar(d);
39
+ const left = total - before;
40
+ if (left <= 0) throw bad(409, 'This order has been refunded in full already.');
41
+ let amount;
42
+ if (requested === null || requested === undefined || requested === '') amount = left;
43
+ else {
44
+ amount = typeof requested === 'number' ? requested : Number(String(requested).trim());
45
+ // Minor units only: the admin UI converts what was typed, so a fraction here is a bug, not a penny.
46
+ if (!Number.isInteger(amount) || amount <= 0) throw bad(400, 'The refund amount must be a whole number of minor units above zero.');
47
+ if (amount > left) throw bad(400, `Only ${left} is left to refund on this order.`);
48
+ }
49
+ const after = before + amount;
50
+ return {amount, before, after, remaining: total - after, full: after >= total};
51
+ }
@@ -19,6 +19,8 @@
19
19
  * @module shopping-cart/lib/sales
20
20
  */
21
21
 
22
+ import {refundsOf} from './refunds.js';
23
+
22
24
  export const SOURCES = {shop: 'Shop', downloads: 'Downloads & keys', takeaway: 'Takeaway', addons: 'Other add-ons'};
23
25
  const PROVIDERS = {stripe: 'Card (Stripe)', paypal: 'PayPal', offline: 'Paid offline', free: 'Free'};
24
26
  const TOOK = new Set(['paid', 'fulfilled', 'refunded']);
@@ -123,13 +125,14 @@ export function salesReport(orders, {from, to, by = 'day', timeZone = 'Europe/Lo
123
125
  const p = periods.get(periodOf(paidDate, by));
124
126
  if (p) { p.orders++; p.takings += f.total; p.commission += c; }
125
127
  }
126
- if (d.status === 'refunded') {
127
- const refDate = dateIn(d.refundedAt || d.paidAt || o.meta?.createdAt, timeZone);
128
+ // Each refund (part or whole) counts on its own day; an order refunded before 1.6.2 reads as one of the total.
129
+ for (const r of refundsOf(d)) {
130
+ const refDate = dateIn(r.at || d.refundedAt || d.paidAt || o.meta?.createdAt, timeZone);
128
131
  if (inRange(refDate)) {
129
- totals.refunds += d.total || 0;
132
+ totals.refunds += r.amount || 0;
130
133
  totals.refundCount++;
131
134
  const p = periods.get(periodOf(refDate, by));
132
- if (p) p.refunds += d.total || 0;
135
+ if (p) p.refunds += r.amount || 0;
133
136
  }
134
137
  }
135
138
  if (d.commission?.reversed) {
@@ -173,19 +176,23 @@ const pounds = (n) => ((n || 0) / 100).toFixed(2);
173
176
 
174
177
  /** One row per order sold or refunded in the range - for the accountant. */
175
178
  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']];
179
+ const rows = [['Order', 'Paid', 'Status', 'Refunded', 'Sources', 'Payment', 'Customer', 'Subtotal', 'Discount', 'Delivery', 'Tax added', 'Tax included', 'Total', 'Commission', 'Commission rate', 'Refunded amount']];
180
+ // "Refunded" is the day of the last refund (part refunds included), so the row shows up in that period too.
181
+ const lastRefund = (o) => { const r = refundsOf(o.data); return r.length ? dateIn(r[r.length - 1].at || o.data.refundedAt || o.data.paidAt || o.meta?.createdAt, timeZone) : ''; };
177
182
  const list = orders.filter(o => TOOK.has(o.data?.status)).filter(o => {
178
183
  const paid = dateIn(o.data.paidAt || o.meta?.createdAt, timeZone);
179
- const refunded = o.data.refundedAt ? dateIn(o.data.refundedAt, timeZone) : '';
184
+ const refunded = lastRefund(o);
180
185
  return (paid >= from && paid <= to) || (refunded && refunded >= from && refunded <= to);
181
186
  }).sort((a, b) => String(a.data.paidAt || a.meta?.createdAt).localeCompare(String(b.data.paidAt || b.meta?.createdAt)));
182
187
  for (const o of list) {
183
188
  const d = o.data;
184
189
  const f = orderFigures(o);
185
- rows.push([`#${d.number}`, dateIn(d.paidAt || o.meta?.createdAt, timeZone), d.status, d.refundedAt ? dateIn(d.refundedAt, timeZone) : '',
190
+ const back = refundsOf(d).reduce((n, r) => n + (r.amount || 0), 0);
191
+ rows.push([`#${d.number}`, dateIn(d.paidAt || o.meta?.createdAt, timeZone), d.status, lastRefund(o),
186
192
  [...new Set((d.lines || []).map(l => SOURCES[sourceOf(o, l)]))].join('; '), PROVIDERS[d.provider] || d.provider, d.customer?.name || d.email || '',
187
193
  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)}` : '']);
194
+ d.commission ? pounds(d.commission.reversed ? 0 : d.commission.amount) : '', d.commission ? `${d.commission.percent}% + ${pounds(d.commission.fixed)}` : '',
195
+ back ? pounds(back) : '']);
189
196
  }
190
197
  return rows.map(r => r.map(csvCell).join(',')).join('\r\n') + '\r\n';
191
198
  }
@@ -143,9 +143,13 @@ export function cleanSettings(input, current) {
143
143
  return {settings: out, errors};
144
144
  }
145
145
 
146
+ /** Moved by every write (withLock, removeProduct, saveSettings) - see withLock. */
147
+ let generation = 0;
148
+
146
149
  export async function saveSettings(next) {
147
150
  const state = getPluginStates()[NAME] || {};
148
151
  savePluginState(NAME, {settings: {...(state.settings || {}), ...next}});
152
+ generation++;
149
153
  }
150
154
 
151
155
  /** The payment methods a shopper can choose now, in order. */
@@ -224,20 +228,31 @@ export const updateProductExt = (id, owner, fn) => withLock(async () => {
224
228
  });
225
229
 
226
230
  export async function removeProduct(id) {
231
+ generation++;
227
232
  await deleteEntry(PRODUCTS, id);
233
+ generation++;
228
234
  await refreshShelf();
229
235
  }
230
236
 
231
237
  // ---- Orders --------------------------------------------------------------------
232
238
 
233
239
  let chain = Promise.resolve();
234
- /** Run `fn` after every write already queued - order writes never interleave. */
240
+ /**
241
+ * Run `fn` after every write already queued - order writes never interleave.
242
+ * It also moves the write generation as each write starts and ends, which is
243
+ * what the badge cache (and Takeaway's, through services.writeGeneration) is
244
+ * checked against: an answer worked out across a write is never kept.
245
+ */
235
246
  export function withLock(fn) {
236
- const run = chain.then(fn, fn);
237
- chain = run.catch(() => {});
247
+ const go = () => { generation++; return fn(); };
248
+ const run = chain.then(go, go);
249
+ chain = run.catch(() => {}).then(() => { generation++; });
238
250
  return run;
239
251
  }
240
252
 
253
+ /** Changes whenever an order, product or the settings are written. */
254
+ export const writeGeneration = () => generation;
255
+
241
256
  export async function allOrders() {
242
257
  const {entries = []} = await listEntries(ORDERS, {limit: 0, sort: 'createdAt', order: 'desc'});
243
258
  return entries;
@@ -416,6 +431,26 @@ export async function moveOrderNow(id, to, {by = 'admin', note = '', restock = f
416
431
  }
417
432
  }
418
433
 
434
+ /**
435
+ * Record money given back without moving the order - a PARTIAL refund. The
436
+ * caller holds the lock (refundOrder wraps the provider call and this in one).
437
+ * A full refund goes through `moveOrderNow(id, 'refunded')` after this.
438
+ *
439
+ * @param {string} id
440
+ * @param {{at: string, amount: number, by: string, note: string, via: string, ref: string}} entry
441
+ * @param {string} line - the history line ("Refunded £5.00 through Stripe - damaged lid")
442
+ */
443
+ export async function recordRefundNow(id, entry, line) {
444
+ const had = await getOrder(id);
445
+ if (!had) throw Object.assign(new Error('No such order.'), {status: 404});
446
+ const d = had.data;
447
+ const refunds = [...(Array.isArray(d.refunds) ? d.refunds : []), entry];
448
+ const refunded = refunds.reduce((n, r) => n + (Number(r.amount) || 0), 0);
449
+ // The history keeps the order's status: a part refund is a note on a paid order, not a move.
450
+ const history = [...(d.history || []), {at: entry.at, status: d.status, by: entry.by, note: String(line || '').slice(0, 500), refund: entry.amount}];
451
+ return updateEntry(ORDERS, id, {...d, refunds, refunded, history});
452
+ }
453
+
419
454
  /** The admin's own note and the tracking details - no status change. */
420
455
  export const annotateOrder = (id, {staffNote, tracking}) => withLock(() => patchOrder(id, {
421
456
  ...(staffNote !== undefined ? {staffNote: String(staffNote).slice(0, 4000)} : {}),
@@ -38,9 +38,10 @@ import {badge, summarise} from './lib/summary.js';
38
38
  import * as pay from './lib/payments.js';
39
39
  import {
40
40
  NAME, allOrders, allProducts, annotateOrder, cleanSettings, cleanTracking, createOrder, ensureCollections, extendOrder,
41
- getOrder, getProduct, liveSettings, markPaid, maskSettings, methodsOf, moveOrder, moveOrderNow, publicOrder, removeOrder,
42
- refreshShelf, removeProduct, saveProduct, saveSettings, setProviderRef, settingsNow, shelfNow, tokenOk, updateProductExt, withLock
41
+ getOrder, getProduct, liveSettings, markPaid, maskSettings, methodsOf, moveOrder, moveOrderNow, publicOrder, recordRefundNow, removeOrder,
42
+ refreshShelf, removeProduct, saveProduct, saveSettings, setProviderRef, settingsNow, shelfNow, tokenOk, updateProductExt, withLock, writeGeneration
43
43
  } from './lib/shop.js';
44
+ import {refundPlan} from './lib/refunds.js';
44
45
  import {cardHtml, esc, gridHtml} from './lib/render.js';
45
46
 
46
47
  /** The Site URL setting as an origin, or ''. */
@@ -202,7 +203,7 @@ export default async function shoppingCart(fastify, options) {
202
203
  // What extensions may use: the Cart's own data, read the Cart's way.
203
204
  registry.services = {
204
205
  name: NAME,
205
- liveSettings, allOrders, getOrder, allProducts, getProduct, extendOrder, publicOrder,
206
+ liveSettings, allOrders, getOrder, allProducts, getProduct, extendOrder, publicOrder, writeGeneration,
206
207
  formatMoney, orderLabel,
207
208
  /** The Site URL as an origin, or '' - sync, for the shopper-facing paths. Email links: `linkBase`. */
208
209
  siteBase: () => siteBase(null),
@@ -263,9 +264,18 @@ export default async function shoppingCart(fastify, options) {
263
264
  .header('Cache-Control', 'no-store').send(salesCsv(orders, {from, to, timeZone: s.timeZone || 'Europe/London'}));
264
265
  });
265
266
 
267
+ // Polled every minute from every admin tab, and it reads every order and product: one answer
268
+ // is kept for a minute unless anything is written (writeGeneration, moved by every write in
269
+ // lib/shop.js - admin, checkout, webhooks and extensions alike). One answer for everyone: the
270
+ // badge is the same for every reader.
271
+ let badgeMemo = {at: 0, gen: -1, value: null};
266
272
  fastify.get('/count', can('read'), async () => {
273
+ const gen = writeGeneration();
274
+ if (badgeMemo.gen === gen && Date.now() - badgeMemo.at < 60_000) return badgeMemo.value;
267
275
  const [orders, settings, products] = await Promise.all([allOrders(), liveSettings(), allProducts()]);
268
- return badge(orders, settings, Date.now(), products);
276
+ const value = badge(orders, settings, Date.now(), products);
277
+ if (writeGeneration() === gen) badgeMemo = {at: Date.now(), gen, value};
278
+ return value;
269
279
  });
270
280
 
271
281
  fastify.get('/extensions', can('read'), async () => ({
@@ -385,47 +395,65 @@ export default async function shoppingCart(fastify, options) {
385
395
  });
386
396
 
387
397
  /**
388
- * Refund an order in full. With Stripe or PayPal the money goes back
389
- * through them first; only when they say yes is the order marked refunded.
390
- * An offline order is marked only - the money goes back the way it came.
398
+ * Refund an order - the rest of it, or `amount` minor units of what is left. With Stripe or PayPal the
399
+ * money goes back through them first; only when they say yes is the refund recorded. An offline order is
400
+ * recorded only - the money goes back the way it came. A refund that leaves nothing outstanding moves the
401
+ * order to `refunded` (stock back when `restock`); a part refund keeps the status and adds to `refunds`.
402
+ * Throws {status, message}.
391
403
  */
392
- /** Refund an order in full (see above). Throws {status, message}. */
393
- async function refundOrder(id, {restock = false, note = '', viaProvider = true, by = 'admin'} = {}) {
404
+ async function refundOrder(id, {amount = null, restock = false, note = '', viaProvider = true, by = 'admin'} = {}) {
394
405
  const bad = (status, message) => Object.assign(new Error(message), {status});
395
406
  // The whole refund runs under the order lock: two refunds at once would both reach the provider, and a
396
407
  // write landing between the provider's yes and ours would be lost. The provider call carries an
397
- // Idempotency-Key from the order id, so a retry after a timeout returns the first refund, not a second.
398
- const {order, from, how} = await withLock(async () => {
408
+ // idempotency key from the order id and the refund's ordinal, so a retry after a timeout returns the first
409
+ // refund, not a second - and the next part refund (a new ordinal) is a new request.
410
+ const {order, from, how, plan} = await withLock(async () => {
399
411
  const o = await getOrder(id);
400
412
  if (!o) throw bad(404, 'No such order.');
401
413
  const d = o.data;
402
414
  if (!['paid', 'fulfilled'].includes(d.status)) throw bad(409, 'Only a paid order can be refunded.');
415
+ const p = refundPlan(d, amount);
416
+ const seq = (Array.isArray(d.refunds) ? d.refunds.length : 0) + 1;
403
417
  const s = await liveSettings();
404
418
  let via = 'marked refunded';
419
+ let ref = '';
405
420
  try {
406
421
  if (viaProvider && d.provider === 'stripe' && d.paymentRef) {
407
422
  if (!s.stripeSecretKey) throw bad(409, 'Stripe is not set up any more - refund it in Stripe, then mark it refunded here.');
408
- await pay.stripeRefund({secretKey: s.stripeSecretKey, paymentIntent: d.paymentRef, idempotencyKey: `${NAME}-refund-${o.id}`});
423
+ const r = await pay.stripeRefund({secretKey: s.stripeSecretKey, paymentIntent: d.paymentRef, amount: p.amount,
424
+ idempotencyKey: `${NAME}-refund-${o.id}-${seq}`});
409
425
  via = 'refunded through Stripe';
426
+ ref = String(r?.id || '');
410
427
  } else if (viaProvider && d.provider === 'paypal' && d.paymentRef) {
411
428
  if (!s.paypalClientId || !s.paypalSecret) throw bad(409, 'PayPal is not set up any more - refund it in PayPal, then mark it refunded here.');
412
- await pay.paypalRefund({creds: {clientId: s.paypalClientId, secret: s.paypalSecret, mode: s.paypalMode}, captureId: d.paymentRef});
429
+ const r = await pay.paypalRefund({creds: {clientId: s.paypalClientId, secret: s.paypalSecret, mode: s.paypalMode}, captureId: d.paymentRef,
430
+ amount: p.amount, currency: d.currency, requestId: `${NAME}-refund-${o.id}-${seq}`});
413
431
  via = 'refunded through PayPal';
432
+ ref = String(r?.id || '');
414
433
  }
415
434
  } catch (err) {
416
435
  throw bad(err.status || 502, err.status === 409 ? err.message : `Not refunded: ${err.message}`);
417
436
  }
418
- const moved = await moveOrderNow(o.id, 'refunded', {by, note: [via, String(note || '')].filter(Boolean).join(' - '), restock: Boolean(restock)});
419
- return {...moved, how: via};
437
+ const now = new Date().toISOString();
438
+ const money = formatMoney(p.amount, d.currency);
439
+ const words = [p.full && !p.before ? via : `${money} ${via}`, String(note || '')].filter(Boolean).join(' - ');
440
+ await recordRefundNow(o.id, {at: now, amount: p.amount, by, note: String(note || '').slice(0, 500), via, ref}, words);
441
+ if (!p.full) {
442
+ const kept = await getOrder(o.id);
443
+ return {order: kept, from: d.status, how: `${via} (${money}; ${formatMoney(p.remaining, d.currency)} left to refund)`, plan: p};
444
+ }
445
+ const moved = await moveOrderNow(o.id, 'refunded', {by, note: p.before ? `the last ${money}` : words, restock: Boolean(restock)});
446
+ return {...moved, how: via, plan: p};
420
447
  });
421
- announceMove(order, from, 'refunded');
422
- return {order, how};
448
+ if (plan.full) announceMove(order, from, 'refunded');
449
+ return {order, how, refund: {amount: plan.amount, refunded: plan.after, remaining: plan.remaining, full: plan.full}};
423
450
  }
424
451
 
452
+ /** `amount` (minor units) refunds part of what is left; left out, the rest goes back. */
425
453
  fastify.post('/orders/:id/refund', can('manage'), async (request, reply) => {
426
- const {restock = false, note = '', viaProvider = true} = request.body || {};
454
+ const {amount = null, restock = false, note = '', viaProvider = true} = request.body || {};
427
455
  try {
428
- return await refundOrder(request.params.id, {restock, note, viaProvider, by: request.user?.name || request.user?.email || 'admin'});
456
+ return await refundOrder(request.params.id, {amount, restock, note, viaProvider, by: request.user?.name || request.user?.email || 'admin'});
429
457
  } catch (err) {
430
458
  return fail(reply, err.status || 500, err.message);
431
459
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "shopping-cart",
3
3
  "displayName": "Shopping Cart",
4
- "version": "1.6.1",
4
+ "version": "1.6.3",
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.6.0",
48
+ "entry": "shopping-cart/admin/views/shop.js?v=1.6.2",
49
49
  "exportName": "shopView"
50
50
  }
51
51
  }