domma-cms 0.55.1 → 0.66.0

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 (211) hide show
  1. package/CLAUDE.md +130 -2
  2. package/README.md +4 -4
  3. package/admin/css/admin.css +1 -1
  4. package/admin/dist/domma/domma-tools.css +3 -3
  5. package/admin/dist/domma/domma-tools.min.js +3 -3
  6. package/admin/js/api.js +1 -1
  7. package/admin/js/app.js +3 -3
  8. package/admin/js/lib/page-picker.js +1 -0
  9. package/admin/js/lib/plugin-accent.js +1 -0
  10. package/admin/js/lib/plugin-chrome.js +1 -0
  11. package/admin/js/lib/shortcode-context-menu.js +2 -2
  12. package/admin/js/lib/sidebar-grouping.js +1 -1
  13. package/admin/js/lib/sidebar-grouping.test.js +1 -1
  14. package/admin/js/lib/sidebar-renderer.js +4 -4
  15. package/admin/js/lib/slideover-resizable.js +1 -0
  16. package/admin/js/templates/context-menu-editor.html +212 -0
  17. package/admin/js/templates/context-menus.html +16 -0
  18. package/admin/js/templates/plugin-code.html +1 -1
  19. package/{plugins/site-search/admin/templates/site-search.html → admin/js/templates/search.html} +50 -4
  20. package/admin/js/templates/settings.html +16 -97
  21. package/admin/js/templates/theme.html +173 -0
  22. package/admin/js/views/context-menu-editor.js +55 -0
  23. package/admin/js/views/context-menus.js +5 -0
  24. package/admin/js/views/form-editor.js +7 -7
  25. package/admin/js/views/index.js +1 -1
  26. package/admin/js/views/plugin-marketplace.js +1 -1
  27. package/admin/js/views/plugins.js +25 -23
  28. package/admin/js/views/search.js +1 -0
  29. package/admin/js/views/settings.js +3 -3
  30. package/admin/js/views/theme.js +1 -0
  31. package/bin/cli.js +2 -0
  32. package/bin/update.js +13 -2
  33. package/config/menus/admin-sidebar.json +129 -23
  34. package/config/plugins.json +5 -5
  35. package/config/search.json +13 -0
  36. package/config/theme.json +18 -0
  37. package/package.json +12 -4
  38. package/plugins/_lib/admin/mail/compose-window.js +914 -0
  39. package/plugins/_lib/admin/mail/contacts.js +301 -0
  40. package/plugins/_lib/admin/mail/diagnostics-section.js +133 -0
  41. package/plugins/_lib/admin/mail/folder-tree.js +254 -0
  42. package/plugins/_lib/admin/mail/identity.js +480 -0
  43. package/plugins/_lib/admin/mail/image-senders-section.js +131 -0
  44. package/plugins/_lib/admin/mail/keyboard.js +136 -0
  45. package/plugins/_lib/admin/mail/mail.css +1 -0
  46. package/plugins/_lib/admin/mail/mail.html +71 -0
  47. package/plugins/_lib/admin/mail/panes.js +253 -0
  48. package/plugins/_lib/admin/mail/reader-view.js +4453 -0
  49. package/plugins/_lib/admin/mail/resizable.js +26 -0
  50. package/plugins/_lib/admin/mail/rules.js +343 -0
  51. package/plugins/_lib/admin/mail/scheduling.js +203 -0
  52. package/plugins/_lib/admin/mail/section-kit.js +277 -0
  53. package/plugins/_lib/admin/mail/templates.js +238 -0
  54. package/plugins/_lib/admin/mail/threads.js +200 -0
  55. package/plugins/_lib/admin/mail/vacation.js +269 -0
  56. package/plugins/_lib/admin/ui/help.css +1 -0
  57. package/plugins/_lib/admin/ui/help.js +174 -0
  58. package/plugins/_lib/admin/ui/resizable.js +151 -0
  59. package/plugins/_lib/dataStore.js +117 -0
  60. package/plugins/_lib/mail/accounts.js +919 -0
  61. package/plugins/_lib/mail/bodyTokens.js +101 -0
  62. package/plugins/_lib/mail/compose.js +256 -0
  63. package/plugins/_lib/mail/defaults.js +57 -0
  64. package/plugins/_lib/mail/diagnostics.js +154 -0
  65. package/plugins/_lib/mail/envelope.js +161 -0
  66. package/plugins/_lib/mail/folders.js +192 -0
  67. package/plugins/_lib/mail/handoff.js +274 -0
  68. package/plugins/_lib/mail/imapPool.js +293 -0
  69. package/plugins/_lib/mail/mbox.js +74 -0
  70. package/plugins/_lib/mail/pollSchedule.js +79 -0
  71. package/plugins/_lib/mail/poller.js +135 -0
  72. package/plugins/_lib/mail/priority.js +138 -0
  73. package/plugins/_lib/mail/readRoutes.js +680 -0
  74. package/plugins/_lib/mail/render.js +291 -0
  75. package/plugins/_lib/mail/ruleRunner.js +152 -0
  76. package/plugins/_lib/mail/scheduler.js +254 -0
  77. package/plugins/_lib/mail/secretbox.js +229 -0
  78. package/plugins/_lib/mail/send.js +396 -0
  79. package/plugins/_lib/mail/store.js +1002 -0
  80. package/plugins/_lib/mail/sync.js +292 -0
  81. package/plugins/_lib/mail/syncPlan.js +126 -0
  82. package/plugins/_lib/mail/syncSelection.js +82 -0
  83. package/plugins/_lib/mail/unsubscribe.js +183 -0
  84. package/plugins/_lib/mail/vacationRunner.js +114 -0
  85. package/plugins/_lib/mail/write.js +473 -0
  86. package/plugins/_lib/schemaSync.js +83 -0
  87. package/plugins/_template/admin/css/index.css +0 -0
  88. package/plugins/_template/admin/templates/index.html +4 -4
  89. package/plugins/_template/admin/views/index.js +7 -0
  90. package/plugins/analytics/admin/css/index.css +1 -0
  91. package/plugins/analytics/admin/templates/analytics.html +22 -13
  92. package/plugins/analytics/plugin.json +3 -0
  93. package/plugins/blog/admin/css/index.css +1 -0
  94. package/plugins/blog/admin/templates/blog.html +30 -18
  95. package/plugins/blog/admin/templates/categories.html +2 -2
  96. package/plugins/blog/admin/templates/comments.html +2 -2
  97. package/plugins/blog/admin/templates/post-editor.html +34 -34
  98. package/plugins/blog/admin/templates/settings.html +6 -3
  99. package/plugins/blog/admin/views/blog.js +8 -5
  100. package/plugins/blog/admin/views/categories.js +5 -10
  101. package/plugins/blog/admin/views/comments.js +5 -5
  102. package/plugins/blog/admin/views/post-editor.js +39 -20
  103. package/plugins/blog/admin/views/settings.js +52 -50
  104. package/plugins/blog/collections/categories/schema.json +7 -6
  105. package/plugins/blog/collections/comments/schema.json +11 -10
  106. package/plugins/blog/collections/posts/schema.json +14 -13
  107. package/plugins/blog/plugin.js +36 -13
  108. package/plugins/blog/plugin.json +13 -5
  109. package/plugins/blog/plugin.public.js +312 -0
  110. package/plugins/contacts/admin/css/index.css +1 -0
  111. package/plugins/contacts/admin/templates/contacts.html +128 -0
  112. package/plugins/contacts/admin/views/contacts.js +237 -4
  113. package/plugins/contacts/collections/user-contacts/schema.json +108 -0
  114. package/plugins/contacts/plugin.js +214 -27
  115. package/plugins/contacts/plugin.json +4 -1
  116. package/plugins/invoice/admin/css/index.css +1 -0
  117. package/plugins/invoice/admin/templates/editor.html +140 -49
  118. package/plugins/invoice/admin/templates/index.html +153 -23
  119. package/plugins/invoice/admin/templates/issuers.html +2 -5
  120. package/plugins/invoice/admin/templates/receivers.html +2 -5
  121. package/plugins/invoice/admin/views/contacts-source.js +266 -0
  122. package/plugins/invoice/admin/views/editor.js +366 -199
  123. package/plugins/invoice/admin/views/export.js +199 -0
  124. package/plugins/invoice/admin/views/help-content.js +61 -0
  125. package/plugins/invoice/admin/views/index.js +582 -94
  126. package/plugins/invoice/admin/views/issuers.js +24 -17
  127. package/plugins/invoice/admin/views/media.js +172 -0
  128. package/plugins/invoice/admin/views/party-view.js +305 -67
  129. package/plugins/invoice/admin/views/payments.js +127 -0
  130. package/plugins/invoice/admin/views/print.js +130 -0
  131. package/plugins/invoice/admin/views/receivers.js +49 -16
  132. package/plugins/invoice/admin/views/send.js +212 -0
  133. package/plugins/invoice/admin/views/settings.js +594 -0
  134. package/plugins/invoice/admin/views/view-lifecycle.js +33 -0
  135. package/plugins/invoice/collections/invoice-issuers/schema.json +77 -11
  136. package/plugins/invoice/collections/invoice-receivers/schema.json +10 -9
  137. package/plugins/invoice/collections/invoices/schema.json +19 -13
  138. package/plugins/invoice/config.js +27 -6
  139. package/plugins/invoice/pdf.js +164 -0
  140. package/plugins/invoice/plugin.js +1217 -44
  141. package/plugins/invoice/plugin.json +10 -9
  142. package/plugins/invoice/templates/_base.css +1 -0
  143. package/plugins/invoice/templates/classic-nologo.html +100 -0
  144. package/plugins/invoice/templates/classic.html +91 -0
  145. package/plugins/invoice/templates/invoice-print.html +24 -0
  146. package/plugins/invoice/templates/minimal.html +99 -0
  147. package/plugins/invoice/templates/modern-nologo.html +114 -0
  148. package/plugins/invoice/templates/modern.html +113 -0
  149. package/plugins/invoice/templates/templates.json +11 -0
  150. package/plugins/mail-reader/admin/views/mail.js +19 -0
  151. package/plugins/mail-reader/config.js +7 -0
  152. package/plugins/mail-reader/plugin.js +48 -0
  153. package/plugins/mail-reader/plugin.json +33 -0
  154. package/plugins/notes/admin/views/notes.js +1 -1
  155. package/plugins/notes/plugin.json +2 -2
  156. package/plugins/surveys/lib/audience.js +37 -0
  157. package/plugins/surveys/lib/campaigns.js +43 -0
  158. package/plugins/surveys/lib/ledger.js +110 -0
  159. package/plugins/surveys/lib/sending.js +106 -0
  160. package/plugins/surveys/lib/stats.js +62 -0
  161. package/plugins/surveys/lib/submit.js +95 -0
  162. package/plugins/surveys/lib/tokens.js +28 -0
  163. package/plugins/surveys/plugin.public.js +149 -0
  164. package/plugins/theme-switcher/admin/templates/theme-switcher.html +1 -1
  165. package/public/css/forms.css +1 -1
  166. package/public/css/search.css +1 -0
  167. package/public/css/site.css +1 -1
  168. package/public/js/collection-context.js +2 -2
  169. package/public/js/context-menus.js +1 -0
  170. package/public/js/form-logic-engine.js +1 -1
  171. package/public/js/forms.js +2 -2
  172. package/public/js/search.js +1 -0
  173. package/public/js/site.js +1 -1
  174. package/scripts/build.js +37 -3
  175. package/scripts/copy-domma.js +48 -0
  176. package/scripts/seed.js +1996 -0
  177. package/server/routes/api/collections.js +34 -0
  178. package/server/routes/api/context-menus.js +104 -0
  179. package/server/routes/api/forms.js +42 -3
  180. package/server/routes/api/notifications.js +69 -19
  181. package/server/routes/api/plugins.js +50 -6
  182. package/server/routes/api/search.js +43 -0
  183. package/server/routes/api/theme.js +69 -0
  184. package/server/routes/public.js +42 -7
  185. package/server/server.js +74 -0
  186. package/server/services/adapters/FileAdapter.js +6 -1
  187. package/server/services/content.js +26 -0
  188. package/server/services/contextMenus.js +477 -0
  189. package/server/services/markdown.js +70 -9
  190. package/server/services/permissionRegistry.js +24 -0
  191. package/server/services/pluginFiles.js +52 -11
  192. package/server/services/plugins.js +229 -6
  193. package/server/services/renderer.js +144 -22
  194. package/server/services/roles.js +1 -1
  195. package/server/services/search-migration.js +82 -0
  196. package/server/services/search.js +413 -0
  197. package/server/services/sidebar-migration.js +1 -0
  198. package/server/services/themeSettings.js +541 -0
  199. package/server/services/users.js +8 -0
  200. package/server/templates/page.html +4 -2
  201. package/plugins/contacts/data/contacts.json +0 -20
  202. package/plugins/notes/data/notes.json +0 -1
  203. package/plugins/site-search/admin/views/site-search.js +0 -116
  204. package/plugins/site-search/config.js +0 -15
  205. package/plugins/site-search/plugin.js +0 -188
  206. package/plugins/site-search/plugin.json +0 -40
  207. package/plugins/site-search/public/inject-body.html +0 -17
  208. package/plugins/site-search/public/inject-head.html +0 -1
  209. package/plugins/site-search/public/search.css +0 -1
  210. package/plugins/site-search/public/search.js +0 -1
  211. package/plugins/todo/data/todos.json +0 -1
