domma-cms 0.14.10 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/admin/js/lib/effect-defs.js +1 -0
  2. package/admin/js/lib/effects-builder.js +3 -0
  3. package/admin/js/lib/markdown-toolbar.js +17 -46
  4. package/admin/js/templates/effects.html +83 -0
  5. package/admin/js/views/page-editor.js +12 -12
  6. package/package.json +2 -2
  7. package/plugins/analytics/admin/templates/analytics.html +52 -1
  8. package/plugins/analytics/admin/views/analytics.js +157 -32
  9. package/plugins/analytics/config.js +10 -2
  10. package/plugins/analytics/plugin.js +214 -25
  11. package/plugins/analytics/plugin.json +9 -5
  12. package/plugins/analytics/public/inject-body.html +25 -7
  13. package/plugins/blog/admin/templates/blog.html +25 -2
  14. package/plugins/blog/admin/views/blog.js +72 -56
  15. package/plugins/blog/admin/views/post-editor.js +98 -79
  16. package/plugins/blog/plugin.js +133 -0
  17. package/plugins/blog/plugin.json +3 -3
  18. package/plugins/blog/templates/post.html +2 -1
  19. package/plugins/invoice/admin/templates/editor.html +129 -0
  20. package/plugins/invoice/admin/templates/index.html +43 -0
  21. package/plugins/invoice/admin/templates/issuers.html +5 -0
  22. package/plugins/invoice/admin/templates/receivers.html +5 -0
  23. package/plugins/invoice/admin/views/editor.js +267 -0
  24. package/plugins/invoice/admin/views/index.js +155 -0
  25. package/plugins/invoice/admin/views/issuers.js +23 -0
  26. package/plugins/invoice/admin/views/party-view.js +148 -0
  27. package/plugins/invoice/admin/views/receivers.js +22 -0
  28. package/plugins/invoice/collections/invoice-issuers/schema.json +16 -0
  29. package/plugins/invoice/collections/invoice-receivers/schema.json +15 -0
  30. package/plugins/invoice/collections/invoices/schema.json +27 -0
  31. package/plugins/invoice/config.js +16 -0
  32. package/plugins/invoice/plugin.js +283 -0
  33. package/plugins/invoice/plugin.json +85 -0
  34. package/plugins/invoice/templates/invoice-print.html +213 -0
  35. package/public/js/effects.js +1 -1
  36. package/server/services/markdown.js +114 -25
  37. package/server/services/renderer.js +9 -3
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Shared CRUD view for issuers and receivers.
3
+ * Renders a sortable table and an edit slideover driven by a fields[] config.
4
+ */
5
+ function escapeHtml(s) {
6
+ return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
7
+ }
8
+
9
+ export async function mountPartyView($container, config) {
10
+ let parties = [];
11
+ let table = null;
12
+
13
+ function buildTable(data) {
14
+ const el = $container.find('#party-table').get(0);
15
+ if (!el) return;
16
+ if (table) {
17
+ table.setData(data);
18
+ Domma.icons.scan(el);
19
+ return;
20
+ }
21
+
22
+ const cols = [
23
+ { key: 'name', title: 'Name', sortable: true,
24
+ render: (v, row) => `<a href="#" data-id="${escapeHtml(row.id)}" class="party-edit" style="font-weight:600;">${escapeHtml(v || '(unnamed)')}</a>`
25
+ }
26
+ ];
27
+ if (config.fields.some((f) => f.key === 'company')) {
28
+ cols.push({ key: 'company', title: 'Company', sortable: true, render: (v) => escapeHtml(v || '—') });
29
+ }
30
+ cols.push(
31
+ { key: 'email', title: 'Email', sortable: true, render: (v) => v ? `<a href="mailto:${escapeHtml(v)}">${escapeHtml(v)}</a>` : '—' },
32
+ { key: 'vatNumber', title: 'VAT', sortable: false, render: (v) => v ? `<code>${escapeHtml(v)}</code>` : '—' },
33
+ { key: 'id', title: 'Actions', sortable: false,
34
+ render: (id) => `<div style="display:flex;gap:.4rem;">
35
+ <button class="btn btn-sm btn-outline party-edit" data-id="${escapeHtml(id)}"><span data-icon="edit" data-icon-size="14"></span></button>
36
+ <button class="btn btn-sm btn-danger party-delete" data-id="${escapeHtml(id)}"><span data-icon="trash" data-icon-size="14"></span></button>
37
+ </div>` }
38
+ );
39
+
40
+ table = T.create(el, { data, columns: cols, emptyMessage: `No ${config.label.toLowerCase()}s yet.` });
41
+
42
+ el.addEventListener('click', async (e) => {
43
+ const editEl = e.target.closest('.party-edit');
44
+ const delEl = e.target.closest('.party-delete');
45
+ if (editEl) {
46
+ e.preventDefault();
47
+ const party = parties.find((p) => p.id === editEl.dataset.id);
48
+ openEditor(party);
49
+ }
50
+ if (delEl) {
51
+ const party = parties.find((p) => p.id === delEl.dataset.id);
52
+ const ok = await E.confirm(`Delete ${config.label.toLowerCase()} "${party?.name ?? ''}"?`);
53
+ if (!ok) return;
54
+ try {
55
+ await H.delete(`${config.endpoint}/${party.id}`);
56
+ E.toast('Deleted.', { type: 'success' });
57
+ await reload();
58
+ } catch {
59
+ E.toast('Delete failed.', { type: 'error' });
60
+ }
61
+ }
62
+ });
63
+
64
+ Domma.icons.scan(el);
65
+ }
66
+
67
+ async function reload() {
68
+ try {
69
+ const res = await H.get(config.endpoint);
70
+ parties = res.data ?? res ?? [];
71
+ } catch {
72
+ parties = [];
73
+ E.toast(`Could not load ${config.label.toLowerCase()}s.`, { type: 'error' });
74
+ }
75
+ buildTable(parties);
76
+ }
77
+
78
+ function openEditor(existing) {
79
+ const isEdit = Boolean(existing);
80
+ const form = document.createElement('div');
81
+ form.style.cssText = 'display:flex;flex-direction:column;gap:.75rem;padding:.5rem;';
82
+
83
+ const inputs = {};
84
+ for (const field of config.fields) {
85
+ const wrap = document.createElement('div');
86
+ const label = document.createElement('label');
87
+ label.className = 'form-label';
88
+ label.textContent = field.label + (field.required ? ' *' : '');
89
+ wrap.appendChild(label);
90
+
91
+ let input;
92
+ if (field.type === 'textarea') {
93
+ input = document.createElement('textarea');
94
+ input.rows = 3;
95
+ } else {
96
+ input = document.createElement('input');
97
+ input.type = 'text';
98
+ }
99
+ input.className = 'form-control';
100
+ input.value = existing?.[field.key] ?? '';
101
+ wrap.appendChild(input);
102
+ form.appendChild(wrap);
103
+ inputs[field.key] = input;
104
+ }
105
+
106
+ const saveBtn = document.createElement('button');
107
+ saveBtn.type = 'button';
108
+ saveBtn.className = 'btn btn-primary mt-2';
109
+ saveBtn.textContent = isEdit ? 'Save changes' : 'Create';
110
+ form.appendChild(saveBtn);
111
+
112
+ const slideover = E.slideover({
113
+ title: isEdit ? `Edit ${config.label}` : `New ${config.label}`,
114
+ size: 'md',
115
+ position: 'right',
116
+ content: form
117
+ });
118
+
119
+ saveBtn.addEventListener('click', async () => {
120
+ const body = {};
121
+ for (const field of config.fields) {
122
+ body[field.key] = inputs[field.key].value;
123
+ }
124
+ if (!body.name?.trim()) {
125
+ E.toast('Name is required.', { type: 'error' });
126
+ return;
127
+ }
128
+ try {
129
+ const res = isEdit
130
+ ? await H.put(`${config.endpoint}/${existing.id}`, body)
131
+ : await H.post(config.endpoint, body);
132
+ if (res.error) throw new Error(res.error);
133
+ slideover.close();
134
+ E.toast(isEdit ? 'Saved.' : 'Created.', { type: 'success' });
135
+ await reload();
136
+ } catch (err) {
137
+ E.toast(err?.message ?? 'Save failed.', { type: 'error' });
138
+ }
139
+ });
140
+
141
+ slideover.open();
142
+ }
143
+
144
+ $container.find('#btn-new').on('click', () => openEditor(null));
145
+
146
+ await reload();
147
+ Domma.icons.scan();
148
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Invoice plugin — receivers CRUD view.
3
+ */
4
+ import {mountPartyView} from './party-view.js?v=1';
5
+
6
+ export const receiversView = {
7
+ templateUrl: '/plugins/invoice/admin/templates/receivers.html',
8
+ onMount: ($container) => mountPartyView($container, {
9
+ kind: 'receiver',
10
+ endpoint: '/api/plugins/invoice/receivers',
11
+ label: 'Receiver',
12
+ fields: [
13
+ { key: 'name', label: 'Contact Name', required: true, type: 'text' },
14
+ { key: 'company', label: 'Company', type: 'text' },
15
+ { key: 'email', label: 'Email', type: 'text' },
16
+ { key: 'phone', label: 'Phone', type: 'text' },
17
+ { key: 'vatNumber', label: 'VAT Number', type: 'text' },
18
+ { key: 'reference', label: 'Reference', type: 'text' },
19
+ { key: 'address', label: 'Address', type: 'textarea' }
20
+ ]
21
+ })
22
+ };
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "invoice-issuers",
3
+ "label": "Invoice Issuers",
4
+ "slug": "invoice-issuers",
5
+ "storage": { "adapter": "file" },
6
+ "fields": [
7
+ { "key": "name", "label": "Trading Name", "type": "text", "required": true },
8
+ { "key": "email", "label": "Email", "type": "text" },
9
+ { "key": "phone", "label": "Phone", "type": "text" },
10
+ { "key": "website", "label": "Website", "type": "text" },
11
+ { "key": "address", "label": "Address", "type": "textarea" },
12
+ { "key": "vatNumber", "label": "VAT Number", "type": "text" },
13
+ { "key": "bankDetails", "label": "Bank Details", "type": "textarea" },
14
+ { "key": "logoUrl", "label": "Logo URL", "type": "text" }
15
+ ]
16
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "invoice-receivers",
3
+ "label": "Invoice Receivers",
4
+ "slug": "invoice-receivers",
5
+ "storage": { "adapter": "file" },
6
+ "fields": [
7
+ { "key": "name", "label": "Name", "type": "text", "required": true },
8
+ { "key": "company", "label": "Company", "type": "text" },
9
+ { "key": "email", "label": "Email", "type": "text" },
10
+ { "key": "phone", "label": "Phone", "type": "text" },
11
+ { "key": "address", "label": "Address", "type": "textarea" },
12
+ { "key": "vatNumber", "label": "VAT Number", "type": "text" },
13
+ { "key": "reference", "label": "Reference", "type": "text" }
14
+ ]
15
+ }
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "invoices",
3
+ "label": "Invoices",
4
+ "slug": "invoices",
5
+ "storage": { "adapter": "file" },
6
+ "fields": [
7
+ { "key": "number", "label": "Number", "type": "text", "required": true },
8
+ { "key": "issuerId", "label": "Issuer", "type": "text", "required": true },
9
+ { "key": "receiverId", "label": "Receiver", "type": "text", "required": true },
10
+ { "key": "issueDate", "label": "Issue Date", "type": "date", "required": true },
11
+ { "key": "dueDate", "label": "Due Date", "type": "date" },
12
+ { "key": "status", "label": "Status", "type": "select", "required": true,
13
+ "options": [
14
+ { "value": "draft", "label": "Draft" },
15
+ { "value": "sent", "label": "Sent" },
16
+ { "value": "paid", "label": "Paid" },
17
+ { "value": "overdue", "label": "Overdue" },
18
+ { "value": "cancelled", "label": "Cancelled" }
19
+ ]
20
+ },
21
+ { "key": "currency", "label": "Currency", "type": "text" },
22
+ { "key": "vatEnabled", "label": "VAT Enabled", "type": "boolean" },
23
+ { "key": "vatRate", "label": "VAT Rate %", "type": "number" },
24
+ { "key": "lineItems", "label": "Line Items", "type": "json" },
25
+ { "key": "notes", "label": "Notes", "type": "textarea" }
26
+ ]
27
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Invoice Plugin — Default Configuration
3
+ *
4
+ * numberPrefix used in generated invoice numbers (e.g. INV-2026-0001)
5
+ * numberPadding minimum digits for the sequential portion
6
+ * defaultVatRate applied when VAT is enabled and no rate is set on the invoice
7
+ * defaultCurrency ISO 4217 code; controls formatting + symbol
8
+ * footerNote optional small print appended to every printed invoice
9
+ */
10
+ export default {
11
+ numberPrefix: 'INV',
12
+ numberPadding: 4,
13
+ defaultVatRate: 20,
14
+ defaultCurrency: 'GBP',
15
+ footerNote: 'Thank you for your business.'
16
+ };
@@ -0,0 +1,283 @@
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
+ * GET /invoices/:id/print admin printable HTML (browser to PDF)
21
+ */
22
+ import path from 'path';
23
+ import fs from 'fs/promises';
24
+ import {fileURLToPath} from 'url';
25
+
26
+ import defaultConfig from './config.js';
27
+ import {createEntry, deleteEntry, getEntry, listEntries, updateEntry} from '../../server/services/collections.js';
28
+
29
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
30
+ const TEMPLATE_PATH = path.join(__dirname, 'templates', 'invoice-print.html');
31
+
32
+ const ISSUERS_SLUG = 'invoice-issuers';
33
+ const RECEIVERS_SLUG = 'invoice-receivers';
34
+ const INVOICES_SLUG = 'invoices';
35
+
36
+ function escapeHtml(str) {
37
+ return String(str ?? '')
38
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
39
+ .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
40
+ }
41
+
42
+ function flattenList(result) {
43
+ const arr = Array.isArray(result) ? result : (result?.entries || []);
44
+ return arr.map((e) => ({ id: e.id, ...(e.data ?? e) }));
45
+ }
46
+
47
+ function toRecord(entry) {
48
+ return { id: entry.id, ...(entry.data ?? entry) };
49
+ }
50
+
51
+ function round2(n) {
52
+ return Math.round((Number(n) + Number.EPSILON) * 100) / 100;
53
+ }
54
+
55
+ function computeTotals(invoice) {
56
+ const items = Array.isArray(invoice.lineItems) ? invoice.lineItems : [];
57
+ let subtotal = 0;
58
+ for (const li of items) {
59
+ const qty = Number(li.quantity) || 0;
60
+ const price = Number(li.unitPrice) || 0;
61
+ subtotal += qty * price;
62
+ }
63
+ subtotal = round2(subtotal);
64
+ const vatRate = invoice.vatEnabled ? (Number(invoice.vatRate) || 0) : 0;
65
+ const vat = round2(subtotal * (vatRate / 100));
66
+ const total = round2(subtotal + vat);
67
+ return { subtotal, vat, total, vatRate };
68
+ }
69
+
70
+ function withTotals(invoice) {
71
+ return { ...invoice, totals: computeTotals(invoice) };
72
+ }
73
+
74
+ function formatMoney(amount, currency = 'GBP') {
75
+ try {
76
+ return new Intl.NumberFormat('en-GB', { style: 'currency', currency }).format(Number(amount) || 0);
77
+ } catch {
78
+ return `${currency} ${Number(amount).toFixed(2)}`;
79
+ }
80
+ }
81
+
82
+ function formatDate(iso) {
83
+ if (!iso) return '';
84
+ return new Date(iso).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' });
85
+ }
86
+
87
+ async function nextInvoiceNumber(prefix, padding) {
88
+ const year = new Date().getFullYear();
89
+ const all = flattenList(await listEntries(INVOICES_SLUG, { limit: 100000 }));
90
+ const re = new RegExp(`^${prefix}-${year}-(\\d+)$`);
91
+ let max = 0;
92
+ for (const inv of all) {
93
+ const m = re.exec(String(inv.number || ''));
94
+ if (m) {
95
+ const n = parseInt(m[1], 10);
96
+ if (n > max) max = n;
97
+ }
98
+ }
99
+ return `${prefix}-${year}-${String(max + 1).padStart(padding, '0')}`;
100
+ }
101
+
102
+ async function loadPrintTemplate() {
103
+ return fs.readFile(TEMPLATE_PATH, 'utf8');
104
+ }
105
+
106
+ function fillTemplate(template, vars) {
107
+ return template.replace(/\{\{(\w+)\}\}/g, (_, key) => {
108
+ const v = vars[key];
109
+ return v === undefined || v === null ? '' : String(v);
110
+ });
111
+ }
112
+
113
+ export default async function invoicePlugin(fastify, options) {
114
+ const { authenticate, requireAdmin } = options.auth;
115
+ const settings = { ...defaultConfig, ...(options.settings || {}) };
116
+ const adminGuard = { preHandler: [authenticate, requireAdmin] };
117
+
118
+ // Issuers
119
+ fastify.get('/issuers', adminGuard, async () => {
120
+ return flattenList(await listEntries(ISSUERS_SLUG, { limit: 1000 }));
121
+ });
122
+
123
+ fastify.post('/issuers', adminGuard, async (request, reply) => {
124
+ const body = request.body ?? {};
125
+ if (!body.name || typeof body.name !== 'string' || !body.name.trim()) {
126
+ return reply.code(400).send({ error: 'name is required' });
127
+ }
128
+ const entry = await createEntry(ISSUERS_SLUG, body, { source: 'admin' });
129
+ return reply.code(201).send(toRecord(entry));
130
+ });
131
+
132
+ fastify.put('/issuers/:id', adminGuard, async (request, reply) => {
133
+ const existing = await getEntry(ISSUERS_SLUG, request.params.id);
134
+ 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 ?? {}) });
136
+ return toRecord(updated);
137
+ });
138
+
139
+ fastify.delete('/issuers/:id', adminGuard, async (request, reply) => {
140
+ const existing = await getEntry(ISSUERS_SLUG, request.params.id);
141
+ if (!existing) return reply.code(404).send({ error: 'Issuer not found' });
142
+ await deleteEntry(ISSUERS_SLUG, request.params.id);
143
+ return { ok: true };
144
+ });
145
+
146
+ // Receivers
147
+ fastify.get('/receivers', adminGuard, async () => {
148
+ return flattenList(await listEntries(RECEIVERS_SLUG, { limit: 1000 }));
149
+ });
150
+
151
+ fastify.post('/receivers', adminGuard, async (request, reply) => {
152
+ const body = request.body ?? {};
153
+ if (!body.name || typeof body.name !== 'string' || !body.name.trim()) {
154
+ return reply.code(400).send({ error: 'name is required' });
155
+ }
156
+ const entry = await createEntry(RECEIVERS_SLUG, body, { source: 'admin' });
157
+ return reply.code(201).send(toRecord(entry));
158
+ });
159
+
160
+ fastify.put('/receivers/:id', adminGuard, async (request, reply) => {
161
+ const existing = await getEntry(RECEIVERS_SLUG, request.params.id);
162
+ 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 ?? {}) });
164
+ return toRecord(updated);
165
+ });
166
+
167
+ fastify.delete('/receivers/:id', adminGuard, async (request, reply) => {
168
+ const existing = await getEntry(RECEIVERS_SLUG, request.params.id);
169
+ if (!existing) return reply.code(404).send({ error: 'Receiver not found' });
170
+ await deleteEntry(RECEIVERS_SLUG, request.params.id);
171
+ return { ok: true };
172
+ });
173
+
174
+ // Invoices
175
+ fastify.get('/invoices', adminGuard, async () => {
176
+ const list = flattenList(await listEntries(INVOICES_SLUG, { limit: 100000, sort: 'createdAt', order: 'desc' }));
177
+ return list.map(withTotals);
178
+ });
179
+
180
+ fastify.get('/invoices/:id', adminGuard, async (request, reply) => {
181
+ const entry = await getEntry(INVOICES_SLUG, request.params.id);
182
+ if (!entry) return reply.code(404).send({ error: 'Invoice not found' });
183
+ return withTotals(toRecord(entry));
184
+ });
185
+
186
+ fastify.post('/invoices', adminGuard, async (request, reply) => {
187
+ const body = request.body ?? {};
188
+ if (!body.issuerId) return reply.code(400).send({ error: 'issuerId is required' });
189
+ if (!body.receiverId) return reply.code(400).send({ error: 'receiverId is required' });
190
+
191
+ const data = {
192
+ number: body.number || await nextInvoiceNumber(settings.numberPrefix, settings.numberPadding),
193
+ issuerId: String(body.issuerId),
194
+ receiverId: String(body.receiverId),
195
+ issueDate: body.issueDate || new Date().toISOString().slice(0, 10),
196
+ dueDate: body.dueDate || '',
197
+ status: body.status || 'draft',
198
+ currency: body.currency || settings.defaultCurrency,
199
+ vatEnabled: body.vatEnabled !== undefined ? Boolean(body.vatEnabled) : true,
200
+ vatRate: body.vatRate !== undefined ? Number(body.vatRate) : settings.defaultVatRate,
201
+ lineItems: Array.isArray(body.lineItems) ? body.lineItems : [],
202
+ notes: body.notes ?? ''
203
+ };
204
+
205
+ const entry = await createEntry(INVOICES_SLUG, data, { source: 'admin' });
206
+ return reply.code(201).send(withTotals(toRecord(entry)));
207
+ });
208
+
209
+ fastify.put('/invoices/:id', adminGuard, async (request, reply) => {
210
+ const existing = await getEntry(INVOICES_SLUG, request.params.id);
211
+ if (!existing) return reply.code(404).send({ error: 'Invoice not found' });
212
+ const merged = { ...existing.data, ...(request.body ?? {}) };
213
+ if (Array.isArray(request.body?.lineItems)) merged.lineItems = request.body.lineItems;
214
+ const updated = await updateEntry(INVOICES_SLUG, request.params.id, merged);
215
+ return withTotals(toRecord(updated));
216
+ });
217
+
218
+ fastify.delete('/invoices/:id', adminGuard, async (request, reply) => {
219
+ const existing = await getEntry(INVOICES_SLUG, request.params.id);
220
+ if (!existing) return reply.code(404).send({ error: 'Invoice not found' });
221
+ await deleteEntry(INVOICES_SLUG, request.params.id);
222
+ return { ok: true };
223
+ });
224
+
225
+ // Print view (HTML; user prints to PDF from browser)
226
+ fastify.get('/invoices/:id/print', adminGuard, async (request, reply) => {
227
+ const entry = await getEntry(INVOICES_SLUG, request.params.id);
228
+ if (!entry) return reply.code(404).send('Invoice not found');
229
+ const invoice = withTotals(toRecord(entry));
230
+
231
+ const [issuerEntry, receiverEntry] = await Promise.all([
232
+ invoice.issuerId ? getEntry(ISSUERS_SLUG, invoice.issuerId) : null,
233
+ invoice.receiverId ? getEntry(RECEIVERS_SLUG, invoice.receiverId) : null
234
+ ]);
235
+ const issuer = issuerEntry ? toRecord(issuerEntry) : {};
236
+ const receiver = receiverEntry ? toRecord(receiverEntry) : {};
237
+
238
+ const currency = invoice.currency || settings.defaultCurrency;
239
+ const itemsHtml = (invoice.lineItems || []).map((li) => {
240
+ const qty = Number(li.quantity) || 0;
241
+ const price = Number(li.unitPrice) || 0;
242
+ const lineTotal = round2(qty * price);
243
+ return `<tr>
244
+ <td>${escapeHtml(li.description || '')}</td>
245
+ <td class="num">${qty}</td>
246
+ <td class="num">${formatMoney(price, currency)}</td>
247
+ <td class="num">${formatMoney(lineTotal, currency)}</td>
248
+ </tr>`;
249
+ }).join('');
250
+
251
+ const vatRow = invoice.vatEnabled
252
+ ? `<tr><th>VAT (${escapeHtml(String(invoice.totals.vatRate))}%)</th><td class="num">${formatMoney(invoice.totals.vat, currency)}</td></tr>`
253
+ : '';
254
+
255
+ const template = await loadPrintTemplate();
256
+ const html = fillTemplate(template, {
257
+ number: escapeHtml(invoice.number || ''),
258
+ issueDate: escapeHtml(formatDate(invoice.issueDate)),
259
+ dueDate: invoice.dueDate ? `<div><strong>Due:</strong> ${escapeHtml(formatDate(invoice.dueDate))}</div>` : '',
260
+ status: escapeHtml((invoice.status || 'draft').toUpperCase()),
261
+ issuerName: escapeHtml(issuer.name || ''),
262
+ issuerLogo: issuer.logoUrl ? `<img src="${escapeHtml(issuer.logoUrl)}" alt="${escapeHtml(issuer.name || '')}" class="logo">` : '',
263
+ issuerAddress: escapeHtml(issuer.address || '').replace(/\n/g, '<br>'),
264
+ issuerEmail: escapeHtml(issuer.email || ''),
265
+ issuerPhone: escapeHtml(issuer.phone || ''),
266
+ issuerVat: issuer.vatNumber ? `<div>VAT: ${escapeHtml(issuer.vatNumber)}</div>` : '',
267
+ receiverName: escapeHtml(receiver.company || receiver.name || ''),
268
+ receiverAddress: escapeHtml(receiver.address || '').replace(/\n/g, '<br>'),
269
+ receiverEmail: escapeHtml(receiver.email || ''),
270
+ receiverVat: receiver.vatNumber ? `<div>VAT: ${escapeHtml(receiver.vatNumber)}</div>` : '',
271
+ items: itemsHtml || '<tr><td colspan="4" class="muted">No line items.</td></tr>',
272
+ subtotal: formatMoney(invoice.totals.subtotal, currency),
273
+ vatRow,
274
+ total: formatMoney(invoice.totals.total, currency),
275
+ notes: invoice.notes ? `<div class="notes"><strong>Notes</strong><br>${escapeHtml(invoice.notes).replace(/\n/g, '<br>')}</div>` : '',
276
+ bankDetails: issuer.bankDetails ? `<div class="bank"><strong>Payment</strong><br>${escapeHtml(issuer.bankDetails).replace(/\n/g, '<br>')}</div>` : '',
277
+ footerNote: escapeHtml(settings.footerNote || ''),
278
+ autoPrint: request.query.print === '1' ? '<script>window.addEventListener("load",()=>setTimeout(()=>window.print(),200));</script>' : ''
279
+ });
280
+
281
+ reply.type('text/html').send(html);
282
+ });
283
+ }
@@ -0,0 +1,85 @@
1
+ {
2
+ "name": "invoice",
3
+ "displayName": "Invoices",
4
+ "version": "1.0.0",
5
+ "description": "Lightweight invoice generator. Manage issuers, receivers, line items, optional VAT, and produce a print-ready invoice you can save as PDF from the browser.",
6
+ "author": "Darryl Waterhouse",
7
+ "date": "2026-05-04",
8
+ "icon": "file-text",
9
+ "admin": {
10
+ "css": [
11
+ "admin/css/index.css"
12
+ ],
13
+ "sidebar": [
14
+ {
15
+ "id": "invoice",
16
+ "text": "Invoices",
17
+ "icon": "file-text",
18
+ "url": "#/plugins/invoice",
19
+ "section": "#/plugins/invoice",
20
+ "children": [
21
+ {
22
+ "id": "invoice-list",
23
+ "text": "Invoices",
24
+ "url": "#/plugins/invoice"
25
+ },
26
+ {
27
+ "id": "invoice-issuers",
28
+ "text": "Issuers",
29
+ "url": "#/plugins/invoice/issuers"
30
+ },
31
+ {
32
+ "id": "invoice-receivers",
33
+ "text": "Receivers",
34
+ "url": "#/plugins/invoice/receivers"
35
+ }
36
+ ]
37
+ }
38
+ ],
39
+ "routes": [
40
+ {
41
+ "path": "/plugins/invoice",
42
+ "view": "plugin-invoice-list",
43
+ "title": "Invoices - Domma CMS"
44
+ },
45
+ {
46
+ "path": "/plugins/invoice/new",
47
+ "view": "plugin-invoice-editor",
48
+ "title": "New Invoice - Domma CMS"
49
+ },
50
+ {
51
+ "path": "/plugins/invoice/edit/:id",
52
+ "view": "plugin-invoice-editor",
53
+ "title": "Edit Invoice - Domma CMS"
54
+ },
55
+ {
56
+ "path": "/plugins/invoice/issuers",
57
+ "view": "plugin-invoice-issuers",
58
+ "title": "Issuers - Domma CMS"
59
+ },
60
+ {
61
+ "path": "/plugins/invoice/receivers",
62
+ "view": "plugin-invoice-receivers",
63
+ "title": "Receivers - Domma CMS"
64
+ }
65
+ ],
66
+ "views": {
67
+ "plugin-invoice-list": {
68
+ "entry": "invoice/admin/views/index.js?v=a7963ce7",
69
+ "exportName": "indexView"
70
+ },
71
+ "plugin-invoice-editor": {
72
+ "entry": "invoice/admin/views/editor.js?v=897f0259",
73
+ "exportName": "editorView"
74
+ },
75
+ "plugin-invoice-issuers": {
76
+ "entry": "invoice/admin/views/issuers.js?v=ce720b84",
77
+ "exportName": "issuersView"
78
+ },
79
+ "plugin-invoice-receivers": {
80
+ "entry": "invoice/admin/views/receivers.js?v=c4b34b26",
81
+ "exportName": "receiversView"
82
+ }
83
+ }
84
+ }
85
+ }