@arcforgelabs/flow 2026.10.30 → 2026.10.32

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 (79) hide show
  1. package/README.md +2 -0
  2. package/npm-shrinkwrap.json +188 -2
  3. package/package.json +2 -1
  4. package/server/api/alerts.js +49 -0
  5. package/server/api/invoicing.js +125 -7
  6. package/server/app.js +29 -2
  7. package/server/core/alerts.js +157 -0
  8. package/server/core/automations.js +17 -3
  9. package/server/core/history.js +65 -0
  10. package/server/core/invoice-pdf.js +266 -0
  11. package/server/core/invoices.js +2 -2
  12. package/server/core/invoicing-rules.js +26 -1
  13. package/server/core/job-board.js +25 -3
  14. package/server/core/ledger.js +3 -2
  15. package/server/core/line-mapping.js +36 -10
  16. package/server/core/pipeline.js +76 -4
  17. package/server/core/price-list.js +348 -0
  18. package/server/core/references.js +99 -0
  19. package/server/core/sending.js +11 -5
  20. package/server/db/commands.js +42 -0
  21. package/server/db/doctor.js +168 -0
  22. package/server/db/migrations/0014_price_list.sql +34 -0
  23. package/server/db/migrations/0015_alert_dismissals.sql +17 -0
  24. package/server/db/migrations/0016_invoice_reference.sql +1 -0
  25. package/server/db/migrations/meta/0014_snapshot.json +3773 -0
  26. package/server/db/migrations/meta/0015_snapshot.json +3854 -0
  27. package/server/db/migrations/meta/0016_snapshot.json +3861 -0
  28. package/server/db/migrations/meta/_journal.json +21 -0
  29. package/server/db/schema.js +46 -0
  30. package/server/flow-server.js +20 -1
  31. package/server/index.js +2 -1
  32. package/server/integrations/call-guard.js +362 -0
  33. package/server/integrations/port.js +21 -1
  34. package/server/integrations/seazona/feed.js +31 -2
  35. package/server/integrations/seazona/governor.js +50 -122
  36. package/server/integrations/seazona/history/backfill.js +208 -0
  37. package/server/integrations/seazona/history/csv.js +65 -0
  38. package/server/integrations/seazona/history/index.js +584 -0
  39. package/server/integrations/seazona/history/read.js +196 -0
  40. package/server/integrations/seazona/index.js +46 -1
  41. package/server/integrations/seazona/lanes.js +187 -51
  42. package/server/integrations/seazona/migrations/0003_call_guard.sql +2 -0
  43. package/server/integrations/seazona/migrations/0004_history_import.sql +68 -0
  44. package/server/integrations/seazona/migrations/meta/0003_snapshot.json +895 -0
  45. package/server/integrations/seazona/migrations/meta/0004_snapshot.json +1323 -0
  46. package/server/integrations/seazona/migrations/meta/_journal.json +14 -0
  47. package/server/integrations/seazona/patients.js +30 -0
  48. package/server/integrations/seazona/routes.js +145 -0
  49. package/server/integrations/seazona/schema.js +94 -0
  50. package/server/integrations/seazona/settings.js +14 -3
  51. package/server/integrations/seazona/sync-once.js +1 -1
  52. package/server/integrations/seazona/tables.js +4 -0
  53. package/server/integrations/xero/client.js +39 -7
  54. package/server/integrations/xero/drafts.js +46 -8
  55. package/server/integrations/xero/import.js +131 -17
  56. package/server/integrations/xero/index.js +38 -7
  57. package/server/integrations/xero/lookup.js +26 -0
  58. package/server/integrations/xero/migrations/0006_xero_call_guard.sql +26 -0
  59. package/server/integrations/xero/migrations/0007_xero_webhooks.sql +30 -0
  60. package/server/integrations/xero/migrations/meta/_journal.json +14 -0
  61. package/server/integrations/xero/mirror.js +27 -10
  62. package/server/integrations/xero/oauth.js +4 -1
  63. package/server/integrations/xero/reconcile.js +173 -0
  64. package/server/integrations/xero/routes.js +55 -0
  65. package/server/integrations/xero/runtime.js +45 -4
  66. package/server/integrations/xero/settings.js +3 -0
  67. package/server/integrations/xero/webhooks.js +311 -0
  68. package/server/release-channel.js +3 -3
  69. package/server/ui/alerts.js +77 -0
  70. package/server/ui/app-page.js +1 -1
  71. package/server/ui/app.js +63 -21
  72. package/server/ui/clerk-appearance.js +42 -9
  73. package/server/ui/flow.css +78 -9
  74. package/server/ui/invoice-notes.js +26 -0
  75. package/server/ui/invoicing-settings.js +4 -2
  76. package/server/ui/invoicing.js +241 -49
  77. package/server/ui/line-mappings.js +3 -2
  78. package/server/ui/price-list.js +247 -0
  79. package/server/ui/routes.js +1 -1
