@arcforgelabs/flow 2026.10.25 → 2026.10.27

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 (60) hide show
  1. package/README.md +6 -0
  2. package/npm-shrinkwrap.json +627 -2
  3. package/package.json +3 -1
  4. package/server/api/invoicing.js +586 -0
  5. package/server/app.js +38 -1
  6. package/server/auth/people.js +26 -0
  7. package/server/auth/routes.js +13 -0
  8. package/server/core/automations.js +140 -0
  9. package/server/core/invoice-rules.js +224 -0
  10. package/server/core/invoices.js +11 -1
  11. package/server/core/invoicing-rules.js +255 -0
  12. package/server/core/ledger.js +50 -14
  13. package/server/core/links.js +3 -3
  14. package/server/core/monitoring.js +34 -0
  15. package/server/core/pipeline.js +495 -0
  16. package/server/core/sending.js +282 -0
  17. package/server/db/migrations/0008_people_appearance.sql +1 -0
  18. package/server/db/migrations/0009_pipeline_matcher.sql +46 -0
  19. package/server/db/migrations/0010_invoicing_rules.sql +51 -0
  20. package/server/db/migrations/0011_invoice_rules.sql +21 -0
  21. package/server/db/migrations/0012_invoice_sending.sql +9 -0
  22. package/server/db/migrations/meta/0008_snapshot.json +2479 -0
  23. package/server/db/migrations/meta/0009_snapshot.json +2809 -0
  24. package/server/db/migrations/meta/0010_snapshot.json +3158 -0
  25. package/server/db/migrations/meta/0011_snapshot.json +3314 -0
  26. package/server/db/migrations/meta/0012_snapshot.json +3380 -0
  27. package/server/db/migrations/meta/_journal.json +35 -0
  28. package/server/db/schema.js +186 -2
  29. package/server/index.js +20 -3
  30. package/server/integrations/adapters.js +2 -1
  31. package/server/integrations/port.js +8 -0
  32. package/server/integrations/seazona/feed.js +11 -0
  33. package/server/integrations/seazona/index.js +7 -1
  34. package/server/integrations/seazona/ledger.js +4 -0
  35. package/server/integrations/sentry/index.js +105 -0
  36. package/server/integrations/xero/client.js +5 -0
  37. package/server/integrations/xero/drafts.js +123 -16
  38. package/server/integrations/xero/import.js +22 -0
  39. package/server/integrations/xero/index.js +21 -3
  40. package/server/integrations/xero/lookup.js +94 -0
  41. package/server/integrations/xero/migrations/0005_xero_send_modes.sql +10 -0
  42. package/server/integrations/xero/migrations/meta/_journal.json +7 -0
  43. package/server/theme/color-contrast.js +39 -2
  44. package/server/theme/contrast-audit.js +63 -0
  45. package/server/theme/themes.js +10 -2
  46. package/server/ui/app-page.js +6 -10
  47. package/server/ui/app.js +364 -105
  48. package/server/ui/appearance.js +3 -2
  49. package/server/ui/automations.js +96 -0
  50. package/server/ui/clerk-appearance.js +18 -2
  51. package/server/ui/flow.css +334 -22
  52. package/server/ui/invoice-rules.js +237 -0
  53. package/server/ui/invoicing-settings.js +108 -0
  54. package/server/ui/invoicing.js +597 -0
  55. package/server/ui/jobs.js +172 -0
  56. package/server/ui/logos/seazona.svg +1 -0
  57. package/server/ui/logos/sentry.svg +1 -0
  58. package/server/ui/logos/xero.svg +1 -0
  59. package/server/ui/monitoring.js +53 -0
  60. package/server/ui/routes.js +5 -1
