@arcforgelabs/flow 2026.10.32 → 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 (42) 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 +56 -24
  5. package/server/app.js +4 -0
  6. package/server/core/invoicing-rules.js +50 -4
  7. package/server/core/job-board.js +15 -4
  8. package/server/core/ledger.js +242 -6
  9. package/server/core/pipeline.js +29 -21
  10. package/server/core/read-before-act.js +96 -0
  11. package/server/core/reconcile.js +350 -0
  12. package/server/core/sending.js +44 -6
  13. package/server/db/migrations/0017_sync_basis.sql +1 -0
  14. package/server/db/migrations/meta/0017_snapshot.json +3867 -0
  15. package/server/db/migrations/meta/_journal.json +7 -0
  16. package/server/db/schema.js +3 -0
  17. package/server/index.js +5 -0
  18. package/server/integrations/port.js +3 -0
  19. package/server/integrations/seazona/act.js +48 -0
  20. package/server/integrations/seazona/feed.js +70 -50
  21. package/server/integrations/seazona/governor.js +28 -2
  22. package/server/integrations/seazona/index.js +60 -3
  23. package/server/integrations/seazona/lanes.js +81 -8
  24. package/server/integrations/seazona/ledger.js +14 -9
  25. package/server/integrations/seazona/migrations/0005_record_reread.sql +1 -0
  26. package/server/integrations/seazona/migrations/0006_sweep_findings.sql +11 -0
  27. package/server/integrations/seazona/migrations/meta/0005_snapshot.json +1329 -0
  28. package/server/integrations/seazona/migrations/meta/0006_snapshot.json +1403 -0
  29. package/server/integrations/seazona/migrations/meta/_journal.json +14 -0
  30. package/server/integrations/seazona/reconcile.js +40 -0
  31. package/server/integrations/seazona/scheduler.js +32 -4
  32. package/server/integrations/seazona/schema.js +21 -0
  33. package/server/integrations/seazona/settings.js +12 -0
  34. package/server/integrations/seazona/store.js +24 -2
  35. package/server/integrations/seazona/sweep.js +574 -0
  36. package/server/integrations/seazona/tables.js +1 -0
  37. package/server/integrations/xero/index.js +4 -0
  38. package/server/integrations/xero/webhooks.js +35 -0
  39. package/server/ui/flow.css +9 -0
  40. package/server/ui/invoicing.js +101 -17
  41. package/server/ui/job-board.js +2 -1
  42. package/server/ui/jobs.js +19 -1
@@ -119,6 +119,7 @@ export async function renderInvoiceList(main, ui) {
119
119
  const res = await flow(`/api/invoicing/invoices/${path}`, { method: 'POST', body: JSON.stringify(payload()) });
120
120
  toast(...done(res));
121
121
  state.ticked.clear();
122
+ seen.clear();
122
123
  await load();
123
124
  alerts.refresh();
124
125
  } catch (error) {
@@ -165,23 +166,56 @@ export async function renderInvoiceList(main, ui) {
165
166
  bar.hidden = !parts.length;
166
167
  }
167
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;
168
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;
169
188
  try {
170
- const query = new URLSearchParams({ status: state.status, ...(state.q ? { q: state.q } : {}) });
171
- body = await flow(`/api/invoicing/invoices?${query}`);
189
+ answer = await flow(`/api/invoicing/invoices?${query}`);
172
190
  } catch (error) {
191
+ if (mine !== asked) return;
192
+ host.removeAttribute('aria-busy');
193
+ delete host.dataset.stale;
173
194
  host.replaceChildren(notice(error.message, 'danger'));
174
195
  return;
175
196
  }
176
- const shown = new Set(body.invoices.map((i) => i.id));
177
- 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) {
178
205
  tabs.replaceChildren(...TABS.map(([id, label]) => h('button', {
179
206
  type: 'button',
180
207
  role: 'tab',
181
208
  'aria-selected': String(state.status === id),
182
- ...(id === 'problem' && body.counts?.problem ? { 'data-tone': 'danger' } : {}),
183
- onclick: () => openTab(id),
184
- }, 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);
185
219
  drawBar();
186
220
  if (!body.invoices.length) {
187
221
  host.replaceChildren(h('p', { class: 'fx-empty' }, state.q
@@ -289,12 +323,12 @@ function navBar(ui, id) {
289
323
  }
290
324
 
291
325
  // 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 ?? [];
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 ?? [];
295
330
  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) });
331
+ const next = at >= 0 ? ids[at + 1] : null;
298
332
  if (next) ui.go(`/invoices/${next}`);
299
333
  else { sessionStorage.setItem(NAV_BACK, '1'); ui.go('/invoices'); }
300
334
  }
@@ -612,7 +646,7 @@ function draftView(invoice, ui, context, redraw) {
612
646
  h('div', { class: 'fx-ph__text' },
613
647
  h('h1', { class: 'fx-title' }, isNew ? 'New invoice' : ready ? 'Ready invoice' : 'Draft invoice'),
614
648
  h('p', { class: 'fx-ph__sub' }, draftNote(to, invoice, ready))),
615
- h('div', { class: 'fx-ph__actions' }, saveButton, markReadyButton, sendButton ?? issueButton)),
649
+ h('div', { class: 'fx-ph__actions' }, saveButton, markReadyButton, isNew ? null : previewButton(ui, invoice.id, 'Shows the saved invoice.'), sendButton ?? issueButton)),
616
650
  problems,
617
651
  h('div', { class: 'fx-invoice__grid' },
618
652
  h('div', { class: 'fx-invoice__main' },
@@ -639,6 +673,39 @@ function draftView(invoice, ui, context, redraw) {
639
673
  });
640
674
  }
641
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');
707
+ }
708
+
642
709
  // An issued or void invoice: the document as sent, and what can happen next.
