@arcforgelabs/flow 2026.10.32-beta.1 → 2026.10.33

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 (59) hide show
  1. package/README.md +3 -1
  2. package/npm-shrinkwrap.json +2 -2
  3. package/package.json +2 -1
  4. package/server/api/invoicing.js +69 -26
  5. package/server/app.js +4 -0
  6. package/server/core/automations.js +17 -3
  7. package/server/core/invoices.js +2 -2
  8. package/server/core/invoicing-rules.js +64 -5
  9. package/server/core/job-board.js +15 -4
  10. package/server/core/ledger.js +245 -8
  11. package/server/core/pipeline.js +30 -22
  12. package/server/core/read-before-act.js +96 -0
  13. package/server/core/reconcile.js +350 -0
  14. package/server/core/references.js +99 -0
  15. package/server/core/sending.js +52 -8
  16. package/server/db/migrations/0016_invoice_reference.sql +1 -0
  17. package/server/db/migrations/0017_sync_basis.sql +1 -0
  18. package/server/db/migrations/meta/0016_snapshot.json +3861 -0
  19. package/server/db/migrations/meta/0017_snapshot.json +3867 -0
  20. package/server/db/migrations/meta/_journal.json +14 -0
  21. package/server/db/schema.js +5 -0
  22. package/server/index.js +5 -0
  23. package/server/integrations/port.js +4 -0
  24. package/server/integrations/seazona/act.js +48 -0
  25. package/server/integrations/seazona/feed.js +77 -50
  26. package/server/integrations/seazona/governor.js +28 -2
  27. package/server/integrations/seazona/index.js +69 -3
  28. package/server/integrations/seazona/lanes.js +82 -9
  29. package/server/integrations/seazona/ledger.js +14 -9
  30. package/server/integrations/seazona/migrations/0005_record_reread.sql +1 -0
  31. package/server/integrations/seazona/migrations/0006_sweep_findings.sql +11 -0
  32. package/server/integrations/seazona/migrations/meta/0005_snapshot.json +1329 -0
  33. package/server/integrations/seazona/migrations/meta/0006_snapshot.json +1403 -0
  34. package/server/integrations/seazona/migrations/meta/_journal.json +14 -0
  35. package/server/integrations/seazona/patients.js +30 -0
  36. package/server/integrations/seazona/reconcile.js +40 -0
  37. package/server/integrations/seazona/scheduler.js +32 -4
  38. package/server/integrations/seazona/schema.js +21 -0
  39. package/server/integrations/seazona/settings.js +12 -0
  40. package/server/integrations/seazona/store.js +24 -2
  41. package/server/integrations/seazona/sweep.js +574 -0
  42. package/server/integrations/seazona/tables.js +1 -0
  43. package/server/integrations/xero/client.js +12 -0
  44. package/server/integrations/xero/import.js +9 -3
  45. package/server/integrations/xero/index.js +7 -1
  46. package/server/integrations/xero/migrations/0007_xero_webhooks.sql +30 -0
  47. package/server/integrations/xero/migrations/meta/_journal.json +7 -0
  48. package/server/integrations/xero/mirror.js +16 -8
  49. package/server/integrations/xero/routes.js +20 -0
  50. package/server/integrations/xero/settings.js +3 -0
  51. package/server/integrations/xero/webhooks.js +346 -0
  52. package/server/ui/app.js +15 -7
  53. package/server/ui/flow.css +56 -7
  54. package/server/ui/invoice-notes.js +26 -0
  55. package/server/ui/invoicing-settings.js +3 -1
  56. package/server/ui/invoicing.js +231 -68
  57. package/server/ui/job-board.js +2 -1
  58. package/server/ui/jobs.js +19 -1
  59. package/server/ui/routes.js +1 -1
@@ -6,12 +6,13 @@
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
8
  import { alertStrip } from '/assets/ui/alerts.js';
9
+ import { draftNote, sendModeNote } from '/assets/ui/invoice-notes.js';
9
10
 
10
11
  const STATUS_LABEL = { draft: 'Draft', issued: 'Issued', void: 'Void' };
11
12
  const STATUS_TONE = { draft: 'info', issued: 'success', void: 'mute' };
12
13
  // Drafts (to check), Problem and Ready are views (server/core/sending.js):
13
- // every draft is in one of them. A person marks a checked draft ready; any
14
- // 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
15
16
  // problem.
16
17
  // Ready first: it opens by default, as the drafts most often wait there to send.
17
18
  const TABS = [['ready', 'Ready'], ['to_check', 'Drafts'], ['problem', 'Problem'], ['issued', 'Issued'], ['void', 'Void']];
