domma-cms 0.66.0 → 0.69.4
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 +64 -1
- package/README.md +14 -10
- package/admin/css/admin.css +1 -1
- package/admin/index.html +1 -0
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +4 -4
- package/admin/js/lib/analytics-shape.js +1 -0
- package/admin/js/lib/contacts-arrange.js +1 -0
- package/admin/js/lib/help-popover.js +1 -0
- package/admin/js/lib/notes-arrange.js +1 -0
- package/admin/js/lib/plugin-accent.js +1 -1
- package/admin/js/lib/plugin-chrome.js +1 -1
- 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 +3 -3
- package/admin/js/lib/todo-arrange.js +1 -0
- package/admin/js/lib/tool-kit.js +1 -0
- package/admin/js/templates/analytics.html +138 -0
- package/admin/js/templates/contacts.html +156 -0
- package/admin/js/templates/notes.html +137 -0
- package/admin/js/templates/plugin-marketplace.html +17 -4
- package/admin/js/templates/plugins.html +13 -7
- package/admin/js/templates/theme.html +38 -0
- package/admin/js/templates/todo.html +113 -0
- package/admin/js/views/analytics.js +62 -0
- package/admin/js/views/contacts.js +174 -0
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/notes.js +104 -0
- package/admin/js/views/plugin-marketplace.js +1 -1
- package/admin/js/views/plugins.js +24 -22
- package/admin/js/views/theme.js +1 -1
- package/admin/js/views/todo.js +96 -0
- package/bin/cli.js +4 -10
- package/bin/lib/plugin-version.js +28 -0
- package/bin/update.js +14 -0
- package/config/menus/admin-sidebar.json +1 -1
- package/config/server.json +3 -1
- package/package.json +3 -7
- package/plugins/_lib/admin/mail/reader-view.js +20 -0
- package/plugins/_lib/mail/store.js +21 -1
- package/plugins/blog/CLAUDE.md +237 -0
- package/plugins/blog/admin/css/index.css +178 -1
- package/plugins/blog/admin/templates/blog.html +35 -53
- package/plugins/blog/admin/templates/categories.html +19 -6
- package/plugins/blog/admin/templates/comments.html +26 -9
- package/plugins/blog/admin/templates/post-editor.html +36 -13
- package/plugins/blog/admin/templates/settings.html +2 -11
- package/plugins/blog/admin/views/blog.js +288 -162
- package/plugins/blog/admin/views/categories.js +212 -201
- package/plugins/blog/admin/views/comments.js +207 -158
- package/plugins/blog/admin/views/kit.js +372 -0
- package/plugins/blog/admin/views/post-editor.js +269 -21
- package/plugins/blog/admin/views/settings-cog.js +56 -0
- package/plugins/blog/admin/views/settings.js +16 -86
- package/plugins/blog/blocks/blog-card-compact.css +17 -0
- package/plugins/blog/blocks/blog-card-compact.html +8 -0
- package/plugins/blog/blocks/blog-card-row.css +22 -0
- package/plugins/blog/blocks/blog-card-row.html +11 -0
- package/plugins/blog/blocks/blog-card.css +35 -0
- package/plugins/blog/blocks/blog-card.html +16 -0
- package/plugins/blog/blocks/blog-post-classic.css +15 -0
- package/plugins/blog/blocks/blog-post-classic.html +28 -0
- package/plugins/blog/blocks/blog-post-feature.css +20 -0
- package/plugins/blog/blocks/blog-post-feature.html +30 -0
- package/plugins/blog/blocks/blog-post-minimal.css +10 -0
- package/plugins/blog/blocks/blog-post-minimal.html +23 -0
- package/plugins/blog/blocks/blog-post-sidebar.css +22 -0
- package/plugins/blog/blocks/blog-post-sidebar.html +36 -0
- package/plugins/blog/collections/posts/schema.json +3 -1
- package/plugins/blog/config.js +6 -0
- package/plugins/blog/lib/layouts.js +304 -0
- package/plugins/blog/lib/page.js +158 -0
- package/plugins/blog/lib/render.js +119 -0
- package/plugins/blog/lib/samples.js +234 -0
- package/plugins/blog/plugin.js +225 -15
- package/plugins/blog/plugin.json +21 -81
- package/plugins/blog/plugin.public.js +115 -210
- package/plugins/blog/public/blog.css +177 -0
- package/plugins/blog/public/blog.js +586 -0
- package/plugins/blog/public/samples/aurora.svg +7 -0
- package/plugins/blog/public/samples/dusk.svg +7 -0
- package/plugins/blog/public/samples/ember.svg +7 -0
- package/plugins/blog/public/samples/harbour.svg +7 -0
- package/plugins/blog/public/samples/meadow.svg +7 -0
- package/plugins/blog/public/samples/rosewood.svg +7 -0
- package/plugins/blog/templates/index.html +9 -8
- package/plugins/blog/templates/post.html +4 -17
- package/plugins/blog/tests/layouts.test.js +97 -0
- package/plugins/blog/tests/public.test.js +71 -0
- package/plugins/free-tier.lock.json +74 -0
- package/plugins/mail-reader/CLAUDE.md +78 -0
- package/plugins/mail-reader/plugin.json +2 -1
- package/public/css/theme-switcher.css +1 -0
- package/public/js/analytics.js +1 -0
- package/public/js/theme-switcher.js +1 -0
- package/scripts/build.js +41 -2
- package/scripts/run-tests.mjs +94 -0
- package/{plugins/analytics/plugin.js → server/routes/api/analytics.js} +89 -41
- package/{plugins/contacts/plugin.js → server/routes/api/contacts.js} +71 -43
- package/server/routes/api/dashboard.js +8 -2
- package/{plugins/notes/plugin.js → server/routes/api/notes.js} +61 -17
- package/server/routes/api/plugin-marketplace.js +78 -23
- package/server/routes/api/plugins.js +148 -3
- package/server/routes/api/todo.js +178 -0
- package/server/server.js +61 -1
- package/server/services/collections.js +13 -3
- package/server/services/managerClient.js +72 -136
- package/server/services/markdown.js +54 -1
- package/server/services/permissionRegistry.js +50 -0
- package/server/services/pluginEntitlement.js +171 -0
- package/server/services/pluginEnvelope.js +242 -0
- package/server/services/pluginInstaller.js +246 -26
- package/server/services/pluginScaffold.js +5 -0
- package/server/services/plugins.js +117 -7
- package/server/services/presetCollections.js +344 -0
- package/server/services/promoted-plugins-migration.js +254 -0
- package/server/services/publicCors.js +46 -0
- package/server/services/renderer.js +83 -3
- package/server/services/sidebarBadges.js +37 -0
- package/server/services/siteGitignore.js +310 -0
- package/server/services/themeSettings.js +39 -0
- package/config/theme.json +0 -18
- package/plugins/analytics/admin/css/index.css +0 -1
- package/plugins/analytics/admin/templates/analytics.html +0 -70
- package/plugins/analytics/admin/views/analytics.js +0 -176
- package/plugins/analytics/config.js +0 -14
- package/plugins/analytics/plugin.json +0 -60
- package/plugins/analytics/public/inject-body.html +0 -60
- package/plugins/analytics/public/inject-head.html +0 -1
- package/plugins/blog/templates/author.html +0 -9
- package/plugins/blog/templates/category.html +0 -9
- package/plugins/blog/templates/tag.html +0 -9
- package/plugins/contacts/admin/templates/contacts.html +0 -254
- package/plugins/contacts/admin/views/contacts.js +0 -943
- package/plugins/contacts/collections/user-contact-groups/schema.json +0 -35
- package/plugins/contacts/collections/user-contacts/schema.json +0 -179
- package/plugins/contacts/config.js +0 -6
- package/plugins/contacts/plugin.json +0 -36
- package/plugins/demo-viewer/config.js +0 -4
- package/plugins/demo-viewer/plugin.js +0 -36
- package/plugins/demo-viewer/plugin.json +0 -9
- package/plugins/invoice/admin/css/index.css +0 -1
- package/plugins/invoice/admin/templates/editor.html +0 -220
- package/plugins/invoice/admin/templates/index.html +0 -173
- package/plugins/invoice/admin/templates/issuers.html +0 -2
- package/plugins/invoice/admin/templates/receivers.html +0 -2
- package/plugins/invoice/admin/views/contacts-source.js +0 -266
- package/plugins/invoice/admin/views/editor.js +0 -434
- package/plugins/invoice/admin/views/export.js +0 -199
- package/plugins/invoice/admin/views/help-content.js +0 -61
- package/plugins/invoice/admin/views/index.js +0 -643
- package/plugins/invoice/admin/views/issuers.js +0 -30
- package/plugins/invoice/admin/views/media.js +0 -172
- package/plugins/invoice/admin/views/party-view.js +0 -386
- package/plugins/invoice/admin/views/payments.js +0 -127
- package/plugins/invoice/admin/views/print.js +0 -130
- package/plugins/invoice/admin/views/receivers.js +0 -55
- package/plugins/invoice/admin/views/send.js +0 -212
- package/plugins/invoice/admin/views/settings.js +0 -594
- package/plugins/invoice/admin/views/view-lifecycle.js +0 -33
- package/plugins/invoice/collections/invoice-issuers/schema.json +0 -82
- package/plugins/invoice/collections/invoice-receivers/schema.json +0 -16
- package/plugins/invoice/collections/invoices/schema.json +0 -33
- package/plugins/invoice/config.js +0 -37
- package/plugins/invoice/pdf.js +0 -164
- package/plugins/invoice/plugin.js +0 -1456
- package/plugins/invoice/plugin.json +0 -86
- package/plugins/invoice/templates/_base.css +0 -1
- package/plugins/invoice/templates/classic-nologo.html +0 -100
- package/plugins/invoice/templates/classic.html +0 -91
- package/plugins/invoice/templates/invoice-print.html +0 -237
- package/plugins/invoice/templates/minimal.html +0 -99
- package/plugins/invoice/templates/modern-nologo.html +0 -114
- package/plugins/invoice/templates/modern.html +0 -113
- package/plugins/invoice/templates/templates.json +0 -11
- package/plugins/notes/admin/templates/notes.html +0 -83
- package/plugins/notes/admin/views/notes.js +0 -281
- package/plugins/notes/collections/user-notes/schema.json +0 -54
- package/plugins/notes/config.js +0 -6
- package/plugins/notes/plugin.json +0 -33
- package/plugins/surveys/admin/templates/audience.html +0 -47
- package/plugins/surveys/admin/templates/results.html +0 -58
- package/plugins/surveys/admin/templates/survey-editor.html +0 -141
- package/plugins/surveys/admin/templates/surveys.html +0 -25
- package/plugins/surveys/admin/views/audience.js +0 -301
- package/plugins/surveys/admin/views/results.js +0 -172
- package/plugins/surveys/admin/views/survey-editor.js +0 -211
- package/plugins/surveys/admin/views/surveys.js +0 -161
- package/plugins/surveys/collections/survey-contacts/schema.json +0 -13
- package/plugins/surveys/collections/survey-groups/schema.json +0 -11
- package/plugins/surveys/collections/survey-invites/schema.json +0 -16
- package/plugins/surveys/collections/surveys/schema.json +0 -23
- package/plugins/surveys/config.js +0 -8
- package/plugins/surveys/lib/audience.js +0 -37
- package/plugins/surveys/lib/campaigns.js +0 -43
- package/plugins/surveys/lib/ledger.js +0 -110
- package/plugins/surveys/lib/sending.js +0 -106
- package/plugins/surveys/lib/stats.js +0 -62
- package/plugins/surveys/lib/submit.js +0 -95
- package/plugins/surveys/lib/tokens.js +0 -28
- package/plugins/surveys/plugin.js +0 -174
- package/plugins/surveys/plugin.json +0 -65
- package/plugins/surveys/plugin.public.js +0 -149
- package/plugins/surveys/public/dist-shared.mjs +0 -1
- package/plugins/surveys/public/survey.css +0 -1
- package/plugins/surveys/public/survey.mjs +0 -1
- package/plugins/surveys/templates/survey-page.html +0 -63
- package/plugins/theme-switcher/admin/templates/theme-switcher.html +0 -86
- package/plugins/theme-switcher/admin/views/theme-switcher.js +0 -65
- package/plugins/theme-switcher/config.js +0 -10
- package/plugins/theme-switcher/plugin.js +0 -26
- package/plugins/theme-switcher/plugin.json +0 -74
- package/plugins/theme-switcher/public/inject-body.html +0 -153
- package/plugins/theme-switcher/public/inject-head.html +0 -260
- package/plugins/todo/admin/templates/todo.html +0 -158
- package/plugins/todo/admin/views/todo.js +0 -343
- package/plugins/todo/collections/todos/schema.json +0 -60
- package/plugins/todo/config.js +0 -7
- package/plugins/todo/plugin.js +0 -102
- package/plugins/todo/plugin.json +0 -33
- /package/{plugins/contacts/admin/css/index.css → admin/css/contacts.css} +0 -0
|
@@ -1,1456 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Invoice Plugin - Server
|
|
3
|
-
*
|
|
4
|
-
* Endpoints (mounted at /api/plugins/invoice):
|
|
5
|
-
* GET /issuers admin list issuers
|
|
6
|
-
* POST /issuers admin create issuer
|
|
7
|
-
* PUT /issuers/:id admin update issuer
|
|
8
|
-
* DELETE /issuers/:id admin delete issuer
|
|
9
|
-
*
|
|
10
|
-
* GET /receivers admin list receivers
|
|
11
|
-
* POST /receivers admin create receiver
|
|
12
|
-
* PUT /receivers/:id admin update receiver
|
|
13
|
-
* DELETE /receivers/:id admin delete receiver
|
|
14
|
-
*
|
|
15
|
-
* GET /invoices admin list invoices (with computed totals)
|
|
16
|
-
* GET /invoices/:id admin single invoice with computed totals
|
|
17
|
-
* POST /invoices admin create invoice (auto-numbers if number omitted)
|
|
18
|
-
* PUT /invoices/:id admin update invoice
|
|
19
|
-
* DELETE /invoices/:id admin delete invoice
|
|
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
|
|
32
|
-
*/
|
|
33
|
-
import path from 'path';
|
|
34
|
-
import {randomUUID} from 'crypto';
|
|
35
|
-
import fs from 'fs/promises';
|
|
36
|
-
import {fileURLToPath} from 'url';
|
|
37
|
-
|
|
38
|
-
import defaultConfig from './config.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';
|
|
45
|
-
|
|
46
|
-
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
47
|
-
|
|
48
|
-
const ISSUERS_SLUG = 'invoice-issuers';
|
|
49
|
-
const RECEIVERS_SLUG = 'invoice-receivers';
|
|
50
|
-
const INVOICES_SLUG = 'invoices';
|
|
51
|
-
|
|
52
|
-
function escapeHtml(str) {
|
|
53
|
-
return String(str ?? '')
|
|
54
|
-
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
55
|
-
.replace(/"/g, '"').replace(/'/g, ''');
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function flattenList(result) {
|
|
59
|
-
const arr = Array.isArray(result) ? result : (result?.entries || []);
|
|
60
|
-
return arr.map((e) => ({ id: e.id, ...(e.data ?? e) }));
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function toRecord(entry) {
|
|
64
|
-
return { id: entry.id, ...(entry.data ?? entry) };
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function round2(n) {
|
|
68
|
-
return Math.round((Number(n) + Number.EPSILON) * 100) / 100;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function computeTotals(invoice) {
|
|
72
|
-
const items = Array.isArray(invoice.lineItems) ? invoice.lineItems : [];
|
|
73
|
-
let subtotal = 0;
|
|
74
|
-
for (const li of items) {
|
|
75
|
-
const qty = Number(li.quantity) || 0;
|
|
76
|
-
const price = Number(li.unitPrice) || 0;
|
|
77
|
-
subtotal += qty * price;
|
|
78
|
-
}
|
|
79
|
-
subtotal = round2(subtotal);
|
|
80
|
-
const vatRate = invoice.vatEnabled ? (Number(invoice.vatRate) || 0) : 0;
|
|
81
|
-
const vat = round2(subtotal * (vatRate / 100));
|
|
82
|
-
const total = round2(subtotal + vat);
|
|
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);
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
function withTotals(invoice) {
|
|
188
|
-
return { ...invoice, totals: computeTotals(invoice), overdue: isOverdue(invoice) };
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
function formatMoney(amount, currency = 'GBP') {
|
|
192
|
-
try {
|
|
193
|
-
return new Intl.NumberFormat('en-GB', { style: 'currency', currency }).format(Number(amount) || 0);
|
|
194
|
-
} catch {
|
|
195
|
-
return `${currency} ${Number(amount).toFixed(2)}`;
|
|
196
|
-
}
|
|
197
|
-
}
|
|
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
|
-
|
|
227
|
-
function formatDate(iso) {
|
|
228
|
-
if (!iso) return '';
|
|
229
|
-
return new Date(iso).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' });
|
|
230
|
-
}
|
|
231
|
-
|
|
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) {
|
|
269
|
-
const all = flattenList(await listEntries(INVOICES_SLUG, { limit: 100000 }));
|
|
270
|
-
const re = new RegExp(`^${prefix}-${year}-(\\d+)$`);
|
|
271
|
-
let max = 0;
|
|
272
|
-
for (const inv of all) {
|
|
273
|
-
const m = re.exec(String(inv.number || ''));
|
|
274
|
-
if (m) {
|
|
275
|
-
const n = parseInt(m[1], 10);
|
|
276
|
-
if (n > max) max = n;
|
|
277
|
-
}
|
|
278
|
-
}
|
|
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')}`;
|
|
289
|
-
}
|
|
290
|
-
|
|
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.');
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
function fillTemplate(template, vars) {
|
|
362
|
-
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => {
|
|
363
|
-
const v = vars[key];
|
|
364
|
-
return v === undefined || v === null ? '' : String(v);
|
|
365
|
-
});
|
|
366
|
-
}
|
|
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
|
-
|
|
476
|
-
export default async function invoicePlugin(fastify, options) {
|
|
477
|
-
const { authenticate, requireAdmin } = options.auth;
|
|
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);
|
|
500
|
-
const adminGuard = { preHandler: [authenticate, requireAdmin] };
|
|
501
|
-
|
|
502
|
-
// Issuers
|
|
503
|
-
fastify.get('/issuers', adminGuard, async () => {
|
|
504
|
-
return flattenList(await listEntries(ISSUERS_SLUG, { limit: 1000 }));
|
|
505
|
-
});
|
|
506
|
-
|
|
507
|
-
fastify.post('/issuers', adminGuard, async (request, reply) => {
|
|
508
|
-
const body = request.body ?? {};
|
|
509
|
-
if (!body.name || typeof body.name !== 'string' || !body.name.trim()) {
|
|
510
|
-
return reply.code(400).send({ error: 'name is required' });
|
|
511
|
-
}
|
|
512
|
-
const entry = await withWriteLock(() => createEntry(ISSUERS_SLUG, body, { source: 'admin' }));
|
|
513
|
-
return reply.code(201).send(toRecord(entry));
|
|
514
|
-
});
|
|
515
|
-
|
|
516
|
-
fastify.put('/issuers/:id', adminGuard, async (request, reply) => {
|
|
517
|
-
const existing = await getEntry(ISSUERS_SLUG, request.params.id);
|
|
518
|
-
if (!existing) return reply.code(404).send({ error: 'Issuer not found' });
|
|
519
|
-
const updated = await withWriteLock(() => updateEntry(ISSUERS_SLUG, request.params.id, { ...existing.data, ...(request.body ?? {}) }));
|
|
520
|
-
return toRecord(updated);
|
|
521
|
-
});
|
|
522
|
-
|
|
523
|
-
fastify.delete('/issuers/:id', adminGuard, async (request, reply) => {
|
|
524
|
-
const existing = await getEntry(ISSUERS_SLUG, request.params.id);
|
|
525
|
-
if (!existing) return reply.code(404).send({ error: 'Issuer not found' });
|
|
526
|
-
await withWriteLock(() => deleteEntry(ISSUERS_SLUG, request.params.id));
|
|
527
|
-
return { ok: true };
|
|
528
|
-
});
|
|
529
|
-
|
|
530
|
-
// Receivers
|
|
531
|
-
fastify.get('/receivers', adminGuard, async () => {
|
|
532
|
-
return flattenList(await listEntries(RECEIVERS_SLUG, { limit: 1000 }));
|
|
533
|
-
});
|
|
534
|
-
|
|
535
|
-
fastify.post('/receivers', adminGuard, async (request, reply) => {
|
|
536
|
-
const body = request.body ?? {};
|
|
537
|
-
if (!body.name || typeof body.name !== 'string' || !body.name.trim()) {
|
|
538
|
-
return reply.code(400).send({ error: 'name is required' });
|
|
539
|
-
}
|
|
540
|
-
const entry = await withWriteLock(() => createEntry(RECEIVERS_SLUG, body, { source: 'admin' }));
|
|
541
|
-
return reply.code(201).send(toRecord(entry));
|
|
542
|
-
});
|
|
543
|
-
|
|
544
|
-
fastify.put('/receivers/:id', adminGuard, async (request, reply) => {
|
|
545
|
-
const existing = await getEntry(RECEIVERS_SLUG, request.params.id);
|
|
546
|
-
if (!existing) return reply.code(404).send({ error: 'Receiver not found' });
|
|
547
|
-
const updated = await withWriteLock(() => updateEntry(RECEIVERS_SLUG, request.params.id, { ...existing.data, ...(request.body ?? {}) }));
|
|
548
|
-
return toRecord(updated);
|
|
549
|
-
});
|
|
550
|
-
|
|
551
|
-
fastify.delete('/receivers/:id', adminGuard, async (request, reply) => {
|
|
552
|
-
const existing = await getEntry(RECEIVERS_SLUG, request.params.id);
|
|
553
|
-
if (!existing) return reply.code(404).send({ error: 'Receiver not found' });
|
|
554
|
-
await withWriteLock(() => deleteEntry(RECEIVERS_SLUG, request.params.id));
|
|
555
|
-
return { ok: true };
|
|
556
|
-
});
|
|
557
|
-
|
|
558
|
-
// Invoices
|
|
559
|
-
fastify.get('/invoices', adminGuard, async () => {
|
|
560
|
-
const list = flattenList(await listEntries(INVOICES_SLUG, { limit: 100000, sort: 'createdAt', order: 'desc' }));
|
|
561
|
-
return list.map(withTotals);
|
|
562
|
-
});
|
|
563
|
-
|
|
564
|
-
fastify.get('/invoices/:id', adminGuard, async (request, reply) => {
|
|
565
|
-
const entry = await getEntry(INVOICES_SLUG, request.params.id);
|
|
566
|
-
if (!entry) return reply.code(404).send({ error: 'Invoice not found' });
|
|
567
|
-
return withTotals(toRecord(entry));
|
|
568
|
-
});
|
|
569
|
-
|
|
570
|
-
fastify.post('/invoices', adminGuard, async (request, reply) => {
|
|
571
|
-
const settings = currentSettings();
|
|
572
|
-
const body = request.body ?? {};
|
|
573
|
-
if (!body.issuerId) return reply.code(400).send({ error: 'issuerId is required' });
|
|
574
|
-
if (!body.receiverId) return reply.code(400).send({ error: 'receiverId is required' });
|
|
575
|
-
|
|
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
|
-
});
|
|
603
|
-
|
|
604
|
-
return reply.code(201).send(withTotals(toRecord(entry)));
|
|
605
|
-
});
|
|
606
|
-
|
|
607
|
-
fastify.put('/invoices/:id', adminGuard, async (request, reply) => {
|
|
608
|
-
const settings = currentSettings();
|
|
609
|
-
const existing = await getEntry(INVOICES_SLUG, request.params.id);
|
|
610
|
-
if (!existing) return reply.code(404).send({ error: 'Invoice not found' });
|
|
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));
|
|
641
|
-
return withTotals(toRecord(updated));
|
|
642
|
-
});
|
|
643
|
-
|
|
644
|
-
fastify.delete('/invoices/:id', adminGuard, async (request, reply) => {
|
|
645
|
-
const existing = await getEntry(INVOICES_SLUG, request.params.id);
|
|
646
|
-
if (!existing) return reply.code(404).send({ error: 'Invoice not found' });
|
|
647
|
-
await withWriteLock(() => deleteEntry(INVOICES_SLUG, request.params.id));
|
|
648
|
-
return { ok: true };
|
|
649
|
-
});
|
|
650
|
-
|
|
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;
|
|
897
|
-
const invoice = withTotals(toRecord(entry));
|
|
898
|
-
|
|
899
|
-
const [issuerEntry, receiverEntry] = await Promise.all([
|
|
900
|
-
invoice.issuerId ? getEntry(ISSUERS_SLUG, invoice.issuerId) : null,
|
|
901
|
-
invoice.receiverId ? getEntry(RECEIVERS_SLUG, invoice.receiverId) : null
|
|
902
|
-
]);
|
|
903
|
-
const issuer = issuerEntry ? toRecord(issuerEntry) : {};
|
|
904
|
-
const receiver = receiverEntry ? toRecord(receiverEntry) : {};
|
|
905
|
-
|
|
906
|
-
const currency = invoice.currency || settings.defaultCurrency;
|
|
907
|
-
const itemsHtml = (invoice.lineItems || []).map((li) => {
|
|
908
|
-
const qty = Number(li.quantity) || 0;
|
|
909
|
-
const price = Number(li.unitPrice) || 0;
|
|
910
|
-
const lineTotal = round2(qty * price);
|
|
911
|
-
return `<tr>
|
|
912
|
-
<td>${escapeHtml(li.description || '')}</td>
|
|
913
|
-
<td class="num">${qty}</td>
|
|
914
|
-
<td class="num">${formatMoney(price, currency)}</td>
|
|
915
|
-
<td class="num">${formatMoney(lineTotal, currency)}</td>
|
|
916
|
-
</tr>`;
|
|
917
|
-
}).join('');
|
|
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
|
-
|
|
926
|
-
const vatRow = invoice.vatEnabled
|
|
927
|
-
? `<tr><th>VAT (${escapeHtml(String(invoice.totals.vatRate))}%)</th><td class="num">${formatMoney(invoice.totals.vat, currency)}</td></tr>`
|
|
928
|
-
: '';
|
|
929
|
-
|
|
930
|
-
const logoTag = logoFor(issuer, settings);
|
|
931
|
-
const template = await loadStyleTemplate(settings.template, Boolean(logoTag));
|
|
932
|
-
return fillTemplate(template, {
|
|
933
|
-
baseCss: await loadBaseCss(),
|
|
934
|
-
number: escapeHtml(invoice.number || ''),
|
|
935
|
-
issueDate: escapeHtml(formatDate(invoice.issueDate)),
|
|
936
|
-
dueDate: invoice.dueDate ? `<div><strong>Due:</strong> ${escapeHtml(formatDate(invoice.dueDate))}</div>` : '',
|
|
937
|
-
status: escapeHtml((invoice.status || 'draft').toUpperCase()),
|
|
938
|
-
issuerName: escapeHtml(issuer.name || ''),
|
|
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,
|
|
944
|
-
issuerAddress: escapeHtml(issuer.address || '').replace(/\n/g, '<br>'),
|
|
945
|
-
issuerEmail: escapeHtml(issuer.email || ''),
|
|
946
|
-
issuerPhone: escapeHtml(issuer.phone || ''),
|
|
947
|
-
issuerVat: issuer.vatNumber ? `<div>VAT: ${escapeHtml(issuer.vatNumber)}</div>` : '',
|
|
948
|
-
receiverName: escapeHtml(receiver.company || receiver.name || ''),
|
|
949
|
-
receiverAddress: escapeHtml(receiver.address || '').replace(/\n/g, '<br>'),
|
|
950
|
-
receiverEmail: escapeHtml(receiver.email || ''),
|
|
951
|
-
receiverVat: receiver.vatNumber ? `<div>VAT: ${escapeHtml(receiver.vatNumber)}</div>` : '',
|
|
952
|
-
items: itemsHtml || '<tr><td colspan="4" class="muted">No line items.</td></tr>',
|
|
953
|
-
subtotal: formatMoney(invoice.totals.subtotal, currency),
|
|
954
|
-
vatRow,
|
|
955
|
-
paidRows,
|
|
956
|
-
total: formatMoney(invoice.totals.total, currency),
|
|
957
|
-
notes: invoice.notes ? `<div class="notes"><strong>Notes</strong><br>${escapeHtml(invoice.notes).replace(/\n/g, '<br>')}</div>` : '',
|
|
958
|
-
bankDetails: buildPaymentBlock(issuer),
|
|
959
|
-
footerNote: escapeHtml(settings.footerNote || ''),
|
|
960
|
-
autoPrint: autoPrint ? '<script>window.addEventListener("load",()=>setTimeout(()=>window.print(),200));</script>' : ''
|
|
961
|
-
});
|
|
962
|
-
}
|
|
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');
|
|
969
|
-
reply.type('text/html').send(html);
|
|
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
|
-
});
|
|
1456
|
-
}
|