@@ -5,14 +5,17 @@
5
5
  // are a preview with the same rule (GST 10% per line, rounded half away from
6
6
  // zero, server/core/money.js). Data comes from /api/invoicing
7
7
  // (server/api/invoicing.js). app.js passes in its shared pieces as `ui`.
8
+ import { alertStrip } from '/assets/ui/alerts.js';
9
+ import { draftNote, sendModeNote } from '/assets/ui/invoice-notes.js';
8
10
 
9
11
  const STATUS_LABEL = { draft: 'Draft', issued: 'Issued', void: 'Void' };
10
12
  const STATUS_TONE = { draft: 'info', issued: 'success', void: 'mute' };
11
13
  // 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
+ // every draft is in one of them. A person marks a checked draft ready; a
15
+ // saved change keeps it there. An issued invoice that failed to send is a
14
16
  // problem.
15
- const TABS = [['to_check', 'Drafts'], ['problem', 'Problem'], ['ready', 'Ready'], ['issued', 'Issued'], ['void', 'Void']];
17
+ // Ready first: it opens by default, as the drafts most often wait there to send.
18
+ const TABS = [['ready', 'Ready'], ['to_check', 'Drafts'], ['problem', 'Problem'], ['issued', 'Issued'], ['void', 'Void']];
16
19
  const VIEW_LABEL = { problem: 'Problem', ready: 'Ready', sending: 'Sending', to_check: 'Draft' };
17
20
  const VIEW_TONE = { problem: 'danger', ready: 'success', sending: 'info', to_check: 'info' };