@@ -17,17 +17,33 @@
17
17
  * POST /invoices admin create invoice (auto-numbers if number omitted)
18
18
  * PUT /invoices/:id admin update invoice
19
19
  * DELETE /invoices/:id admin delete invoice
20
- * GET /invoices/:id/print admin printable HTML (browser to PDF)
20
+ * POST /invoices/:id/duplicate admin copy into a fresh numbered draft
21
+ * GET /invoices/:id/print admin printable HTML (Bearer clients)
22
+ * GET /invoices/:id/print-html admin the same markup as {html} JSON, for the admin
23
+ *
24
+ * GET /settings admin plugin settings, defaults merged in
25
+ * PUT /settings admin save plugin settings (whitelisted)
26
+ *
27
+ * POST /invoices/batch-pdf-link admin mint a 2-minute link for MANY invoices
28
+ * GET /invoices/batch-pdf?token= one PDF, a page per invoice (token auth)
29
+ *
30
+ * GET /templates admin the print layouts on offer
31
+ * GET /templates/:id/preview admin a worked example in that layout
21
32
  */
22
33
  import path from 'path';
34
+ import {randomUUID} from 'crypto';
23
35
  import fs from 'fs/promises';
24
36
  import {fileURLToPath} from 'url';
25
37
 
26
38
  import defaultConfig from './config.js';
27
- import {createEntry, deleteEntry, getEntry, listEntries, updateEntry} from '../../server/services/collections.js';
39
+ import {pdfCapability, renderPdf} from './pdf.js';
40
+ import {getConfig} from '../../server/config.js';
41
+ import {createTransport} from '../../server/services/email.js';
42
+ import {createEntry, deleteEntry, getCollection, getEntry, listEntries, updateCollection, updateEntry} from '../../server/services/collections.js';
43
+ import {getPluginStates, savePluginState} from '../../server/services/plugins.js';
44
+ import {syncPluginSchemas} from '../_lib/schemaSync.js';
28
45
 
29
46
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
30
- const TEMPLATE_PATH = path.join(__dirname, 'templates', 'invoice-print.html');
31
47
 
32
48
  const ISSUERS_SLUG = 'invoice-issuers';
33
49
  const RECEIVERS_SLUG = 'invoice-receivers';
@@ -64,11 +80,112 @@ function computeTotals(invoice) {
64
80
  const vatRate = invoice.vatEnabled ? (Number(invoice.vatRate) || 0) : 0;
65
81
  const vat = round2(subtotal * (vatRate / 100));
66
82
  const total = round2(subtotal + vat);
67
- return { subtotal, vat, total, vatRate };
83
+ const paid = round2((Array.isArray(invoice.payments) ? invoice.payments : [])
84
+ .reduce((sum, p) => sum + (Number(p?.amount) || 0), 0));
85
+ // Overpayment is a real thing (a rounding, a duplicate transfer), so the
86
+ // balance is allowed to go negative rather than being clamped into a lie.
87
+ const balance = round2(total - paid);
88
+ return { subtotal, vat, total, vatRate, paid, balance };
89
+ }
90
+
91
+ /** Settled once nothing is outstanding. A zero-total invoice is not "paid". */
92
+ function isSettled(invoice) {
93
+ const t = computeTotals(invoice);
94
+ return t.total > 0 && t.balance <= 0;
95
+ }
96
+
97
+ /**
98
+ * Is this invoice past its due date and still unpaid?
99
+ *
100
+ * Derived on read rather than stored, for the same reason `totals` is: a stored
101
+ * flag needs a job to flip it and is wrong until the job runs. The stored
102
+ * `status` is left alone - this is an additional fact about it, not a rewrite -
103
+ * so a PUT round-trip cannot silently turn `sent` into `overdue`.
104
+ *
105
+ * @param {object} invoice
106
+ * @param {Date} [now]
107
+ * @returns {boolean}
108
+ */
109
+ function isOverdue(invoice, now = new Date()) {
110
+ if (!invoice.dueDate) return false;
111
+ if (invoice.status === 'paid' || invoice.status === 'cancelled') return false;
112
+ if (invoice.status === 'draft') return false;
113
+ // Paid in full but nobody moved the status: still not overdue.
114
+ if (computeTotals(invoice).balance <= 0) return false;
115
+ // Due "on" a date means the whole of that day is still in time.
116
+ const due = new Date(`${invoice.dueDate}T23:59:59.999Z`);
117
+ if (Number.isNaN(due.getTime())) return false;
118
+ return due.getTime() < now.getTime();
119
+ }
120
+
121
+ const VALID_STATUSES = ['draft', 'sent', 'paid', 'overdue', 'cancelled'];
122
+
123
+ /**
124
+ * Keep only the three fields a line item has, and coerce them.
125
+ *
126
+ * The totals are computed from these on every read, so anything else a client
127
+ * sends is dead weight in the stored entry at best.
128
+ *
129
+ * @param {*} items
130
+ * @returns {Array<{description: string, quantity: number, unitPrice: number}>}
131
+ */
132
+ function cleanLineItems(items) {
133
+ if (!Array.isArray(items)) return [];
134
+ return items.map((li) => ({
135
+ description: String(li?.description ?? ''),
136
+ quantity: Number(li?.quantity) || 0,
137
+ unitPrice: Number(li?.unitPrice) || 0
138
+ }));
139
+ }
140
+
141
+ /** Rough shape check. The SMTP server is the real judge; this catches typing. */
142
+ const LOOKS_LIKE_EMAIL = /^[^\s@,;]+@[^\s@,;]+\.[^\s@,;]+$/;
143
+
144
+ /**
145
+ * One invoice to a whole mailing list is a mistake, not a feature - and every
146
+ * recipient sees the others, because these go in To.
147
+ */
148
+ const MAX_RECIPIENTS = 25;
149
+
150
+ /**
151
+ * Normalise whatever the client called a recipient into a list of addresses.
152
+ *
153
+ * @param {string|string[]|undefined} value
154
+ * @returns {string[]} unique, trimmed, in the order given
155
+ */
156
+ function parseRecipients(value) {
157
+ const raw = Array.isArray(value) ? value : String(value ?? '').split(/[,;]/);
158
+ const seen = new Set();
159
+ const out = [];
160
+ for (const item of raw) {
161
+ const address = String(item ?? '').trim();
162
+ if (!address) continue;
163
+ const key = address.toLowerCase();
164
+ if (seen.has(key)) continue;
165
+ seen.add(key);
166
+ out.push(address);
167
+ }
168
+ return out;
169
+ }
170
+
171
+ /**
172
+ * The due date a payment term implies.
173
+ *
174
+ * @param {string} issueDate ISO yyyy-mm-dd
175
+ * @param {number} days
176
+ * @returns {string} ISO yyyy-mm-dd, or '' when there is no term to apply
177
+ */
178
+ function dueDateFrom(issueDate, days) {
179
+ const terms = Number(days);
180
+ if (!issueDate || !Number.isFinite(terms) || terms <= 0) return '';
181
+ const date = new Date(`${issueDate}T00:00:00Z`);
182
+ if (Number.isNaN(date.getTime())) return '';
183
+ date.setUTCDate(date.getUTCDate() + terms);
184
+ return date.toISOString().slice(0, 10);
68
185
  }
69
186
 
70
187
  function withTotals(invoice) {
71
- return { ...invoice, totals: computeTotals(invoice) };
188
+ return { ...invoice, totals: computeTotals(invoice), overdue: isOverdue(invoice) };
72
189
  }
73
190
 
74
191
  function formatMoney(amount, currency = 'GBP') {
@@ -79,13 +196,76 @@ function formatMoney(amount, currency = 'GBP') {
79
196
  }
80
197
  }
81
198
 