@@ -75,29 +76,18 @@ function localDate(date = new Date()) {
75
76
 
76
77
  const showDate = (iso) => (iso ? new Date(`${iso}T00:00:00`).toLocaleDateString('en-AU', { day: 'numeric', month: 'short', year: 'numeric' }) : '—');
77
78
 
78
- // What happens to a ready invoice in the accounting system, one fact per
79
- // sentence. It follows the integration's settings: its connection, whether it
80
- // takes writes, and the send mode on its page.
81
- export function sendModeNote(to, sendMode) {
82
- if (!to) return 'No accounting system is on. Flow keeps each invoice here only.';
83
- if (sendMode && !sendMode.connected) return `${to} is not connected. Connect it in Integrations before you send.`;
84
- if (sendMode && !sendMode.writesAllowed) return `${to} does not take invoices from Flow yet. Turn this on in Integrations, ${to}.`;
85
- const text = sendMode?.mode === 'approve_and_email'
86
- ? [`Flow sends each invoice to ${to} as approved.`, `${to} emails it to the customer at once.`]
87
- : [`Flow sends each invoice to ${to} as a draft.`, `You approve and send it in ${to}.`];
88
- const others = sendMode?.otherCustomers ?? 0;
89
- if (others) text.push(`${others} ${others === 1 ? 'customer has' : 'customers have'} a different setting.`);
90
- return text.join(' ');
91
- }
92
-
93
79
  // ── The list ───────────────────────────────────────────────────────────────
94
80
 
95
81
  export async function renderInvoiceList(main, ui) {
96
82
  const { h, flow, pageHeader, notice, go, toast } = ui;
97
83
  const saved = sessionStorage.getItem('flow_invoice_tab');
98
- const state = { status: TABS.some(([id]) => id === saved) ? saved : 'ready', 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() };
99
89
  const tabs = h('div', { class: 'fx-tabs', role: 'tablist' });
100
- 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 });
101
91
  const bar = h('div', { class: 'fx-sendbar', hidden: true });
102
92
  const host = h('div', { class: 'fx-panel' }, h('div', { class: 'fx-skeleton' }));
103
93
  const context = await flow('/api/invoicing/context').catch(() => ({ canIssue: false }));