18
21
  const EMPTY = {
@@ -26,6 +29,31 @@ const TICKS = { to_check: true, ready: true };
26
29
  const money = new Intl.NumberFormat('en-AU', { style: 'currency', currency: 'AUD' });
27
30
  export const formatCents = (cents) => money.format((cents ?? 0) / 100);
28
31
 
32
+ // Xero's status of an issued invoice, from Flow's copy of Xero (issue #132).
33
+ // A finding (a difference, or a void on one side only) shows as Out of sync.
34
+ const XERO_TONE = {
35
+ not_sent: 'mute', sending: 'info', not_copied: 'info', draft: 'info',
36
+ approved: 'success', part_paid: 'success', paid: 'success', voided: 'mute', deleted: 'mute',
37
+ };
38
+ const FIELD_LABEL = { contact: 'Client', subtotal: 'Subtotal', tax: 'GST', total: 'Total', 'line count': 'Lines', items: 'Items', date: 'Date' };
39
+
40
+ export function xeroTag(ui, status) {
41
+ const { h } = ui;
42
+ if (!status) return null;
43
+ return status.finding
44
+ ? h('span', { class: 'fx-status', 'data-tone': 'danger', title: status.message }, 'Out of sync with Xero')
45
+ : h('span', { class: 'fx-status', 'data-tone': XERO_TONE[status.state] ?? 'mute' }, status.label);
46
+ }
47
+
48
+ const showField = (field, value) => (['subtotal', 'tax', 'total'].includes(field) && Number.isFinite(value) ? formatCents(value) : String(value ?? '—'));
49
+
50
+ // Xero's status for some invoices, or an empty map while Xero is off.
51
+ async function xeroStatuses(ui, organisationId, invoiceIds) {
52
+ if (!organisationId || !invoiceIds.length) return new Map();
53
+ const res = await ui.flow('/api/xero/invoices/status', { method: 'POST', body: JSON.stringify({ organisationId, invoiceIds }) }).catch(() => null);
54
+ return new Map((res?.invoices ?? []).map((s) => [s.invoiceId, s]));
55
+ }
56
+
29
57
  function gstCents(lineAmountCents) {
30
58
  const sign = Math.sign(lineAmountCents);
31
59
  const abs = Math.abs(lineAmountCents) * 1000;
@@ -53,17 +81,24 @@ const showDate = (iso) => (iso ? new Date(`${iso}T00:00:00`).toLocaleDateString(
53
81
  export async function renderInvoiceList(main, ui) {
54
82
  const { h, flow, pageHeader, notice, go, toast } = ui;
55
83
  const saved = sessionStorage.getItem('flow_invoice_tab');
56
- const state = { status: TABS.some(([id]) => id === saved) ? saved : 'to_check', q: '', ticked: new Set() };
84
+ // Back from an invoice comes here with the tab, search and scroll it left.
85
+ const back = readNav();
86
+ const returning = sessionStorage.getItem(NAV_BACK) === '1';
87
+ sessionStorage.removeItem(NAV_BACK);
88
+ const state = { status: TABS.some(([id]) => id === saved) ? saved : 'ready', q: returning ? back?.q ?? '' : '', ticked: new Set() };
57
89
  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' });
90
+ const search = h('input', { class: 'fx-input fx-search', type: 'search', placeholder: 'Find a number, client or reference', 'aria-label': 'Find an invoice', value: state.q });
59
91
  const bar = h('div', { class: 'fx-sendbar', hidden: true });
60
92
  const host = h('div', { class: 'fx-panel' }, h('div', { class: 'fx-skeleton' }));
61
93
  const context = await flow('/api/invoicing/context').catch(() => ({ canIssue: false }));
62
94
  let body = null;
95
+ const openTab = (id) => { state.status = id; state.ticked.clear(); sessionStorage.setItem('flow_invoice_tab', id); load(); };
96
+ const alerts = alertStrip(ui, 'invoices', { onTab: openTab });
63
97
  main.append(
64
98
  pageHeader('Invoices', {
65
99
  actions: [h('a', { class: 'fx-btn fx-btn--primary', href: '/invoices/new', onclick: go }, 'New invoice')],
66
100
  }),
101
+ alerts.element,
67
102
  h('div', { class: 'fx-toolbar' }, tabs, search),
68
103
  bar,
69
104
  host);
@@ -85,6 +120,7 @@ export async function renderInvoiceList(main, ui) {
85
120
  toast(...done(res));
86
121
  state.ticked.clear();
87
122
  await load();
123
+ alerts.refresh();
88
124
  } catch (error) {
89
125
  toast(error.message, 'danger');
90
126
  el.disabled = false;
@@ -107,10 +143,10 @@ export async function renderInvoiceList(main, ui) {
107
143
  : [`${plural(res.marked, 'draft', 'drafts')} ready.`, 'success'])));
108
144
  }
109
145
  } 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.'));
146
+ parts.push(h('p', { class: 'fx-dim' }, sendModeNote(to, body.sendMode)));
111
147
  const buttons = [];
112
148
  if (ticked.length) {
113
- buttons.push(action(`Back to drafts (${ticked.length})`, 'mark-not-ready', () => ({ invoiceIds: ticked }),
149
+ buttons.push(action(`Move ${ticked.length} to Drafts`, 'mark-not-ready', () => ({ invoiceIds: ticked }),
114
150
  (res) => [`${plural(res.moved, 'invoice', 'invoices')} back in Drafts.`, 'success'], 'ghost'));
115
151
  }
116
152
  if (context.canIssue && body.counts.ready > 0) {
@@ -144,7 +180,7 @@ export async function renderInvoiceList(main, ui) {
144
180
  role: 'tab',
145
181
  'aria-selected': String(state.status === id),
146
182
  ...(id === 'problem' && body.counts?.problem ? { 'data-tone': 'danger' } : {}),
147
- onclick: () => { state.status = id; state.ticked.clear(); sessionStorage.setItem('flow_invoice_tab', id); load(); },
183
+ onclick: () => openTab(id),
148
184
  }, label, h('span', { class: 'fx-tabs__count' }, String(body.counts?.[id] ?? 0)))));
149
185
  drawBar();
150
186
  if (!body.invoices.length) {
@@ -153,6 +189,9 @@ export async function renderInvoiceList(main, ui) {
153
189
  : EMPTY[state.status] ?? `No ${STATUS_LABEL[state.status].toLowerCase()} invoices yet.`));
154
190
  return;
155
191
  }
192
+ // The invoice page steps through this list, in this order (navBar).
193
+ saveNav({ tab: state.status, q: state.q, ids: body.invoices.map((i) => i.id) });
194
+ const open = (href) => { saveNav({ ...readNav(), scrollY: window.scrollY }); go(href); };
156
195
  const ticks = TICKS[state.status];
157
196
  const showProblem = state.status === 'problem';
158
197
  const created = ['to_check', 'problem', 'ready'].includes(state.status);
@@ -175,6 +214,7 @@ export async function renderInvoiceList(main, ui) {
175
214
  drawBar();
176
215
  },
177
216
  });