199
+ /**
200
+ * The issuer's payment details, as an invoice prints them.
201
+ *
202
+ * Structured fields first, each labelled, because "sort code" and "account
203
+ * number" are read off the page by a human typing them into a banking app -
204
+ * a free-text blob is fine to write and miserable to use. `bankDetails` is
205
+ * kept as the overflow for anything that does not fit the six fields, and is
206
+ * appended rather than replaced so no existing issuer loses what it had.
207
+ *
208
+ * @param {object} issuer
209
+ * @returns {Array<{label: string, value: string}>}
210
+ */
211
+ function paymentLines(issuer) {
212
+ const rows = [];
213
+ const add = (label, value) => {
214
+ const v = String(value ?? '').trim();
215
+ if (v) rows.push({ label, value: v });
216
+ };
217
+
218
+ add('Account name', issuer.accountName);
219
+ add('Bank', issuer.bankName);
220
+ add('Sort code', issuer.sortCode);
221
+ add('Account number', issuer.accountNumber);
222
+ add('IBAN', issuer.iban);
223
+ add('BIC / SWIFT', issuer.bic);
224
+ return rows;
225
+ }
226
+
82
227
  function formatDate(iso) {
83
228
  if (!iso) return '';
84
229
  return new Date(iso).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' });
85
230
  }
86
231
 
87
- async function nextInvoiceNumber(prefix, padding) {
88
- const year = new Date().getFullYear();
232
+ /**
233
+ * Serialise this plugin's writes.
234
+ *
235
+ * FileAdapter's insert/update/delete are an unlocked read-modify-write over the
236
+ * whole collection file (server/services/adapters/FileAdapter.js): read the
237
+ * array, mutate it, write it back. Concurrent writes all read the same base and
238
+ * the last one wins - MEASURED, not assumed: twelve simultaneous creates each
239
+ * answered 201 with its own number and exactly one of them survived on disk.
240
+ *
241
+ * That is a core fault and it is not this plugin's to fix, but routing every
242
+ * mutating route here means the plugin cannot lose its own writes. It does not
243
+ * protect against another writer touching the same collection, and it holds
244
+ * only within a process.
245
+ *
246
+ * @template T
247
+ * @param {() => Promise<T>} fn
248
+ * @returns {Promise<T>}
249
+ */
250
+ let writeGate = Promise.resolve();
251
+
252
+ function withWriteLock(fn) {
253
+ const run = writeGate.then(fn);
254
+ // The chain must not stay rejected or every later write inherits the failure.
255
+ writeGate = run.then(() => {}, () => {});
256
+ return run;
257
+ }
258
+
259
+ /**
260
+ * Next number for the current year. Assumes the caller holds the write lock -
261
+ * taking it here as well would deadlock against withWriteLock.
262
+ *
263
+ * The high-water mark is remembered per year, so the scan happens once per
264
+ * process per year rather than once per invoice created.
265
+ */
266
+ const highWater = new Map(); // "PREFIX-YYYY" -> highest sequence seen
267
+
268
+ async function scanHighest(prefix, year) {
89
269
  const all = flattenList(await listEntries(INVOICES_SLUG, { limit: 100000 }));
90
270
  const re = new RegExp(`^${prefix}-${year}-(\\d+)$`);
91
271
  let max = 0;
@@ -96,11 +276,86 @@ async function nextInvoiceNumber(prefix, padding) {
96
276
  if (n > max) max = n;
97
277
  }
98
278
  }
99
- return `${prefix}-${year}-${String(max + 1).padStart(padding, '0')}`;
279
+ return max;
280
+ }
281
+
282
+ async function allocateNumber(prefix, padding) {
283
+ const year = new Date().getFullYear();
284
+ const key = `${prefix}-${year}`;
285
+ if (!highWater.has(key)) highWater.set(key, await scanHighest(prefix, year));
286
+ const next = highWater.get(key) + 1;
287
+ highWater.set(key, next);
288
+ return `${prefix}-${year}-${String(next).padStart(padding, '0')}`;
100
289
  }
101
290
 
102
- async function loadPrintTemplate() {
103
- return fs.readFile(TEMPLATE_PATH, 'utf8');
291
+ /** A number supplied by hand still has to move the mark, or the next auto-number collides. */
292
+ function noteNumber(number, prefix) {
293
+ const m = new RegExp(`^${prefix}-(\\d{4})-(\\d+)$`).exec(String(number || ''));
294
+ if (!m) return;
295
+ const key = `${prefix}-${m[1]}`;
296
+ const n = parseInt(m[2], 10);
297
+ if (!highWater.has(key) || n > highWater.get(key)) highWater.set(key, n);
298
+ }
299
+
300
+ const TEMPLATES_DIR = path.join(__dirname, 'templates');
301
+
302
+ /** The shared print stylesheet, read once and inlined into every template. */
303
+ let baseCssCache = null;
304
+
305
+ async function loadBaseCss() {
306
+ if (baseCssCache === null) {
307
+ baseCssCache = await fs.readFile(path.join(TEMPLATES_DIR, '_base.css'), 'utf8')
308
+ .catch(() => '');
309
+ }
310
+ return baseCssCache;
311
+ }
312
+
313
+ /**
314
+ * The catalogue a site can choose from.
315
+ *
316
+ * Read from `templates/templates.json` rather than hard-coded, so dropping in
317
+ * `house-style.html` plus an entry is all it takes to add one.
318
+ *
319
+ * @returns {Promise<object[]>}
320
+ */
321
+ async function loadTemplateCatalogue() {
322
+ try {
323
+ const raw = await fs.readFile(path.join(TEMPLATES_DIR, 'templates.json'), 'utf8');
324
+ const list = JSON.parse(raw).templates;
325
+ return Array.isArray(list) ? list : [];
326
+ } catch {
327
+ return [];
328
+ }
329
+ }
330
+
331
+ /**
332
+ * Pick the template file for a style, honouring whether there is a logo.
333
+ *
334
+ * An invoice with no logo does NOT get the logo template with an empty slot -
335
+ * a masthead that is a blank 80px gap reads as a page that failed to load. It
336
+ * gets a template built for having no logo, where the trading name takes that
337
+ * space. A style with nowhere to put a logo (minimal) has no `-nologo`
338
+ * sibling and is used as-is.
339
+ *
340
+ * @param {string} style
341
+ * @param {boolean} hasLogo
342
+ * @returns {Promise<string>} the template markup
343
+ */
344
+ async function loadStyleTemplate(style, hasLogo) {
345
+ const id = String(style || 'classic').replace(/[^a-z0-9-]/gi, '');
346
+ const candidates = hasLogo
347
+ ? [`${id}.html`]
348
+ : [`${id}-nologo.html`, `${id}.html`];
349
+ // Always ends at the original file, so a site that has only ever had
350
+ // invoice-print.html keeps printing.
351
+ candidates.push('classic.html', 'invoice-print.html');
352
+
353
+ for (const name of candidates) {
354
+ try {
355
+ return await fs.readFile(path.join(TEMPLATES_DIR, name), 'utf8');
356
+ } catch { /* try the next one */ }
357
+ }
358
+ throw new Error('No invoice template could be read.');
104
359
  }
105
360
 
106
361
  function fillTemplate(template, vars) {
@@ -110,9 +365,138 @@ function fillTemplate(template, vars) {
110
365
  });
111
366
  }
112
367
 