@@ -0,0 +1,597 @@
1
+ // Invoices: the list (/invoices) and one invoice (/invoices/new,
2
+ // /invoices/<id>). A draft is edited in place; issuing gives it its number
3
+ // and keeps the document exactly as sent. Money is whole cents on the wire
4
+ // and dollars on screen. The server works out every total; the totals here
5
+ // are a preview with the same rule (GST 10% per line, rounded half away from
6
+ // zero, server/core/money.js). Data comes from /api/invoicing
7
+ // (server/api/invoicing.js). app.js passes in its shared pieces as `ui`.
8
+
9
+ const STATUS_LABEL = { draft: 'Draft', issued: 'Issued', void: 'Void' };
10
+ const STATUS_TONE = { draft: 'info', issued: 'success', void: 'mute' };
11
+ // Drafts (to check), Problem and Ready are views (server/core/sending.js):
12
+ // every draft is in one of them. A person marks a checked draft ready; any
13
+ // later change sends it back. An issued invoice that failed to send is a
14
+ // problem.
15
+ const TABS = [['to_check', 'Drafts'], ['problem', 'Problem'], ['ready', 'Ready'], ['issued', 'Issued'], ['void', 'Void']];
16
+ const VIEW_LABEL = { problem: 'Problem', ready: 'Ready', sending: 'Sending', to_check: 'Draft' };
17
+ const VIEW_TONE = { problem: 'danger', ready: 'success', sending: 'info', to_check: 'info' };
18
+ const EMPTY = {
19
+ to_check: 'No drafts to check. Press New invoice to start one.',
20
+ problem: 'No problems.',
21
+ ready: 'Nothing is ready. Tick drafts and press Mark ready.',
22
+ };
23
+ // Tabs whose rows can be ticked, and what the ticked rows can do.
24
+ const TICKS = { to_check: true, ready: true };
25
+
26
+ const money = new Intl.NumberFormat('en-AU', { style: 'currency', currency: 'AUD' });
27
+ export const formatCents = (cents) => money.format((cents ?? 0) / 100);
28
+
29
+ function gstCents(lineAmountCents) {
30
+ const sign = Math.sign(lineAmountCents);
31
+ const abs = Math.abs(lineAmountCents) * 1000;
32
+ return sign * Math.floor((abs * 2 + 10_000) / 20_000);
33
+ }
34
+
35
+ // Dollars typed by a person, to whole cents; null when it is not a number.
36
+ function toCents(text) {
37
+ const clean = String(text ?? '').replace(/[$,\s]/g, '');
38
+ if (!/^-?\d+(\.\d{0,2})?$/.test(clean)) return null;
39
+ return Math.round(Number(clean) * 100);
40
+ }
41
+
42
+ const fromCents = (cents) => (cents == null ? '' : (cents / 100).toFixed(2));
43
+
44
+ function localDate(date = new Date()) {
45
+ const pad = (n) => String(n).padStart(2, '0');
46
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
47
+ }
48
+
49
+ const showDate = (iso) => (iso ? new Date(`${iso}T00:00:00`).toLocaleDateString('en-AU', { day: 'numeric', month: 'short', year: 'numeric' }) : '—');
50
+
51
+ // ── The list ───────────────────────────────────────────────────────────────
52
+
53
+ export async function renderInvoiceList(main, ui) {
54
+ const { h, flow, pageHeader, notice, go, toast } = ui;
55
+ const saved = sessionStorage.getItem('flow_invoice_tab');
56
+ const state = { status: TABS.some(([id]) => id === saved) ? saved : 'to_check', q: '', ticked: new Set() };
57
+ const tabs = h('div', { class: 'fx-tabs', role: 'tablist' });
58
+ const search = h('input', { class: 'fx-input fx-search', type: 'search', placeholder: 'Find a number, client or reference', 'aria-label': 'Find an invoice' });
59
+ const bar = h('div', { class: 'fx-sendbar', hidden: true });
60
+ const host = h('div', { class: 'fx-panel' }, h('div', { class: 'fx-skeleton' }));
61
+ const context = await flow('/api/invoicing/context').catch(() => ({ canIssue: false }));
62
+ let body = null;
63
+ main.append(
64
+ pageHeader('Invoices', {
65
+ actions: [h('a', { class: 'fx-btn fx-btn--primary', href: '/invoices/new', onclick: go }, 'New invoice')],
66
+ }),
67
+ h('div', { class: 'fx-toolbar' }, tabs, search),
68
+ bar,
69
+ host);
70
+
71
+ let timer;
72
+ search.addEventListener('input', () => {
73
+ clearTimeout(timer);
74
+ timer = setTimeout(() => { state.q = search.value.trim(); load(); }, 200);
75
+ });
76
+
77
+ const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
78
+ const action = (label, path, payload, done, tone = 'primary') => {
79
+ const el = h('button', {
80
+ class: `fx-btn fx-btn--${tone}`, type: 'button',
81
+ onclick: async () => {
82
+ el.disabled = true;
83
+ try {
84
+ const res = await flow(`/api/invoicing/invoices/${path}`, { method: 'POST', body: JSON.stringify(payload()) });
85
+ toast(...done(res));
86
+ state.ticked.clear();
87
+ await load();
88
+ } catch (error) {
89
+ toast(error.message, 'danger');
90
+ el.disabled = false;
91
+ }
92
+ },
93
+ }, label);
94
+ return el;
95
+ };
96
+
97
+ // The bar above the list: what a person can do with this view.
98
+ function drawBar() {
99
+ const to = body.sendsTo;
100
+ const ticked = [...state.ticked];
101
+ const parts = [];
102
+ if (state.status === 'to_check') {
103
+ parts.push(h('p', { class: 'fx-dim' }, ticked.length ? `${plural(ticked.length, 'draft', 'drafts')} ticked.` : 'Check each draft, tick it, then mark it ready.'));
104
+ if (ticked.length) {
105
+ parts.push(action(`Mark ${ticked.length} ready`, 'mark-ready', () => ({ invoiceIds: ticked }), (res) => (res.refused.length
106
+ ? [`${res.marked} ready. ${plural(res.refused.length, 'draft has', 'drafts have')} a problem and moved to Problem.`, 'danger']
107
+ : [`${plural(res.marked, 'draft', 'drafts')} ready.`, 'success'])));
108
+ }
109
+ } else if (state.status === 'ready') {
110
+ parts.push(h('p', { class: 'fx-dim' }, to ? `Each invoice is issued, then goes to ${to}.` : 'Each invoice is issued. No accounting system is on.'));
111
+ const buttons = [];
112
+ if (ticked.length) {
113
+ buttons.push(action(`Back to drafts (${ticked.length})`, 'mark-not-ready', () => ({ invoiceIds: ticked }),
114
+ (res) => [`${plural(res.moved, 'invoice', 'invoices')} back in Drafts.`, 'success'], 'ghost'));
115
+ }
116
+ if (context.canIssue && body.counts.ready > 0) {
117
+ buttons.push(action(to ? `Send all ${body.counts.ready} ready to ${to}` : `Issue all ${body.counts.ready} ready`, 'send-ready', () => ({ issueDate: localDate() }),
118
+ (res) => [`${res.issued} issued.${to ? ` They are on the way to ${to}.` : ''}${res.remaining ? ` ${res.remaining} more are ready: press again.` : ''}`, 'success']));
119
+ }
120
+ if (buttons.length) parts.push(h('div', { class: 'fx-sendbar__actions' }, buttons));
121
+ } else if (state.status === 'problem') {
122
+ const failed = body.invoices.filter((i) => i.status === 'issued').length;
123
+ if (context.canIssue && to && failed > 0) {
124
+ parts.push(h('p', { class: 'fx-dim' }, `${plural(failed, 'issued invoice', 'issued invoices')} did not reach ${to}. Fix the cause, then send again.`));
125
+ parts.push(action('Send again', 'send-again', () => ({}), (res) => [`${res.queued} on the way to ${to} again.`, 'success']));
126
+ }
127
+ }
128
+ bar.replaceChildren(...parts);
129
+ bar.hidden = !parts.length;
130
+ }
131
+
132
+ async function load() {
133
+ try {
134
+ const query = new URLSearchParams({ status: state.status, ...(state.q ? { q: state.q } : {}) });
135
+ body = await flow(`/api/invoicing/invoices?${query}`);
136
+ } catch (error) {
137
+ host.replaceChildren(notice(error.message, 'danger'));
138
+ return;
139
+ }
140
+ const shown = new Set(body.invoices.map((i) => i.id));
141
+ for (const id of state.ticked) if (!shown.has(id)) state.ticked.delete(id);
142
+ tabs.replaceChildren(...TABS.map(([id, label]) => h('button', {
143
+ type: 'button',
144
+ role: 'tab',
145
+ 'aria-selected': String(state.status === id),
146
+ ...(id === 'problem' && body.counts?.problem ? { 'data-tone': 'danger' } : {}),
147
+ onclick: () => { state.status = id; state.ticked.clear(); sessionStorage.setItem('flow_invoice_tab', id); load(); },
148
+ }, label, h('span', { class: 'fx-tabs__count' }, String(body.counts?.[id] ?? 0)))));
149
+ drawBar();
150
+ if (!body.invoices.length) {
151
+ host.replaceChildren(h('p', { class: 'fx-empty' }, state.q
152
+ ? 'No invoice matches that search.'
153
+ : EMPTY[state.status] ?? `No ${STATUS_LABEL[state.status].toLowerCase()} invoices yet.`));
154
+ return;
155
+ }
156
+ const ticks = TICKS[state.status];
157
+ const showProblem = state.status === 'problem';
158
+ const created = ['to_check', 'problem', 'ready'].includes(state.status);
159
+ const boxes = [];
160
+ const tickBox = (invoice) => {
161
+ const box = h('input', {
162
+ type: 'checkbox', class: 'fx-tick', 'aria-label': `Tick ${invoice.number ?? 'draft'} for ${invoice.contactName ?? 'no client'}`,
163
+ ...(state.ticked.has(invoice.id) ? { checked: true } : {}),
164
+ onclick: (event) => event.stopPropagation(),
165
+ onchange: () => { if (box.checked) state.ticked.add(invoice.id); else state.ticked.delete(invoice.id); all.checked = state.ticked.size === body.invoices.length; drawBar(); },
166
+ });
167
+ boxes.push(box);
168
+ return box;
169
+ };
170
+ const all = h('input', {
171
+ type: 'checkbox', class: 'fx-tick', 'aria-label': 'Tick every invoice shown',
172
+ onchange: () => {
173
+ for (const invoice of body.invoices) { if (all.checked) state.ticked.add(invoice.id); else state.ticked.delete(invoice.id); }
174
+ for (const box of boxes) box.checked = all.checked;
175
+ drawBar();
176
+ },
177
+ });
178
+ host.replaceChildren(h('table', { class: 'fx-table fx-table--rows' },
179
+ h('thead', {}, h('tr', {},
180
+ ticks ? h('th', { class: 'fx-tick-cell' }, all) : null,
181
+ h('th', {}, 'Number'), h('th', {}, 'Client'), h('th', {}, showProblem ? 'Problem' : 'Reference'),
182
+ h('th', {}, created ? 'Created' : 'Issued'), h('th', { class: 'fx-num' }, 'Total'), h('th', {}, 'Status'))),
183
+ h('tbody', {}, body.invoices.map((invoice) => {
184
+ const href = `/invoices/${invoice.id}`;
185
+ const first = invoice.problems?.[0];
186
+ const view = invoice.status === 'void' ? null : invoice.view;
187
+ return h('tr', { tabindex: '0', onclick: () => go(href), onkeydown: (event) => { if (event.key === 'Enter') go(href); } },
188
+ ticks ? h('td', { class: 'fx-tick-cell', onclick: (event) => event.stopPropagation() }, tickBox(invoice)) : null,
189
+ h('td', {}, h('a', { class: 'fx-strong', href, onclick: go }, invoice.number ?? 'Draft')),
190
+ h('td', {}, invoice.contactName ?? '—'),
191
+ showProblem
192
+ ? h('td', { class: 'fx-problem' }, first?.message ?? '—', invoice.problems.length > 1 ? h('span', { class: 'fx-dim' }, ` +${invoice.problems.length - 1} more`) : null)
193
+ : h('td', { class: 'fx-dim' }, invoice.job?.caseRef ?? invoice.reference ?? '—'),
194
+ h('td', { class: 'fx-dim' }, showDate(invoice.issueDate ?? invoice.createdAt?.slice(0, 10))),
195
+ h('td', { class: 'fx-num fx-strong' }, formatCents(invoice.totalCents)),
196
+ h('td', {}, view
197
+ ? h('span', { class: 'fx-status', 'data-tone': VIEW_TONE[view] }, VIEW_LABEL[view])
198
+ : h('span', { class: 'fx-status', 'data-tone': STATUS_TONE[invoice.status] }, STATUS_LABEL[invoice.status])));
199
+ }))));
200
+ }
201
+ await load();
202
+ }
203
+
204
+ // ── One invoice ────────────────────────────────────────────────────────────
205
+
206
+ export async function renderInvoice(main, ui, id) {
207
+ const { h, flow, notice } = ui;
208
+ const host = h('div', { class: 'fx-invoice' }, h('div', { class: 'fx-skeleton' }));
209
+ main.append(host);
210
+ let context;
211
+ let invoice = null;
212
+ try {
213
+ [context, invoice] = await Promise.all([
214
+ flow('/api/invoicing/context'),
215
+ id === 'new' ? null : flow(`/api/invoicing/invoices/${id}`).then((body) => body.invoice),
216
+ ]);
217
+ } catch (error) {
218
+ host.replaceChildren(notice(error.status === 404 ? 'No invoice here. It may have been removed.' : error.message, 'danger'));
219
+ return;
220
+ }
221
+ // Send to Xero shows only when Xero is on and connected (404 while it is off).
222
+ const organisationId = context.organisations[0]?.id;
223
+ const xero = organisationId ? await flow(`/api/xero/status?organisationId=${organisationId}`).catch(() => null) : null;
224
+ context.xero = Boolean(xero?.connected);
225
+ const draw = (current) => {
226
+ host.replaceChildren(...(current?.status && current.status !== 'draft'
227
+ ? issuedView(current, ui, context, draw)
228
+ : draftView(current, ui, context, draw)));
229
+ };
230
+ draw(invoice);
231
+ }
232
+
233
+ function crumbs(ui, label) {
234
+ const { h, go } = ui;
235
+ return h('nav', { class: 'fx-crumbs', 'aria-label': 'Breadcrumb' },
236
+ h('a', { href: '/invoices', onclick: go }, 'Invoices'), h('span', { 'aria-hidden': 'true' }, '/'), h('span', {}, label));
237
+ }
238
+
239
+ function totalsBox(ui, { subtotalCents, taxCents, totalCents }) {
240
+ const { h } = ui;
241
+ return h('dl', { class: 'fx-totals' },
242
+ h('dt', {}, 'Subtotal'), h('dd', {}, formatCents(subtotalCents)),
243
+ h('dt', {}, 'GST'), h('dd', {}, formatCents(taxCents)),
244
+ h('dt', { class: 'fx-totals__grand' }, 'Total'), h('dd', { class: 'fx-totals__grand' }, formatCents(totalCents)));
245
+ }
246
+
247
+ function jobCard(ui, job) {
248
+ const { h } = ui;
249
+ if (!job) return null;
250
+ return h('section', { class: 'fx-card fx-side-card' },
251
+ h('h3', { class: 'fx-label' }, 'Job'),
252
+ h('dl', { class: 'fx-facts' },
253
+ h('dt', {}, 'Case'), h('dd', {}, job.caseRef ?? job.sourceJobRef),
254
+ job.practitioner ? [h('dt', {}, 'Practitioner'), h('dd', {}, job.practitioner)] : null,
255
+ job.shippedOn ? [h('dt', {}, 'Shipped'), h('dd', {}, showDate(job.shippedOn))] : null,
256
+ h('dt', {}, 'From'), h('dd', {}, job.sourceSystem === 'seazona' ? 'Seazona' : job.sourceSystem)));
257
+ }
258
+
259
+ // A draft, or a new invoice before its first save.
260
+ function draftView(invoice, ui, context, redraw) {
261
+ const { h, flow, toast, go, notice } = ui;
262
+ const isNew = !invoice;
263
+ const lines = (invoice?.lines ?? []).map((line) => ({ ...line }));
264
+ if (!lines.length) lines.push({ description: '', quantity: 1, unitAmountCents: null, taxCode: 'GST', priceItemId: null });
265
+ let contact = invoice?.contact ?? null;
266
+
267
+ // Client: type to find a customer.
268
+ const clientInput = h('input', { class: 'fx-input', type: 'text', autocomplete: 'off', placeholder: 'Type a client name', value: contact?.name ?? '', 'aria-label': 'Client' });
269
+ const clientList = h('div', { class: 'fx-suggest', role: 'listbox', hidden: true });
270
+ let clientTimer;
271
+ clientInput.addEventListener('input', () => {
272
+ contact = null;
273
+ clearTimeout(clientTimer);
274
+ clientTimer = setTimeout(async () => {
275
+ const q = clientInput.value.trim();
276
+ if (!q) { clientList.hidden = true; return; }
277
+ try {
278
+ const { contacts } = await flow(`/api/invoicing/contacts?${new URLSearchParams({ q, limit: '8' })}`);
279
+ clientList.replaceChildren(...(contacts.length ? contacts.map((c) => h('button', {
280
+ type: 'button',
281
+ role: 'option',
282
+ onmousedown: (event) => {
283
+ event.preventDefault();
284
+ contact = c;
285
+ clientInput.value = c.name;
286
+ clientList.hidden = true;
287
+ },
288
+ }, c.name, c.email ? h('span', { class: 'fx-dim' }, c.email) : null)) : [h('p', { class: 'fx-dim fx-suggest__none' }, 'No client by that name.')]));
289
+ clientList.hidden = false;
290
+ } catch (error) {
291
+ toast(error.message, 'danger');
292
+ }
293
+ }, 180);
294
+ });
295
+ clientInput.addEventListener('blur', () => { setTimeout(() => { clientList.hidden = true; }, 120); });
296
+
297
+ const reference = h('input', { class: 'fx-input', type: 'text', value: invoice?.reference ?? '', placeholder: 'Case or order number' });
298
+ const dueDate = h('input', { class: 'fx-input', type: 'date', value: invoice?.dueDate ?? '' });
299
+
300
+ // Lines.
301
+ let items = null;
302
+ const itemsReady = flow('/api/invoicing/price-items').then((body) => { items = body.priceItems ?? body.items ?? []; return items; }).catch(() => { items = []; return items; });
303
+ const tbody = h('tbody');
304
+ const datalist = h('datalist', { id: 'fx-price-items' });
305
+ const totalsHost = h('div');
306
+ const preview = () => {
307
+ let subtotalCents = 0;
308
+ let taxCents = 0;
309
+ for (const line of lines) {
310
+ if (line.unitAmountCents == null || !(line.quantity > 0)) continue;
311
+ const amount = line.quantity * line.unitAmountCents;
312
+ subtotalCents += amount;
313
+ taxCents += line.taxCode === 'GST' ? gstCents(amount) : 0;
314
+ }
315
+ totalsHost.replaceChildren(totalsBox(ui, { subtotalCents, taxCents, totalCents: subtotalCents + taxCents }));
316
+ };
317
+
318
+ const drawLines = () => {
319
+ tbody.replaceChildren(...lines.map((line, index) => {
320
+ const amount = h('td', { class: 'fx-num fx-strong' });
321
+ const showAmount = () => {
322
+ amount.textContent = line.unitAmountCents == null || !(line.quantity > 0) ? '—' : formatCents(line.quantity * line.unitAmountCents);
323
+ };
324
+ showAmount();
325
+ const item = h('input', {
326
+ class: 'fx-input', type: 'text', list: 'fx-price-items', placeholder: 'Code or name', autocomplete: 'off',
327
+ value: line.itemCode ?? '', 'aria-label': `Item, line ${index + 1}`,
328
+ });
329
+ const description = h('input', { class: 'fx-input', type: 'text', value: line.description ?? '', 'aria-label': `Description, line ${index + 1}` });
330
+ const quantity = h('input', { class: 'fx-input fx-num', type: 'number', min: '1', step: '1', value: String(line.quantity ?? 1), 'aria-label': `Quantity, line ${index + 1}` });
331
+ const price = h('input', { class: 'fx-input fx-num', type: 'text', inputmode: 'decimal', value: fromCents(line.unitAmountCents), placeholder: '0.00', 'aria-label': `Unit price, line ${index + 1}` });
332
+ const gst = h('select', { class: 'fx-select', 'aria-label': `GST, line ${index + 1}` },
333
+ h('option', { value: 'GST', selected: line.taxCode !== 'GST_FREE' }, 'GST'),
334
+ h('option', { value: 'GST_FREE', selected: line.taxCode === 'GST_FREE' }, 'GST free'));
335
+ item.addEventListener('change', async () => {
336
+ const list = items ?? await itemsReady;
337
+ const typed = item.value.trim().toLowerCase();
338
+ const match = list.find((i) => i.code.toLowerCase() === typed || `${i.code} · ${i.name}`.toLowerCase() === typed || i.name.toLowerCase() === typed);
339
+ if (!match) { line.priceItemId = null; line.itemCode = null; return; }
340
+ Object.assign(line, { priceItemId: match.id, itemCode: match.code, description: match.name, unitAmountCents: match.unitAmountCents, taxCode: match.taxCode });
341
+ item.value = match.code;
342
+ description.value = match.name;
343
+ price.value = fromCents(match.unitAmountCents);
344
+ gst.value = match.taxCode;
345
+ showAmount();
346
+ preview();
347
+ });
348
+ description.addEventListener('input', () => { line.description = description.value; });
349
+ quantity.addEventListener('input', () => { line.quantity = Number.parseInt(quantity.value, 10); showAmount(); preview(); });
350
+ price.addEventListener('input', () => { line.unitAmountCents = toCents(price.value); showAmount(); preview(); });
351
+ gst.addEventListener('change', () => { line.taxCode = gst.value; preview(); });
352
+ const remove = h('button', {
353
+ class: 'fx-btn fx-btn--ghost fx-btn--sm fx-btn--icon', type: 'button', title: 'Remove line', 'aria-label': `Remove line ${index + 1}`,
354
+ onclick: () => { lines.splice(index, 1); if (!lines.length) lines.push({ description: '', quantity: 1, unitAmountCents: null, taxCode: 'GST', priceItemId: null }); drawLines(); preview(); },
355
+ }, '×');
356
+ return h('tr', {},
357
+ h('td', { class: 'fx-lines__item' }, item),
358
+ h('td', {}, description),
359
+ h('td', { class: 'fx-lines__qty' }, quantity),
360
+ h('td', { class: 'fx-lines__price' }, price),
361
+ h('td', { class: 'fx-lines__gst' }, gst),
362
+ amount,
363
+ h('td', {}, remove));
364
+ }));
365
+ };
366
+ drawLines();
367
+ preview();
368
+ itemsReady.then((list) => {
369
+ // A saved line knows its price item by id; show the item's code.
370
+ const codes = new Map(list.map((i) => [i.id, i.code]));
371
+ let named = false;
372
+ for (const line of lines) {
373
+ if (line.priceItemId && !line.itemCode && codes.has(line.priceItemId)) { line.itemCode = codes.get(line.priceItemId); named = true; }
374
+ }
375
+ if (named) drawLines();
376
+ datalist.replaceChildren(...list.map((i) => h('option', { value: i.code }, `${i.name} · ${formatCents(i.unitAmountCents)}`)));
377
+ });
378
+
379
+ const body = () => ({
380
+ contactId: contact?.id,
381
+ reference: reference.value.trim() || null,
382
+ dueDate: dueDate.value || null,
383
+ lines: lines
384
+ .filter((line) => line.description?.trim() || line.priceItemId || line.unitAmountCents != null)
385
+ .map(({ priceItemId, description, quantity, unitAmountCents, taxCode }) => ({
386
+ ...(priceItemId ? { priceItemId } : {}),
387
+ description: description?.trim() || undefined,
388
+ quantity: Number.isInteger(quantity) && quantity > 0 ? quantity : 1,
389
+ ...(unitAmountCents == null ? {} : { unitAmountCents }),
390
+ taxCode,
391
+ })),
392
+ });
393
+
394
+ const check = () => {
395
+ if (!contact) return 'Choose a client from the list.';
396
+ if (lines.some((line) => (line.description?.trim() || line.priceItemId) && line.unitAmountCents == null)) return 'Each line needs a price.';
397
+ return null;
398
+ };
399
+
400
+ const save = async () => {
401
+ const problem = check();
402
+ if (problem) { toast(problem, 'danger'); return null; }
403
+ try {
404
+ const res = isNew
405
+ ? await flow('/api/invoicing/invoices', { method: 'POST', body: JSON.stringify(body()) })
406
+ : await flow(`/api/invoicing/invoices/${invoice.id}`, { method: 'PATCH', body: JSON.stringify(body()) });
407
+ return res.invoice;
408
+ } catch (error) {
409
+ toast(error.message, 'danger');
410
+ return null;
411
+ }
412
+ };
413
+
414
+ const saveButton = h('button', {
415
+ class: 'fx-btn', type: 'button',
416
+ onclick: async () => {
417
+ const saved = await save();
418
+ if (!saved) return;
419
+ toast('Draft saved.', 'success');
420
+ if (isNew) go(`/invoices/${saved.id}`, { replace: true });
421
+ else redraw(saved);
422
+ },
423
+ }, 'Save draft');
424
+ const issueButton = context.canIssue ? h('button', {
425
+ class: 'fx-btn fx-btn--primary', type: 'button',
426
+ onclick: async () => {
427
+ const saved = await save();
428
+ if (!saved) return;
429
+ const ok = await ui.dialog({
430
+ title: 'Issue this invoice?',
431
+ body: h('p', {}, `It gets the next number (${context.organisations[0]?.invoicePrefix ?? ''}${String(context.organisations[0]?.nextInvoiceNumber ?? '').padStart(4, '0')}) and is kept exactly as issued. To change it later, revise it.`),
432
+ confirm: 'Issue',
433
+ });
434
+ if (!ok) { if (isNew) go(`/invoices/${saved.id}`, { replace: true }); else redraw(saved); return; }
435
+ try {
436
+ const { invoice: issued } = await flow(`/api/invoicing/invoices/${saved.id}/issue`, { method: 'POST', body: JSON.stringify({ issueDate: localDate() }) });
437
+ toast(`${issued.number} issued.`, 'success');
438
+ if (isNew) go(`/invoices/${issued.id}`, { replace: true });
439
+ else redraw(issued);
440
+ } catch (error) {
441
+ toast(error.message, 'danger');
442
+ }
443
+ },
444
+ }, 'Issue invoice') : null;
445
+
446
+ // Mark ready: the draft is saved, then Flow checks it; one with a problem
447
+ // stays a draft. Back to drafts undoes it.
448
+ const ready = invoice?.view === 'ready';
449
+ const readyButton = isNew ? null : h('button', {
450
+ class: 'fx-btn', type: 'button',
451
+ onclick: async () => {
452
+ const current = ready ? invoice : await save();
453
+ if (!current) return;
454
+ try {
455
+ if (ready) {
456
+ await flow('/api/invoicing/invoices/mark-not-ready', { method: 'POST', body: JSON.stringify({ invoiceIds: [invoice.id] }) });
457
+ toast('Back in Drafts.', 'success');
458
+ } else {
459
+ const res = await flow('/api/invoicing/invoices/mark-ready', { method: 'POST', body: JSON.stringify({ invoiceIds: [invoice.id] }) });
460
+ if (res.marked) toast('Ready to send.', 'success');
461
+ else toast(res.refused[0]?.problems.map((p) => p.message).join(' ') ?? 'It has a problem.', 'danger');
462
+ }
463
+ const { invoice: fresh } = await flow(`/api/invoicing/invoices/${invoice.id}`);
464
+ redraw(fresh);
465
+ } catch (error) {
466
+ toast(error.message, 'danger');
467
+ }
468
+ },
469
+ }, ready ? 'Back to drafts' : 'Mark ready');
470
+ const problems = invoice?.problems?.length
471
+ ? notice(invoice.problems.map((p) => p.message).join(' '), 'danger', 'Problem.')
472
+ : null;
473
+
474
+ return [
475
+ crumbs(ui, isNew ? 'New invoice' : ready ? 'Ready' : 'Draft'),
476
+ h('header', { class: 'fx-ph' },
477
+ h('div', { class: 'fx-ph__text' },
478
+ h('h1', { class: 'fx-title' }, isNew ? 'New invoice' : ready ? 'Ready invoice' : 'Draft invoice'),
479
+ h('p', { class: 'fx-ph__sub' }, ready ? 'A person checked it. Send all ready issues it. A change sends it back to Drafts.' : 'The number is given when you issue it.')),
480
+ h('div', { class: 'fx-ph__actions' }, saveButton, readyButton, issueButton)),
481
+ problems,
482
+ h('div', { class: 'fx-invoice__grid' },
483
+ h('div', { class: 'fx-invoice__main' },
484
+ h('section', { class: 'fx-card fx-invoice__head' },
485
+ h('label', { class: 'fx-field fx-field--client' }, h('span', { class: 'fx-label' }, 'Client'), h('div', { class: 'fx-suggest-host' }, clientInput, clientList)),
486
+ h('label', { class: 'fx-field' }, h('span', { class: 'fx-label' }, 'Reference'), reference),
487
+ h('label', { class: 'fx-field' }, h('span', { class: 'fx-label' }, 'Due date'), dueDate)),
488
+ h('section', { class: 'fx-card fx-lines' },
489
+ datalist,
490
+ h('table', { class: 'fx-lines__table' },
491
+ h('thead', {}, h('tr', {},
492
+ h('th', {}, 'Item'), h('th', {}, 'Description'), h('th', { class: 'fx-num' }, 'Qty'),
493
+ h('th', { class: 'fx-num' }, 'Unit price'), h('th', {}, 'Tax'), h('th', { class: 'fx-num' }, 'Amount'),
494
+ h('th', {}, h('span', { class: 'fx-sr' }, 'Remove')))),
495
+ tbody),
496
+ h('div', { class: 'fx-lines__foot' },
497
+ h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button', onclick: () => { lines.push({ description: '', quantity: 1, unitAmountCents: null, taxCode: 'GST', priceItemId: null }); drawLines(); } }, '+ Add line'),
498
+ totalsHost))),
499
+ h('aside', { class: 'fx-invoice__aside' }, jobCard(ui, invoice?.job))),
500
+ ];
501
+ }
502
+
503
+ // An issued or void invoice: the document as sent, and what can happen next.
504
+ function issuedView(invoice, ui, context, redraw) {
505
+ const { h, flow, toast } = ui;
506
+ const act = async (path, done, confirmText) => {
507
+ if (confirmText) {
508
+ const ok = await ui.dialog(confirmText);
509
+ if (!ok) return;
510
+ }
511
+ try {
512
+ const res = await flow(`/api/invoicing/invoices/${invoice.id}/${path}`, { method: 'POST', body: '{}' });
513
+ toast(done, 'success');
514
+ redraw(res.invoice);
515
+ } catch (error) {
516
+ toast(error.message, 'danger');
517
+ }
518
+ };
519
+ const actions = invoice.status === 'issued' && context.canIssue ? [
520
+ h('button', {
521
+ class: 'fx-btn fx-btn--ghost fx-btn--danger', type: 'button',
522
+ onclick: () => act('void', `${invoice.number} is void.`, { title: `Void ${invoice.number}?`, body: h('p', {}, 'The number stays used. A void invoice cannot be issued again.'), confirm: 'Void', tone: 'danger' }),
523
+ }, 'Void'),
524
+ h('button', { class: 'fx-btn', type: 'button', onclick: () => act('revise', `${invoice.number} is a draft again.`) }, 'Revise'),
525
+ sendToXero(invoice, ui, context),
526
+ ] : [];
527
+ return [
528
+ crumbs(ui, invoice.number),
529
+ h('header', { class: 'fx-ph' },
530
+ h('div', { class: 'fx-ph__text' },
531
+ h('div', { class: 'fx-row' },
532
+ h('h1', { class: 'fx-title' }, invoice.number),
533
+ h('span', { class: 'fx-status', 'data-tone': STATUS_TONE[invoice.status] }, STATUS_LABEL[invoice.status])),
534
+ h('p', { class: 'fx-ph__sub' }, `${invoice.contact?.name ?? ''} · issued ${showDate(invoice.issueDate)}`)),
535
+ h('div', { class: 'fx-ph__actions' }, actions)),
536
+ h('div', { class: 'fx-invoice__grid' },
537
+ h('div', { class: 'fx-invoice__main' },
538
+ h('section', { class: 'fx-card fx-doc' },
539
+ h('dl', { class: 'fx-doc__meta' },
540
+ h('div', {}, h('dt', { class: 'fx-label' }, 'Bill to'), h('dd', {}, invoice.contact?.name ?? '—', invoice.contact?.email ? h('span', { class: 'fx-dim' }, invoice.contact.email) : null)),
541
+ h('div', {}, h('dt', { class: 'fx-label' }, 'Reference'), h('dd', {}, invoice.reference ?? '—')),
542
+ h('div', {}, h('dt', { class: 'fx-label' }, 'Issued'), h('dd', {}, showDate(invoice.issueDate))),
543
+ h('div', {}, h('dt', { class: 'fx-label' }, 'Due'), h('dd', {}, showDate(invoice.dueDate)))),
544
+ h('table', { class: 'fx-lines__table fx-lines__table--read' },
545
+ h('thead', {}, h('tr', {}, h('th', {}, 'Description'), h('th', { class: 'fx-num' }, 'Qty'), h('th', { class: 'fx-num' }, 'Unit price'), h('th', {}, 'Tax'), h('th', { class: 'fx-num' }, 'Amount'))),
546
+ h('tbody', {}, invoice.lines.map((line) => h('tr', {},
547
+ h('td', {}, line.description),
548
+ h('td', { class: 'fx-num' }, String(line.quantity)),
549
+ h('td', { class: 'fx-num' }, formatCents(line.unitAmountCents)),
550
+ h('td', { class: 'fx-dim' }, line.taxCode === 'GST' ? 'GST' : 'GST free'),
551
+ h('td', { class: 'fx-num fx-strong' }, formatCents(line.lineAmountCents)))))),
552
+ h('div', { class: 'fx-lines__foot' }, h('span'), totalsBox(ui, invoice)))),
553
+ h('aside', { class: 'fx-invoice__aside' },
554
+ jobCard(ui, invoice.job),
555
+ invoice.versions?.length ? h('section', { class: 'fx-card fx-side-card' },
556
+ h('h3', { class: 'fx-label' }, 'History'),
557
+ h('ol', { class: 'fx-timeline' }, invoice.versions.map((v) => h('li', {},
558
+ h('span', { class: 'fx-strong' }, v.version === 1 ? 'Issued' : `Reissued (version ${v.version})`),
559
+ h('span', { class: 'fx-dim' }, new Date(v.issuedAt).toLocaleString('en-AU', { dateStyle: 'medium', timeStyle: 'short' })))))) : null)),
560
+ ];
561
+ }
562
+
563
+ // Send to Xero (POST /api/xero/invoices/push). The customer's send mode
564
+ // (Settings, Invoicing) decides what Xero gets: a draft that a person approves
565
+ // there, or an approved invoice that Xero emails to the customer.
566
+ function sendToXero(invoice, ui, context) {
567
+ const { h, flow, toast } = ui;
568
+ const organisationId = context.organisations[0]?.id;
569
+ if (!organisationId || !context.xero) return null;
570
+ const button = h('button', {
571
+ class: 'fx-btn fx-btn--primary', type: 'button',
572
+ onclick: async () => {
573
+ button.disabled = true;
574
+ try {
575
+ const res = await flow('/api/xero/invoices/push', { method: 'POST', body: JSON.stringify({ organisationId, invoiceIds: [invoice.id] }) });
576
+ const result = res.invoices?.[0];
577
+ const email = result?.email;
578
+ if (email && ['failed', 'unknown'].includes(email.state)) toast(email.error, 'danger');
579
+ else if (email?.state === 'sent') toast(`${invoice.number} is approved in Xero, and Xero emailed it to the customer.`, 'success');
580
+ else if (['created', 'reconciled', 'existing'].includes(result?.outcome)) toast(`${invoice.number} is in Xero as a draft.`, 'success');
581
+ else if (result?.outcome === 'already_in_xero') toast(`Xero already has ${invoice.number}.`, 'info');
582
+ else toast(result?.error?.message ?? 'Xero did not take it.', 'danger');
583
+ } catch (error) {
584
+ toast(error.message, 'danger');
585
+ } finally {
586
+ button.disabled = false;
587
+ }
588
+ },
589
+ }, 'Send to Xero');
590
+ // The preview reads only Flow's copy of Xero: it costs no Xero calls.
591
+ flow('/api/xero/invoices/preview', { method: 'POST', body: JSON.stringify({ organisationId, invoiceIds: [invoice.id] }) })
592
+ .then((res) => {
593
+ if (res.invoices?.[0]?.sendMode === 'approve_and_email') button.textContent = 'Send to Xero and email';
594
+ })
595
+ .catch(() => {});
596
+ return button;
597
+ }