217
+ const statusCells = new Map();
178
218
  host.replaceChildren(h('table', { class: 'fx-table fx-table--rows' },
179
219
  h('thead', {}, h('tr', {},
180
220
  ticks ? h('th', { class: 'fx-tick-cell' }, all) : null,
@@ -184,21 +224,79 @@ export async function renderInvoiceList(main, ui) {
184
224
  const href = `/invoices/${invoice.id}`;
185
225
  const first = invoice.problems?.[0];
186
226
  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); } },
227
+ return h('tr', { tabindex: '0', onclick: () => open(href), onkeydown: (event) => { if (event.key === 'Enter') open(href); } },
188
228
  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')),
229
+ h('td', {}, h('a', { class: 'fx-strong', href, onclick: (event) => { event.preventDefault(); event.stopPropagation(); open(href); } }, invoice.number ?? 'Draft')),
190
230
  h('td', {}, invoice.contactName ?? '—'),
191
231
  showProblem
192
232
  ? 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 ?? '—'),
233
+ : h('td', { class: 'fx-dim' }, invoice.reference ?? '—'),
194
234
  h('td', { class: 'fx-dim' }, showDate(invoice.issueDate ?? invoice.createdAt?.slice(0, 10))),
195
235
  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])));
236
+ (() => {
237
+ const cell = h('td', { class: 'fx-status-cell' }, view
238
+ ? h('span', { class: 'fx-status', 'data-tone': VIEW_TONE[view] }, VIEW_LABEL[view])
239
+ : h('span', { class: 'fx-status', 'data-tone': STATUS_TONE[invoice.status] }, STATUS_LABEL[invoice.status]));
240
+ statusCells.set(invoice.id, cell);
241
+ return cell;
242
+ })());
199
243
  }))));
244
+ // Issued and void invoices also show Xero's status, from Flow's copy.
245
+ if (['issued', 'void'].includes(state.status)) {
246
+ const shownBody = body;
247
+ const statuses = await xeroStatuses(ui, context.organisations?.[0]?.id, body.invoices.map((i) => i.id));
248
+ if (shownBody !== body) return;
249
+ for (const [invoiceId, status] of statuses) {
250
+ if (status.state !== 'not_sent' || status.finding) statusCells.get(invoiceId)?.append(' ', xeroTag(ui, status));
251
+ }
252
+ }
200
253
  }
201
254
  await load();