368
+
369
+ /**
370
+ * Pull the parts a printable invoice contributes to a merged document.
371
+ *
372
+ * Every template is a WHOLE html document (see templates/classic.html), so a
373
+ * batch cannot simply be concatenated - it would be N doctypes and N <head>s.
374
+ * The stylesheet is inline rather than fetched, which is what makes taking it
375
+ * apart with a regex reasonable here: the markup is ours, generated three
376
+ * lines earlier by fillTemplate, not arbitrary html off the network.
377
+ *
378
+ * @param {string} html a full document from buildPrintHtml()
379
+ * @returns {{css: string, body: string}}
380
+ */
381
+ function splitPrintDoc(html) {
382
+ const css = [...html.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/gi)]
383
+ .map((m) => m[1]).join('\n');
384
+ const body = html.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
385
+ return { css, body: body ? body[1] : '' };
386
+ }
387
+
388
+ /**
389
+ * Selectors that mean "the page" and therefore have to become "this sheet".
390
+ *
391
+ * Anchored to the start of a rule - after `}`, after `{` (a rule inside
392
+ * `@media print`), after `;`, after a comment, or at the very beginning - so
393
+ * the word `body` inside a declaration, a string or a class name is left
394
+ * alone. `body.something` deliberately does not match: the lookahead demands
395
+ * the selector ends here.
396
+ */
397
+ const SHEET_ROOT_SELECTOR = /(^|[{};]|\*\/)(\s*)(?:html\s*,\s*)?(?::root|body)(?=\s*[,{])/g;
398
+
399
+ /**
400
+ * Confine one invoice's stylesheet to one sheet of a merged document.
401
+ *
402
+ * The templates were written to be alone in a document and they fight when
403
+ * they are not: `modern` hides `.top`, `classic-nologo` hides
404
+ * `.top .party .name`. A site only ever picks ONE style, but the logo and
405
+ * no-logo variants of it are chosen per invoice (see loadStyleTemplate), so a
406
+ * batch spanning an issuer with a logo and an issuer without one mixes two
407
+ * templates - and unscoped, the no-logo rule would delete the trading name
408
+ * from the invoices that do have a logo.
409
+ *
410
+ * `@scope` does the confining without rewriting a single selector, which is
411
+ * why it is used instead of prefixing: nested `@media` blocks, `*` and
412
+ * selector lists all keep working untouched. It is Chrome-only in practice and
413
+ * that is fine - Chrome is the only thing that ever renders this (see pdf.js).
414
+ *
415
+ * The exception is the handful of selectors that address the DOCUMENT rather
416
+ * than the invoice. `:scope` is outside nothing, so `body { padding: 24px }`
417
+ * would simply stop applying; rewritten to `:scope` it lands on the sheet
418
+ * wrapper, which is what the body used to be.
419
+ *
420
+ * @param {string} css
421
+ * @param {string} selector the sheet's own selector
422
+ * @returns {string}
423
+ */
424
+ function scopeSheetCss(css, selector) {
425
+ const confined = css.replace(SHEET_ROOT_SELECTOR, (_, before, space) => `${before}${space}:scope`);
426
+ return `@scope (${selector}) {\n${confined}\n}`;
427
+ }
428
+
429
+ /**
430
+ * One A4 document holding many invoices, one per page.
431
+ *
432
+ * Identical stylesheets are emitted ONCE and shared by class rather than
433
+ * repeated per sheet - a batch of a hundred invoices is a hundred copies of
434
+ * the same eight kilobytes otherwise, and in the ordinary single-template case
435
+ * this collapses to exactly one `@scope` block for the whole document.
436
+ *
437
+ * @param {string[]} docs full documents from buildPrintHtml()
438
+ * @param {string} title
439
+ * @returns {string}
440
+ */
441
+ function mergePrintDocs(docs, title) {
442
+ const styles = []; // distinct css, in first-seen order
443
+ const sheets = [];
444
+
445
+ for (const doc of docs) {
446
+ const { css, body } = splitPrintDoc(doc);
447
+ let index = styles.indexOf(css);
448
+ if (index === -1) index = styles.push(css) - 1;
449
+ sheets.push(`<div class="dm-sheet dm-sheet-s${index}">${body}</div>`);
450
+ }
451
+
452
+ const scoped = styles.map((css, index) => scopeSheetCss(css, `.dm-sheet-s${index}`)).join('\n');
453
+
454
+ return `<!DOCTYPE html>
455
+ <html lang="en">
456
+ <head>
457
+ <meta charset="utf-8">
458
+ <title>${escapeHtml(title)}</title>
459
+ <style>
460
+ /* The document's own rules. The per-invoice stylesheets below are scoped to a
461
+ sheet and no longer reach out here, so the page itself is dressed once. */
462
+ :root { color-scheme: light; }
463
+ html, body { margin: 0; padding: 0; background: #fff; }
464
+ .dm-sheet + .dm-sheet { break-before: page; }
465
+ /* A single invoice may still run to two pages; only the join is forced. */
466
+ .dm-sheet { break-inside: auto; }
467
+ ${scoped}
468
+ </style>
469
+ </head>
470
+ <body>
471
+ ${sheets.join('\n')}
472
+ </body>
473
+ </html>`;
474
+ }
475
+
113
476
  export default async function invoicePlugin(fastify, options) {
114
477
  const { authenticate, requireAdmin } = options.auth;
115
- const settings = { ...defaultConfig, ...(options.settings || {}) };
478
+
479
+ /**
480
+ * The settings as they are RIGHT NOW.
481
+ *
482
+ * `options.settings` is a snapshot taken when the plugin was registered, so
483
+ * a handler closing over it keeps serving the numbering prefix and the
484
+ * email wording the server booted with - and the settings panel appears to
485
+ * save nothing until someone restarts. getConfig() reads the file on every
486
+ * call (there is no cache behind it), so a fresh read per request is both
487
+ * correct and cheap.
488
+ *
489
+ * @returns {object}
490
+ */
491
+ function currentSettings() {
492
+ return { ...defaultConfig, ...(getPluginStates()?.invoice?.settings ?? options.settings ?? {}) };
493
+ }
494
+
495
+ // Payments, the send trail and the issuer's bank fields were all added
496
+ // after the collections were first created; without this they would exist
497
+ // only on a fresh install.
498
+ await syncPluginSchemas(path.join(__dirname, 'collections'),
499
+ {getCollection, updateCollection}, fastify.log);
116
500
  const adminGuard = { preHandler: [authenticate, requireAdmin] };
117
501
 
118
502
  // Issuers
@@ -125,21 +509,21 @@ export default async function invoicePlugin(fastify, options) {
125
509
  if (!body.name || typeof body.name !== 'string' || !body.name.trim()) {
126
510
  return reply.code(400).send({ error: 'name is required' });
127
511
  }
128
- const entry = await createEntry(ISSUERS_SLUG, body, { source: 'admin' });
512
+ const entry = await withWriteLock(() => createEntry(ISSUERS_SLUG, body, { source: 'admin' }));
129
513
  return reply.code(201).send(toRecord(entry));
130
514
  });
131
515
 
132
516
  fastify.put('/issuers/:id', adminGuard, async (request, reply) => {
133
517
  const existing = await getEntry(ISSUERS_SLUG, request.params.id);
134
518
  if (!existing) return reply.code(404).send({ error: 'Issuer not found' });
135
- const updated = await updateEntry(ISSUERS_SLUG, request.params.id, { ...existing.data, ...(request.body ?? {}) });
519
+ const updated = await withWriteLock(() => updateEntry(ISSUERS_SLUG, request.params.id, { ...existing.data, ...(request.body ?? {}) }));
136
520
  return toRecord(updated);
137
521
  });
138
522
 
139
523
  fastify.delete('/issuers/:id', adminGuard, async (request, reply) => {
140
524
  const existing = await getEntry(ISSUERS_SLUG, request.params.id);
141
525
  if (!existing) return reply.code(404).send({ error: 'Issuer not found' });
142
- await deleteEntry(ISSUERS_SLUG, request.params.id);
526
+ await withWriteLock(() => deleteEntry(ISSUERS_SLUG, request.params.id));
143
527
  return { ok: true };
144
528
  });
145
529
 
@@ -153,21 +537,21 @@ export default async function invoicePlugin(fastify, options) {
153
537
  if (!body.name || typeof body.name !== 'string' || !body.name.trim()) {
154
538
  return reply.code(400).send({ error: 'name is required' });
155
539
  }
156
- const entry = await createEntry(RECEIVERS_SLUG, body, { source: 'admin' });
540
+ const entry = await withWriteLock(() => createEntry(RECEIVERS_SLUG, body, { source: 'admin' }));
157
541
  return reply.code(201).send(toRecord(entry));
158
542
  });
159
543
 
160
544
  fastify.put('/receivers/:id', adminGuard, async (request, reply) => {
161
545
  const existing = await getEntry(RECEIVERS_SLUG, request.params.id);
162
546
  if (!existing) return reply.code(404).send({ error: 'Receiver not found' });
163
- const updated = await updateEntry(RECEIVERS_SLUG, request.params.id, { ...existing.data, ...(request.body ?? {}) });
547
+ const updated = await withWriteLock(() => updateEntry(RECEIVERS_SLUG, request.params.id, { ...existing.data, ...(request.body ?? {}) }));
164
548
  return toRecord(updated);
165
549
  });
166
550
 
167
551
  fastify.delete('/receivers/:id', adminGuard, async (request, reply) => {
168
552
  const existing = await getEntry(RECEIVERS_SLUG, request.params.id);
169
553
  if (!existing) return reply.code(404).send({ error: 'Receiver not found' });
170
- await deleteEntry(RECEIVERS_SLUG, request.params.id);
554
+ await withWriteLock(() => deleteEntry(RECEIVERS_SLUG, request.params.id));
171
555
  return { ok: true };
172
556
  });
173
557
 
@@ -184,48 +568,332 @@ export default async function invoicePlugin(fastify, options) {
184
568
  });
185
569
 
186
570
  fastify.post('/invoices', adminGuard, async (request, reply) => {
571
+ const settings = currentSettings();
187
572
  const body = request.body ?? {};
188
573
  if (!body.issuerId) return reply.code(400).send({ error: 'issuerId is required' });
189
574
  if (!body.receiverId) return reply.code(400).send({ error: 'receiverId is required' });
190
575
 
191
- const data = {
192
- number: body.number || await nextInvoiceNumber(settings.numberPrefix, settings.numberPadding),
193
- issuerId: String(body.issuerId),
194
- receiverId: String(body.receiverId),
195
- issueDate: body.issueDate || new Date().toISOString().slice(0, 10),
196
- dueDate: body.dueDate || '',
197
- status: body.status || 'draft',
198
- currency: body.currency || settings.defaultCurrency,
199
- vatEnabled: body.vatEnabled !== undefined ? Boolean(body.vatEnabled) : true,
200
- vatRate: body.vatRate !== undefined ? Number(body.vatRate) : settings.defaultVatRate,
201
- lineItems: Array.isArray(body.lineItems) ? body.lineItems : [],
202
- notes: body.notes ?? ''
203
- };
576
+ const issueDate = body.issueDate || new Date().toISOString().slice(0, 10);
577
+
578
+ const entry = await withWriteLock(async () => {
579
+ if (body.number) noteNumber(body.number, settings.numberPrefix);
580
+
581
+ const data = {
582
+ number: body.number || await allocateNumber(settings.numberPrefix, settings.numberPadding),
583
+ issuerId: String(body.issuerId),
584
+ receiverId: String(body.receiverId),
585
+ issueDate,
586
+ // A blank due date is a real choice (invoices payable on
587
+ // receipt), so the term only fills one in when the client did
588
+ // not say - it never overrides an explicit ''.
589
+ dueDate: body.dueDate !== undefined
590
+ ? (body.dueDate || '')
591
+ : dueDateFrom(issueDate, settings.defaultPaymentTermsDays),
592
+ status: VALID_STATUSES.includes(body.status) ? body.status : 'draft',
593
+ currency: body.currency || settings.defaultCurrency,
594
+ vatEnabled: body.vatEnabled !== undefined
595
+ ? Boolean(body.vatEnabled)
596
+ : settings.vatEnabledByDefault !== false,
597
+ vatRate: body.vatRate !== undefined ? Number(body.vatRate) : settings.defaultVatRate,
598
+ lineItems: cleanLineItems(body.lineItems),
599
+ notes: body.notes ?? ''
600
+ };
601
+ return createEntry(INVOICES_SLUG, data, { source: 'admin' });
602
+ });
204
603
 
205
- const entry = await createEntry(INVOICES_SLUG, data, { source: 'admin' });
206
604
  return reply.code(201).send(withTotals(toRecord(entry)));
207
605
  });
208
606
 
209
607
  fastify.put('/invoices/:id', adminGuard, async (request, reply) => {
608
+ const settings = currentSettings();
210
609
  const existing = await getEntry(INVOICES_SLUG, request.params.id);
211
610
  if (!existing) return reply.code(404).send({ error: 'Invoice not found' });
212
- const merged = { ...existing.data, ...(request.body ?? {}) };
213
- if (Array.isArray(request.body?.lineItems)) merged.lineItems = request.body.lineItems;
214
- const updated = await updateEntry(INVOICES_SLUG, request.params.id, merged);
611
+ const body = request.body ?? {};
612
+
613
+ // Whitelist rather than spread. A bare merge let a client write any key
614
+ // it liked onto the stored entry, including `totals`, which is supposed
615
+ // to be derived on read and would then sit there going stale.
616
+ const merged = { ...existing.data };
617
+ if (body.number !== undefined) { merged.number = String(body.number).trim(); noteNumber(merged.number, settings.numberPrefix); }
618
+ if (body.issuerId !== undefined) merged.issuerId = String(body.issuerId);
619
+ if (body.receiverId !== undefined) merged.receiverId = String(body.receiverId);
620
+ if (body.issueDate !== undefined) merged.issueDate = String(body.issueDate);
621
+ if (body.dueDate !== undefined) merged.dueDate = body.dueDate ? String(body.dueDate) : '';
622
+ if (body.currency !== undefined) merged.currency = String(body.currency);
623
+ if (body.vatEnabled !== undefined) merged.vatEnabled = Boolean(body.vatEnabled);
624
+ if (body.vatRate !== undefined) merged.vatRate = Number(body.vatRate) || 0;
625
+ if (body.notes !== undefined) merged.notes = String(body.notes);
626
+ if (body.lineItems !== undefined) merged.lineItems = cleanLineItems(body.lineItems);
627
+ // Archived is a shelf, not a status: an archived invoice keeps whatever
628
+ // it was paid, cancelled or overdue, and the list simply stops showing
629
+ // it. Deleting a settled invoice is the thing nobody should have to do.
630
+ if (body.archived !== undefined) merged.archived = Boolean(body.archived);
631
+ // `payments` is deliberately NOT settable here: it has its own routes,
632
+ // which is what keeps the status reconciled with the ledger.
633
+ if (body.status !== undefined) {
634
+ if (!VALID_STATUSES.includes(body.status)) {
635
+ return reply.code(400).send({ error: `status must be one of: ${VALID_STATUSES.join(', ')}` });
636
+ }
637
+ merged.status = body.status;
638
+ }
639
+
640
+ const updated = await withWriteLock(() => updateEntry(INVOICES_SLUG, request.params.id, merged));
215
641
  return withTotals(toRecord(updated));
216
642
  });