@@ -129,6 +119,7 @@ export async function renderInvoiceList(main, ui) {
129
119
  const res = await flow(`/api/invoicing/invoices/${path}`, { method: 'POST', body: JSON.stringify(payload()) });
130
120
  toast(...done(res));
131
121
  state.ticked.clear();
122
+ seen.clear();
132
123
  await load();
133
124
  alerts.refresh();
134
125
  } catch (error) {
@@ -156,7 +147,7 @@ export async function renderInvoiceList(main, ui) {
156
147
  parts.push(h('p', { class: 'fx-dim' }, sendModeNote(to, body.sendMode)));
157
148
  const buttons = [];
158
149
  if (ticked.length) {
159
- buttons.push(action(`Back to drafts (${ticked.length})`, 'mark-not-ready', () => ({ invoiceIds: ticked }),
150
+ buttons.push(action(`Move ${ticked.length} to Drafts`, 'mark-not-ready', () => ({ invoiceIds: ticked }),
160
151
  (res) => [`${plural(res.moved, 'invoice', 'invoices')} back in Drafts.`, 'success'], 'ghost'));
161
152
  }
162
153
  if (context.canIssue && body.counts.ready > 0) {
@@ -175,23 +166,56 @@ export async function renderInvoiceList(main, ui) {
175
166
  bar.hidden = !parts.length;
176
167
  }
177
168
 
169
+ // A tab switch shows at once: the tab is selected, the last list seen for
170
+ // it (if any) is drawn and dimmed, and the fresh list replaces it. Only the
171
+ // newest request draws, so quick clicks never show an older tab's list.
172
+ const seen = new Map();
173
+ let asked = 0;
178
174
  async function load() {
175
+ const query = new URLSearchParams({ status: state.status, ...(state.q ? { q: state.q } : {}) });
176
+ const key = String(query);
177
+ const mine = ++asked;
178
+ if (seen.has(key)) {
179
+ delete host.dataset.stale;
180
+ draw(seen.get(key));
181
+ } else if (body) {
182
+ drawTabs(body.counts);
183
+ bar.hidden = true;
184
+ host.dataset.stale = '';
185
+ }
186
+ host.setAttribute('aria-busy', 'true');
187
+ let answer;
179
188
  try {
180
- const query = new URLSearchParams({ status: state.status, ...(state.q ? { q: state.q } : {}) });
181
- body = await flow(`/api/invoicing/invoices?${query}`);
189
+ answer = await flow(`/api/invoicing/invoices?${query}`);
182
190
  } catch (error) {
191
+ if (mine !== asked) return;
192
+ host.removeAttribute('aria-busy');
193
+ delete host.dataset.stale;
183
194
  host.replaceChildren(notice(error.message, 'danger'));
184
195
  return;
185
196
  }
186
- const shown = new Set(body.invoices.map((i) => i.id));
187
- for (const id of state.ticked) if (!shown.has(id)) state.ticked.delete(id);
197
+ if (mine !== asked) return;
198
+ seen.set(key, answer);
199
+ host.removeAttribute('aria-busy');
200
+ delete host.dataset.stale;
201
+ draw(answer);
202
+ }
203
+
204
+ function drawTabs(counts) {
188
205
  tabs.replaceChildren(...TABS.map(([id, label]) => h('button', {
189
206
  type: 'button',
190
207
  role: 'tab',
191
208
  'aria-selected': String(state.status === id),
192
- ...(id === 'problem' && body.counts?.problem ? { 'data-tone': 'danger' } : {}),
193
- onclick: () => openTab(id),
194
- }, label, h('span', { class: 'fx-tabs__count' }, String(body.counts?.[id] ?? 0)))));
209
+ ...(id === 'problem' && counts?.problem ? { 'data-tone': 'danger' } : {}),
210
+ onclick: () => { if (state.status !== id) openTab(id); },
211
+ }, label, h('span', { class: 'fx-tabs__count' }, String(counts?.[id] ?? 0)))));
212
+ }
213
+
214
+ async function draw(answer) {
215
+ body = answer;
216
+ const shown = new Set(body.invoices.map((i) => i.id));
217
+ for (const id of state.ticked) if (!shown.has(id)) state.ticked.delete(id);
218
+ drawTabs(body.counts);
195
219
  drawBar();
196
220
  if (!body.invoices.length) {
197
221
  host.replaceChildren(h('p', { class: 'fx-empty' }, state.q
@@ -199,6 +223,9 @@ export async function renderInvoiceList(main, ui) {
199
223
  : EMPTY[state.status] ?? `No ${STATUS_LABEL[state.status].toLowerCase()} invoices yet.`));
200
224
  return;
201
225
  }
226
+ // The invoice page steps through this list, in this order (navBar).
227
+ saveNav({ tab: state.status, q: state.q, ids: body.invoices.map((i) => i.id) });
228
+ const open = (href) => { saveNav({ ...readNav(), scrollY: window.scrollY }); go(href); };
202
229
  const ticks = TICKS[state.status];
203
230
  const showProblem = state.status === 'problem';
204
231
  const created = ['to_check', 'problem', 'ready'].includes(state.status);
@@ -231,13 +258,13 @@ export async function renderInvoiceList(main, ui) {
231
258
  const href = `/invoices/${invoice.id}`;
232
259
  const first = invoice.problems?.[0];
233
260
  const view = invoice.status === 'void' ? null : invoice.view;
234
- return h('tr', { tabindex: '0', onclick: () => go(href), onkeydown: (event) => { if (event.key === 'Enter') go(href); } },
261
+ return h('tr', { tabindex: '0', onclick: () => open(href), onkeydown: (event) => { if (event.key === 'Enter') open(href); } },
235
262
  ticks ? h('td', { class: 'fx-tick-cell', onclick: (event) => event.stopPropagation() }, tickBox(invoice)) : null,
236
- h('td', {}, h('a', { class: 'fx-strong', href, onclick: go }, invoice.number ?? 'Draft')),
263
+ h('td', {}, h('a', { class: 'fx-strong', href, onclick: (event) => { event.preventDefault(); event.stopPropagation(); open(href); } }, invoice.number ?? 'Draft')),
237
264
  h('td', {}, invoice.contactName ?? '—'),
238
265
  showProblem
239
266
  ? h('td', { class: 'fx-problem' }, first?.message ?? '—', invoice.problems.length > 1 ? h('span', { class: 'fx-dim' }, ` +${invoice.problems.length - 1} more`) : null)
240
- : h('td', { class: 'fx-dim' }, invoice.job?.caseRef ?? invoice.reference ?? '—'),
267
+ : h('td', { class: 'fx-dim' }, invoice.reference ?? '—'),
241
268
  h('td', { class: 'fx-dim' }, showDate(invoice.issueDate ?? invoice.createdAt?.slice(0, 10))),
242
269
  h('td', { class: 'fx-num fx-strong' }, formatCents(invoice.totalCents)),
243
270
  (() => {
@@ -259,6 +286,51 @@ export async function renderInvoiceList(main, ui) {
259
286
  }
260
287
  }
261
288
  await load();
289
+ if (returning && back?.scrollY) window.scrollTo(0, back.scrollY);
290
+ }
291
+
292
+ // The list an invoice was opened from: its tab, search, invoice ids in order,
293
+ // and scroll. The invoice page's bar steps through it and goes back to it.
294
+ const NAV = 'flow_invoice_nav';
295
+ const NAV_BACK = 'flow_invoice_back';
296
+ function readNav() {
297
+ try { return JSON.parse(sessionStorage.getItem(NAV) ?? 'null'); } catch { return null; }
298
+ }
299
+ function saveNav(nav) {
300
+ try { sessionStorage.setItem(NAV, JSON.stringify(nav)); } catch { /* the bar then shows Back only */ }
301
+ }
302
+
303
+ // The bar above one invoice: Back to the list as it was, and the previous and
304
+ // next invoice of that list.
305
+ function navBar(ui, id) {
306
+ const { h, go } = ui;
307
+ const nav = readNav();
308
+ const ids = nav?.ids ?? [];
309
+ const at = ids.indexOf(id);
310
+ const back = () => { sessionStorage.setItem(NAV_BACK, '1'); go('/invoices'); };
311
+ const step = (to, label, path) => {
312
+ const button = h('button', {
313
+ class: 'fx-pager__step', type: 'button', disabled: !to, 'aria-label': label, title: label,
314
+ onclick: () => go(`/invoices/${to}`),
315
+ });
316
+ 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>`;
317
+ return button;
318
+ };
319
+ return h('nav', { class: 'fx-pager', 'aria-label': 'Invoices' },
320
+ h('button', { class: 'fx-pager__back', type: 'button', onclick: back }, '← Back'),
321
+ at >= 0 ? h('span', { class: 'fx-pager__count' }, `${at + 1} of ${ids.length}`) : null,
322
+ 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);
323
+ }
324
+
325
+ // After a send, the next invoice of the list opens; at its end, the list.
326
+ // The sent invoice stays in the bar's list, so the count holds (2 of 35) and
327
+ // Previous opens it again, now sent. Back reloads the list without it.
328
+ export function openNext(ui, id) {
329
+ const ids = readNav()?.ids ?? [];
330
+ const at = ids.indexOf(id);
331
+ const next = at >= 0 ? ids[at + 1] : null;
332
+ if (next) ui.go(`/invoices/${next}`);
333
+ else { sessionStorage.setItem(NAV_BACK, '1'); ui.go('/invoices'); }
262
334
  }
263
335
 
264
336
  // ── One invoice ────────────────────────────────────────────────────────────
@@ -285,17 +357,11 @@ export async function renderInvoice(main, ui, id) {
285
357
  const draw = (current) => {
286
358
  host.replaceChildren(...(current?.status && current.status !== 'draft'
287
359
  ? issuedView(current, ui, context, draw)
288
- : draftView(current, ui, context, draw)));
360
+ : draftView(current, ui, context, draw)).filter(Boolean));
289
361
  };
290
362
  draw(invoice);
291
363
  }
292
364
 
293
- function crumbs(ui, label) {
294
- const { h, go } = ui;
295
- return h('nav', { class: 'fx-crumbs', 'aria-label': 'Breadcrumb' },
296
- h('a', { href: '/invoices', onclick: go }, 'Invoices'), h('span', { 'aria-hidden': 'true' }, '/'), h('span', {}, label));
297
- }
298
-
299
365
  function totalsBox(ui, { subtotalCents, taxCents, totalCents }) {
300
366
  const { h } = ui;
301
367
  return h('dl', { class: 'fx-totals' },
@@ -304,13 +370,13 @@ function totalsBox(ui, { subtotalCents, taxCents, totalCents }) {
304
370
  h('dt', { class: 'fx-totals__grand' }, 'Total'), h('dd', { class: 'fx-totals__grand' }, formatCents(totalCents)));
305
371
  }
306
372
 
373
+ // The job an invoice bills. The case id stays on the record, not on screen.
307
374
  function jobCard(ui, job) {
308
375
  const { h } = ui;
309
376
  if (!job) return null;
310
377
  return h('section', { class: 'fx-card fx-side-card' },
311
378
  h('h3', { class: 'fx-label' }, 'Job'),
312
379
  h('dl', { class: 'fx-facts' },
313
- h('dt', {}, 'Case'), h('dd', {}, job.caseRef ?? job.sourceJobRef),
314
380
  job.practitioner ? [h('dt', {}, 'Practitioner'), h('dd', {}, job.practitioner)] : null,
315
381
  job.shippedOn ? [h('dt', {}, 'Shipped'), h('dd', {}, showDate(job.shippedOn))] : null,
316
382
  h('dt', {}, 'From'), h('dd', {}, job.sourceSystem === 'seazona' ? 'Seazona' : job.sourceSystem)));
@@ -471,24 +537,56 @@ function draftView(invoice, ui, context, redraw) {
471
537
  }
472
538
  };
473
539
 
540
+ // Save shows only when there is something to save: a new invoice, or a
541
+ // change since the page opened. Saving keeps the invoice where it is.
474
542
  const saveButton = h('button', {
475
- class: 'fx-btn', type: 'button',
543
+ class: 'fx-btn', type: 'button', hidden: !isNew,
476
544
  onclick: async () => {
477
545
  const saved = await save();
478
546
  if (!saved) return;
479
- toast('Draft saved.', 'success');
547
+ toast(isNew ? 'Draft saved.' : 'Changes saved.', 'success');
480
548
  if (isNew) go(`/invoices/${saved.id}`, { replace: true });
481
549
  else redraw(saved);
482
550
  },
483
- }, 'Save draft');
484
- const issueButton = context.canIssue ? h('button', {
551
+ }, isNew ? 'Save draft' : 'Save changes');
552
+ const opened = JSON.stringify(body());
553
+ const changed = () => !isNew && JSON.stringify(body()) !== opened;
554
+ const watchChanges = () => { if (!isNew) saveButton.hidden = !changed(); };
555
+ // With an accounting system on, a ready invoice is sent there: Flow issues
556
+ // it and queues it, the same as Send all ready. With none, Flow issues it
557
+ // and keeps it. A draft that is not ready has no send: mark it ready first.
558
+ const to = invoice?.sendsTo ?? null;
559
+ const ready = invoice?.view === 'ready';
560
+ const nextNumber = `${context.organisations[0]?.invoicePrefix ?? ''}${String(context.organisations[0]?.nextInvoiceNumber ?? '').padStart(4, '0')}`;
561
+ const emails = invoice?.sendMode?.mode === 'approve_and_email';
562
+ const sendButton = context.canIssue && to && ready ? h('button', {
563
+ class: 'fx-btn fx-btn--primary', type: 'button',
564
+ onclick: async () => {
565
+ if (changed() && !(await save())) return;
566
+ const ok = await ui.dialog({
567
+ title: `Send this invoice to ${to}?`,
568
+ body: h('p', {}, `It gets the next number (${nextNumber}). ${sendModeNote(to, invoice.sendMode, 'it')}`),
569
+ confirm: emails ? 'Send and email' : 'Send',
570
+ });
571
+ if (!ok) return;
572
+ try {
573
+ await flow('/api/invoicing/invoices/send-ready', { method: 'POST', body: JSON.stringify({ issueDate: localDate(), invoiceIds: [invoice.id] }) });
574
+ const { invoice: fresh } = await flow(`/api/invoicing/invoices/${invoice.id}`);
575
+ toast(`${fresh.number} is on the way to ${to}.`, 'success');
576
+ openNext(ui, invoice.id);
577
+ } catch (error) {
578
+ toast(error.message, 'danger');
579
+ }
580
+ },
581
+ }, emails ? `Send to ${to} and email` : `Send to ${to}`) : null;
582
+ const issueButton = context.canIssue && !to ? h('button', {
485
583
  class: 'fx-btn fx-btn--primary', type: 'button',
486
584
  onclick: async () => {
487
585
  const saved = await save();
488
586
  if (!saved) return;
489
587
  const ok = await ui.dialog({
490
588
  title: 'Issue this invoice?',
491
- 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.`),
589
+ body: h('p', {}, `It gets the next number (${nextNumber}). Flow keeps it exactly as issued. To change it later, revise it.`),
492
590
  confirm: 'Issue',
493
591
  });
494
592
  if (!ok) { if (isNew) go(`/invoices/${saved.id}`, { replace: true }); else redraw(saved); return; }
@@ -504,40 +602,51 @@ function draftView(invoice, ui, context, redraw) {
504
602
  }, 'Issue invoice') : null;
505
603
 
506
604
  // Mark ready: the draft is saved, then Flow checks it; one with a problem
507
- // stays a draft. Back to drafts undoes it.
508
- const ready = invoice?.view === 'ready';
509
- const readyButton = isNew ? null : h('button', {
605
+ // stays a draft. Move to Drafts, on the Status card, undoes it.
606
+ const markReadyButton = isNew || ready ? null : h('button', {
510
607
  class: 'fx-btn', type: 'button',
511
608
  onclick: async () => {
512
- const current = ready ? invoice : await save();
513
- if (!current) return;
609
+ if (!(await save())) return;
514
610
  try {
515
- if (ready) {
516
- await flow('/api/invoicing/invoices/mark-not-ready', { method: 'POST', body: JSON.stringify({ invoiceIds: [invoice.id] }) });
517
- toast('Back in Drafts.', 'success');
518
- } else {
519
- const res = await flow('/api/invoicing/invoices/mark-ready', { method: 'POST', body: JSON.stringify({ invoiceIds: [invoice.id] }) });
520
- if (res.marked) toast('Ready to send.', 'success');
521
- else toast(res.refused[0]?.problems.map((p) => p.message).join(' ') ?? 'It has a problem.', 'danger');
522
- }
611
+ const res = await flow('/api/invoicing/invoices/mark-ready', { method: 'POST', body: JSON.stringify({ invoiceIds: [invoice.id] }) });
612
+ if (res.marked) toast('Ready to send.', 'success');
613
+ else toast(res.refused[0]?.problems.map((p) => p.message).join(' ') ?? 'It has a problem.', 'danger');
523
614
  const { invoice: fresh } = await flow(`/api/invoicing/invoices/${invoice.id}`);
524
615
  redraw(fresh);
525
616
  } catch (error) {
526
617
  toast(error.message, 'danger');
527
618
  }
528
619
  },
529
- }, ready ? 'Back to drafts' : 'Mark ready');
620
+ }, 'Mark ready');
621
+ // A ready invoice can go back to Drafts. It is a status change, so it sits
622
+ // with the status, not with the page's main buttons.
623
+ const statusCard = ready ? h('section', { class: 'fx-card fx-side-card' },
624
+ h('h3', { class: 'fx-label' }, 'Status'),
625
+ h('p', {}, 'Ready to send.'),
626
+ h('button', {
627
+ class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button',
628
+ onclick: async () => {
629
+ try {
630
+ await flow('/api/invoicing/invoices/mark-not-ready', { method: 'POST', body: JSON.stringify({ invoiceIds: [invoice.id] }) });
631
+ toast('Moved to Drafts.', 'success');
632
+ const { invoice: fresh } = await flow(`/api/invoicing/invoices/${invoice.id}`);
633
+ redraw(fresh);
634
+ } catch (error) {
635
+ toast(error.message, 'danger');
636
+ }
637
+ },
638
+ }, 'Move to Drafts')) : null;
530
639
  const problems = invoice?.problems?.length
531
640
  ? notice(invoice.problems.map((p) => p.message).join(' '), 'danger', 'Problem.')
532
641
  : null;
533
642
 
534
643
  return [
535
- crumbs(ui, isNew ? 'New invoice' : ready ? 'Ready' : 'Draft'),
644
+ navBar(ui, invoice?.id ?? null),
536
645
  h('header', { class: 'fx-ph' },
537
646
  h('div', { class: 'fx-ph__text' },
538
647
  h('h1', { class: 'fx-title' }, isNew ? 'New invoice' : ready ? 'Ready invoice' : 'Draft invoice'),
539
- 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.')),
540
- h('div', { class: 'fx-ph__actions' }, saveButton, readyButton, issueButton)),
648
+ h('p', { class: 'fx-ph__sub' }, draftNote(to, invoice, ready))),
649
+ h('div', { class: 'fx-ph__actions' }, saveButton, markReadyButton, isNew ? null : previewButton(ui, invoice.id, 'Shows the saved invoice.'), sendButton ?? issueButton)),
541
650
  problems,
542
651
  h('div', { class: 'fx-invoice__grid' },
543
652
  h('div', { class: 'fx-invoice__main' },
@@ -556,8 +665,45 @@ function draftView(invoice, ui, context, redraw) {
556
665
  h('div', { class: 'fx-lines__foot' },
557
666
  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'),
558
667
  totalsHost))),
559
- h('aside', { class: 'fx-invoice__aside' }, jobCard(ui, invoice?.job))),
560
- ];
668
+ h('aside', { class: 'fx-invoice__aside' }, statusCard, jobCard(ui, invoice?.job))),
669
+ ].map((part) => {
670
+ // Any edit on the page shows Save changes; undoing it hides it again.
671
+ for (const kind of ['input', 'change', 'click']) part?.addEventListener?.(kind, () => setTimeout(watchChanges));
672
+ return part;
673
+ });
674
+ }
675
+
676
+ // Preview: the invoice as a PDF in a new tab (GET /api/invoicing/invoices/:id/pdf).
677
+ // The tab opens at once, because a browser blocks a tab opened after a wait,
678
+ // and shows the PDF when it is ready. If Flow cannot make it, the tab closes
679
+ // and a toast says why.
680
+ function previewButton(ui, invoiceId, title = 'Shows the invoice as a PDF.') {
681
+ const { h, toast } = ui;
682
+ return h('button', {
683
+ class: 'fx-btn', type: 'button', title,
684
+ onclick: async () => {
685
+ const tab = window.open('', '_blank');
686
+ if (tab) {
687
+ tab.document.title = 'Preparing PDF';
688
+ tab.document.body.textContent = 'Preparing PDF…';
689
+ }
690
+ try {
691
+ const token = await window.Clerk.session?.getToken();
692
+ const res = await fetch(`/api/invoicing/invoices/${invoiceId}/pdf`, { headers: { Authorization: `Bearer ${token}` } });
693
+ if (!res.ok) {
694
+ const body = await res.json().catch(() => ({}));
695
+ throw new Error(body.error || `HTTP ${res.status}`);
696
+ }
697
+ const url = URL.createObjectURL(await res.blob());
698
+ if (tab) tab.location.href = url;
699
+ else window.location.assign(url);
700
+ setTimeout(() => URL.revokeObjectURL(url), 60_000);
701
+ } catch (error) {
702
+ tab?.close();
703
+ toast(`The PDF could not be made. ${error.message}`, 'danger');
704
+ }
705
+ },
706
+ }, 'Preview');
561
707
  }