255
+ if (returning && back?.scrollY) window.scrollTo(0, back.scrollY);
256
+ }
257
+
258
+ // The list an invoice was opened from: its tab, search, invoice ids in order,
259
+ // and scroll. The invoice page's bar steps through it and goes back to it.
260
+ const NAV = 'flow_invoice_nav';
261
+ const NAV_BACK = 'flow_invoice_back';
262
+ function readNav() {
263
+ try { return JSON.parse(sessionStorage.getItem(NAV) ?? 'null'); } catch { return null; }
264
+ }
265
+ function saveNav(nav) {
266
+ try { sessionStorage.setItem(NAV, JSON.stringify(nav)); } catch { /* the bar then shows Back only */ }
267
+ }
268
+
269
+ // The bar above one invoice: Back to the list as it was, and the previous and
270
+ // next invoice of that list.
271
+ function navBar(ui, id) {
272
+ const { h, go } = ui;
273
+ const nav = readNav();
274
+ const ids = nav?.ids ?? [];
275
+ const at = ids.indexOf(id);
276
+ const back = () => { sessionStorage.setItem(NAV_BACK, '1'); go('/invoices'); };
277
+ const step = (to, label, path) => {
278
+ const button = h('button', {
279
+ class: 'fx-pager__step', type: 'button', disabled: !to, 'aria-label': label, title: label,
280
+ onclick: () => go(`/invoices/${to}`),
281
+ });
282
+ button.innerHTML = `<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path d="${path}" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
283
+ return button;
284
+ };
285
+ return h('nav', { class: 'fx-pager', 'aria-label': 'Invoices' },
286
+ h('button', { class: 'fx-pager__back', type: 'button', onclick: back }, '← Back'),
287
+ at >= 0 ? h('span', { class: 'fx-pager__count' }, `${at + 1} of ${ids.length}`) : null,
288
+ at >= 0 ? h('span', { class: 'fx-pager__steps' }, step(ids[at - 1], 'Previous invoice', 'M14.5 6l-6 6 6 6'), step(ids[at + 1], 'Next invoice', 'M9.5 6l6 6-6 6')) : null);
289
+ }
290
+
291
+ // After a send, the next invoice of the list opens; at its end, the list.
292
+ function openNext(ui, id) {
293
+ const nav = readNav();
294
+ const ids = nav?.ids ?? [];
295
+ const at = ids.indexOf(id);
296
+ const next = at >= 0 ? (ids[at + 1] ?? ids[at - 1]) : null;
297
+ if (at >= 0) saveNav({ ...nav, ids: ids.filter((other) => other !== id) });
298
+ if (next) ui.go(`/invoices/${next}`);
299
+ else { sessionStorage.setItem(NAV_BACK, '1'); ui.go('/invoices'); }
202
300
  }
203
301
 
204
302
  // ── One invoice ────────────────────────────────────────────────────────────
@@ -225,17 +323,11 @@ export async function renderInvoice(main, ui, id) {
225
323
  const draw = (current) => {
226
324
  host.replaceChildren(...(current?.status && current.status !== 'draft'
227
325
  ? issuedView(current, ui, context, draw)
228
- : draftView(current, ui, context, draw)));
326
+ : draftView(current, ui, context, draw)).filter(Boolean));
229
327
  };
230
328
  draw(invoice);
231
329
  }
232
330
 
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
331
  function totalsBox(ui, { subtotalCents, taxCents, totalCents }) {
240
332
  const { h } = ui;
241
333
  return h('dl', { class: 'fx-totals' },
@@ -244,13 +336,13 @@ function totalsBox(ui, { subtotalCents, taxCents, totalCents }) {
244
336
  h('dt', { class: 'fx-totals__grand' }, 'Total'), h('dd', { class: 'fx-totals__grand' }, formatCents(totalCents)));
245
337
  }
246
338
 
339
+ // The job an invoice bills. The case id stays on the record, not on screen.
247
340
  function jobCard(ui, job) {
248
341
  const { h } = ui;
249
342
  if (!job) return null;
250
343
  return h('section', { class: 'fx-card fx-side-card' },
251
344
  h('h3', { class: 'fx-label' }, 'Job'),
252
345
  h('dl', { class: 'fx-facts' },
253
- h('dt', {}, 'Case'), h('dd', {}, job.caseRef ?? job.sourceJobRef),
254
346
  job.practitioner ? [h('dt', {}, 'Practitioner'), h('dd', {}, job.practitioner)] : null,
255
347
  job.shippedOn ? [h('dt', {}, 'Shipped'), h('dd', {}, showDate(job.shippedOn))] : null,
256
348
  h('dt', {}, 'From'), h('dd', {}, job.sourceSystem === 'seazona' ? 'Seazona' : job.sourceSystem)));
@@ -411,24 +503,56 @@ function draftView(invoice, ui, context, redraw) {
411
503
  }
412
504
  };
413
505
 
506
+ // Save shows only when there is something to save: a new invoice, or a
507
+ // change since the page opened. Saving keeps the invoice where it is.
414
508
  const saveButton = h('button', {
415
- class: 'fx-btn', type: 'button',
509
+ class: 'fx-btn', type: 'button', hidden: !isNew,
416
510
  onclick: async () => {
417
511
  const saved = await save();
418
512
  if (!saved) return;
419
- toast('Draft saved.', 'success');
513
+ toast(isNew ? 'Draft saved.' : 'Changes saved.', 'success');
420
514
  if (isNew) go(`/invoices/${saved.id}`, { replace: true });
421
515
  else redraw(saved);
422
516
  },
423
- }, 'Save draft');
424
- const issueButton = context.canIssue ? h('button', {
517
+ }, isNew ? 'Save draft' : 'Save changes');
518
+ const opened = JSON.stringify(body());
519
+ const changed = () => !isNew && JSON.stringify(body()) !== opened;
520
+ const watchChanges = () => { if (!isNew) saveButton.hidden = !changed(); };
521
+ // With an accounting system on, a ready invoice is sent there: Flow issues
522
+ // it and queues it, the same as Send all ready. With none, Flow issues it
523
+ // and keeps it. A draft that is not ready has no send: mark it ready first.
524
+ const to = invoice?.sendsTo ?? null;
525
+ const ready = invoice?.view === 'ready';
526
+ const nextNumber = `${context.organisations[0]?.invoicePrefix ?? ''}${String(context.organisations[0]?.nextInvoiceNumber ?? '').padStart(4, '0')}`;
527
+ const emails = invoice?.sendMode?.mode === 'approve_and_email';
528
+ const sendButton = context.canIssue && to && ready ? h('button', {
529
+ class: 'fx-btn fx-btn--primary', type: 'button',
530
+ onclick: async () => {
531
+ if (changed() && !(await save())) return;
532
+ const ok = await ui.dialog({
533
+ title: `Send this invoice to ${to}?`,
534
+ body: h('p', {}, `It gets the next number (${nextNumber}). ${sendModeNote(to, invoice.sendMode, 'it')}`),
535
+ confirm: emails ? 'Send and email' : 'Send',
536
+ });
537
+ if (!ok) return;
538
+ try {
539
+ await flow('/api/invoicing/invoices/send-ready', { method: 'POST', body: JSON.stringify({ issueDate: localDate(), invoiceIds: [invoice.id] }) });
540
+ const { invoice: fresh } = await flow(`/api/invoicing/invoices/${invoice.id}`);
541
+ toast(`${fresh.number} is on the way to ${to}.`, 'success');
542
+ openNext(ui, invoice.id);
543
+ } catch (error) {
544
+ toast(error.message, 'danger');
545
+ }
546
+ },
547
+ }, emails ? `Send to ${to} and email` : `Send to ${to}`) : null;
548
+ const issueButton = context.canIssue && !to ? h('button', {
425
549
  class: 'fx-btn fx-btn--primary', type: 'button',
426
550
  onclick: async () => {
427
551
  const saved = await save();
428
552
  if (!saved) return;
429
553
  const ok = await ui.dialog({
430
554
  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.`),
555
+ body: h('p', {}, `It gets the next number (${nextNumber}). Flow keeps it exactly as issued. To change it later, revise it.`),
432
556
  confirm: 'Issue',
433
557
  });