217
643
 
218
644
  fastify.delete('/invoices/:id', adminGuard, async (request, reply) => {
219
645
  const existing = await getEntry(INVOICES_SLUG, request.params.id);
220
646
  if (!existing) return reply.code(404).send({ error: 'Invoice not found' });
221
- await deleteEntry(INVOICES_SLUG, request.params.id);
647
+ await withWriteLock(() => deleteEntry(INVOICES_SLUG, request.params.id));
222
648
  return { ok: true };
223
649
  });
224
650
 
225
- // Print view (HTML; user prints to PDF from browser)
226
- fastify.get('/invoices/:id/print', adminGuard, async (request, reply) => {
227
- const entry = await getEntry(INVOICES_SLUG, request.params.id);
228
- if (!entry) return reply.code(404).send('Invoice not found');
651
+ /**
652
+ * Copy an invoice into a fresh draft.
653
+ *
654
+ * Most invoices are last month's invoice with the dates moved on, which is
655
+ * otherwise a dozen fields retyped. The copy takes the parties, the money
656
+ * and the wording, and deliberately takes none of the history: it gets its
657
+ * own number, today's issue date, no payments and no send trail. A copy
658
+ * that inherited `sentAt` would claim to have been sent.
659
+ */
660
+ fastify.post('/invoices/:id/duplicate', adminGuard, async (request, reply) => {
661
+ const settings = currentSettings();
662
+ const existing = await getEntry(INVOICES_SLUG, request.params.id);
663
+ if (!existing) return reply.code(404).send({ error: 'Invoice not found' });
664
+ const source = existing.data ?? {};
665
+
666
+ const entry = await withWriteLock(async () => createEntry(INVOICES_SLUG, {
667
+ number: await allocateNumber(settings.numberPrefix, settings.numberPadding),
668
+ issuerId: source.issuerId ?? '',
669
+ receiverId: source.receiverId ?? '',
670
+ issueDate: new Date().toISOString().slice(0, 10),
671
+ dueDate: dueDateFrom(new Date().toISOString().slice(0, 10), settings.defaultPaymentTermsDays),
672
+ status: 'draft',
673
+ currency: source.currency ?? settings.defaultCurrency,
674
+ vatEnabled: source.vatEnabled !== false,
675
+ vatRate: Number(source.vatRate) || settings.defaultVatRate,
676
+ lineItems: cleanLineItems(source.lineItems),
677
+ notes: source.notes ?? ''
678
+ }, { source: 'admin' }));
679
+
680
+ return reply.code(201).send(withTotals(toRecord(entry)));
681
+ });
682
+
683
+ // ---- Templates -------------------------------------------------------
684
+
685
+ /**
686
+ * The styles a printed invoice can be laid out in.
687
+ *
688
+ * `hasNoLogoVariant` is what the settings screen uses to tell somebody
689
+ * that a style behaves differently without a logo, rather than leaving
690
+ * them to discover it on a customer's invoice.
691
+ */
692
+ fastify.get('/templates', adminGuard, async () => {
693
+ const settings = currentSettings();
694
+ const catalogue = await loadTemplateCatalogue();
695
+ const [issuers] = await Promise.all([
696
+ listEntries(ISSUERS_SLUG, { limit: 1000 }).then(flattenList).catch(() => [])
697
+ ]);
698
+ return {
699
+ current: settings.template || 'classic',
700
+ // Whether a preview will actually show a logo: the house one, or
701
+ // any issuer carrying its own.
702
+ hasLogo: Boolean(settings.logoUrl) || issuers.some(i => i.logoUrl),
703
+ templates: catalogue.map(t => ({
704
+ id: t.id,
705
+ name: t.name || t.id,
706
+ description: t.description || '',
707
+ hasNoLogoVariant: t.nologo !== false
708
+ }))
709
+ };
710
+ });
711
+
712
+ /**
713
+ * A worked example in a given style, for the picker to show.
714
+ *
715
+ * Sample data rather than a real invoice, and deliberately: choosing a
716
+ * template is something you do before there are any invoices, and a
717
+ * preview that says "create an invoice first" is no help at all. The
718
+ * issuer is the real one where there is one, so the preview shows YOUR
719
+ * name and YOUR logo.
720
+ */
721
+ fastify.get('/templates/:id/preview', adminGuard, async (request, reply) => {
722
+ const settings = currentSettings();
723
+ const style = String(request.params.id || 'classic');
724
+ const catalogue = await loadTemplateCatalogue();
725
+ if (catalogue.length && !catalogue.some(t => t.id === style)) {
726
+ return reply.code(404).send({ error: `No such template: ${style}` });
727
+ }
728
+
729
+ const issuers = await listEntries(ISSUERS_SLUG, { limit: 1000 })
730
+ .then(flattenList).catch(() => []);
731
+ const issuer = issuers.find(i => i.id === settings.defaultIssuerId) || issuers[0] || {};
732
+
733
+ // `?logo=0` previews the same style as an issuer with no logo sees it,
734
+ // which is the only way to look at the -nologo layout before you have
735
+ // an issuer without one.
736
+ const wantLogo = request.query.logo !== '0';
737
+ const logoTag = wantLogo ? logoFor(issuer, settings) : '';
738
+
739
+ const currency = settings.defaultCurrency || 'GBP';
740
+ const vatRate = Number(settings.defaultVatRate) || 0;
741
+ const lines = [
742
+ { description: 'Website design and build', quantity: 1, unitPrice: 2400 },
743
+ { description: 'Support retainer (March)', quantity: 3, unitPrice: 150 },
744
+ { description: 'Domain and hosting, annual', quantity: 1, unitPrice: 96 }
745
+ ];
746
+ const subtotal = round2(lines.reduce((sum, l) => sum + l.quantity * l.unitPrice, 0));
747
+ const vat = settings.vatEnabledByDefault !== false ? round2(subtotal * vatRate / 100) : 0;
748
+ const today = new Date().toISOString().slice(0, 10);
749
+
750
+ const template = await loadStyleTemplate(style, Boolean(logoTag));
751
+ const html = fillTemplate(template, {
752
+ baseCss: await loadBaseCss(),
753
+ number: `${settings.numberPrefix || 'INV'}-${new Date().getFullYear()}-${'1'.padStart(Number(settings.numberPadding) || 4, '0')}`,
754
+ issueDate: escapeHtml(formatDate(today)),
755
+ dueDate: `<div><strong>Due:</strong> ${escapeHtml(formatDate(dueDateFrom(today, settings.defaultPaymentTermsDays) || today))}</div>`,
756
+ status: 'SENT',
757
+ issuerLogo: logoTag,
758
+ issuerName: escapeHtml(issuer.name || 'Your Company'),
759
+ issuerAddress: escapeHtml(issuer.address || '1 Example Street\nYour Town\nAB1 2CD').replace(/\n/g, '<br>'),
760
+ issuerEmail: escapeHtml(issuer.email || 'accounts@example.com'),
761
+ issuerPhone: escapeHtml(issuer.phone || '01234 567890'),
762
+ issuerVat: issuer.vatNumber ? `<div>VAT: ${escapeHtml(issuer.vatNumber)}</div>` : '<div>VAT: GB000000000</div>',
763
+ receiverName: 'Sample Client Ltd',
764
+ receiverAddress: '42 Client Road<br>Their Town<br>XY9 8ZZ',
765
+ receiverEmail: 'accounts@sampleclient.example',
766
+ receiverVat: '',
767
+ items: lines.map(l => `<tr>
768
+ <td>${escapeHtml(l.description)}</td>
769
+ <td class="num">${l.quantity}</td>
770
+ <td class="num">${formatMoney(l.unitPrice, currency)}</td>
771
+ <td class="num">${formatMoney(round2(l.quantity * l.unitPrice), currency)}</td>
772
+ </tr>`).join(''),
773
+ subtotal: formatMoney(subtotal, currency),
774
+ vatRow: vat ? `<tr><th>VAT (${escapeHtml(String(vatRate))}%)</th><td class="num">${formatMoney(vat, currency)}</td></tr>` : '',
775
+ paidRows: '',
776
+ total: formatMoney(round2(subtotal + vat), currency),
777
+ notes: '<div class="notes"><strong>Notes</strong><br>Thanks very much - payment within the terms above, please.</div>',
778
+ bankDetails: buildPaymentBlock(issuer),
779
+ footerNote: escapeHtml(settings.footerNote || ''),
780
+ // A preview must never raise a print dialog, and the Print button
781
+ // belongs to the printout rather than to a thumbnail of it.
782
+ autoPrint: '<style>.actions{display:none}</style>'
783
+ });
784
+
785
+ return { html, style, withLogo: Boolean(logoTag) };
786
+ });
787
+
788
+ // ---- Settings --------------------------------------------------------
789
+ //
790
+ // Plugin settings live in config/plugins.json under invoice.settings, and
791
+ // the way to reach them is getPluginStates() / savePluginState().
792
+ // getConfig()/saveConfig() in server/config.js take a FILE NAME, which is
793
+ // the mistake this same endpoint shipped with in two other plugins.
794
+
795
+ fastify.get('/settings', adminGuard, async () => {
796
+ return currentSettings();
797
+ });
798
+
799
+ fastify.put('/settings', adminGuard, async (request, reply) => {
800
+ const body = request.body ?? {};
801
+
802
+ const text = (key) => (body[key] !== undefined ? { [key]: String(body[key] ?? '') } : null);
803
+ const bool = (key) => (body[key] !== undefined ? { [key]: Boolean(body[key]) } : null);
804
+ const int = (key, min, max, fallback) => {
805
+ if (body[key] === undefined) return null;
806
+ const n = Math.round(Number(body[key]));
807
+ return { [key]: Number.isFinite(n) ? Math.min(Math.max(n, min), max) : fallback };
808
+ };
809
+ const num = (key, min, max, fallback) => {
810
+ if (body[key] === undefined) return null;
811
+ const n = Number(body[key]);
812
+ return { [key]: Number.isFinite(n) ? Math.min(Math.max(n, min), max) : fallback };
813
+ };
814
+
815
+ // A PATCH over what is stored, not over the defaults: the settings
816
+ // panel saves one tab at a time, and starting from defaultConfig would
817
+ // have saving the email wording quietly reset the currency and the
818
+ // numbering to factory values.
819
+ //
820
+ // Whitelisted, and clamped where a number has a sane range: the padding
821
+ // reaches padStart() and the VAT rate reaches arithmetic on every
822
+ // invoice, so "abc" or 10000 arriving here would be discovered later,
823
+ // in the totals, by someone reading a wrong invoice.
824
+ const merged = Object.assign(currentSettings(),
825
+ text('numberPrefix'),
826
+ int('numberPadding', 1, 10, defaultConfig.numberPadding),
827
+ num('defaultVatRate', 0, 100, defaultConfig.defaultVatRate),
828
+ text('defaultCurrency'),
829
+ bool('vatEnabledByDefault'),
830
+ int('defaultPaymentTermsDays', 0, 365, defaultConfig.defaultPaymentTermsDays),
831
+ text('defaultIssuerId'),
832
+ text('logoUrl'),
833
+ text('template'),
834
+ text('footerNote'),
835
+ text('emailSubject'),
836
+ text('emailIntro'),
837
+ bool('attachPdf')
838
+ );
839
+
840
+ // A currency code reaches Intl.NumberFormat, which THROWS on a bad one
841
+ // - and it is called while rendering, so a typo here would take the
842
+ // print route down rather than show a wrong symbol.
843
+ merged.defaultCurrency = String(merged.defaultCurrency || '').trim().toUpperCase();
844
+ if (!/^[A-Z]{3}$/.test(merged.defaultCurrency)) {
845
+ return reply.code(400).send({ error: 'defaultCurrency must be a three-letter ISO code, e.g. GBP.' });
846
+ }
847
+
848
+ savePluginState('invoice', { settings: merged });
849
+ return { ok: true, settings: merged };
850
+ });
851
+
852
+ /**
853
+ * The Payment panel: the six structured fields as a small table, then
854
+ * whatever free text the issuer kept in `bankDetails` beneath it.
855
+ *
856
+ * @param {object} issuer
857
+ * @returns {string} markup, or '' when there is nothing to pay into
858
+ */
859
+ function buildPaymentBlock(issuer) {
860
+ const rows = paymentLines(issuer);
861
+ const notes = String(issuer.bankDetails ?? '').trim();
862
+ if (!rows.length && !notes) return '';
863
+
864
+ const table = rows.length
865
+ ? `<table class="pay">${rows.map(r =>
866
+ `<tr><th>${escapeHtml(r.label)}</th><td>${escapeHtml(r.value)}</td></tr>`).join('')}</table>`
867
+ : '';
868
+ const free = notes ? `<div class="pay-notes">${escapeHtml(notes).replace(/\n/g, '<br>')}</div>` : '';
869
+
870
+ return `<div class="bank"><strong>Payment</strong>${table}${free}</div>`;
871
+ }
872
+
873
+ /**
874
+ * The logo to print for an issuer: theirs, or the one from settings.
875
+ *
876
+ * @param {object} issuer
877
+ * @param {object} settings
878
+ * @returns {string} an <img> tag, or ''
879
+ */
880
+ function logoFor(issuer, settings) {
881
+ const src = String(issuer.logoUrl || settings.logoUrl || '').trim();
882
+ if (!src) return '';
883
+ return `<img src="${escapeHtml(src)}" alt="${escapeHtml(issuer.name || '')}" class="logo">`;
884
+ }
885
+
886
+ /**
887
+ * Build the printable A4 markup for one invoice.
888
+ *
889
+ * @param {string} invoiceId
890
+ * @param {boolean} autoPrint emit the window.print() bootstrap
891
+ * @returns {Promise<string|null>} markup, or null when the invoice is gone
892
+ */
893
+ async function buildPrintHtml(invoiceId, autoPrint) {
894
+ const settings = currentSettings();
895
+ const entry = await getEntry(INVOICES_SLUG, invoiceId);
896
+ if (!entry) return null;
229
897
  const invoice = withTotals(toRecord(entry));
230
898
 
231
899
  const [issuerEntry, receiverEntry] = await Promise.all([
@@ -248,18 +916,31 @@ export default async function invoicePlugin(fastify, options) {
248
916
  </tr>`;
249
917
  }).join('');
250
918
 
919
+ const paidRows = invoice.totals.paid > 0
920
+ ? `<tr><th>Paid</th><td class="num">-${formatMoney(invoice.totals.paid, currency)}</td></tr>`
921
+ + `<tr class="balance${invoice.totals.balance <= 0 ? ' settled' : ''}">`
922
+ + `<th>${invoice.totals.balance <= 0 ? 'Paid in full' : 'Balance due'}</th>`
923
+ + `<td class="num">${formatMoney(Math.max(0, invoice.totals.balance), currency)}</td></tr>`
924
+ : '';
925
+
251
926
  const vatRow = invoice.vatEnabled
252
927
  ? `<tr><th>VAT (${escapeHtml(String(invoice.totals.vatRate))}%)</th><td class="num">${formatMoney(invoice.totals.vat, currency)}</td></tr>`
253
928
  : '';
254
929
 
255
- const template = await loadPrintTemplate();
256
- const html = fillTemplate(template, {
930
+ const logoTag = logoFor(issuer, settings);
931
+ const template = await loadStyleTemplate(settings.template, Boolean(logoTag));
932
+ return fillTemplate(template, {
933
+ baseCss: await loadBaseCss(),
257
934
  number: escapeHtml(invoice.number || ''),
258
935
  issueDate: escapeHtml(formatDate(invoice.issueDate)),
259
936
  dueDate: invoice.dueDate ? `<div><strong>Due:</strong> ${escapeHtml(formatDate(invoice.dueDate))}</div>` : '',
260
937
  status: escapeHtml((invoice.status || 'draft').toUpperCase()),
261
938
  issuerName: escapeHtml(issuer.name || ''),
262
- issuerLogo: issuer.logoUrl ? `<img src="${escapeHtml(issuer.logoUrl)}" alt="${escapeHtml(issuer.name || '')}" class="logo">` : '',
939
+ // The issuer's own logo wins; the one uploaded in settings is the
940
+ // house default, so a single-company install sets it once and every
941
+ // issuer it ever adds is already branded. Empty here means the
942
+ // -nologo template was chosen above, which has no slot for it.
943
+ issuerLogo: logoTag,
263
944
  issuerAddress: escapeHtml(issuer.address || '').replace(/\n/g, '<br>'),
264
945
  issuerEmail: escapeHtml(issuer.email || ''),
265
946
  issuerPhone: escapeHtml(issuer.phone || ''),
@@ -271,13 +952,505 @@ export default async function invoicePlugin(fastify, options) {
271
952
  items: itemsHtml || '<tr><td colspan="4" class="muted">No line items.</td></tr>',
272
953
  subtotal: formatMoney(invoice.totals.subtotal, currency),
273
954
  vatRow,
955
+ paidRows,
274
956
  total: formatMoney(invoice.totals.total, currency),
275
957
  notes: invoice.notes ? `<div class="notes"><strong>Notes</strong><br>${escapeHtml(invoice.notes).replace(/\n/g, '<br>')}</div>` : '',
276
- bankDetails: issuer.bankDetails ? `<div class="bank"><strong>Payment</strong><br>${escapeHtml(issuer.bankDetails).replace(/\n/g, '<br>')}</div>` : '',
958
+ bankDetails: buildPaymentBlock(issuer),
277
959
  footerNote: escapeHtml(settings.footerNote || ''),
278
- autoPrint: request.query.print === '1' ? '<script>window.addEventListener("load",()=>setTimeout(()=>window.print(),200));</script>' : ''
960
+ autoPrint: autoPrint ? '<script>window.addEventListener("load",()=>setTimeout(()=>window.print(),200));</script>' : ''
279
961
  });
962
+ }
280
963
 
964
+ // Print view (HTML; user prints to PDF from browser). Kept for Bearer-holding
965
+ // clients - a browser tab cannot reach it, see /print-html below.
966
+ fastify.get('/invoices/:id/print', adminGuard, async (request, reply) => {
967
+ const html = await buildPrintHtml(request.params.id, request.query.print === '1');
968
+ if (html === null) return reply.code(404).send('Invoice not found');
281
969
  reply.type('text/html').send(html);
282
970
  });
971
+
972
+ // ---- Payments --------------------------------------------------------
973
+
974
+ const PAYMENT_METHODS = ['bank-transfer', 'card', 'cash', 'cheque', 'direct-debit', 'other'];
975
+
976
+ /**
977
+ * Move the stored status to match what has actually been paid.
978
+ *
979
+ * Deliberately a transition triggered by recording or removing a payment,
980
+ * not something derived on read like `overdue`: "paid" is a claim about the
981
+ * real world that somebody made, and it should survive being looked at.
982
+ * Only the two statuses that describe an open invoice are touched, so a
983
+ * cancelled invoice stays cancelled whatever the ledger says.
984
+ *
985
+ * @param {object} data invoice data, with payments already applied
986
+ * @returns {object} the same object, possibly with a new status
987
+ */
988
+ function reconcileStatus(data) {
989
+ const settled = isSettled(data);
990
+ if (settled && (data.status === 'sent' || data.status === 'overdue' || data.status === 'draft')) {
991
+ data.status = 'paid';
992
+ } else if (!settled && data.status === 'paid') {
993
+ data.status = 'sent';
994
+ }
995
+ return data;
996
+ }
997
+
998
+ /** GET /invoices/:id/payments */
999
+ fastify.get('/invoices/:id/payments', adminGuard, async (request, reply) => {
1000
+ const entry = await getEntry(INVOICES_SLUG, request.params.id);
1001
+ if (!entry) return reply.code(404).send({ error: 'Invoice not found' });
1002
+ const invoice = withTotals(toRecord(entry));
1003
+ return { payments: invoice.payments ?? [], totals: invoice.totals };
1004
+ });
1005
+
1006
+ /** POST /invoices/:id/payments */
1007
+ fastify.post('/invoices/:id/payments', adminGuard, async (request, reply) => {
1008
+ const body = request.body ?? {};
1009
+
1010
+ const amount = Number(body.amount);
1011
+ if (!Number.isFinite(amount) || amount <= 0) {
1012
+ return reply.code(400).send({ error: 'amount must be a positive number' });
1013
+ }
1014
+ if (body.method !== undefined && !PAYMENT_METHODS.includes(body.method)) {
1015
+ return reply.code(400).send({ error: `method must be one of: ${PAYMENT_METHODS.join(', ')}` });
1016
+ }
1017
+
1018
+ const updated = await withWriteLock(async () => {
1019
+ const entry = await getEntry(INVOICES_SLUG, request.params.id);
1020
+ if (!entry) return null;
1021
+
1022
+ const payment = {
1023
+ id: randomUUID(),
1024
+ date: body.date || new Date().toISOString().slice(0, 10),
1025
+ amount: round2(amount),
1026
+ method: body.method || 'bank-transfer',
1027
+ reference: String(body.reference ?? '').trim(),
1028
+ recordedAt: new Date().toISOString()
1029
+ };
1030
+
1031
+ const data = {
1032
+ ...entry.data,
1033
+ payments: [...(Array.isArray(entry.data.payments) ? entry.data.payments : []), payment]
1034
+ };
1035
+ return updateEntry(INVOICES_SLUG, request.params.id, reconcileStatus(data));
1036
+ });
1037
+
1038
+ if (!updated) return reply.code(404).send({ error: 'Invoice not found' });
1039
+ return reply.code(201).send(withTotals(toRecord(updated)));
1040
+ });
1041
+
1042
+ /** DELETE /invoices/:id/payments/:paymentId */
1043
+ fastify.delete('/invoices/:id/payments/:paymentId', adminGuard, async (request, reply) => {
1044
+ const result = await withWriteLock(async () => {
1045
+ const entry = await getEntry(INVOICES_SLUG, request.params.id);
1046
+ if (!entry) return { missing: 'invoice' };
1047
+
1048
+ const payments = Array.isArray(entry.data.payments) ? entry.data.payments : [];
1049
+ const next = payments.filter(p => p.id !== request.params.paymentId);
1050
+ if (next.length === payments.length) return { missing: 'payment' };
1051
+
1052
+ const data = { ...entry.data, payments: next };
1053
+ return { updated: await updateEntry(INVOICES_SLUG, request.params.id, reconcileStatus(data)) };
1054
+ });
1055
+
1056
+ if (result.missing) return reply.code(404).send({ error: `${result.missing === 'invoice' ? 'Invoice' : 'Payment'} not found` });
1057
+ return withTotals(toRecord(result.updated));
1058
+ });
1059
+
1060
+ // ---- Sending ---------------------------------------------------------
1061
+
1062
+ /**
1063
+ * Email an invoice to its receiver.
1064
+ *
1065
+ * The PDF is attached when the host can render one; where it cannot, the
1066
+ * printable invoice goes inline as the HTML body instead, so the mail is
1067
+ * still a usable invoice rather than a note saying one exists.
1068
+ *
1069
+ * This calls transport.sendMail directly rather than the email service's
1070
+ * sendEmail(), which destructures a fixed set of options and has no
1071
+ * attachments parameter - and server/ is replaced wholesale by the updater,
1072
+ * so it is not ours to extend. The cost is that these sends do not reach
1073
+ * getLastSendResult(), i.e. they are absent from the SMTP line in Health.
1074
+ */
1075
+ /**
1076
+ * What the Send dialog should open with.
1077
+ *
1078
+ * The subject and covering note are templated from settings, and templating
1079
+ * them here rather than in the browser keeps one source of truth for them.
1080
+ */
1081
+ fastify.get('/invoices/:id/send-defaults', adminGuard, async (request, reply) => {
1082
+ const settings = currentSettings();
1083
+ const entry = await getEntry(INVOICES_SLUG, request.params.id);
1084
+ if (!entry) return reply.code(404).send({ error: 'Invoice not found' });
1085
+ const invoice = withTotals(toRecord(entry));
1086
+
1087
+ const [issuerEntry, receiverEntry] = await Promise.all([
1088
+ invoice.issuerId ? getEntry(ISSUERS_SLUG, invoice.issuerId) : null,
1089
+ invoice.receiverId ? getEntry(RECEIVERS_SLUG, invoice.receiverId) : null
1090
+ ]);
1091
+ const issuer = issuerEntry ? toRecord(issuerEntry) : {};
1092
+ const receiver = receiverEntry ? toRecord(receiverEntry) : {};
1093
+
1094
+ const vars = {
1095
+ number: invoice.number || '',
1096
+ issuer: issuer.name || '',
1097
+ receiver: receiver.company || receiver.name || '',
1098
+ total: formatMoney(invoice.totals.total, invoice.currency || settings.defaultCurrency),
1099
+ dueDate: invoice.dueDate ? formatDate(invoice.dueDate) : ''
1100
+ };
1101
+
1102
+ const cap = await pdfCapability();
1103
+ return {
1104
+ to: receiver.email || '',
1105
+ subject: fillTemplate(settings.emailSubject, vars),
1106
+ message: fillTemplate(settings.emailIntro, vars),
1107
+ canAttachPdf: cap.available,
1108
+ attachPdf: cap.available && settings.attachPdf !== false,
1109
+ sentAt: invoice.sentAt || null,
1110
+ sendCount: Number(invoice.sendCount) || 0
1111
+ };
1112
+ });
1113
+
1114
+ fastify.post('/invoices/:id/send', adminGuard, async (request, reply) => {
1115
+ const settings = currentSettings();
1116
+ const body = request.body ?? {};
1117
+
1118
+ const entry = await getEntry(INVOICES_SLUG, request.params.id);
1119
+ if (!entry) return reply.code(404).send({ error: 'Invoice not found' });
1120
+ const invoice = withTotals(toRecord(entry));
1121
+
1122
+ const [issuerEntry, receiverEntry] = await Promise.all([
1123
+ invoice.issuerId ? getEntry(ISSUERS_SLUG, invoice.issuerId) : null,
1124
+ invoice.receiverId ? getEntry(RECEIVERS_SLUG, invoice.receiverId) : null
1125
+ ]);
1126
+ const issuer = issuerEntry ? toRecord(issuerEntry) : {};
1127
+ const receiver = receiverEntry ? toRecord(receiverEntry) : {};
1128
+
1129
+ // `to` may be one address, a comma/semicolon list, or an array - the
1130
+ // Send dialog builds it from a contact group, so a dozen recipients in
1131
+ // one field is the ordinary case rather than the exotic one.
1132
+ const recipients = parseRecipients(body.to ?? receiver.email);
1133
+ if (!recipients.length) {
1134
+ return reply.code(400).send({
1135
+ error: 'No recipient. This receiver has no email address, so pass one in `to`.'
1136
+ });
1137
+ }
1138
+ const rejected = recipients.filter(address => !LOOKS_LIKE_EMAIL.test(address));
1139
+ if (rejected.length) {
1140
+ return reply.code(400).send({ error: `Not an email address: ${rejected.join(', ')}` });
1141
+ }
1142
+ if (recipients.length > MAX_RECIPIENTS) {
1143
+ return reply.code(400).send({
1144
+ error: `That is ${recipients.length} recipients; ${MAX_RECIPIENTS} is the limit for one invoice.`
1145
+ });
1146
+ }
1147
+ const to = recipients.join(', ');
1148
+
1149
+ const currency = invoice.currency || settings.defaultCurrency;
1150
+ const vars = {
1151
+ number: invoice.number || '',
1152
+ issuer: issuer.name || '',
1153
+ receiver: receiver.company || receiver.name || '',
1154
+ total: formatMoney(invoice.totals.total, currency),
1155
+ dueDate: invoice.dueDate ? formatDate(invoice.dueDate) : ''
1156
+ };
1157
+
1158
+ const subject = String(body.subject || fillTemplate(settings.emailSubject, vars)).trim()
1159
+ || `Invoice ${vars.number}`;
1160
+ const intro = String(body.message ?? fillTemplate(settings.emailIntro, vars));
1161
+
1162
+ const html = await buildPrintHtml(request.params.id, false);
1163
+ if (html === null) return reply.code(404).send({ error: 'Invoice not found' });
1164
+
1165
+ const wantPdf = body.attachPdf !== undefined ? Boolean(body.attachPdf) : settings.attachPdf !== false;
1166
+ const cap = await pdfCapability();
1167
+ const attachments = [];
1168
+ if (wantPdf && cap.available) {
1169
+ try {
1170
+ attachments.push({
1171
+ filename: `${String(vars.number || 'invoice').replace(/[^\w.-]/g, '_')}.pdf`,
1172
+ content: await renderPdf(html, { base: `${request.protocol}://${request.hostname}/` }),
1173
+ contentType: 'application/pdf'
1174
+ });
1175
+ } catch (err) {
1176
+ // An invoice that arrives without its attachment beats one that
1177
+ // never arrives, so this is a warning and not a failure.
1178
+ request.log.warn({ err }, '[invoice] PDF attach failed; sending without it');
1179
+ }
1180
+ }
1181
+
1182
+ const summary = [
1183
+ `<p>${escapeHtml(intro)}</p>`,
1184
+ '<ul>',
1185
+ `<li><strong>Invoice:</strong> ${escapeHtml(vars.number)}</li>`,
1186
+ `<li><strong>Total:</strong> ${escapeHtml(vars.total)}</li>`,
1187
+ vars.dueDate ? `<li><strong>Due:</strong> ${escapeHtml(vars.dueDate)}</li>` : '',
1188
+ '</ul>'
1189
+ ].join('');
1190
+
1191
+ // No attachment means the invoice itself has to be the body.
1192
+ const emailHtml = attachments.length
1193
+ ? `<div style="font-family:system-ui,sans-serif;font-size:15px;line-height:1.5;">${summary}<p>${escapeHtml(settings.footerNote || '')}</p></div>`
1194
+ : `<div style="font-family:system-ui,sans-serif;font-size:15px;line-height:1.5;">${summary}</div><hr>${html}`;
1195
+
1196
+ const text = [intro, '', `Invoice: ${vars.number}`, `Total: ${vars.total}`,
1197
+ vars.dueDate ? `Due: ${vars.dueDate}` : '', '', settings.footerNote || ''
1198
+ ].filter(Boolean).join('\n');
1199
+
1200
+ const smtp = getConfig('site').smtp || {};
1201
+ try {
1202
+ const transport = await createTransport(smtp);
1203
+ await transport.sendMail({
1204
+ from: smtp.fromName ? `"${smtp.fromName}" <${smtp.fromAddress}>` : smtp.fromAddress,
1205
+ to,
1206
+ subject,
1207
+ text,
1208
+ html: emailHtml,
1209
+ attachments
1210
+ });
1211
+ } catch (err) {
1212
+ request.log.error({ err }, '[invoice] send failed');
1213
+ return reply.code(502).send({ error: `Could not send the invoice: ${err.message}` });
1214
+ }
1215
+
1216
+ // The send trail, and the status move that goes with it. A draft that
1217
+ // has been emailed is not a draft any more.
1218
+ const merged = {
1219
+ ...entry.data,
1220
+ sentAt: new Date().toISOString(),
1221
+ sentTo: to,
1222
+ sendCount: (Number(entry.data.sendCount) || 0) + 1
1223
+ };
1224
+ const markSent = body.markSent !== undefined ? Boolean(body.markSent) : true;
1225
+ if (markSent && merged.status === 'draft') merged.status = 'sent';
1226
+
1227
+ const updated = await withWriteLock(() => updateEntry(INVOICES_SLUG, request.params.id, merged));
1228
+ return {
1229
+ ok: true,
1230
+ to,
1231
+ subject,
1232
+ attached: attachments.length > 0,
1233
+ invoice: withTotals(toRecord(updated))
1234
+ };
1235
+ });
1236
+
1237
+ // ---- PDF -------------------------------------------------------------
1238
+
1239
+ const PDF_TOKEN_TYPE = 'invoice-pdf';
1240
+
1241
+ /** Can this host render one at all? The admin asks before offering the button. */
1242
+ fastify.get('/pdf/status', adminGuard, async () => {
1243
+ const cap = await pdfCapability();
1244
+ return { available: cap.available, reason: cap.reason };
1245
+ });
1246
+
1247
+ // A download is a browser NAVIGATION, and `authenticate` is Bearer-only, so
1248
+ // the admin cannot simply link to the PDF route - that is the same 401 the
1249
+ // Print button used to hit. The token goes in the QUERY STRING, not a path
1250
+ // param: Fastify caps a route param at 100 characters and a signed token is
1251
+ // far longer, so /pdf/:token would silently 404 (see previewLinks.js, which
1252
+ // learned this the same way). Two minutes is short enough that there is
1253
+ // nothing to revoke.
1254
+ fastify.post('/invoices/:id/pdf-link', adminGuard, async (request, reply) => {
1255
+ const cap = await pdfCapability();
1256
+ if (!cap.available) return reply.code(501).send({ error: cap.reason });
1257
+
1258
+ const entry = await getEntry(INVOICES_SLUG, request.params.id);
1259
+ if (!entry) return reply.code(404).send({ error: 'Invoice not found' });
1260
+
1261
+ const token = fastify.jwt.sign(
1262
+ { type: PDF_TOKEN_TYPE, id: request.params.id },
1263
+ { expiresIn: '2m' }
1264
+ );
1265
+ return {
1266
+ url: `/api/plugins/invoice/invoices/${encodeURIComponent(request.params.id)}/pdf?token=${encodeURIComponent(token)}`,
1267
+ filename: `${toRecord(entry).number || 'invoice'}.pdf`
1268
+ };
1269
+ });
1270
+
1271
+ fastify.get('/invoices/:id/pdf', async (request, reply) => {
1272
+ const token = request.query?.token;
1273
+ if (!token) return reply.code(401).send({ error: 'A download token is required' });
1274
+
1275
+ let claims;
1276
+ try {
1277
+ claims = fastify.jwt.verify(String(token));
1278
+ } catch {
1279
+ return reply.code(401).send({ error: 'That download link has expired' });
1280
+ }
1281
+ // A token minted for one invoice must not fetch another, and an ordinary
1282
+ // access token must not be usable here.
1283
+ if (claims.type !== PDF_TOKEN_TYPE || claims.id !== request.params.id) {
1284
+ return reply.code(403).send({ error: 'Forbidden' });
1285
+ }
1286
+
1287
+ const html = await buildPrintHtml(request.params.id, false);
1288
+ if (html === null) return reply.code(404).send({ error: 'Invoice not found' });
1289
+
1290
+ const entry = await getEntry(INVOICES_SLUG, request.params.id);
1291
+ const number = toRecord(entry).number || 'invoice';
1292
+
1293
+ let pdf;
1294
+ try {
1295
+ pdf = await renderPdf(html, { base: `${request.protocol}://${request.hostname}/` });
1296
+ } catch (err) {
1297
+ request.log.error({ err }, '[invoice] PDF render failed');
1298
+ return reply.code(500).send({ error: `Could not render the PDF: ${err.message}` });
1299
+ }
1300
+
1301
+ return reply
1302
+ .type('application/pdf')
1303
+ .header('Content-Disposition', `attachment; filename="${number.replace(/[^\w.-]/g, '_')}.pdf"`)
1304
+ .header('Cache-Control', 'no-store')
1305
+ .send(pdf);
1306
+ });
1307
+
1308
+ // ---- Bulk PDF --------------------------------------------------------
1309
+ //
1310
+ // The list's CSV and Excel buttons export the rows; this exports the
1311
+ // INVOICES - every one currently shown, as the documents a client would
1312
+ // receive, one per page of a single file.
1313
+ //
1314
+ // A cap, because this is the one button here that can ask a machine for
1315
+ // real work: Chrome lays out every sheet before a single byte comes back,
1316
+ // and a careless "select everything" on a site with four years of history
1317
+ // would otherwise hold a request open for minutes.
1318
+ const BATCH_MAX = 100;
1319
+
1320
+ const BATCH_TOKEN_TYPE = 'invoice-pdf-batch';
1321
+
1322
+ /**
1323
+ * Which invoices a batch token stands for.
1324
+ *
1325
+ * The ids do NOT travel in the token. A hundred uuids is four kilobytes of
1326
+ * JWT and the token goes in a query string, which is the one place a long
1327
+ * value is most likely to be truncated by something in the middle. The
1328
+ * token carries a claim ticket instead and the list stays here.
1329
+ *
1330
+ * One-shot and short-lived, so this never grows: the entry is deleted when
1331
+ * it is redeemed, and a sweep on write clears anything a cancelled
1332
+ * download left behind.
1333
+ *
1334
+ * @type {Map<string, {ids: string[], expires: number}>}
1335
+ */
1336
+ const batchJobs = new Map();
1337
+
1338
+ /**
1339
+ * @param {string[]} ids
1340
+ * @returns {string} the claim ticket
1341
+ */
1342
+ function stashBatch(ids) {
1343
+ const now = Date.now();
1344
+ for (const [key, job] of batchJobs) {
1345
+ if (job.expires <= now) batchJobs.delete(key);
1346
+ }
1347
+ const ticket = randomUUID();
1348
+ batchJobs.set(ticket, { ids, expires: now + 2 * 60 * 1000 });
1349
+ return ticket;
1350
+ }
1351
+
1352
+ /**
1353
+ * Build one document holding every invoice asked for, in the order asked.
1354
+ *
1355
+ * Invoices that have been deleted since the list was drawn are skipped
1356
+ * rather than failing the batch - the alternative is a download that dies
1357
+ * because of a row somebody else removed a minute ago.
1358
+ *
1359
+ * @param {string[]} ids
1360
+ * @returns {Promise<{html: string|null, count: number}>}
1361
+ */
1362
+ async function buildBatchHtml(ids) {
1363
+ const docs = [];
1364
+ for (const id of ids) {
1365
+ const html = await buildPrintHtml(id, false);
1366
+ if (html !== null) docs.push(html);
1367
+ }
1368
+ if (!docs.length) return { html: null, count: 0 };
1369
+ return { html: mergePrintDocs(docs, `Invoices (${docs.length})`), count: docs.length };
1370
+ }
1371
+
1372
+ fastify.post('/invoices/batch-pdf-link', adminGuard, async (request, reply) => {
1373
+ const cap = await pdfCapability();
1374
+ if (!cap.available) return reply.code(501).send({ error: cap.reason });
1375
+
1376
+ const ids = Array.isArray(request.body?.ids)
1377
+ ? request.body.ids.map(String).filter(Boolean)
1378
+ : [];
1379
+ if (!ids.length) return reply.code(400).send({ error: 'No invoices were selected.' });
1380
+ if (ids.length > BATCH_MAX) {
1381
+ return reply.code(400).send({
1382
+ error: `That is ${ids.length} invoices. Narrow the filters to ${BATCH_MAX} or fewer and try again.`
1383
+ });
1384
+ }
1385
+
1386
+ const token = fastify.jwt.sign(
1387
+ { type: BATCH_TOKEN_TYPE, ticket: stashBatch(ids) },
1388
+ { expiresIn: '2m' }
1389
+ );
1390
+ return {
1391
+ url: `/api/plugins/invoice/invoices/batch-pdf?token=${encodeURIComponent(token)}`,
1392
+ filename: `invoices-${new Date().toISOString().slice(0, 10)}.pdf`,
1393
+ count: ids.length
1394
+ };
1395
+ });
1396
+
1397
+ fastify.get('/invoices/batch-pdf', async (request, reply) => {
1398
+ const token = request.query?.token;
1399
+ if (!token) return reply.code(401).send({ error: 'A download token is required' });
1400
+
1401
+ let claims;
1402
+ try {
1403
+ claims = fastify.jwt.verify(String(token));
1404
+ } catch {
1405
+ return reply.code(401).send({ error: 'That download link has expired' });
1406
+ }
1407
+ if (claims.type !== BATCH_TOKEN_TYPE || !claims.ticket) {
1408
+ return reply.code(403).send({ error: 'Forbidden' });
1409
+ }
1410
+
1411
+ // Redeemed once. A token that has already produced its file cannot be
1412
+ // replayed out of a browser's history or a proxy log.
1413
+ const job = batchJobs.get(claims.ticket);
1414
+ batchJobs.delete(claims.ticket);
1415
+ if (!job || job.expires <= Date.now()) {
1416
+ return reply.code(401).send({ error: 'That download link has expired' });
1417
+ }
1418
+
1419
+ const { html, count } = await buildBatchHtml(job.ids);
1420
+ if (html === null) return reply.code(404).send({ error: 'None of those invoices could be found.' });
1421
+
1422
+ let pdf;
1423
+ try {
1424
+ // Chrome lays the whole document out before it emits anything, so
1425
+ // the ceiling has to grow with the batch - the single-invoice
1426
+ // default would time out somewhere around the twentieth sheet.
1427
+ pdf = await renderPdf(html, {
1428
+ base: `${request.protocol}://${request.hostname}/`,
1429
+ timeoutMs: Math.min(180000, 30000 + count * 1500)
1430
+ });
1431
+ } catch (err) {
1432
+ request.log.error({ err, count }, '[invoice] batch PDF render failed');
1433
+ return reply.code(500).send({ error: `Could not render the PDF: ${err.message}` });
1434
+ }
1435
+
1436
+ const name = `invoices-${new Date().toISOString().slice(0, 10)}.pdf`;
1437
+ return reply
1438
+ .type('application/pdf')
1439
+ .header('Content-Disposition', `attachment; filename="${name}"`)
1440
+ .header('Cache-Control', 'no-store')
1441
+ .send(pdf);
1442
+ });
1443
+
1444
+ // Same markup, JSON-wrapped, for the admin.
1445
+ //
1446
+ // `authenticate` is Bearer-only and a target="_blank" navigation sends no
1447
+ // Authorization header, so an <a href> straight to /print always 401s. The
1448
+ // admin fetches through the intercepted H client (which does attach the
1449
+ // token) and writes the result into a window it opened synchronously on the
1450
+ // click, which is also what keeps the popup blocker out of it.
1451
+ fastify.get('/invoices/:id/print-html', adminGuard, async (request, reply) => {
1452
+ const html = await buildPrintHtml(request.params.id, request.query.print === '1');
1453
+ if (html === null) return reply.code(404).send({ error: 'Invoice not found' });
1454
+ return { html };
1455
+ });
283
1456
  }