562
708
 
563
709
  // An issued or void invoice: the document as sent, and what can happen next.
@@ -579,10 +725,12 @@ function issuedView(invoice, ui, context, redraw) {
579
725
  const organisationId = context.organisations?.[0]?.id;
580
726
  const xeroHost = h('div');
581
727
  let xeroStatus = null;
728
+ const send = invoice.status === 'issued' && context.canIssue ? sendToXero(invoice, ui, context) : null;
582
729
  const loadXero = async () => {
583
730
  if (!context.xero) return;
584
731
  xeroStatus = (await xeroStatuses(ui, organisationId, [invoice.id])).get(invoice.id) ?? null;
585
732
  xeroHost.replaceChildren(xeroCard(invoice, ui, context, xeroStatus, loadXero) ?? '');
733
+ if (xeroStatus && xeroStatus.state !== 'not_sent') showSent(send);
586
734
  };
587
735
  loadXero();
588
736
  // Voiding in Flow never changes Xero: say so while Xero still holds it live.
@@ -598,17 +746,16 @@ function issuedView(invoice, ui, context, redraw) {
598
746
  onclick: () => act('void', `${invoice.number} is void.`, { title: `Void ${invoice.number}?`, body: voidBody(), confirm: 'Void', tone: 'danger' }),
599
747
  }, 'Void'),