434
558
  if (!ok) { if (isNew) go(`/invoices/${saved.id}`, { replace: true }); else redraw(saved); return; }
@@ -444,40 +568,51 @@ function draftView(invoice, ui, context, redraw) {
444
568
  }, 'Issue invoice') : null;
445
569
 
446
570
  // 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', {
571
+ // stays a draft. Move to Drafts, on the Status card, undoes it.
572
+ const markReadyButton = isNew || ready ? null : h('button', {
450
573
  class: 'fx-btn', type: 'button',
451
574
  onclick: async () => {
452
- const current = ready ? invoice : await save();
453
- if (!current) return;
575
+ if (!(await save())) return;
454
576
  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
- }
577
+ const res = await flow('/api/invoicing/invoices/mark-ready', { method: 'POST', body: JSON.stringify({ invoiceIds: [invoice.id] }) });
578
+ if (res.marked) toast('Ready to send.', 'success');
579
+ else toast(res.refused[0]?.problems.map((p) => p.message).join(' ') ?? 'It has a problem.', 'danger');
463
580
  const { invoice: fresh } = await flow(`/api/invoicing/invoices/${invoice.id}`);
464
581
  redraw(fresh);
465
582
  } catch (error) {
466
583
  toast(error.message, 'danger');
467
584
  }
468
585
  },
469
- }, ready ? 'Back to drafts' : 'Mark ready');
586
+ }, 'Mark ready');
587
+ // A ready invoice can go back to Drafts. It is a status change, so it sits
588
+ // with the status, not with the page's main buttons.
589
+ const statusCard = ready ? h('section', { class: 'fx-card fx-side-card' },
590
+ h('h3', { class: 'fx-label' }, 'Status'),
591
+ h('p', {}, 'Ready to send.'),
592
+ h('button', {
593
+ class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button',
594
+ onclick: async () => {
595
+ try {
596
+ await flow('/api/invoicing/invoices/mark-not-ready', { method: 'POST', body: JSON.stringify({ invoiceIds: [invoice.id] }) });
597
+ toast('Moved to Drafts.', 'success');
598
+ const { invoice: fresh } = await flow(`/api/invoicing/invoices/${invoice.id}`);
599
+ redraw(fresh);
600
+ } catch (error) {
601
+ toast(error.message, 'danger');
602
+ }
603
+ },
604
+ }, 'Move to Drafts')) : null;
470
605
  const problems = invoice?.problems?.length
