domma-cms 0.54.2 → 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.
- package/CLAUDE.md +151 -77
- package/README.md +4 -4
- package/admin/css/admin.css +1 -1
- package/admin/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +3 -3
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +3 -3
- package/admin/js/lib/page-picker.js +1 -0
- package/admin/js/lib/plugin-accent.js +1 -0
- package/admin/js/lib/plugin-chrome.js +1 -0
- package/admin/js/lib/shortcode-context-menu.js +2 -2
- package/admin/js/lib/sidebar-grouping.js +1 -1
- package/admin/js/lib/sidebar-grouping.test.js +1 -1
- package/admin/js/lib/sidebar-renderer.js +4 -4
- package/admin/js/lib/slideover-resizable.js +1 -0
- package/admin/js/templates/context-menu-editor.html +212 -0
- package/admin/js/templates/context-menus.html +16 -0
- package/admin/js/templates/menu-editor.html +19 -25
- package/admin/js/templates/plugin-code.html +1 -1
- package/{plugins/site-search/admin/templates/site-search.html → admin/js/templates/search.html} +50 -4
- package/admin/js/templates/settings.html +27 -97
- package/admin/js/templates/theme.html +173 -0
- package/admin/js/views/context-menu-editor.js +55 -0
- package/admin/js/views/context-menus.js +5 -0
- package/admin/js/views/form-editor.js +7 -7
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/menu-editor.js +13 -13
- package/admin/js/views/plugin-marketplace.js +1 -1
- package/admin/js/views/plugins.js +25 -23
- package/admin/js/views/search.js +1 -0
- package/admin/js/views/settings.js +3 -3
- package/admin/js/views/theme.js +1 -0
- package/bin/cli.js +11 -2
- package/bin/lib/smtp-defaults.js +53 -0
- package/bin/update.js +13 -2
- package/config/menus/admin-sidebar.json +129 -23
- package/config/plugins.json +5 -5
- package/config/search.json +13 -0
- package/config/theme.json +18 -0
- package/package.json +12 -4
- package/plugins/_lib/admin/mail/compose-window.js +914 -0
- package/plugins/_lib/admin/mail/contacts.js +301 -0
- package/plugins/_lib/admin/mail/diagnostics-section.js +133 -0
- package/plugins/_lib/admin/mail/folder-tree.js +254 -0
- package/plugins/_lib/admin/mail/identity.js +480 -0
- package/plugins/_lib/admin/mail/image-senders-section.js +131 -0
- package/plugins/_lib/admin/mail/keyboard.js +136 -0
- package/plugins/_lib/admin/mail/mail.css +1 -0
- package/plugins/_lib/admin/mail/mail.html +71 -0
- package/plugins/_lib/admin/mail/panes.js +253 -0
- package/plugins/_lib/admin/mail/reader-view.js +4453 -0
- package/plugins/_lib/admin/mail/resizable.js +26 -0
- package/plugins/_lib/admin/mail/rules.js +343 -0
- package/plugins/_lib/admin/mail/scheduling.js +203 -0
- package/plugins/_lib/admin/mail/section-kit.js +277 -0
- package/plugins/_lib/admin/mail/templates.js +238 -0
- package/plugins/_lib/admin/mail/threads.js +200 -0
- package/plugins/_lib/admin/mail/vacation.js +269 -0
- package/plugins/_lib/admin/ui/help.css +1 -0
- package/plugins/_lib/admin/ui/help.js +174 -0
- package/plugins/_lib/admin/ui/resizable.js +151 -0
- package/plugins/_lib/dataStore.js +117 -0
- package/plugins/_lib/mail/accounts.js +919 -0
- package/plugins/_lib/mail/bodyTokens.js +101 -0
- package/plugins/_lib/mail/compose.js +256 -0
- package/plugins/_lib/mail/defaults.js +57 -0
- package/plugins/_lib/mail/diagnostics.js +154 -0
- package/plugins/_lib/mail/envelope.js +161 -0
- package/plugins/_lib/mail/folders.js +192 -0
- package/plugins/_lib/mail/handoff.js +274 -0
- package/plugins/_lib/mail/imapPool.js +293 -0
- package/plugins/_lib/mail/mbox.js +74 -0
- package/plugins/_lib/mail/pollSchedule.js +79 -0
- package/plugins/_lib/mail/poller.js +135 -0
- package/plugins/_lib/mail/priority.js +138 -0
- package/plugins/_lib/mail/readRoutes.js +680 -0
- package/plugins/_lib/mail/render.js +291 -0
- package/plugins/_lib/mail/ruleRunner.js +152 -0
- package/plugins/_lib/mail/scheduler.js +254 -0
- package/plugins/_lib/mail/secretbox.js +229 -0
- package/plugins/_lib/mail/send.js +396 -0
- package/plugins/_lib/mail/store.js +1002 -0
- package/plugins/_lib/mail/sync.js +292 -0
- package/plugins/_lib/mail/syncPlan.js +126 -0
- package/plugins/_lib/mail/syncSelection.js +82 -0
- package/plugins/_lib/mail/unsubscribe.js +183 -0
- package/plugins/_lib/mail/vacationRunner.js +114 -0
- package/plugins/_lib/mail/write.js +473 -0
- package/plugins/_lib/schemaSync.js +83 -0
- package/plugins/_template/admin/css/index.css +0 -0
- package/plugins/_template/admin/templates/index.html +4 -4
- package/plugins/_template/admin/views/index.js +7 -0
- package/plugins/analytics/admin/css/index.css +1 -0
- package/plugins/analytics/admin/templates/analytics.html +22 -13
- package/plugins/analytics/plugin.json +3 -0
- package/plugins/blog/admin/css/index.css +1 -0
- package/plugins/blog/admin/templates/blog.html +30 -18
- package/plugins/blog/admin/templates/categories.html +2 -2
- package/plugins/blog/admin/templates/comments.html +2 -2
- package/plugins/blog/admin/templates/post-editor.html +34 -34
- package/plugins/blog/admin/templates/settings.html +6 -3
- package/plugins/blog/admin/views/blog.js +8 -5
- package/plugins/blog/admin/views/categories.js +5 -10
- package/plugins/blog/admin/views/comments.js +5 -5
- package/plugins/blog/admin/views/post-editor.js +39 -20
- package/plugins/blog/admin/views/settings.js +52 -50
- package/plugins/blog/collections/categories/schema.json +7 -6
- package/plugins/blog/collections/comments/schema.json +11 -10
- package/plugins/blog/collections/posts/schema.json +14 -13
- package/plugins/blog/plugin.js +36 -13
- package/plugins/blog/plugin.json +13 -5
- package/plugins/blog/plugin.public.js +312 -0
- package/plugins/contacts/admin/css/index.css +1 -0
- package/plugins/contacts/admin/templates/contacts.html +128 -0
- package/plugins/contacts/admin/views/contacts.js +237 -4
- package/plugins/contacts/collections/user-contacts/schema.json +108 -0
- package/plugins/contacts/plugin.js +214 -27
- package/plugins/contacts/plugin.json +4 -1
- package/plugins/invoice/admin/css/index.css +1 -0
- package/plugins/invoice/admin/templates/editor.html +140 -49
- package/plugins/invoice/admin/templates/index.html +153 -23
- package/plugins/invoice/admin/templates/issuers.html +2 -5
- package/plugins/invoice/admin/templates/receivers.html +2 -5
- package/plugins/invoice/admin/views/contacts-source.js +266 -0
- package/plugins/invoice/admin/views/editor.js +366 -199
- package/plugins/invoice/admin/views/export.js +199 -0
- package/plugins/invoice/admin/views/help-content.js +61 -0
- package/plugins/invoice/admin/views/index.js +582 -94
- package/plugins/invoice/admin/views/issuers.js +24 -17
- package/plugins/invoice/admin/views/media.js +172 -0
- package/plugins/invoice/admin/views/party-view.js +305 -67
- package/plugins/invoice/admin/views/payments.js +127 -0
- package/plugins/invoice/admin/views/print.js +130 -0
- package/plugins/invoice/admin/views/receivers.js +49 -16
- package/plugins/invoice/admin/views/send.js +212 -0
- package/plugins/invoice/admin/views/settings.js +594 -0
- package/plugins/invoice/admin/views/view-lifecycle.js +33 -0
- package/plugins/invoice/collections/invoice-issuers/schema.json +77 -11
- package/plugins/invoice/collections/invoice-receivers/schema.json +10 -9
- package/plugins/invoice/collections/invoices/schema.json +19 -13
- package/plugins/invoice/config.js +27 -6
- package/plugins/invoice/pdf.js +164 -0
- package/plugins/invoice/plugin.js +1217 -44
- package/plugins/invoice/plugin.json +10 -9
- package/plugins/invoice/templates/_base.css +1 -0
- package/plugins/invoice/templates/classic-nologo.html +100 -0
- package/plugins/invoice/templates/classic.html +91 -0
- package/plugins/invoice/templates/invoice-print.html +24 -0
- package/plugins/invoice/templates/minimal.html +99 -0
- package/plugins/invoice/templates/modern-nologo.html +114 -0
- package/plugins/invoice/templates/modern.html +113 -0
- package/plugins/invoice/templates/templates.json +11 -0
- package/plugins/mail-reader/admin/views/mail.js +19 -0
- package/plugins/mail-reader/config.js +7 -0
- package/plugins/mail-reader/plugin.js +48 -0
- package/plugins/mail-reader/plugin.json +33 -0
- package/plugins/notes/admin/views/notes.js +1 -1
- package/plugins/notes/plugin.json +2 -2
- package/plugins/surveys/lib/audience.js +37 -0
- package/plugins/surveys/lib/campaigns.js +43 -0
- package/plugins/surveys/lib/ledger.js +110 -0
- package/plugins/surveys/lib/sending.js +106 -0
- package/plugins/surveys/lib/stats.js +62 -0
- package/plugins/surveys/lib/submit.js +95 -0
- package/plugins/surveys/lib/tokens.js +28 -0
- package/plugins/surveys/plugin.public.js +149 -0
- package/plugins/theme-switcher/admin/templates/theme-switcher.html +1 -1
- package/public/css/forms.css +1 -1
- package/public/css/menu-highlight.css +1 -1
- package/public/css/search.css +1 -0
- package/public/css/site.css +1 -1
- package/public/js/collection-context.js +2 -2
- package/public/js/context-menus.js +1 -0
- package/public/js/form-logic-engine.js +1 -1
- package/public/js/forms.js +2 -2
- package/public/js/menu-decor.mjs +1 -1
- package/public/js/search.js +1 -0
- package/public/js/site.js +1 -1
- package/scripts/build.js +37 -3
- package/scripts/copy-domma.js +48 -0
- package/scripts/seed.js +1996 -0
- package/scripts/setup.js +8 -0
- package/server/routes/api/collections.js +34 -0
- package/server/routes/api/context-menus.js +104 -0
- package/server/routes/api/forms.js +42 -3
- package/server/routes/api/notifications.js +69 -19
- package/server/routes/api/plugins.js +50 -6
- package/server/routes/api/search.js +43 -0
- package/server/routes/api/theme.js +69 -0
- package/server/routes/public.js +42 -7
- package/server/server.js +74 -0
- package/server/services/adapters/FileAdapter.js +6 -1
- package/server/services/content.js +26 -0
- package/server/services/contextMenus.js +477 -0
- package/server/services/email.js +29 -3
- package/server/services/health.js +23 -2
- package/server/services/markdown.js +70 -9
- package/server/services/menuRender.js +28 -4
- package/server/services/menus.js +25 -1
- package/server/services/permissionRegistry.js +24 -0
- package/server/services/pluginFiles.js +52 -11
- package/server/services/plugins.js +229 -6
- package/server/services/renderer.js +148 -22
- package/server/services/roles.js +1 -1
- package/server/services/search-migration.js +82 -0
- package/server/services/search.js +413 -0
- package/server/services/sidebar-migration.js +1 -0
- package/server/services/themeSettings.js +541 -0
- package/server/services/users.js +8 -0
- package/server/templates/page.html +4 -2
- package/plugins/contacts/data/contacts.json +0 -20
- package/plugins/notes/data/notes.json +0 -1
- package/plugins/site-search/admin/views/site-search.js +0 -116
- package/plugins/site-search/config.js +0 -15
- package/plugins/site-search/plugin.js +0 -188
- package/plugins/site-search/plugin.json +0 -40
- package/plugins/site-search/public/inject-body.html +0 -17
- package/plugins/site-search/public/inject-head.html +0 -1
- package/plugins/site-search/public/search.css +0 -1
- package/plugins/site-search/public/search.js +0 -1
- 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
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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
|
|
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
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
|
256
|
-
const
|
|
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
|
-
|
|
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:
|
|
958
|
+
bankDetails: buildPaymentBlock(issuer),
|
|
277
959
|
footerNote: escapeHtml(settings.footerNote || ''),
|
|
278
|
-
autoPrint:
|
|
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
|
}
|