643
710
  function issuedView(invoice, ui, context, redraw) {
644
711
  const { h, flow, toast } = ui;
@@ -658,10 +725,12 @@ function issuedView(invoice, ui, context, redraw) {
658
725
  const organisationId = context.organisations?.[0]?.id;
659
726
  const xeroHost = h('div');
660
727
  let xeroStatus = null;
728
+ const send = invoice.status === 'issued' && context.canIssue ? sendToXero(invoice, ui, context) : null;
661
729
  const loadXero = async () => {
662
730
  if (!context.xero) return;
663
731
  xeroStatus = (await xeroStatuses(ui, organisationId, [invoice.id])).get(invoice.id) ?? null;
664
732
  xeroHost.replaceChildren(xeroCard(invoice, ui, context, xeroStatus, loadXero) ?? '');
733
+ if (xeroStatus && xeroStatus.state !== 'not_sent') showSent(send);
665
734
  };
666
735
  loadXero();
667
736
  // Voiding in Flow never changes Xero: say so while Xero still holds it live.
@@ -677,7 +746,6 @@ function issuedView(invoice, ui, context, redraw) {
677
746
  onclick: () => act('void', `${invoice.number} is void.`, { title: `Void ${invoice.number}?`, body: voidBody(), confirm: 'Void', tone: 'danger' }),
678
747
  }, 'Void'),
679
748
  h('button', { class: 'fx-btn', type: 'button', onclick: () => act('revise', `${invoice.number} is a draft again.`) }, 'Revise'),
680
- sendToXero(invoice, ui, context),
681
749
  ] : [];
682
750
  return [
683
751
  navBar(ui, invoice.id),
@@ -687,7 +755,7 @@ function issuedView(invoice, ui, context, redraw) {
687
755
  h('h1', { class: 'fx-title' }, invoice.number),
688
756
  h('span', { class: 'fx-status', 'data-tone': STATUS_TONE[invoice.status] }, STATUS_LABEL[invoice.status])),
689
757
  h('p', { class: 'fx-ph__sub' }, `${invoice.contact?.name ?? ''} · issued ${showDate(invoice.issueDate)}`)),
690
- h('div', { class: 'fx-ph__actions' }, actions)),
758
+ h('div', { class: 'fx-ph__actions' }, actions, previewButton(ui, invoice.id), send)),
691
759
  h('div', { class: 'fx-invoice__grid' },
692
760
  h('div', { class: 'fx-invoice__main' },
693
761
  h('section', { class: 'fx-card fx-doc' },
@@ -763,6 +831,7 @@ function sendToXero(invoice, ui, context) {
763
831
  class: 'fx-btn fx-btn--primary', type: 'button',
764
832
  onclick: async () => {
765
833
  button.disabled = true;
834
+ let sent = false;
766
835
  try {
767
836
  const res = await flow('/api/xero/invoices/push', { method: 'POST', body: JSON.stringify({ organisationId, invoiceIds: [invoice.id] }) });
768
837
  const result = res.invoices?.[0];
@@ -772,18 +841,33 @@ function sendToXero(invoice, ui, context) {
772
841
  else if (['created', 'reconciled', 'existing'].includes(result?.outcome)) toast(`${invoice.number} is in Xero as a draft.`, 'success');
773
842
  else if (result?.outcome === 'already_in_xero') toast(`Xero already has ${invoice.number}.`, 'info');
774
843
  else toast(result?.error?.message ?? 'Xero did not take it.', 'danger');
844
+ sent = ['created', 'reconciled', 'existing', 'already_in_xero'].includes(result?.outcome);
775
845
  } catch (error) {
776
846
  toast(error.message, 'danger');
777
847
  } finally {
778
- button.disabled = false;
848
+ if (sent) showSent(button);
849
+ else button.disabled = false;
779
850
  }
780
851
  },
781
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
+ }
782
858
  // The preview reads only Flow's copy of Xero: it costs no Xero calls.
783
859
  flow('/api/xero/invoices/preview', { method: 'POST', body: JSON.stringify({ organisationId, invoiceIds: [invoice.id] }) })
784
860
  .then((res) => {
785
- 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';
786
862
  })
787
863
  .catch(() => {});
788
864
  return button;
789
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).