471
606
  ? notice(invoice.problems.map((p) => p.message).join(' '), 'danger', 'Problem.')
472
607
  : null;
473
608
 
474
609
  return [
475
- crumbs(ui, isNew ? 'New invoice' : ready ? 'Ready' : 'Draft'),
610
+ navBar(ui, invoice?.id ?? null),
476
611
  h('header', { class: 'fx-ph' },
477
612
  h('div', { class: 'fx-ph__text' },
478
613
  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)),
614
+ h('p', { class: 'fx-ph__sub' }, draftNote(to, invoice, ready))),
615
+ h('div', { class: 'fx-ph__actions' }, saveButton, markReadyButton, sendButton ?? issueButton)),
481
616
  problems,
482
617
  h('div', { class: 'fx-invoice__grid' },
483
618
  h('div', { class: 'fx-invoice__main' },
@@ -496,8 +631,12 @@ function draftView(invoice, ui, context, redraw) {
496
631
  h('div', { class: 'fx-lines__foot' },
497
632
  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
633
  totalsHost))),
499
- h('aside', { class: 'fx-invoice__aside' }, jobCard(ui, invoice?.job))),
500
- ];
634
+ h('aside', { class: 'fx-invoice__aside' }, statusCard, jobCard(ui, invoice?.job))),
635
+ ].map((part) => {
636
+ // Any edit on the page shows Save changes; undoing it hides it again.
637
+ for (const kind of ['input', 'change', 'click']) part?.addEventListener?.(kind, () => setTimeout(watchChanges));
638
+ return part;
639
+ });
501
640
  }
502
641
 
503
642
  // An issued or void invoice: the document as sent, and what can happen next.
@@ -516,16 +655,32 @@ function issuedView(invoice, ui, context, redraw) {
516
655
  toast(error.message, 'danger');
517
656
  }
518
657
  };
658
+ const organisationId = context.organisations?.[0]?.id;
659
+ const xeroHost = h('div');
660
+ let xeroStatus = null;
661
+ const loadXero = async () => {
662
+ if (!context.xero) return;
663
+ xeroStatus = (await xeroStatuses(ui, organisationId, [invoice.id])).get(invoice.id) ?? null;
664
+ xeroHost.replaceChildren(xeroCard(invoice, ui, context, xeroStatus, loadXero) ?? '');
665
+ };
666
+ loadXero();
667
+ // Voiding in Flow never changes Xero: say so while Xero still holds it live.
668
+ const voidBody = () => {
669
+ const live = xeroStatus?.xero && !['VOIDED', 'DELETED'].includes(xeroStatus.xero.status);
670
+ return h('div', {},
671
+ h('p', {}, 'The number stays used. A void invoice cannot be issued again.'),
672
+ live ? h('p', { class: 'fx-problem' }, `Xero still holds it as ${xeroStatus.label.replace(/ in Xero$/, '').toLowerCase()}${xeroStatus.xero.number ? ` (${xeroStatus.xero.number})` : ''}. Flow does not change Xero: void it in Xero too.`) : null);
673
+ };
519
674
  const actions = invoice.status === 'issued' && context.canIssue ? [
520
675
  h('button', {
521
676
  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' }),
677
+ onclick: () => act('void', `${invoice.number} is void.`, { title: `Void ${invoice.number}?`, body: voidBody(), confirm: 'Void', tone: 'danger' }),
523
678
  }, 'Void'),
524
679
  h('button', { class: 'fx-btn', type: 'button', onclick: () => act('revise', `${invoice.number} is a draft again.`) }, 'Revise'),
525
680
  sendToXero(invoice, ui, context),
526
681
  ] : [];
527
682
  return [
528
- crumbs(ui, invoice.number),
683
+ navBar(ui, invoice.id),
529
684
  h('header', { class: 'fx-ph' },
530
685
  h('div', { class: 'fx-ph__text' },
531
686
  h('div', { class: 'fx-row' },
@@ -551,6 +706,7 @@ function issuedView(invoice, ui, context, redraw) {
551
706
  h('td', { class: 'fx-num fx-strong' }, formatCents(line.lineAmountCents)))))),
552
707
  h('div', { class: 'fx-lines__foot' }, h('span'), totalsBox(ui, invoice)))),