600
748
  h('button', { class: 'fx-btn', type: 'button', onclick: () => act('revise', `${invoice.number} is a draft again.`) }, 'Revise'),
601
- sendToXero(invoice, ui, context),
602
749
  ] : [];
603
750
  return [
604
- crumbs(ui, invoice.number),
751
+ navBar(ui, invoice.id),
605
752
  h('header', { class: 'fx-ph' },
606
753
  h('div', { class: 'fx-ph__text' },
607
754
  h('div', { class: 'fx-row' },
608
755
  h('h1', { class: 'fx-title' }, invoice.number),
609
756
  h('span', { class: 'fx-status', 'data-tone': STATUS_TONE[invoice.status] }, STATUS_LABEL[invoice.status])),
610
757
  h('p', { class: 'fx-ph__sub' }, `${invoice.contact?.name ?? ''} · issued ${showDate(invoice.issueDate)}`)),
611
- h('div', { class: 'fx-ph__actions' }, actions)),
758
+ h('div', { class: 'fx-ph__actions' }, actions, previewButton(ui, invoice.id), send)),
612
759
  h('div', { class: 'fx-invoice__grid' },
613
760
  h('div', { class: 'fx-invoice__main' },
614
761
  h('section', { class: 'fx-card fx-doc' },
@@ -674,7 +821,7 @@ function xeroCard(invoice, ui, context, status, reload) {
674
821
  }
675
822
 
676
823
  // Send to Xero (POST /api/xero/invoices/push). The customer's send mode
677
- // (Settings, Invoicing) decides what Xero gets: a draft that a person approves
824
+ // (Settings, Invoice rules) decides what Xero gets: a draft that a person approves
678
825
  // there, or an approved invoice that Xero emails to the customer.
679
826
  function sendToXero(invoice, ui, context) {
680
827
  const { h, flow, toast } = ui;
@@ -684,6 +831,7 @@ function sendToXero(invoice, ui, context) {
684
831
  class: 'fx-btn fx-btn--primary', type: 'button',
685
832
  onclick: async () => {
686
833
  button.disabled = true;
834
+ let sent = false;
687
835
  try {
688
836
  const res = await flow('/api/xero/invoices/push', { method: 'POST', body: JSON.stringify({ organisationId, invoiceIds: [invoice.id] }) });
689
837
  const result = res.invoices?.[0];
@@ -693,18 +841,33 @@ function sendToXero(invoice, ui, context) {
693
841
  else if (['created', 'reconciled', 'existing'].includes(result?.outcome)) toast(`${invoice.number} is in Xero as a draft.`, 'success');
694
842
  else if (result?.outcome === 'already_in_xero') toast(`Xero already has ${invoice.number}.`, 'info');
695
843
  else toast(result?.error?.message ?? 'Xero did not take it.', 'danger');
844
+ sent = ['created', 'reconciled', 'existing', 'already_in_xero'].includes(result?.outcome);
696
845
  } catch (error) {
697
846
  toast(error.message, 'danger');
698
847
  } finally {
699
- button.disabled = false;
848
+ if (sent) showSent(button);
849
+ else button.disabled = false;
700
850
  }
701
851
  },
702
852
  }, 'Send to Xero');
853
+ // Sent or on its way (Send, or Send all ready): it cannot be sent twice.
854
+ if (['queued', 'sent'].includes(invoice.sendState)) {
855
+ showSent(button);
856
+ return button;
857
+ }
703
858
  // The preview reads only Flow's copy of Xero: it costs no Xero calls.
704
859
  flow('/api/xero/invoices/preview', { method: 'POST', body: JSON.stringify({ organisationId, invoiceIds: [invoice.id] }) })
705
860
  .then((res) => {
706
- if (res.invoices?.[0]?.sendMode === 'approve_and_email') button.textContent = 'Send to Xero and email';
861
+ if (res.invoices?.[0]?.sendMode === 'approve_and_email' && !button.disabled) button.textContent = 'Send to Xero and email';
707
862
  })
708
863
  .catch(() => {});
709
864
  return button;
710
865
  }
866
+
867
+ // A sent invoice's Send button stays, greyed, and says it went.
868
+ function showSent(button) {
869
+ if (!button) return;
870
+ button.disabled = true;
871
+ button.textContent = 'Sent to Xero';
872
+ button.title = 'Flow sent this invoice to Xero.';
873
+ }
@@ -91,7 +91,8 @@ function reasonFix(reason, acct) {
91
91
  if (reason === 'the work system report is out of date') {
92
92
  return { label: 'Out of date', text: 'Flow has no news of this job from the work system for more than a day.', todo: 'Check the work system under Settings, Integrations.' };
93
93
  }
94
- if (reason === 'the job changed after its invoice was made') return fix('Job changed', 'Compare the invoice with the job, then correct the invoice.');
94
+ if (reason === 'the job changed after its invoice was made') return fix('Job changed', 'Compare the invoice with the job, then correct the invoice. If only the job changed, use its new lines.');
95
+ if (reason === 'the job status moved back after its invoice was made') return fix('Status moved back', 'Check the job in the work system. Void or hold its invoice if the work is not done.');
95
96
  if (/deleted in /i.test(reason)) return fix('Deleted', 'Check the job in the work system. Void its invoice if it has one.');
96
97
  if (/^the accounting evidence is ambiguous/.test(reason)) return fix(`${acct} match unclear`, `Find the invoice for this job in ${acct}, then link it.`);
97
98
  if (/^the accounting evidence is conflict/.test(reason)) return fix(`Two ${acct} invoices`, `Find the right invoice for this job in ${acct}. Void the other.`);
package/server/ui/jobs.js CHANGED
@@ -18,6 +18,7 @@ const ACTIVITY = {
18
18
  in_work: 'Put back in work', drafted: 'Draft invoice made', draft_refused: 'Draft invoice refused',
19
19
  accounting_checked: 'Checked in the accounting system', payment_recorded: 'Payment recorded', paid: 'Paid in full',
20
20
  held: 'Put on hold', review: 'Sent to review', released: 'Hold removed',
21
+ status_moved_back: 'The job status moved back in the lab system', source_lines_used: 'New job lines used on the draft',
21
22
  writeback_sent: 'Status sent to the lab system', writeback_failed: 'Status not sent to the lab system',
22
23
  writeback_not_supported: 'The lab system does not take the status',
23
24
  };
@@ -172,7 +173,24 @@ export async function renderJobs(main, ui) {
172
173
  if (!o) return [];
173
174
  if (o.invoiceId) {
174
175
  const label = o.invoiceStatus === 'draft' ? 'Open draft invoice' : `Open invoice ${o.invoiceNumber ?? ''}`.trim();
175
- return [h('a', { class: 'fx-btn', href: `/invoices/${o.invoiceId}`, onclick: (event) => { close(); ui.go(event); } }, label)];
176
+ const open = h('a', { class: 'fx-btn', href: `/invoices/${o.invoiceId}`, onclick: (event) => { close(); ui.go(event); } }, label);
177
+ // Only the job changed and the draft is not sent (ledger.js): one tap
178
+ // puts the job's new lines on Flow's own draft.
179
+ if (!o.canUseNewLines) return [open];
180
+ return [h('button', {
181
+ class: 'fx-btn fx-btn--primary', type: 'button',
182
+ onclick: async (event) => {
183
+ event.currentTarget.disabled = true;
184
+ try {
185
+ await flow(`/api/invoicing/jobs/${o.id}/use-new-lines`, { method: 'POST', body: '{}' });
186
+ ui.toast('The draft has the new job lines.', 'success');
187
+ } catch (error) {
188
+ ui.toast(error.message, 'danger');
189
+ }
190
+ close();
191
+ await load();
192
+ },
193
+ }, 'Use new lines'), open];
176
194
  }
177
195
  // Flow's own stage: a person marks a job ready to invoice, or an
178
196
  // automation does (Settings, Automations).
@@ -20,7 +20,7 @@ const themeDir = join(here, '..', 'theme');
20
20
  const logoDir = join(here, 'logos');
21
21
  const LOGO_FILES = new Set(['xero.svg', 'seazona.svg', 'sentry.svg']);
22
22
  const THEME_FILES = new Set(['themes.js', 'color-contrast.js', 'openclaw-palettes.js', 'font-families.js']);
23
- const UI_FILES = new Set(['flow.css', 'appearance.js', 'clerk-appearance.js', 'app.js', 'invoicing.js', 'invoicing-settings.js', 'automations.js', 'invoice-rules.js', 'line-mappings.js', 'price-list.js', 'jobs.js', 'job-board.js', 'monitoring.js', 'search.js', 'alerts.js', 'flow-mark.svg']);
23
+ const UI_FILES = new Set(['flow.css', 'appearance.js', 'clerk-appearance.js', 'app.js', 'invoicing.js', 'invoice-notes.js', 'invoicing-settings.js', 'automations.js', 'invoice-rules.js', 'line-mappings.js', 'price-list.js', 'jobs.js', 'job-board.js', 'monitoring.js', 'search.js', 'alerts.js', 'flow-mark.svg']);
24
24
  const MAX_AGE = '1h';
25
25
 
26
26
  // Send one named file from dir. Passing dir as root limits the dotfile check