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.
- package/admin/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +3 -3
- package/package.json +2 -2
- package/plugins/_lib/admin/mail/mail.css +1 -1
- package/plugins/_lib/admin/mail/oauth-panel.js +303 -0
- package/plugins/_lib/admin/mail/reader-view.js +126 -13
- package/plugins/_lib/mail/accounts.js +548 -225
- package/plugins/_lib/mail/imapPool.js +63 -13
- package/plugins/_lib/mail/oauth.js +453 -0
- package/plugins/_lib/mail/oauthRoutes.js +294 -0
- package/plugins/_lib/mail/readRoutes.js +23 -2
- package/plugins/_lib/mail/scheduler.js +30 -6
- package/plugins/blog/CLAUDE.md +11 -1
- package/plugins/blog/lib/cache.js +43 -0
- package/plugins/blog/lib/page.js +28 -9
- package/plugins/blog/plugin.js +14 -13
- package/plugins/blog/plugin.json +1 -1
- package/plugins/blog/plugin.public.js +1 -1
- package/plugins/blog/tests/cache.test.js +65 -0
- package/plugins/booking/CLAUDE.md +14 -2
- package/plugins/booking/admin/lib/diary-layout.js +18 -0
- package/plugins/booking/admin/views/diary.js +153 -152
- package/plugins/booking/admin/views/people.js +64 -66
- package/plugins/booking/admin/views/settings.js +31 -32
- package/plugins/booking/plugin.json +1 -1
- package/plugins/booking/tests/admin-diary.test.js +9 -1
- package/plugins/feedback/CLAUDE.md +62 -0
- package/plugins/feedback/admin/css/index.css +16 -1
- package/plugins/feedback/admin/lib/kit.js +106 -0
- package/plugins/feedback/admin/lib/settings.js +107 -0
- package/plugins/feedback/admin/views/feedback.js +4 -1
- package/plugins/feedback/admin/views/inbox.js +11 -2
- package/plugins/feedback/admin/views/send.js +74 -7
- package/plugins/feedback/docs/guide.md +9 -9
- package/plugins/feedback/lib/attachments.js +133 -0
- package/plugins/feedback/lib/cache.js +43 -0
- package/plugins/feedback/lib/receiver.js +36 -6
- package/plugins/feedback/lib/sender.js +89 -6
- package/plugins/feedback/lib/shape.js +111 -6
- package/plugins/feedback/plugin.js +173 -17
- package/plugins/feedback/plugin.json +2 -2
- package/plugins/feedback/tests/api.test.js +125 -3
- package/plugins/feedback/tests/settings.test.js +59 -0
- package/plugins/feedback/tests/shape.test.js +87 -1
- package/plugins/free-tier.lock.json +74 -62
- package/plugins/mail-reader/CLAUDE.md +15 -4
- package/plugins/mail-reader/plugin.json +1 -1
- package/plugins/shopping-cart/CLAUDE.md +35 -2
- package/plugins/shopping-cart/admin/css/index.css +3 -0
- package/plugins/shopping-cart/admin/views/orders.js +75 -23
- package/plugins/shopping-cart/admin/views/shop.js +1 -1
- package/plugins/shopping-cart/docs/guide.md +7 -3
- package/plugins/shopping-cart/lib/payments.js +15 -2
- package/plugins/shopping-cart/lib/refunds.js +51 -0
- package/plugins/shopping-cart/lib/sales.js +15 -8
- package/plugins/shopping-cart/lib/shop.js +38 -3
- package/plugins/shopping-cart/plugin.js +47 -19
- package/plugins/shopping-cart/plugin.json +2 -2
- package/plugins/shopping-cart/tests/api.test.js +52 -0
- package/plugins/shopping-cart/tests/commission.test.js +1 -1
- package/plugins/shopping-cart/tests/pure.test.js +16 -0
- package/plugins/shopping-cart/tests/refunds.test.js +42 -0
- package/plugins/support/CLAUDE.md +58 -0
- package/plugins/support/admin/css/index.css +16 -1
- package/plugins/support/admin/lib/kit.js +192 -8
- package/plugins/support/admin/lib/settings.js +123 -0
- package/plugins/support/admin/views/desk.js +55 -12
- package/plugins/support/admin/views/inbox.js +17 -4
- package/plugins/support/admin/views/support.js +4 -1
- package/plugins/support/docs/guide.md +7 -9
- package/plugins/support/lib/attachments.js +133 -0
- package/plugins/support/lib/cache.js +43 -0
- package/plugins/support/lib/receiver.js +45 -10
- package/plugins/support/lib/sender.js +96 -10
- package/plugins/support/lib/shape.js +153 -12
- package/plugins/support/plugin.js +159 -29
- package/plugins/support/plugin.json +2 -2
- package/plugins/support/tests/api.test.js +125 -3
- package/plugins/support/tests/settings.test.js +68 -0
- package/plugins/support/tests/shape.test.js +76 -2
- package/server/routes/outreach-public.js +2 -2
- 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
|
|
67
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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.
|
|
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
|
-
-
|
|
73
|
-
-
|
|
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
|
-
|
|
277
|
-
|
|
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
|
-
|
|
127
|
-
|
|
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 +=
|
|
132
|
+
totals.refunds += r.amount || 0;
|
|
130
133
|
totals.refundCount++;
|
|
131
134
|
const p = periods.get(periodOf(refDate, by));
|
|
132
|
-
if (p) p.refunds +=
|
|
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 =
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
237
|
-
|
|
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
|
-
|
|
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
|
|
389
|
-
* through them first; only when they say yes is the order
|
|
390
|
-
*
|
|
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
|
-
|
|
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
|
-
//
|
|
398
|
-
|
|
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,
|
|
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
|
|
419
|
-
|
|
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.
|
|
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.
|
|
48
|
+
"entry": "shopping-cart/admin/views/shop.js?v=1.6.2",
|
|
49
49
|
"exportName": "shopView"
|
|
50
50
|
}
|
|
51
51
|
}
|