553
708
  h('aside', { class: 'fx-invoice__aside' },
709
+ xeroHost,
554
710
  jobCard(ui, invoice.job),
555
711
  invoice.versions?.length ? h('section', { class: 'fx-card fx-side-card' },
556
712
  h('h3', { class: 'fx-label' }, 'History'),
@@ -560,8 +716,44 @@ function issuedView(invoice, ui, context, redraw) {
560
716
  ];
561
717
  }
562
718
 
719
+ // Xero's copy of this invoice: its status, any difference from what Flow
720
+ // sent, and Mark resolved, which reads Xero's changes and checks again. It
721
+ // never writes to Xero: a person fixes the difference where they choose.
722
+ function xeroCard(invoice, ui, context, status, reload) {
723
+ const { h, flow, toast } = ui;
724
+ if (!status || (status.state === 'not_sent' && !status.finding)) return null;
725
+ const rows = status.differences.map((d) => h('tr', {},
726
+ h('td', {}, FIELD_LABEL[d.field] ?? d.field),
727
+ h('td', { class: 'fx-num' }, showField(d.field, d.flow)),
728
+ h('td', { class: 'fx-num' }, showField(d.field, d.xero))));
729
+ const resolve = status.finding && context.canIssue ? h('button', {
730
+ class: 'fx-btn fx-btn--sm', type: 'button',
731
+ onclick: async () => {
732
+ resolve.disabled = true;
733
+ try {
734
+ const res = await flow(`/api/xero/invoices/${invoice.id}/recheck`, { method: 'POST', body: JSON.stringify({ organisationId: context.organisations[0].id }) });
735
+ toast(res.resolved ? 'Flow and Xero agree now.' : 'Still different. Fix it in Xero or in Flow, then try again.', res.resolved ? 'success' : 'danger');
736
+ await reload();
737
+ } catch (error) {
738
+ toast(error.message, 'danger');
739
+ resolve.disabled = false;
740
+ }
741
+ },
742
+ }, 'Mark resolved') : null;
743
+ return h('section', { class: 'fx-card fx-side-card' },
744
+ h('h3', { class: 'fx-label' }, 'Xero'),
745
+ h('div', { class: 'fx-row' }, xeroTag(ui, status), status.xero?.number ? h('span', { class: 'fx-dim' }, status.xero.number) : null),
746
+ status.finding ? h('p', { class: 'fx-problem' }, status.message) : null,
747
+ rows.length ? h('table', { class: 'fx-table' },
748
+ h('thead', {}, h('tr', {}, h('th', {}, ''), h('th', { class: 'fx-num' }, 'Flow sent'), h('th', { class: 'fx-num' }, 'Xero now'))),
749
+ h('tbody', {}, rows)) : null,
750
+ h('div', { class: 'fx-row' },
751
+ status.xero?.url ? h('a', { class: 'fx-btn fx-btn--sm fx-btn--ghost', href: status.xero.url, target: '_blank', rel: 'noopener' }, 'Open in Xero') : null,
752
+ resolve));
753
+ }
754
+
563
755
  // 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
756
+ // (Settings, Invoice rules) decides what Xero gets: a draft that a person approves
565
757
  // there, or an approved invoice that Xero emails to the customer.
566
758
  function sendToXero(invoice, ui, context) {
567
759
  const { h, flow, toast } = ui;
@@ -28,8 +28,9 @@ export function lineMappingSection(ui) {
28
28
  return;
29
29
  }
30
30
  const waiting = labels.filter((l) => l.unmapped > 0);
31
+ const listLink = row('Price list', 'Your codes and prices, and their links.', h('a', { class: 'fx-btn fx-btn--ghost fx-btn--sm', href: '/price-list', onclick: (event) => ui.go(event) }, 'Open'));
31
32
  if (!waiting.length) {
32
- body.replaceChildren(row('Every line has a price code', 'Lines from Seazona find their price item by themselves.', h('span', { class: 'fx-dim' }, '')));
33
+ body.replaceChildren(row('Every line has a price code', 'Lines from Seazona find their price item by themselves.', h('span', { class: 'fx-dim' }, '')), listLink);
33
34
  return;
34
35
  }
35
36
  const options = [h('option', { value: '' }, 'Choose a price item…'),
@@ -53,7 +54,7 @@ export function lineMappingSection(ui) {
53
54
  });
54
55
  const lines = label.unmapped === 1 ? '1 line waits' : `${label.unmapped} lines wait`;
55
56
  return row(label.sample, `${money(label.unitAmountCents)} · ${lines}`, select);
56
- }));
57
+ }), listLink);
57
58
  }
58
59
  load();
59
60
  return section;